API reference
Multi-tenant arbitrary-precision counter service. It’s a small REST surface — authenticate with an organization API key, then add, subtract, clear, and read counters. This reference is generated from the counters.dev OpenAPI contract, so it never drifts from the API. Prefer a client library? Jump to the SDKs.
https://api.counters.dev/v1Preview
Client libraries
Official SDKs wrap the API with a fluent client and automatic per-counter batching. Published SDKs are in beta and include verified install commands; unpublished guides show their status and point to the stable REST API.
Authentication
Organization API key. Verified locally; never round-trips to WorkOS on the request hot path. All data is scoped to the key’s organization. Keep the key server-side — put it in an environment variable or secret, never in client code.
curl "https://api.counters.dev/v1/counters" \
-H "Authorization: Bearer $COUNTERS_API_KEY"Conventions
Arbitrary precision
amount and value are decimal-digit strings, never JSON numbers — a JSON number is an IEEE-754 double and loses precision above 253. Counters never overflow.
Idempotent writes
Every write accepts an Idempotency-Key header. Retrying with the same key is de-duplicated within the dedup window — at-least-once delivery becomes effectively-once.
Clear vs. delete
clear starts a new epoch — the value resets to zero but history is retained. delete tombstones the counter. Counters may also go negative.
Batch is the fast path
/batch coalesces many operations into one call, each with its own idempotency key. It’s the primary write path for the SDKs — reach for it before looping single writes.
Errors
Errors use RFC 9457 problem details (application/problem+json) with a consistent shape:
| Field | Type | Description |
|---|---|---|
| type | string | |
| title | string | |
| status | integer | |
| detail | string | |
| instance | string |
{
"type": "https://counters.dev/errors/quota-exceeded",
"title": "Counter limit reached",
"status": 403,
"detail": "Your plan allows 100 counters; delete one or upgrade."
}counters
Counter registry and metadata.
/countersList counters in the organization
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| cursor | query | string | Opaque pagination cursor from a previous response. |
| limit | query | integer (1–200, default 50) |
Request
curl -X GET "https://api.counters.dev/v1/counters" \
-H "Authorization: Bearer $COUNTERS_API_KEY"const res = await fetch("https://api.counters.dev/v1/counters", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.COUNTERS_API_KEY}`,
},
});
const data = await res.json();Responses
| Status | Description |
|---|---|
| 200 | A page of counters.(CounterPage) |
| 401 | Missing or invalid API key.(Problem) |
| 429 | Rate limit exceeded.(Problem) |
{
"data": [
{
"key": "registrations",
"value": "0",
"epoch": 3,
"createdAt": "2026-01-01T00:00:00Z",
"updatedAt": "2026-01-01T00:00:00Z"
}
],
"nextCursor": "eyJvZmZzZXQiOjUwfQ"
}/counters/{counterKey}Get a counter's metadata and current value
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| counterKey * | path | string (len 1–200) | Counter identifier, unique within the organization (e.g. "registrations"). |
Request
curl -X GET "https://api.counters.dev/v1/counters/your-counter-key" \
-H "Authorization: Bearer $COUNTERS_API_KEY"const res = await fetch("https://api.counters.dev/v1/counters/your-counter-key", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.COUNTERS_API_KEY}`,
},
});
const data = await res.json();Responses
| Status | Description |
|---|---|
| 200 | The counter.(Counter) |
| 401 | Missing or invalid API key.(Problem) |
| 404 | Counter not found.(Problem) |
{
"key": "registrations",
"value": "0",
"epoch": 3,
"createdAt": "2026-01-01T00:00:00Z",
"updatedAt": "2026-01-01T00:00:00Z"
}operations
Mutations — add, subtract, clear, delete, and batch.
/counters/{counterKey}Delete (tombstone) a counter
Marks the counter deleted and stops serving it; events are purged asynchronously per retention.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| counterKey * | path | string (len 1–200) | Counter identifier, unique within the organization (e.g. "registrations"). |
| Idempotency-Key | header | string (len 0–255) | Client-supplied key; retries with the same key are de-duplicated within the dedup window. |
Request
curl -X DELETE "https://api.counters.dev/v1/counters/your-counter-key" \
-H "Authorization: Bearer $COUNTERS_API_KEY" \
-H "Idempotency-Key: $(uuidgen)"const res = await fetch("https://api.counters.dev/v1/counters/your-counter-key", {
method: "DELETE",
headers: {
Authorization: `Bearer ${process.env.COUNTERS_API_KEY}`,
"Idempotency-Key": crypto.randomUUID(),
},
});Responses
| Status | Description |
|---|---|
| 204 | Deleted. |
| 401 | Missing or invalid API key.(Problem) |
| 404 | Counter not found.(Problem) |
/counters/{counterKey}/addAdd to a counter
Increments by a non-negative amount. Creates the counter if absent (subject to the plan's counter limit).
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| counterKey * | path | string (len 1–200) | Counter identifier, unique within the organization (e.g. "registrations"). |
| Idempotency-Key | header | string (len 0–255) | Client-supplied key; retries with the same key are de-duplicated within the dedup window. |
Request body (required)
| Field | Type | Description |
|---|---|---|
| amount * | string | Non-negative integer magnitude, arbitrary precision, as a decimal-digit string.pattern: ^[0-9]+$ |
Request
curl -X POST "https://api.counters.dev/v1/counters/your-counter-key/add" \
-H "Authorization: Bearer $COUNTERS_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"amount":"1"}'const res = await fetch("https://api.counters.dev/v1/counters/your-counter-key/add", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.COUNTERS_API_KEY}`,
"Idempotency-Key": crypto.randomUUID(),
"Content-Type": "application/json",
},
body: JSON.stringify({"amount":"1"}),
});
const data = await res.json();Responses
| Status | Description |
|---|---|
| 200 | Applied; returns the new value.(Counter) |
| 400 | Invalid request.(Problem) |
| 401 | Missing or invalid API key.(Problem) |
| 403 | A plan limit was reached (e.g. maximum counters).(Problem) |
| 429 | Rate limit exceeded.(Problem) |
{
"key": "registrations",
"value": "0",
"epoch": 3,
"createdAt": "2026-01-01T00:00:00Z",
"updatedAt": "2026-01-01T00:00:00Z"
}/counters/{counterKey}/subtractSubtract from a counter
Decrements by a non-negative amount. The counter may go negative.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| counterKey * | path | string (len 1–200) | Counter identifier, unique within the organization (e.g. "registrations"). |
| Idempotency-Key | header | string (len 0–255) | Client-supplied key; retries with the same key are de-duplicated within the dedup window. |
Request body (required)
| Field | Type | Description |
|---|---|---|
| amount * | string | Non-negative integer magnitude, arbitrary precision, as a decimal-digit string.pattern: ^[0-9]+$ |
Request
curl -X POST "https://api.counters.dev/v1/counters/your-counter-key/subtract" \
-H "Authorization: Bearer $COUNTERS_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"amount":"1"}'const res = await fetch("https://api.counters.dev/v1/counters/your-counter-key/subtract", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.COUNTERS_API_KEY}`,
"Idempotency-Key": crypto.randomUUID(),
"Content-Type": "application/json",
},
body: JSON.stringify({"amount":"1"}),
});
const data = await res.json();Responses
| Status | Description |
|---|---|
| 200 | Applied; returns the new value.(Counter) |
| 400 | Invalid request.(Problem) |
| 401 | Missing or invalid API key.(Problem) |
| 429 | Rate limit exceeded.(Problem) |
{
"key": "registrations",
"value": "0",
"epoch": 3,
"createdAt": "2026-01-01T00:00:00Z",
"updatedAt": "2026-01-01T00:00:00Z"
}/counters/{counterKey}/clearClear a counter (reset to zero)
Starts a new epoch; the current value becomes zero. Historical series are retained.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| counterKey * | path | string (len 1–200) | Counter identifier, unique within the organization (e.g. "registrations"). |
| Idempotency-Key | header | string (len 0–255) | Client-supplied key; retries with the same key are de-duplicated within the dedup window. |
Request
curl -X POST "https://api.counters.dev/v1/counters/your-counter-key/clear" \
-H "Authorization: Bearer $COUNTERS_API_KEY" \
-H "Idempotency-Key: $(uuidgen)"const res = await fetch("https://api.counters.dev/v1/counters/your-counter-key/clear", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.COUNTERS_API_KEY}`,
"Idempotency-Key": crypto.randomUUID(),
},
});
const data = await res.json();Responses
| Status | Description |
|---|---|
| 200 | Cleared; returns the counter at value 0 in the new epoch.(Counter) |
| 401 | Missing or invalid API key.(Problem) |
| 404 | Counter not found.(Problem) |
{
"key": "registrations",
"value": "0",
"epoch": 3,
"createdAt": "2026-01-01T00:00:00Z",
"updatedAt": "2026-01-01T00:00:00Z"
}/batchApply a batch of operations
The SDK's primary write path. Operations are coalesced client-side and submitted together. Each operation carries its own idempotency key, so the batch is safe to retry. The HTTP call succeeding means the batch was accepted — inspect each per-operation result.
Request body (required)
| Field | Type | Description |
|---|---|---|
| operations * | array<Operation> |
Request
curl -X POST "https://api.counters.dev/v1/batch" \
-H "Authorization: Bearer $COUNTERS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"operations":[{"counterKey":"registrations","op":"add","amount":"1","idempotencyKey":"b1a7c1e2-3f4d-5a6b-7c8d-9e0f1a2b3c4d"}]}'const res = await fetch("https://api.counters.dev/v1/batch", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.COUNTERS_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({"operations":[{"counterKey":"registrations","op":"add","amount":"1","idempotencyKey":"b1a7c1e2-3f4d-5a6b-7c8d-9e0f1a2b3c4d"}]}),
});
const data = await res.json();Responses
| Status | Description |
|---|---|
| 200 | Per-operation results.(BatchResponse) |
| 400 | Invalid request.(Problem) |
| 401 | Missing or invalid API key.(Problem) |
| 429 | Rate limit exceeded.(Problem) |
{
"results": [
{
"counterKey": "registrations",
"status": "applied",
"value": "0",
"error": {
"type": "string",
"title": "string",
"status": 0,
"detail": "string",
"instance": "string"
}
}
]
}read
Reads — current value and time series.
/counters/{counterKey}/valueGet a counter's current value
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| counterKey * | path | string (len 1–200) | Counter identifier, unique within the organization (e.g. "registrations"). |
Request
curl -X GET "https://api.counters.dev/v1/counters/your-counter-key/value" \
-H "Authorization: Bearer $COUNTERS_API_KEY"const res = await fetch("https://api.counters.dev/v1/counters/your-counter-key/value", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.COUNTERS_API_KEY}`,
},
});
const data = await res.json();Responses
| Status | Description |
|---|---|
| 200 | The current value.(ValueResponse) |
| 401 | Missing or invalid API key.(Problem) |
| 404 | Counter not found.(Problem) |
{
"key": "registrations",
"value": "0",
"epoch": 3
}/counters/{counterKey}/seriesGet a counter's time series (delta per bucket)
Returns the per-bucket change in the counter over [from, to). Granularity (`bucket`) and lookback are constrained by the organization's plan. Empty buckets are omitted unless `gapfill=true`; clients treat a missing bucket as zero.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| counterKey * | path | string (len 1–200) | Counter identifier, unique within the organization (e.g. "registrations"). |
| from * | query | string · date-time | |
| to * | query | string · date-time | |
| bucket * | query | enum: 1m | 5m | 1h | 1d | 1w | 1mo | Bucket size. Allowed values depend on plan (finer buckets require higher plans). |
| mode | query | enum: delta | Only delta-per-bucket is supported in v0.1; cumulative is a future, separate read path. |
| tz | query | string (default "UTC") | IANA timezone for calendar bucket boundaries (e.g. Europe/London). |
| gapfill | query | boolean (default false) |
Request
curl -X GET "https://api.counters.dev/v1/counters/your-counter-key/series?from=2026-01-01T00%3A00%3A00Z&to=2026-01-08T00%3A00%3A00Z&bucket=1m" \
-H "Authorization: Bearer $COUNTERS_API_KEY"const res = await fetch("https://api.counters.dev/v1/counters/your-counter-key/series?from=2026-01-01T00%3A00%3A00Z&to=2026-01-08T00%3A00%3A00Z&bucket=1m", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.COUNTERS_API_KEY}`,
},
});
const data = await res.json();Responses
| Status | Description |
|---|---|
| 200 | The series.(SeriesResponse) |
| 400 | Invalid request.(Problem) |
| 401 | Missing or invalid API key.(Problem) |
| 403 | Requested granularity or lookback exceeds the plan's entitlement.(Problem) |
{
"counterKey": "registrations",
"bucket": "string",
"mode": "delta",
"tz": "string",
"range": {
"from": "2026-01-01T00:00:00Z",
"to": "2026-01-08T00:00:00Z"
},
"points": [
{
"t": "2026-01-01T00:00:00Z",
"v": "0"
}
]
}