Requests, responses, and headers

Every endpoint in this API behaves the same way, which means the wrapper you write once works everywhere and the behaviour you learn on one route holds on all of them. This page is the contract: what a response looks like, what headers travel in each direction, and what we promise not to change without a new version.

The response envelope

Responses are always JSON objects, never bare arrays. A successful call puts the payload under data; a failure puts a machine-readable error under error and omits data entirely. Both carry request_id.

Single resource

{
  "data": { ... },
  "request_id": "7b82b7f7-4d13-497b-9f20-58d46fd7a510"
}

List

{
  "data": [ ... ],
  "page": {
    "has_more": true,
    "next_cursor": "eyJ2YWx1ZSI6Ii4uLiJ9"
  },
  "request_id": "7b82b7f7-4d13-497b-9f20-58d46fd7a510"
}

Error

{
  "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 envelope exists so that adding a field is never a breaking change. A bare array at the top level has nowhere to put pagination or a request ID, which is how APIs end up shipping v2 for something that should have been additive.

Test response.ok, not the presence of data. An error body has no data key at all, so a client that reads payload.data.id optimistically throws a TypeError and loses the actual error message.

Request headers

Two are always required. The rest apply to specific operations.

HeaderWhenPurpose
AuthorizationEvery requestBearer <api key>. There is no other way to authenticate.
Content-TypePOST and PATCHapplication/json. Bodies in any other format are rejected.
Idempotency-KeyPOST /timers, POST /timers/{id}/duplicateRequired. Makes a retry safe instead of creating a second timer.
If-MatchPATCH /timers/{id}Required. The timer's current revision — rejects the write if it changed since you read it.
Idempotency-Key and If-Match are required, not optional hardening. A create without a key and an update without a revision are both rejected, which is deliberate: the failure modes they prevent are silent, and an API that lets you opt out of safety is one where everyone opts out. See idempotency.

Response headers

Four headers come back on authenticated responses. Reading the rate-limit trio is what lets a client slow down before it gets a 429 rather than after.

HeaderExampleMeaning
X-Request-Id7b82b7f7-4d13-…Identifies this exact call in our logs. Also repeated in the body.
RateLimit-Limit300Requests allowed in the current one-minute window.
RateLimit-Remaining287Requests left before the limit applies.
RateLimit-Reset1893506460Unix seconds at which the window resets.
Retry-After18Sent only with a 429. Seconds to wait before retrying.

The request ID is the whole debugging story

Every response carries a request_id, in the body and in the X-Request-Id header. It is the only identifier that ties what your code saw to what our servers recorded.

Log it on failures. “The API returned a 400” cannot be investigated; a request ID can be looked up directly. It is also queryable from your own side through GET /usage/requests, so you can find the call before you contact anyone.

A wrapper worth writing once

// One place that knows the envelope. Everything else gets plain data.
async function call(path, options = {}) {
  const response = await fetch("https://countdownshare.com/api/v1" + path, {
    ...options,
    headers: {
      Authorization: `Bearer ${process.env.COUNTDOWNSHARE_API_KEY}`,
      "Content-Type": "application/json",
      ...options.headers,
    },
  });

  const payload = await response.json();

  if (!response.ok) {
    // Attach the request_id to the error so it survives into your logs.
    // Without it, "the API returned a 400" is unactionable for support.
    const error = new Error(payload.error.message);
    error.code = payload.error.code;
    error.status = response.status;
    error.requestId = payload.request_id;
    throw error;
  }

  return payload;
}

Field types and their edges

Nothing exotic, but a few conventions are worth stating rather than inferring.

Timestamps

ISO 8601 with milliseconds, always UTC on the way out: 2030-01-01T14:00:00.000Z. On the way in, any valid ISO 8601 with Z or a numeric offset is accepted.

IDs

UUIDs as strings. A path segment that is not a valid UUID returns invalid_timer_id before any lookup happens.

Durations

Whole seconds as integers. No ISO 8601 duration strings, no floats.

Absent versus null

Omitting a field in a PATCH leaves it unchanged. Sending null clears it, where the field allows clearing.

Unknown fields

Rejected rather than ignored, so a typo surfaces as validation_error instead of silently doing nothing.

Metadata

Up to 50 key-value pairs of your own. Values may be string, number, boolean, or null. Returned verbatim with the timer.

The absent-versus-null distinction is the one that bites. Serialising an object with undefined values drops those keys, which is usually what you want. Serialising with explicit null clears the field. Check what your HTTP client does before assuming.

Versioning

The version is in the path: https://countdownshare.com/api/v1. There is no version header and no date-based pinning — the URL you call is the version you get.

Changes we may ship to v1 without warning

  • New endpoints
  • New optional request fields
  • New fields in a response object
  • New values in an enum you do not send us — new event types, for instance
  • New error codes for conditions that previously returned a more general one

Changes that would require v2

  • Removing or renaming a field
  • Changing a field's type
  • Making an optional request field required
  • Changing the meaning of an existing value
The practical consequence: parse responses tolerantly. A client that rejects unknown fields or throws on an unrecognised enum value will break on an additive change that every other integration absorbs silently. Ignore what you do not recognise.