Countdown Timer API Documentation

Everything needed to create a countdown with the REST API, retrieve its remaining time, control duration timers, and receive verified webhook events. Every example on this page runs against the live API.

Base URL
https://countdownshare.com/api/v1
Protocol
REST over HTTPS, JSON
Authentication
Bearer API key
Environments
Sandbox · Production

OpenAPI 3.1 specification

There is no SDK to install. Import the spec into Postman or Insomnia, or generate a typed client in your language.

Download openapi.yaml

Quick start

Create a timer, keep the timer ID it returns, then use that ID for every later call. Start in Sandbox — it is free with any account and behaves like Production.

  1. Step 01

    Create an API key

    Open the developer dashboard and issue a Sandbox key. The secret is shown once.

  2. Step 02

    Create a timer

    POST to /timers with a name, a type, and its timing field. Save the returned ID.

  3. Step 03

    Read or automate it

    Poll the status endpoint when you render, or attach a webhook rule and be called instead.

cURL

curl -X POST https://countdownshare.com/api/v1/timers \
  -H "Authorization: Bearer $COUNTDOWNSHARE_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: product-launch-2030" \
  -d '{
    "name": "Product launch",
    "type": "fixed",
    "deadline_at": "2030-01-01T14:00:00Z"
  }'

Node.js / JavaScript

const response = await fetch(
  "https://countdownshare.com/api/v1/timers",
  {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.COUNTDOWNSHARE_API_KEY}`,
      "Content-Type": "application/json",
      "Idempotency-Key": "product-launch-2030",
    },
    body: JSON.stringify({
      name: "Product launch",
      type: "fixed",
      deadline_at: "2030-01-01T14:00:00Z",
    }),
  },
);

const { data, request_id } = await response.json();
if (!response.ok) throw new Error(`${data?.code} (${request_id})`);

console.log(data.id, data.remaining_seconds);
Need a key? Manage API keys in the developer dashboard, or create an account first.

Countdown API authentication

Send your secret API key as a bearer token on every request. Keep it on your server: a key in frontend JavaScript, a mobile binary, or a public repository is a key anyone can use to create timers on your account.

Every request

Authorization: Bearer cs_live_a1b2c3d4_...
Content-Type: application/json

API keys

Shown once

The full secret appears only in the response that creates it. We store a hash, so we cannot show it to you again — issue a new key instead.

Environment-bound

A cs_test_ key reaches Sandbox only; a cs_live_ key reaches Production only. The prefix tells you which you are holding.

Revocable

Revoking takes effect on the next request. Create the replacement first if you need zero downtime.

Per-plan count

Sandbox allows 1 key, Starter 3, and Growth 10.

Authentication failures return unauthorized or invalid_api_key with HTTP 401. A valid key on a route it is not scoped for returns forbidden with HTTP 403. See error codes.

Sandbox and Production

Every account has both. They share the same code paths, so anything that works in Sandbox works in Production — but they never share data. The separation is enforced by database constraints, not only by application logic, so a Sandbox key cannot read a Production timer under any circumstances.

SandboxProduction
Key prefixcs_test_cs_live_
CostFree with any accountStarter, Growth, or Custom plan
New timers per month1001,000 – 10,000+
Requests per minute30300
Webhook destinations15 – 25+
Webhook deliveries per month100Unlimited
Publish public pages and embedsNot availableAvailable
Destination URL schemehttp:// or https://https:// only

Timer types

Every type uses the same endpoints. The type you send at creation decides which timing field is required and is fixed for the life of the timer.

Fixed-date countdown

type: fixed

Counts down to one exact ISO 8601 deadline. An optional timezone controls display and editing.

Required: deadline_at

Duration timer

type: duration

Starts with a duration in seconds and can be started, paused, resumed, or reset.

Required: duration_seconds

Recurring countdown

type: recurring

Repeats on a daily, weekly, monthly, or custom RRULE schedule. The timezone defaults to UTC.

Required: recurrence

Personalized countdown

type: personalized

Fixes a separate deadline for each external recipient or customer identifier.

Required: duration_seconds, external_user_id

Timer status is separate from timer type: a timer is draft, published, or archived. Its live running state — scheduled, running, paused, ended — comes from the status endpoint.

Endpoint reference

All paths are relative to https://countdownshare.com/api/v1 and return JSON. Webhook endpoints are listed under webhooks.

MethodEndpointPurpose
GET/timersList timers
POST/timersCreate a timer
GET/timers/{timer_id}Get a timer and its publication status
PATCH/timers/{timer_id}Update a timer
DELETE/timers/{timer_id}Delete a timer
POST/timers/{timer_id}/duplicateCreate a copy of a timer
POST/timers/{timer_id}/archiveArchive a timer
POST/timers/{timer_id}/restoreRestore an archived timer
POST/timers/{timer_id}/actionsStart, pause, resume, or reset a duration timer
GET/timers/{timer_id}/statusRead the live status and remaining time
GET/timers/{timer_id}/outputsGet display options
GET/timers/{timer_id}/activityList timer changes and control actions
GET/timers/{timer_id}/metricsRead views and activity totals
GET/timezonesList accepted IANA timezones
GET/usageRead current plan usage and limits
GET/usage/requestsList API request logs

Create a countdown with the REST API

POST /timers requires an Idempotency-Key header. New timers are private drafts unless you send publish: true, which is Production only. Save the returned timer ID: every later call needs it.

FieldTypeRequiredDescription
namestringYes1–200 characters. A clear internal name for the timer.
typestringYesfixed, duration, recurring, or personalized.
deadline_atstringfixedISO 8601 deadline. Must include Z or a numeric UTC offset.
duration_secondsintegerduration, personalizedStarting duration in seconds. Minimum 1.
timezonestringNoIANA timezone identifier. Defaults to UTC.
recurrenceobjectrecurringfrequency and local_time, plus the fields the chosen schedule needs.
external_user_idstringpersonalized1–255 characters. Your identifier for the recipient.
publishbooleanNoPublish immediately when true. Defaults to false. Production only.
expiryobjectNoWhat the public page and embeds show once the timer ends.
metadataobjectNoUp to 50 of your own key-value pairs, returned with the timer.
Recurring and personalized timers. A recurring timer needs frequency and local_time. Weekly schedules also need days_of_week, monthly schedules need day_of_month, and custom schedules need a DAILY, WEEKLY, or MONTHLY rrule. A personalized timer needs external_user_id and duration_seconds; creation fixes a separate deadline for that identity.

Request

curl -X POST https://countdownshare.com/api/v1/timers \
  -H "Authorization: Bearer $COUNTDOWNSHARE_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: product-launch-2030" \
  -d '{
    "name": "Product launch",
    "type": "fixed",
    "deadline_at": "2030-01-01T14:00:00Z"
  }'

201 Created

{
  "data": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "name": "Product launch",
    "type": "fixed",
    "status": "draft",
    "revision": 1,
    "deadline_at": "2030-01-01T14:00:00.000Z",
    "duration_seconds": null,
    "remaining_seconds": 2692800,
    "timezone": "UTC",
    "recurrence": null,
    "external_user_id": null,
    "expiry": { "behavior": "show_message" },
    "metadata": {},
    "outputs": null,
    "created_at": "2029-12-01T10:00:00.000Z",
    "updated_at": "2029-12-01T10:00:00.000Z"
  },
  "request_id": "7b82b7f7-4d13-497b-9f20-58d46fd7a510"
}
Creating a timer is the only operation that counts toward your monthly allowance — along with POST /timers/{timer_id}/duplicate, since a duplicate is a new timer. See pricing.

Retrieve remaining countdown time

GET /timers/{timer_id}/status is the endpoint you call when you render. The remaining time is computed on the server from the authoritative clock, so a browser with the wrong system time, a phone that was asleep, and a server-rendered page all agree.

Request

curl https://countdownshare.com/api/v1/timers/$TIMER_ID/status \
  -H "Authorization: Bearer $COUNTDOWNSHARE_API_KEY"

200 OK

{
  "data": {
    "status": "scheduled",
    "server_time": "2029-12-28T14:00:00.000Z",
    "target_at": "2030-01-01T14:00:00.000Z",
    "timezone": "UTC",
    "remaining": {
      "total_seconds": 345600,
      "days": 4,
      "hours": 0,
      "minutes": 0,
      "seconds": 0
    },
    "progress": 0.87,
    "ended": false
  },
  "request_id": "7b82b7f7-4d13-497b-9f20-58d46fd7a510"
}

Status values

scheduled

Has not started counting yet.

running

Counting down now.

paused

A duration timer that was paused. Remaining time is frozen.

ended

Reached zero. remaining.total_seconds is 0 and ended is true.

Rendering it in JavaScript

A common pattern: fetch the authoritative remaining time once on load or on the server, then tick locally between fetches so the UI stays smooth without hammering the API.

Node.js · React Server Component · route handler

export async function remainingTime(timerId) {
  const response = await fetch(
    `https://countdownshare.com/api/v1/timers/${timerId}/status`,
    {
      headers: {
        Authorization: `Bearer ${process.env.COUNTDOWNSHARE_API_KEY}`,
      },
      cache: "no-store", // the point of this call is that it is fresh
    },
  );

  const { data } = await response.json();
  return data.ended ? 0 : data.remaining.total_seconds;
}
GET /timers/{timer_id} returns the full timer object instead — definition, revision, expiry, metadata, and outputs. Use the status endpoint when you only need the clock.

Update, control, duplicate, and delete

Managing an existing timer never counts against your monthly allowance. Updates use optimistic concurrency: send the timer's current revision in an If-Match header and the API rejects the write if the timer changed since you read it.

Update a countdown

Send only the fields that change. At least one is required. Omitted fields keep their current values.

FieldTypeDescription
namestringRename the timer.
deadline_atstringFixed timers only. Move the deadline.
duration_secondsintegerBetween 1 and 31,536,000 seconds (one year).
timezonestringChange the display and scheduling timezone.
recurrenceobject or nullReplace the schedule, or send null to remove it.
external_user_idstringReassign a personalized countdown.
publishbooleanMake the public page available or unavailable.
expiryobjectChange the behavior after the timer ends.
metadataobjectReplace your stored key-value data.

Update a timer

curl -X PATCH https://countdownshare.com/api/v1/timers/$TIMER_ID \
  -H "Authorization: Bearer $COUNTDOWNSHARE_API_KEY" \
  -H "Content-Type: application/json" \
  -H "If-Match: 1" \
  -d '{ "deadline_at": "2030-01-02T09:00:00-05:00" }'

Control a duration timer

curl -X POST \
  https://countdownshare.com/api/v1/timers/$TIMER_ID/actions \
  -H "Authorization: Bearer $COUNTDOWNSHARE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "action": "pause" }'

# start · pause · resume · reset
# Duration timers only.
A stale If-Match returns HTTP 409 with revision_conflict. Re-read the timer, reapply your change to the new revision, and retry. Pausing is for duration timers; a fixed-date countdown is changed by moving deadline_at.

Duplicate, archive, restore, delete

MethodEndpointPurpose
POST/timers/{timer_id}/duplicateCopy a timer. Requires Idempotency-Key. Counts as a new timer.
POST/timers/{timer_id}/archiveMove a timer out of the active set without deleting it.
POST/timers/{timer_id}/restoreBring an archived timer back.
DELETE/timers/{timer_id}Delete permanently. This cannot be undone.
GET/timers/{timer_id}/activityPaginated history of changes and control actions.
GET/timers/{timer_id}/metricsViews, unique visitors, interactions, and conversions.

Embeds and hosted pages

GET /timers/{timer_id}/outputs returns every way to display a timer. The JSON status URL is always present. The hosted page, website embed, and email image are returned for published Production timers and are null otherwise — including for every Sandbox timer.

public_page_url

A hosted page at /api/c/{timer_id} for countdowns, or /api/t/{timer_id} for duration timers.

website_embed_html

A responsive iframe snippet, 300×250 at its natural size, that scales to its container.

email_embed_html

A linked animated image that renders the current remaining time when the inbox opens it, with no JavaScript.

json_data_url

The status endpoint for this timer, for building your own interface.

Hosted pages and embeds reflect a start, pause, resume, or reset immediately. Email images use the timer state at the moment the image is requested: a running timer animates for 30 seconds, while a paused or reset timer renders a still frame. What happens after zero follows the expiry you configured — show_message, hide, or redirect.

Countdown webhooks

Webhooks replace polling. Register a destination once, then attach rules to the timers you care about. When a rule matches, we send a signed HTTPS POST to your endpoint and retry it if your service does not answer.

Destination

A URL and a signing secret, stored once and reused by rules across many timers. Plan limits apply to destinations.

Rule

A condition on one timer, pointing at one destination, with once-or-repeat delivery. Rules are unlimited on every plan.

MethodEndpointPurpose
GET/webhook-destinationsList destinations and retrieve their IDs
POST/webhook-destinationsRegister a destination and receive its signing secret
GET/webhook-destinations/{destination_id}Get one destination without its secret
PATCH/webhook-destinations/{destination_id}Change its name, URL, description, or enabled state
DELETE/webhook-destinations/{destination_id}Delete a destination that no rule depends on
POST/webhook-destinations/{destination_id}/testSend a signed test delivery
POST/webhook-destinations/{destination_id}/rotate-secretReplace the signing secret and return it once
GET/timers/{timer_id}/webhook-rulesList the rules attached to a timer
POST/timers/{timer_id}/webhook-rulesAttach a rule to a timer
PATCH/timers/{timer_id}/webhook-rules/{rule_id}Change a rule condition, destination, or state
DELETE/timers/{timer_id}/webhook-rules/{rule_id}Remove a rule
GET/webhook-deliveriesList deliveries, optionally filtered by status
GET/webhook-deliveries/{delivery_id}Get one delivery with its attempt history
POST/webhook-deliveries/{delivery_id}/replayReplay a delivery for the same event

Register a webhook destination

Create the destination before any rule that uses it. The response data.id is the value you pass later as webhook_destination_id. The signing_secret is returned only here and on rotation — store it somewhere your webhook handler can read it.

FieldTypeRequiredDescription
namestringYes1–100 characters. Internal name for this destination.
urlstringYesProduction destinations must be public HTTPS URLs.
descriptionstringNoUp to 500 characters. Internal note about the receiving service.
enabledbooleanNoAccept deliveries when true. Defaults to true.

Request

curl -X POST \
  https://countdownshare.com/api/v1/webhook-destinations \
  -H "Authorization: Bearer $COUNTDOWNSHARE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Order automation",
    "url": "https://example.com/webhooks/countdownshare",
    "enabled": true
  }'

201 Created

{
  "data": {
    "id": "0f4726a4-c05b-4eba-97d2-30ba8e59446e",
    "name": "Order automation",
    "url": "https://example.com/webhooks/countdownshare",
    "enabled": true,
    "signing_secret_prefix": "whsec_1a2b",
    "signing_secret": "whsec_...",
    "secret_rotated_at": "2029-12-01T10:00:00.000Z",
    "created_at": "2029-12-01T10:00:00.000Z",
    "updated_at": "2029-12-01T10:00:00.000Z"
  },
  "request_id": "7b82b7f7-4d13-497b-9f20-58d46fd7a510"
}
Production destinations must be public HTTPS URLs. Private, loopback, and link-local addresses are rejected in both environments; Sandbox additionally accepts http:// so you can point at a local tunnel while developing.

Create a webhook rule

Rules live under the timer they watch. Each is a single condition: a field, an operator, and a value.

FieldTypeRequiredDescription
namestringYes1–100 characters. Internal name for the rule.
webhook_destination_idstringYesID returned by POST or GET /webhook-destinations.
condition.fieldstringYesstatus, remaining_seconds, progress_percent, server_time, action, or recurrence_cycle.
condition.operatorstringYesequals, less_than_or_equal, or greater_than_or_equal.
condition.valuemixedYesThe value compared with the chosen field.
deliverystringNoonce or repeat. Defaults to once.
enabledbooleanNoEvaluate the rule when true. Defaults to true.

Request

curl -X POST \
  https://countdownshare.com/api/v1/timers/$TIMER_ID/webhook-rules \
  -H "Authorization: Bearer $COUNTDOWNSHARE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "One hour remaining",
    "webhook_destination_id": "0f4726a4-c05b-4eba-97d2-30ba8e59446e",
    "condition": {
      "field": "remaining_seconds",
      "operator": "less_than_or_equal",
      "value": 3600
    },
    "delivery": "once"
  }'

Supported rule conditions

TriggerFieldOperatorExample value
Countdown endedstatusequals"ended"
Time remaining reachedremaining_secondsless_than_or_equal3600
Progress reachedprogress_percentgreater_than_or_equal75
Wall-clock moment passedserver_timegreater_than_or_equal"2030-01-01T14:00:00Z"
Timer action occurredactionequals"paused"
Recurring cycle completedrecurrence_cycleequals"completed"
Use delivery: once for a milestone that should fire a single time in the timer's life. Use delivery: repeat when a rule should become eligible again after a recurring countdown rolls into its next cycle.

Countdown webhook events

These are the domain events recorded against a timer and stored on the delivery record. One important detail: the type in the outbound body is timer.rule.matched for any delivery a webhook rule produced. The underlying event type is on the delivery record you can read back from the API.

timer.created · timer.updated · timer.deleted

The timer definition changed.

timer.duplicated · timer.archived · timer.restored

The timer was copied or moved out of and back into the active set.

timer.started · timer.paused · timer.resumed · timer.reset

A duration timer was controlled.

timer.completed

The countdown reached zero.

timer.recurrence_completed

A recurring cycle finished and the timer advanced.

Delivery headers

POST /webhooks/countdownshare HTTP/1.1
Content-Type: application/json
X-CountdownShare-Event-Id: d59cf3d3-a20c-46bf-b187-c27cd6ff51e2
X-CountdownShare-Timestamp: 1893506400
X-CountdownShare-Signature: v1=9f86d0818...

Delivery body

{
  "id": "d59cf3d3-a20c-46bf-b187-c27cd6ff51e2",
  "type": "timer.rule.matched",
  "occurred_at": "2030-01-01T13:00:00.000Z",
  "delivery_id": "0ec5af80-1125-43c8-9b72-04706b6da54a",
  "timer": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "name": "Product launch",
    "type": "fixed",
    "status": "published"
  },
  "rule": {
    "id": "264f4933-c839-47b5-a7ba-55a73ed56774",
    "name": "One hour remaining"
  },
  "data": {
    "status": "running",
    "remaining_seconds": 3600,
    "progress_percent": 99.86,
    "server_time": "2030-01-01T13:00:00.000Z"
  }
}

Verify a delivery

The signature covers the raw request body. Read the body as bytes and verify before parsing — a JSON round-trip changes whitespace and key order, and the check will fail.

Node.js verification

import { createHmac, timingSafeEqual } from "node:crypto";

// Express: app.post(path, express.raw({ type: "application/json" }), handler)
// The signature covers the RAW body. Parsing it first will break the check.
export function verify(rawBody, headers, secret) {
  const timestamp = headers["x-countdownshare-timestamp"];
  const signature = headers["x-countdownshare-signature"];
  if (!timestamp || !signature) return false;

  // Reject anything older than five minutes.
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

  const expected = createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");
  const received = signature.replace(/^v1=/, "");

  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(received, "hex");
  return a.length === b.length && timingSafeEqual(a, b);
}

Answer fast

Return any 2xx within 10 seconds. Anything slower is treated as a timeout and retried. Queue real work and acknowledge first.

Retries are automatic

Timeouts, network failures, and 408, 425, 429, and 5xx responses retry after 1 minute, 5 minutes, 30 minutes, 2 hours, and 12 hours. Other 4xx responses are not retried.

Handle duplicates

Delivery is at-least-once. The same X-CountdownShare-Event-Id can arrive twice after a retry or a replay, so make your handler idempotent.

Delivery records keep the event ID, destination, rule, attempt count, response status, next retry time, and final result. Inspect them with GET /webhook-deliveries?status=failed, or from the webhooks page of your dashboard. Replaying sends the same event again without re-evaluating the rule.

Dates and timezones

Fixed deadlines are exact instants. Recurring schedules are local wall-clock times that need a zone. Durations are elapsed time and need neither.

Timer typeTimezone requiredTiming fieldBehavior
Fixed dateNodeadline_atThe timestamp must carry Z or a numeric UTC offset. timezone only preserves a display and editing location.
DurationNoduration_secondsA duration is elapsed time, so timezone is not used.
RecurringNorecurrenceEach occurrence is computed in the supplied IANA zone. Omitting it runs the schedule in UTC.
PersonalizedNoduration_secondsEach identity gets an elapsed-time deadline, so timezone is not used.

Accepted

UTC, or a canonical IANA Area/Location identifier such as Asia/Kolkata, America/New_York, Europe/London, or Australia/Sydney.

Rejected

Abbreviations like IST or EST, numeric offsets like +05:30, and Windows zone names. The offset belongs in deadline_at, not in timezone. These return HTTP 400 with invalid_timezone.

Get the exact supported list

GET /timezones is the source of truth for values this API accepts. Use it to build a selector or validate input, with the optional search query to filter. Runtime lists such as Intl.supportedValuesOf('timeZone') in JavaScript, zoneinfo.available_timezones() in Python, and ZoneId.getAvailableZoneIds() in Java vary by installed database version, so use them for your UI and this endpoint for validation.

Timezone lookup

GET /timezones?search=kolkata

{
  "data": [{ "id": "Asia/Kolkata" }],
  "meta": {
    "default": "UTC",
    "source": "IANA Time Zone Database"
  },
  "request_id": "7b82b7f7-4d13-497b-9f20-58d46fd7a510"
}
A deadline like 2030-01-01T09:00:00-05:00 already identifies an exact instant. Omitting timezone does not move it — it only means the timer uses UTC as its display and editing zone.

Countdown API error codes

Every error carries a stable machine-readable code, a human-readable message, and the request_id that also appears in the X-Request-Id response header. Quote that ID when contacting support.

CodeHTTPWhen it happens
unauthorized401No API key was sent, or the Authorization header was malformed.
invalid_api_key401The key does not exist, was revoked, or does not match this environment.
forbidden403The key is valid but lacks the scope this route requires.
suspended403The account or its entitlement is not active.
not_found404No timer, destination, rule, or delivery with that ID exists for this account.
invalid_timer_id400The timer ID in the path is not a UUID.
missing_field400A required field was omitted from the request body.
invalid_timezone400The timezone is not a canonical IANA identifier.
validation_error400A field failed validation. The message names the field.
idempotency_conflict409The Idempotency-Key was already used with a different request body.
revision_conflict409The If-Match revision is stale. Re-read the timer and retry.
rate_limited429The per-minute request limit was exceeded. See Retry-After.
quota_exhausted429The monthly timer allowance is spent. details names the metric and reset time.
internal_error500An unexpected server error. The request_id identifies it in our logs.

Error response

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"
}

Rate limits and usage

There is no monthly cap on API requests on any plan. A per-minute rate limit protects the service — 300 requests per minute in Production and 30 in Sandbox — counted per key and per account in a fixed one-minute window.

RateLimit-Limit

Requests allowed in the current window.

RateLimit-Remaining

Requests left before the limit bites.

RateLimit-Reset

Unix seconds when the window resets.

Exceeding the rate limit returns HTTP 429 with rate_limited and a Retry-After header. Exhausting the monthly timer allowance returns HTTP 429 with quota_exhausted and a details object naming the metric, the limit, the amount used, and when the cycle resets. Existing timers keep running and stay readable either way.

Check usage from the API

GET /usage reports the current billing cycle and every metered metric. GET /usage/requests returns your API request log, filterable by status, route, key, and request ID.

Usage response

GET /usage

{
  "data": {
    "environment": "production",
    "cycle": {
      "id": "3f9d6a2c-...",
      "starts_at": "2030-01-01T00:00:00.000Z",
      "resets_at": "2030-02-01T00:00:00.000Z"
    },
    "metrics": [
      {
        "metric": "new_timers",
        "total": 214,
        "limit": 1000,
        "remaining": 786,
        "warning_threshold": 0,
        "starts_at": "2030-01-01T00:00:00.000Z",
        "resets_at": "2030-02-01T00:00:00.000Z"
      },
      {
        "metric": "api_requests",
        "total": 48213,
        "limit": "unlimited",
        "remaining": "unlimited",
        "warning_threshold": 0,
        "starts_at": "2030-01-01T00:00:00.000Z",
        "resets_at": "2030-02-01T00:00:00.000Z"
      }
    ]
  },
  "request_id": "7b82b7f7-4d13-497b-9f20-58d46fd7a510"
}

Requests, retries, and pagination

The same conventions apply across every endpoint, so behavior you learn once holds everywhere.

Cursor pagination

List endpoints return page.next_cursor and page.has_more. Pass the cursor back to fetch the next page; limit accepts 1–100 and defaults to 25.

Safe retries

POST /timers and POST /timers/{id}/duplicate require an Idempotency-Key. Replaying the same key with the same body returns the original result instead of creating a second timer.

Optimistic concurrency

Every timer carries a revision. PATCH requires it in If-Match, so two writers cannot silently overwrite each other.

Environment-bound keys

A cs_test_ key reaches Sandbox only and a cs_live_ key reaches Production only. The boundary is enforced in the database, not just in code.

Traceable errors

Every response carries X-Request-Id, and every error body repeats it as request_id.

Versioned base URL

The version is part of the path: /api/v1. Breaking changes would ship under a new version.

Paginated response

GET /timers?limit=25&sort=-created_at

{
  "data": [
    {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "name": "Product launch",
      "type": "fixed",
      "status": "published"
    }
  ],
  "page": {
    "has_more": true,
    "next_cursor": "eyJ2YWx1ZSI6Ii4uLiJ9"
  },
  "request_id": "7b82b7f7-4d13-497b-9f20-58d46fd7a510"
}

Ready to connect your first countdown?

Sandbox is free with any account and needs no plan. When you are ready to publish, pick a plan and swap your cs_test_ key for a cs_live_ one.

Back to the API overview