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.
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.
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] }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.
# 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" }]
}
}] }] }]
}'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:
| Attribute | Maps 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. |
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.
Wire behavior at a glance
| OTLP | counters.dev |
|---|---|
| Metric.sum datapoints | accepted — monotonic and non-monotonic |
| Gauge / Histogram / Summary / traces / logs | rejected: “unsupported metric type” (per-point) |
| metric name | counter key (sanitized, ≤200 chars) |
| NumberDataPoint.asInt | op amount (exact, arbitrary precision) |
| NumberDataPoint.asDouble | accepted iff integral; otherwise rejected (exact only to 2⁵³) |
| delta sign | > 0 → add, < 0 → subtract, = 0 → accepted no-op |
| TimeUnixNano | occurredAt (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.