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

CodeHTTPCauseWhat to do
unauthorized401No API key was sent, or the Authorization header was malformed.Check the header reads "Bearer <key>".
invalid_api_key401The 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.
forbidden403The key is valid but not scoped for this route.Issue a key with the required scope.
suspended403The account or its entitlement is not active.Check billing, then contact support.

Request problems

CodeHTTPCauseWhat to do
missing_field400A required field or header was omitted.The message names it. Common on a create with no Idempotency-Key.
validation_error400A field failed validation.The message names the field and the rule. Do not retry — it will fail identically.
invalid_timer_id400The ID in the path is not a UUID.Usually a template variable that did not interpolate.
invalid_timezone400Not a canonical IANA identifier.Use Area/Location. GET /timezones is the authoritative list.
not_found404No 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

CodeHTTPCauseWhat to do
idempotency_conflict409The Idempotency-Key was already used with a different body.Use a new key for a genuinely new operation, or send the original body.
revision_conflict409The If-Match revision is stale — something else wrote first.Re-read the timer, reapply your change, retry. Do not resend the same revision.

Limits

CodeHTTPCauseWhat to do
rate_limited429The per-minute request limit was exceeded.Transient. Wait for Retry-After and retry.
quota_exhausted429The monthly timer allowance is spent.Not transient. Waiting will not help until the cycle resets.

Server

CodeHTTPCauseWhat to do
internal_error500An 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_limited

You are going too fast. Clears within a minute. Retry-After says exactly when. Retry.

quota_exhausted

The 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"
}
Existing timers keep running and stay readable in both cases. Only creating new ones is blocked, so a quota problem degrades your integration rather than breaking what is already live. See rate limits and usage.

What is worth retrying

CodeRetryWhy
rate_limitedYes, after Retry-AfterTransient by definition.
internal_errorYes, with backoffMay be a momentary problem on our side.
revision_conflictYes, after re-readingRetry the operation, not the request — the revision must be fresh.
quota_exhaustedNoWill not clear until the cycle resets.
validation_errorNoThe same request will fail the same way.
missing_fieldNoSame.
invalid_timezoneNoSame.
idempotency_conflictNoThe key already means something else.
unauthorized / invalid_api_keyNoFix the credentials.
not_foundNoThe 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

  1. 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.

  2. 02

    missing_field on a create that looks complete

    The Idempotency-Key header, not a body field. It is required on POST /timers.

  3. 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.

  4. 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.