Documentation
counr
counr is an API that decides whether something can happen — based on usage and limits.
No Redis scripts to write or maintain, no counters, no edge cases — just a single decision API.
Use it for rate limiting, quotas, credits, and feature gating.
All requests require an API key.
Authenticated, non-idempotent requests count toward account caps, including denied ones.
Quickstart
Make your first allow/deny decision in under 30 seconds.
1. Get your API key
Create an API key from the dashboard. A valid API key is required on every request.
2. Make your first request
Base URL: https://api.countr.dev
Replace ck_use_live_a1b2c3d4e5f67890abcd1234ef567890 with your API key.
curl "https://api.countr.dev/v1/check-consume" \
-X POST \
-H "Authorization: Bearer ck_use_live_a1b2c3d4e5f67890abcd1234ef567890" \
-H "Content-Type: application/json" \
-d '{
"subject": "user_123",
"metric": "api_calls",
"cost": 1
}'Or use the SDK:
npm install countr-sdkUse the SDK once you're up and running for easier integration.
Response — allowed
{
"allowed": true,
"remaining": 997,
"reason": null
}Response — denied (account cap)
{
"allowed": false,
"remaining": 0,
"reason": "owner_rate_limit_exceeded"
}Response — denied (subject limit)
{
"allowed": false,
"remaining": 3,
"reason": "limit_exceeded"
}Requests are denied when remaining < cost, so blocked responses may still show a positive remaining when cost > 1.
Core Concepts
owner — you, the API key holder. Your account-level limits apply to all requests you make.
subject — the entity being tracked (a user ID, IP address, tenant, etc.). Each subject has its own usage counter.
metric — what you're counting (api_calls, ai_tokens, exports). Configure metrics and limits in the dashboard.
cost — how much to consume. Usually 1 for simple rate limiting, or a token count for AI usage.
What You Can Build
Rate limiting
Block requests after a threshold. Set a daily limit on api_calls and check on every request.
Quotas
Limit usage per day or month. Counters reset automatically — no cron jobs needed.
Credits
Charge per action. Pass the actual cost (e.g. token count) as cost.
Feature gating
Restrict features by plan. Use subject overrides to give Pro users a higher (or unlimited) limit.
Why counr
Instead of building this with Redis + Lua scripts:
- No race conditions to solve
- No distributed state to manage
- No infra to provision or maintain
- Multiple limits handled in one call
- Works globally with a single API
Failure Behaviour
Default: fail open (recommended for most apps)
counr is designed for the request path, but failures can happen. If counr cannot be reached, you decide whether to allow or deny the request based on your risk tolerance.
Fail open (recommended for most apps)
Allow the request if counr is unreachable. Keeps your app available during outages.
import { CountrClient } from "countr-sdk";
const client = new CountrClient({ apiKey: "ck_use_live_a1b2c3d4e5f67890abcd1234ef567890", baseUrl: "https://api.countr.dev" });
function shouldFailOpen(error) {
const status = error?.status ?? error?.statusCode;
if (typeof status === "number") return status >= 500;
const code = typeof error?.code === "string" ? error.code : "";
const name = typeof error?.name === "string" ? error.name : "";
return (
["ECONNRESET", "ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "ETIMEDOUT", "ECONNABORTED"].includes(code)
|| name === "AbortError"
|| name === "TimeoutError"
);
}
async function isAllowed(subject, metric) {
try {
const result = await client.checkConsume({ subject, metric, cost: 1 });
return result.allowed;
} catch (error) {
if (shouldFailOpen(error)) {
return true; // fail open — allow only when Countr is unreachable
}
throw error;
}
}import { CountrClient } from "countr-sdk";
const client = new CountrClient({ apiKey: "ck_use_live_a1b2c3d4e5f67890abcd1234ef567890", baseUrl: "https://api.countr.dev" });
function shouldFailOpen(error: unknown): boolean {
if (typeof error !== "object" || error === null) return false;
const e = error as { status?: number; statusCode?: number; code?: string; name?: string };
const status = e.status ?? e.statusCode;
if (typeof status === "number") return status >= 500;
return (
["ECONNRESET", "ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "ETIMEDOUT", "ECONNABORTED"].includes(e.code ?? "")
|| e.name === "AbortError"
|| e.name === "TimeoutError"
);
}
async function isAllowed(subject: string, metric: string): Promise<boolean> {
try {
const result = await client.checkConsume({ subject, metric, cost: 1 });
return result.allowed;
} catch (error) {
if (shouldFailOpen(error)) {
return true; // fail open — allow only when Countr is unreachable
}
throw error;
}
}Fail closed
Block the request if counr is unreachable. Use only when strict enforcement is required.
import { CountrClient } from "countr-sdk";
const client = new CountrClient({ apiKey: "ck_use_live_a1b2c3d4e5f67890abcd1234ef567890", baseUrl: "https://api.countr.dev" });
async function isAllowed(subject, metric) {
try {
const result = await client.checkConsume({ subject, metric, cost: 1 });
return result.allowed;
} catch {
return false; // fail closed — block if Countr is unreachable
}
}import { CountrClient } from "countr-sdk";
const client = new CountrClient({ apiKey: "ck_use_live_a1b2c3d4e5f67890abcd1234ef567890", baseUrl: "https://api.countr.dev" });
async function isAllowed(subject: string, metric: string): Promise<boolean> {
try {
const result = await client.checkConsume({ subject, metric, cost: 1 });
return result.allowed;
} catch {
return false; // fail closed — block if Countr is unreachable
}
}Performance
counr is designed for low-latency decisions and is safe to call on every request.
- ~50ms average response time
- <100ms p95 (US region)
For live status, see the status page.
Decisions
Every checkConsume call returns a decision:
allowed: true— proceed; subject usage is incremented only when a limit is configured (that is, whenremainingis a number). Ifremainingisnull, no limit is configured and the subject counter is not incrementedallowed: false— blocked; subject usage is not incremented, but the request still counts toward your account-wide caps (monthly/minute/second)reason— set whenallowedisfalse
| reason | Meaning |
|---|---|
limit_exceeded | Subject's limit was reached; wait for the window to reset if applicable, or increase the limit |
owner_rate_limit_exceeded_second | Too many requests in a short burst on your account; try again in a moment |
owner_rate_limit_exceeded | Too many requests per minute on your account |
owner_monthly_limit_exceeded | Account monthly cap reached; resets next calendar month |
API Reference
/v1/check-consumeChecks whether a subject is within their limit for a given metric, then records the usage. Returns allowed: true and a remaining quota value when the request is permitted (where remaining is a number, or null when no limit is configured / quota is unlimited), or allowed: false with a reason field when the request is denied. Requests may be denied by a subject/metric limit (reason: "limit_exceeded"), the account-wide monthly cap (reason: "owner_monthly_limit_exceeded"), the account-wide per-minute rate limit (reason: "owner_rate_limit_exceeded"), or the per-second burst limit (reason: "owner_rate_limit_exceeded_second"). All accounts are subject to account-wide limits — requests may be rejected if the account-level monthly, per-minute, or burst limit is exceeded regardless of any subject-specific limits configured.
Monthly allowance: authenticated, non-idempotent counr API requests count toward your monthly allowance, whether they are allowed or rejected due to account-level limits. Subject-level limit denials do not consume subject quota, but they do count as API requests toward your account cap. Idempotency replays do not count again toward owner/account caps or counters.
Authorization
Authorization: Bearer <token>Pass your API key as a Bearer token.
Idempotency (optional)
Idempotency-Key: <string>Use this to prevent double consumption when retrying on network timeouts or transient failures — for example, to avoid charging a user twice if your request to counr times out before you receive a response. An opaque string (max 100 chars): when the decision is allowed, the result is cached for 24 hours for both configured limits and unlimited subjects (including responses with remaining: null). Retried requests with the same key return the cached response without re-incrementing usage, and replays also bypass owner caps/counters. Denied decisions are not cached either; those always re-evaluate live state.
curl "https://api.countr.dev/v1/check-consume" \
-H "Authorization: Bearer ck_use_live_a1b2c3d4e5f67890abcd1234ef567890" \
-H "Idempotency-Key: req_01J9ABCDEF" \
-H "Content-Type: application/json" \
-d '{"subject":"user_123","metric":"api_calls","cost":1}'Request body
{
"subject": "user_123", // string, required, max 200 chars
"metric": "api_calls", // string, required, max 200 chars
"cost": 1 // positive integer, required
}Example request
curl "https://api.countr.dev/v1/check-consume" \
-X POST \
-H "Authorization: Bearer ck_use_live_a1b2c3d4e5f67890abcd1234ef567890" \
-H "Content-Type: application/json" \
-d '{
"subject": "user_123",
"metric": "api_calls",
"cost": 1
}'import { CountrClient } from "countr-sdk";
const client = new CountrClient({ apiKey: "ck_use_live_a1b2c3d4e5f67890abcd1234ef567890", baseUrl: "https://api.countr.dev" });
await client.checkConsume({
subject: "user_123",
metric: "api_calls",
cost: 1,
});import { CountrClient } from "countr-sdk";
const client = new CountrClient({ apiKey: "ck_use_live_a1b2c3d4e5f67890abcd1234ef567890", baseUrl: "https://api.countr.dev" });
const result = await client.checkConsume({
subject: "user_123",
metric: "api_calls",
cost: 1,
});
console.log(result.allowed);Success response · 200 · allowed
{
"allowed": true,
"remaining": 997,
"reason": null
}Success response · 200 · denied
{
"allowed": false,
"remaining": 0,
"reason": "limit_exceeded"
}Error response · 400 / 401 / 503
{
"error": {
"code": "validation_error",
"message": "Invalid request body",
"details": {
"cost": "Cost must be a positive integer"
}
}
}Possible error.code values: validation_error (400 — invalid or missing fields), invalid_json (400 — malformed JSON body), unauthorized (401 — missing or invalid API key), service_unavailable (503 — service temporarily unavailable).
/v1/usageReturns the current usage state for a subject and metric — without consuming any quota. Use this to display live usage to your end users or to check headroom before performing an action. This endpoint does not increment subject usage. Owner-level request caps still apply: each call counts against your account's monthly, per-minute, and per-second limits.
Authorization
Authorization: Bearer <token>Pass your API key as a Bearer token.
Query parameters
subject string required max 200 chars
metric string required max 200 charsExample request
curl "https://api.countr.dev/v1/usage?subject=user_123&metric=api_calls" \
-H "Authorization: Bearer ck_use_live_a1b2c3d4e5f67890abcd1234ef567890"import { CountrClient } from "countr-sdk";
const client = new CountrClient({ apiKey: "ck_use_live_a1b2c3d4e5f67890abcd1234ef567890", baseUrl: "https://api.countr.dev" });
const usage = await client.getUsage({
subject: "user_123",
metric: "api_calls",
});
console.log(usage.current, usage.remaining);import { CountrClient } from "countr-sdk";
const client = new CountrClient({ apiKey: "ck_use_live_a1b2c3d4e5f67890abcd1234ef567890", baseUrl: "https://api.countr.dev" });
const usage = await client.getUsage({
subject: "user_123",
metric: "api_calls",
});
console.log(usage.current, usage.remaining);Success response · 200 · limit configured
{
"subject": "user_123",
"metric": "api_calls",
"current": 58,
"limit": 100,
"remaining": 42,
"window": "day"
}window reflects the configured time window: "none" (lifetime), "day" (resets at UTC midnight), or "month" (resets at the start of each UTC month). remaining is never negative.
Success response · 200 · no limit configured (unlimited)
{
"subject": "user_123",
"metric": "api_calls",
"current": 0,
"limit": null,
"remaining": null,
"window": "none"
}When no limit is configured for the subject+metric, limit and remaining are null (unlimited). current will typically be 0 — usage counters are only incremented for subjects with a configured limit. A non-zero value may appear only if the subject previously had a lifetime limit ("window": "none") that was later removed.
Error response · 400 / 401 / 429 / 503
{
"error": {
"code": "validation_error",
"message": "Invalid query parameters",
"details": {
"subject": "Subject is required"
}
}
}Possible error.code values: validation_error (400 — missing or invalid query params), unauthorized (401 — missing, invalid, or inactive API key), owner_monthly_limit_exceeded, owner_rate_limit_exceeded, owner_rate_limit_exceeded_second (429 — account-wide cap exceeded), service_unavailable (503 — service temporarily unavailable).
/v1/healthChecks that the API and Redis are reachable. No authentication required. Suitable for uptime monitoring (api.countr.dev).
Success response · 200
{ "status": "ok" }Error response · 500
{ "status": "error" }/healthConfirms the app is available. No authentication required. Suitable for uptime monitoring (countr.dev).
Success response · 200
{ "status": "ok" }Account limits & plans
Every account is on a plan that controls monthly request caps, rate limits, and the number of API keys allowed. All limits apply across all subjects and metrics and count authenticated, non-idempotent request volume — idempotency-key replays served from cache are not counted.
| Plan | Monthly cap | Per-minute | Per-second | API keys |
|---|---|---|---|---|
| Free | 2,000 | 30 | 5 | 1 |
| Starter | 100,000 | 120 | 20 | 1 |
| Growth | 500,000 | 300 | 50 | 3 |
| Scale | 1,000,000 (+ overages) | 1,000 | 100 | Unlimited |
Overages available at ~$0.10 per 10,000 requests beyond included monthly allowance. Plan changes are handled manually at this time — contact us to upgrade.
When a request is denied due to an account-wide or subject-specific limit, the API returns a 200 response with allowed: false and one of the limit-related reasons below. Requests with an invalid or inactive API key receive a 401 error response instead.
| reason | Explanation |
|---|---|
owner_monthly_limit_exceeded | You reached your monthly request limit for your plan |
owner_rate_limit_exceeded | You exceeded the per-minute request limit for your plan |
owner_rate_limit_exceeded_second | Too many requests in a short burst |
limit_exceeded | The configured limit for this subject was exceeded |
Authentication error responses
Authentication failures are returned as HTTP 401 JSON errors, not as { allowed: false, reason: ... } responses.
| status | error.code | Explanation |
|---|---|---|
401 | unauthorized | Authentication failed. Check error.message for the specific reason (invalid key, inactive key, missing or malformed Authorization header). |
All authenticated, non-idempotent requests count toward your monthly allowance, whether allowed or denied.
Configure metrics & limits
Define the metrics you want to track and set limits in your dashboard. You can create any number of custom metrics, set owner-wide default limits, and override limits for specific subjects.
Limits support three time windows: Lifetime (usage accumulates indefinitely — the default), Daily (resets each UTC day), and Monthly (resets at the start of each UTC month). Once the window period ends, usage automatically starts fresh — no manual reset required.
Example limits
api_calls 1,000 / day — resets each UTC midnight
ai_tokens 10,000 / month — resets each calendar month
storage_bytes unbounded (lifetime)Example: check and consume ai_tokens for a subject
curl "https://api.countr.dev/v1/check-consume" \
-H "Authorization: Bearer ck_use_live_a1b2c3d4e5f67890abcd1234ef567890" \
-H "Content-Type: application/json" \
-d '{"subject":"user_123","metric":"ai_tokens","cost":500}'Dashboard
The dashboard is where you configure everything:
- API Keys — create and revoke keys (formatted as
ck_use_live_xxx) - Metrics — define the metrics you want to track (e.g.
api_calls,ai_tokens); dashboard metric keys must be lowercase snake_case (max 64 chars) — if no limit is configured for a metric, requests against it are always allowed - Default Limits — set owner-wide limits per metric and time window
- Subject Overrides — give a specific subject a higher or lower limit
- Usage — view account usage for the current month and recent request decisions. The monthly summary reflects live account usage when available. The activity table shows recent requests and decisions for debugging (best-effort — may not include every request).
What counr stores
counr stores usage counters per subject and metric, and logs recent request decisions to power the dashboard usage view. Counters are scoped to your account and are used only for limit enforcement and visibility. counr does not store your application payloads; for request accounting and recent decision logs, it stores the subject identifier, metric key, cost, and decision metadata needed for enforcement and the dashboard. If you send an Idempotency-Key, that value is also persisted as part of Redis idempotency state for about 24 hours to deduplicate retries, so do not put sensitive data in it.
Examples
API rate limiting
Allow each user 1,000 API calls per day. Set a daily limit of 1000 on the api_calls metric, then call check-consume on each request:
curl "https://api.countr.dev/v1/check-consume" \
-X POST \
-H "Authorization: Bearer ck_use_live_a1b2c3d4e5f67890abcd1234ef567890" \
-H "Content-Type: application/json" \
-d '{
"subject": "user_123",
"metric": "api_calls",
"cost": 1
}'import { CountrClient } from "countr-sdk";
const client = new CountrClient({ apiKey: "ck_use_live_a1b2c3d4e5f67890abcd1234ef567890", baseUrl: "https://api.countr.dev" });
await client.checkConsume({
subject: "user_123",
metric: "api_calls",
cost: 1,
});import { CountrClient } from "countr-sdk";
const client = new CountrClient({ apiKey: "ck_use_live_a1b2c3d4e5f67890abcd1234ef567890", baseUrl: "https://api.countr.dev" });
const result = await client.checkConsume({
subject: "user_123",
metric: "api_calls",
cost: 1,
});
console.log(result.allowed);AI token usage
Allow each user 100,000 tokens per month. Pass the actual token count as the cost:
curl "https://api.countr.dev/v1/check-consume" \
-X POST \
-H "Authorization: Bearer ck_use_live_a1b2c3d4e5f67890abcd1234ef567890" \
-H "Content-Type: application/json" \
-d '{
"subject": "user_123",
"metric": "ai_tokens",
"cost": 1452
}'import { CountrClient } from "countr-sdk";
const client = new CountrClient({ apiKey: "ck_use_live_a1b2c3d4e5f67890abcd1234ef567890", baseUrl: "https://api.countr.dev" });
await client.checkConsume({
subject: "user_123",
metric: "ai_tokens",
cost: 1452,
});import { CountrClient } from "countr-sdk";
const client = new CountrClient({ apiKey: "ck_use_live_a1b2c3d4e5f67890abcd1234ef567890", baseUrl: "https://api.countr.dev" });
const result = await client.checkConsume({
subject: "user_123",
metric: "ai_tokens",
cost: 1452,
});
console.log(result.allowed);Per-user quotas with overrides
Set a default limit of 500 exports per month, then give a Pro user a higher limit via a subject override in the dashboard:
curl "https://api.countr.dev/v1/check-consume" \
-X POST \
-H "Authorization: Bearer ck_use_live_a1b2c3d4e5f67890abcd1234ef567890" \
-H "Content-Type: application/json" \
-d '{
"subject": "pro_user_456",
"metric": "exports",
"cost": 1
}'import { CountrClient } from "countr-sdk";
const client = new CountrClient({ apiKey: "ck_use_live_a1b2c3d4e5f67890abcd1234ef567890", baseUrl: "https://api.countr.dev" });
await client.checkConsume({
subject: "pro_user_456",
metric: "exports",
cost: 1,
});import { CountrClient } from "countr-sdk";
const client = new CountrClient({ apiKey: "ck_use_live_a1b2c3d4e5f67890abcd1234ef567890", baseUrl: "https://api.countr.dev" });
const result = await client.checkConsume({
subject: "pro_user_456",
metric: "exports",
cost: 1,
});
console.log(result.allowed);