Countdown Timer API error codes
Every error carries a stable machine-readable code, a human-readable message, and the request_id that identifies the call in our logs. Branch on the code — messages are written for people and may be reworded, while codes are part of the contract and will not change meaning within v1.
The error response
An error body has no data key at all, which is why testing response.ok is more reliable than checking whether a field you expected is present.
Every error looks like this
HTTP/1.1 400 Bad Request
X-Request-Id: 7b82b7f7-4d13-497b-9f20-58d46fd7a510
{
"error": {
"code": "validation_error",
"message": "deadline_at must be an ISO 8601 date-time with Z or a numeric UTC offset"
},
"request_id": "7b82b7f7-4d13-497b-9f20-58d46fd7a510"
}The request_id appears twice on purpose: in the body for your logs, and in the X-Request-Id header so it is available even when a response body is not captured. Quote it when you contact support — it turns a vague report into a single lookup.
Every code
Authentication and access
| Code | HTTP | Cause | What to do |
|---|---|---|---|
unauthorized | 401 | No API key was sent, or the Authorization header was malformed. | Check the header reads "Bearer <key>". |
invalid_api_key | 401 | The key does not exist, was revoked, or belongs to the other environment. | Check the cs_test_ / cs_live_ prefix first — environment mismatch is the usual cause. |
forbidden | 403 | The key is valid but not scoped for this route. | Issue a key with the required scope. |
suspended | 403 | The account or its entitlement is not active. | Check billing, then contact support. |
Request problems
| Code | HTTP | Cause | What to do |
|---|---|---|---|
missing_field | 400 | A required field or header was omitted. | The message names it. Common on a create with no Idempotency-Key. |
validation_error | 400 | A field failed validation. | The message names the field and the rule. Do not retry — it will fail identically. |
invalid_timer_id | 400 | The ID in the path is not a UUID. | Usually a template variable that did not interpolate. |
invalid_timezone | 400 | Not a canonical IANA identifier. | Use Area/Location. GET /timezones is the authoritative list. |
not_found | 404 | No timer, destination, rule, or delivery with that ID exists for this account. | Check the ID and the environment — a Sandbox key genuinely cannot see Production resources. |
Conflicts
| Code | HTTP | Cause | What to do |
|---|---|---|---|
idempotency_conflict | 409 | The Idempotency-Key was already used with a different body. | Use a new key for a genuinely new operation, or send the original body. |
revision_conflict | 409 | The If-Match revision is stale — something else wrote first. | Re-read the timer, reapply your change, retry. Do not resend the same revision. |
Limits
| Code | HTTP | Cause | What to do |
|---|---|---|---|
rate_limited | 429 | The per-minute request limit was exceeded. | Transient. Wait for Retry-After and retry. |
quota_exhausted | 429 | The monthly timer allowance is spent. | Not transient. Waiting will not help until the cycle resets. |
Server
| Code | HTTP | Cause | What to do |
|---|---|---|---|
internal_error | 500 | An unexpected error on our side. | Retry with backoff. If it persists, send us the request_id. |
The two 429s are not the same
Both are HTTP 429, and treating them identically is the most consequential error-handling mistake available here. A client that backs off and retries on quota_exhausted will retry all month.
rate_limitedYou are going too fast. Clears within a minute. Retry-After says exactly when. Retry.
quota_exhaustedThe monthly timer allowance is spent. Clears when the billing cycle resets. Alert someone; do not retry.
quota_exhausted carries details
{
"error": {
"code": "quota_exhausted",
"message": "The monthly new timer allowance for this plan is spent",
"details": {
"metric": "new_timers",
"limit": 1000,
"used": 1000,
"resets_at": "2030-02-01T00:00:00.000Z"
}
},
"request_id": "7b82b7f7-4d13-497b-9f20-58d46fd7a510"
}What is worth retrying
| Code | Retry | Why |
|---|---|---|
rate_limited | Yes, after Retry-After | Transient by definition. |
internal_error | Yes, with backoff | May be a momentary problem on our side. |
revision_conflict | Yes, after re-reading | Retry the operation, not the request — the revision must be fresh. |
quota_exhausted | No | Will not clear until the cycle resets. |
validation_error | No | The same request will fail the same way. |
missing_field | No | Same. |
invalid_timezone | No | Same. |
idempotency_conflict | No | The key already means something else. |
unauthorized / invalid_api_key | No | Fix the credentials. |
not_found | No | The resource is not there. |
Handling by code
// Branch on code, never on message. Messages are written for
// humans and may be reworded; codes are part of the contract.
try {
await createTimer(body, idempotencyKey);
} catch (error) {
switch (error.code) {
case "quota_exhausted":
// Not transient — the allowance resets with the billing cycle.
await notifyOps("Countdown timer allowance exhausted");
return fallbackWithoutCountdown();
case "rate_limited":
// Transient. Retry-After says how long to wait.
await sleep(error.retryAfter * 1000);
return retry();
case "revision_conflict":
// Someone wrote first. Re-read and reapply.
return retryWithFreshRevision();
case "validation_error":
case "missing_field":
case "invalid_timezone":
// Our bug. Retrying will fail identically.
logger.error("Bad request to countdown API", {
code: error.code,
message: error.message,
requestId: error.requestId,
});
throw error;
default:
throw error;
}
}revision_conflict is the subtle one. Retrying the identical request fails identically, because the revision it carries is still stale. The retry has to re-read the timer first — see update and delete.The four that catch first integrations
- 01
invalid_api_key on a key you just created
Environment mismatch. A cs_test_ key against a Production timer, or the reverse. Check the prefix before anything else.
- 02
missing_field on a create that looks complete
The Idempotency-Key header, not a body field. It is required on POST /timers.
- 03
validation_error on deadline_at
The timestamp has no Z and no numeric offset. 2030-01-01T14:00:00 does not identify an instant.
- 04
not_found on a timer you can see in the dashboard
Same environment problem as the first, wearing a different hat. The timer exists — just not for the key you are holding.
Investigating an error you cannot reproduce
GET /usage/requests returns your own request log, filterable by status, route, key, and request ID. It shows what we received and what we answered, which resolves most “it works locally” reports without anyone opening a ticket.
If you do need support, send the request_id. It is the fastest path to an answer, and without it the first reply will only ask for it.