Integration

Point your OpenTelemetry metrics at us.

POST /otlp/v1/metrics speaks OTLP/HTTP faithfully enough that an OTel Collector’s otlphttp exporter — pointed at us with only an endpoint and an Authorization header — just works. If your Sum metrics already flow through OpenTelemetry, you can try counters.dev by adding one exporter, with no instrumentation rewrite.

An external contract, not a REST endpoint

OpenTelemetry owns the OTLP wire spec, so this ingest path lives outside our OpenAPI reference (the same precedent as the dashboard plane) and the client SDKs don’t implement it. We accept Sum datapoints only — monotonic and non-monotonic; every other shape (Gauge, Histogram, Summary) has its datapoints rejected loudly-but-partially, never a 5xx.

1

Add the exporter to your collector

The collector appends /v1/metrics to the endpoint, so …/otlp becomes …/otlp/v1/metrics. Use a full organization API key (sk_…) — ingest is a write, so a read-only publishable pk_ token is rejected with 403. Create a key under Dashboard → API keys.

otel-collector-config.yaml
exporters:
  otlphttp/counters:
    endpoint: https://api.counters.dev/otlp
    headers: { Authorization: "Bearer <org api key>" }
    # recommended for exact-once-shaped ingest:
    # set your SDK/collector to delta temporality for sums
service:
  pipelines:
    metrics: { exporters: [otlphttp/counters] }
2

Or POST OTLP directly

No collector required — anything that can speak OTLP/HTTP works. Body is canonical protobuf (application/x-protobuf) or OTLP/JSON; both may be gzipped. Up to 1,000 Sum datapoints per request.

any OTLP client
# OTLP/JSON — one Sum datapoint, gzip optional
curl -fsS -X POST "https://api.counters.dev/otlp/v1/metrics" \
  -H "Authorization: Bearer $COUNTERS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "resourceMetrics": [{ "scopeMetrics": [{ "metrics": [{
      "name": "orders.shipped",
      "sum": {
        "aggregationTemporality": 1,
        "isMonotonic": true,
        "dataPoints": [{ "asInt": "1", "timeUnixNano": "0" }]
      }
    }] }] }]
  }'
3

Route datapoints with two reserved attributes

Every attribute is identity-only (it feeds the series cursor, never a label) — grouped counters are the labels story — except two reserved datapoint attributes:

AttributeMaps to
counters.key
Overrides the counter key for this datapoint
Sanitized like a metric name (chars outside [A-Za-z0-9._:-] → -, ≤200). Lets one metric fan out to several counters.
counters.member
Routes the op to a leaderboard sum-board member
Accumulates against the member's per-epoch value; the group counter stays the plain total. Member key charset [A-Za-z0-9._:@|-]{1,256}. Rejected on score-mode (latest/min/max) boards.
4

Delta vs. cumulative — both work

Delta temporality (what a delta-preference collector sends) is the clean path: the datapoint value is the op amount, and the idempotency key otlp:<seriesHash>:<timeUnixNano> makes collector retries deduplicate for free.

Cumulative (most SDKs’ default) is supported via a per-series cursor that turns running totals into deltas — out-of-order points are skipped silently, and process restarts are detected as resets. One caveat: the first observation of a cumulative series imports its entire running total as a single delta so the counter matches your source system — an intentional one-time spike in the ingest bucket. Prefer delta temporality when you can to avoid it.

5

Wire behavior at a glance

OTLPcounters.dev
Metric.sum datapointsaccepted — monotonic and non-monotonic
Gauge / Histogram / Summary / traces / logsrejected: “unsupported metric type” (per-point)
metric namecounter key (sanitized, ≤200 chars)
NumberDataPoint.asIntop amount (exact, arbitrary precision)
NumberDataPoint.asDoubleaccepted iff integral; otherwise rejected (exact only to 2⁵³)
delta sign> 0 → add, < 0 → subtract, = 0 → accepted no-op
TimeUnixNanooccurredAt (UTC); future / past-retention points rejected

Rate limiting charges one permit per Sum datapoint (the /batch precedent), so a fat export can’t multiply your plan’s throughput. Fully-accepted requests return an empty 200; partial rejections come back as partial_success with the first reasons, never a 5xx.