Create a countdown with the REST API
POST /timers is the only endpoint that consumes your monthly allowance, and the only one whose choices you cannot change later — the timer type is permanent once set. Everything else about a timer can be edited for free afterwards. This page covers every field, what each type requires, and the validation errors that catch people first.
The smallest working request
A name, a type, and the timing field that type needs. The Idempotency-Key header is required — a create without one is rejected.
Request
curl -X POST https://countdownshare.com/api/v1/timers \
-H "Authorization: Bearer $COUNTDOWNSHARE_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: launch-2030" \
-d '{
"name": "Product launch",
"type": "fixed",
"deadline_at": "2030-01-01T14:00:00Z"
}'201 Created
HTTP/1.1 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",
"remaining_seconds": 2692800,
"timezone": "UTC",
"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"
}status: "draft" and outputs: null. A new timer is private until you publish it, so there is nothing public to link to yet. That is the safe default while you are still wiring things up — send publish: true when you actually want it live.Every field
The Required column names the types a field is mandatory for. Fields marked “No” are optional for all four.
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | 1–200 characters. Your internal label — it is not shown publicly unless your display uses it. |
type | string | Yes | fixed, duration, recurring, or personalized. Permanent. |
deadline_at | string | fixed | ISO 8601. Must carry Z or a numeric UTC offset. |
duration_seconds | integer | duration, personalized | 1 to 31,536,000 seconds (one year). |
recurrence | object | recurring | frequency and local_time, plus whatever the chosen schedule needs. |
external_user_id | string | personalized | 1–255 characters. Your identifier for the recipient. |
timezone | string | No | IANA identifier. Defaults to UTC. Only changes behaviour for recurring timers. |
publish | boolean | No | Publish immediately. Defaults to false. Production only. |
expiry | object | No | What the public page and embeds do after the timer ends. |
metadata | object | No | Up to 50 of your own key-value pairs, returned with the timer. |
What each type requires
The four minimum bodies, side by side. Everything else is optional on top of these.
Fixed and duration
fixed — one shared deadline
{
"name": "Product launch",
"type": "fixed",
"deadline_at": "2030-01-01T14:00:00Z"
}duration — elapsed time, controllable
{
"name": "Checkout reservation",
"type": "duration",
"duration_seconds": 900
}Recurring and personalized
recurring — repeats on a schedule
{
"name": "Weekly deploy freeze",
"type": "recurring",
"recurrence": {
"frequency": "weekly",
"days_of_week": ["friday"],
"local_time": "16:00"
},
"timezone": "Europe/Berlin"
}personalized — per-recipient deadline
{
"name": "Trial ends",
"type": "personalized",
"duration_seconds": 1209600,
"external_user_id": "customer_8f21c"
}frequency and local_time. Weekly schedules add days_of_week, monthly schedules add day_of_month, and custom schedules add an rrule from the DAILY, WEEKLY, and MONTHLY subset of RFC 5545. Full detail on recurring timers.Deciding what happens at zero
The expiry object controls the hosted page and embeds after the countdown ends. It has no effect on webhooks — those are driven by rules, separately.
| behavior | Also send | Result |
|---|---|---|
show_message | message (optional, ≤500 chars) | The default. Displays your message in place of the clock. |
hide | — | The timer disappears. Useful when an expired countdown should leave no trace on the page. |
redirect | redirect_url | Sends visitors to another URL — an "offer closed" page, or the next campaign. |
Metadata, and what it is for
Up to 50 key-value pairs stored with the timer and returned verbatim on every read. Values may be strings, numbers, booleans, or null.
A fully specified create
{
"name": "Spring sale ends",
"type": "fixed",
"deadline_at": "2030-03-31T23:59:59-04:00",
"timezone": "America/New_York",
"publish": true,
"expiry": {
"behavior": "redirect",
"redirect_url": "https://example.com/sale-ended"
},
"metadata": {
"campaign_id": "spring-2030",
"segment": "returning",
"internal_owner": "growth"
}
}The useful pattern is to store what connects the timer back to your own system — the order ID, the campaign, the customer segment, the internal owner. Then a timer that shows up in a list six months later can be traced to what created it without a lookup table.
The validation errors people hit first
All return HTTP 400 unless noted. The message names the offending field.
| What you sent | Code | Why |
|---|---|---|
deadline_at without Z or an offset | validation_error | A bare local timestamp does not identify an instant. Send 2030-01-01T14:00:00Z. |
type: "fixed" with duration_seconds | validation_error | A fixed timer counts to a date. Use type "duration" for elapsed time. |
type: "recurring" without local_time | missing_field | Both frequency and local_time are always required for a schedule. |
timezone: "EST" | invalid_timezone | Abbreviations are ambiguous. Use America/New_York. |
publish: true with a cs_test_ key | validation_error | Publishing is Production only. Sandbox timers stay private. |
No Idempotency-Key header | missing_field | Required on create. See idempotency. |
A reused key with a changed body | idempotency_conflict | HTTP 409. The key already identifies a different request. |
A create past your monthly allowance | quota_exhausted | HTTP 429. details names the metric and reset time. |
quota_exhausted is a 429 like a rate limit but it does not clear by waiting a few seconds — the allowance resets with the billing cycle. Distinguish the two by code, not by status. See error codes.After creation
Store data.id against whatever the timer represents. Then:
- To display it — publish it and read the outputs for a hosted page, embed, and email GIF.
- To render your own UI — read the status endpoint for the authoritative remaining time.
- To act when it ends — attach a webhook rule rather than polling.
- To change it later — update it with If-Match. Updates are free and unlimited.