Developers
API reference
Create countdown timers from your own code, change them after the email is sent, and check a timer URL before a campaign goes out.
Introduction
The tikio API lets your own code do everything the dashboard's link wizard does and more: create a link with a stored timer, change it later, read its statistics, and check a timer URL before a campaign goes out. It is a JSON-over-HTTPS REST API with predictable resource URLs, standard HTTP verbs and status codes.
The base URL is https://api.tikio.io/v1. Requests and responses are JSON; fields are snake_case; dates are ISO 8601 in UTC (…Z).
New here? Start with the Timer object — it is the one idea everything else builds on — then create a link and put its short URL in your email.
curl https://api.tikio.io/v1/links \
-H "Authorization: Bearer $TIKIO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"template_id": "b2e4f6a8-1c3d-4e5f-8a9b-0c1d2e3f4a5b",
"name": "Black Friday",
"timer": {
"type": "deadline",
"source": "fixed",
"deadline": "2026-11-27T17:00:00Z"
}
}'Authentication
Authenticate with an API key in the Authorization header: Authorization: Bearer tikio_sk_…. Create keys in Settings → API keys & AI access. A key is shown once, and
can expire after a period you choose. The same key works for the REST API and for MCP; OAuth tokens that AI clients get for MCP do not work here.
Keep keys on your server. Anyone with a key can act on your account within its scopes; revoke a leaked key in the same settings page.
Scopes
| Scope | Allows |
|---|---|
tikio:read | Always included. Read templates, links and statistics; validate timer URLs. |
tikio:write | Draft changes recipients do not see: folders, templates, design. Used by MCP; the REST API does not need it yet. |
tikio:publish | Everything that changes live timers, including in emails already sent: create and update links, read signing keys. |
tikio:delete | Delete links. REST API keys only — off by default, and never available to AI clients over MCP. |
A request without a key gets 401 with missing_api_key; an unknown,
revoked or expired key — invalid_api_key. A request the key's scopes do not cover
gets 403 with insufficient_scope and the scope in required_scope.
curl https://api.tikio.io/v1/links \
-H "Authorization: Bearer tikio_sk_..."{
"error": {
"type": "permission_error",
"code": "insufficient_scope",
"message": "This API key lacks the tikio:delete scope. Create a key with it in Settings → API keys & AI access.",
"param": null,
"doc_url": "https://tikio.io/developers#errors",
"required_scope": "tikio:delete"
},
"request_id": "req_1c7a90e3b25d4f68a0b9c2e1"
}Errors
tikio uses HTTP status codes: 2xx is success, 4xx is a problem with
the request, 5xx is ours. Every error has the same body: an error object and a request_id, which is also sent in the Tikio-Request-Id header of every response.
type— the class of error, from the table below.code— a stable, machine-readable reason, such astimer_required.message— a human-readable explanation. It can change; do not parse it.param— the field at fault, as a dotted path (timer.deadline), ornull.doc_url— a link to this reference.
| Type | Status | Meaning |
|---|---|---|
invalid_request_error | 400 | The request is malformed or a parameter is invalid. param names the field. |
authentication_error | 401 | No API key, or an unknown, revoked or expired one. |
permission_error | 403 | The key is valid but lacks a scope. required_scope names it. |
not_found | 404 | The object does not exist or belongs to another account, or the URL is not an API route. |
rate_limit_error | 429 | Too many requests. Wait for the number of seconds in Retry-After. |
api_error | 500 | Something went wrong on our side. Retry later and send us the request_id. |
Common codes
| Code | Reason |
|---|---|
parameter_missing | A required parameter is absent. param names it. |
parameter_invalid | A parameter has the wrong type, format or value. |
invalid_json | The request body is not valid JSON. |
missing_api_key | No Authorization: Bearer … header. |
invalid_api_key | The key is unknown, revoked or expired — or it is an OAuth token, which only works for MCP. |
insufficient_scope | The key lacks the scope in required_scope. |
resource_missing | No such link or template on your account (also template_id on create), or no such route. |
rate_limited | Over the per-minute limit of the key. |
daily_create_limit | Too many links created today through the API and MCP together. |
internal_error | Our bug. Quote the request_id. |
Timer codes
A timer that cannot be read is rejected with 400 and one of these codes:
| Code | Reason |
|---|---|
timer_required | A link needs a timer: timer is missing on create, or null on update. |
bad_timer | The timer object has an unknown type, source or lang, or misses a field. |
deadline_without_timezone | A date has no time zone. Use unix seconds or ISO with Z / ±HH:MM. |
bad_date | A date cannot be read at all. |
target_out_of_range | A date is more than 10 years away, or negative unix seconds. |
bad_duration | duration_seconds is not a whole number from 60 to 31,536,000 (365 days). |
bad_interval | interval_seconds is out of the same range. |
bad_schedule | repeat, at, on or timezone of a recurring timer cannot be read, or timezone is missing. |
countup_without_start | A fixed count-up has no start. |
{
"error": {
"type": "invalid_request_error",
"code": "deadline_without_timezone",
"message": "timer.deadline has no time zone: add Z or an offset like +02:00 (or pass unix seconds).",
"param": "timer.deadline",
"doc_url": "https://tikio.io/developers#errors"
},
"request_id": "req_4f1c2a9b7e6d5c4b3a291807"
}Pagination
List endpoints return a page of objects, newest first, in a list object. Pass limit (1 to 100, default 20) and, for the next page, starting_after set to the next_cursor of the previous one. When has_more is false, you have everything.
curl "https://api.tikio.io/v1/links?limit=100&starting_after=<next_cursor>" \
-H "Authorization: Bearer $TIKIO_API_KEY"{
"object": "list",
"data": [
"…"
],
"has_more": true,
"next_cursor": "5f1c2a9e-8b3d-4c61-9a0e-2d7b4e8f1a36"
}Rate limits
Limits are counted per API key, per minute:
- 120 read requests (
GET); - 30 write requests (create, update, delete);
- 60 URL validations, counted separately.
New links also count against a daily budget per account, shared with links created by AI clients
over MCP; over it, creating a link gets 429 with daily_create_limit.
Responses carry X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (unix seconds). Over the limit you get 429 with rate_limited and a Retry-After header in seconds.
Versioning
The version is in the path: /v1. Within v1 we only make additive changes — new
endpoints, new optional parameters, new fields in responses, new error codes and new timer
types. Write your client to ignore fields it does not know. Anything that would break an
existing integration ships as /v2, with /v1 kept running.
The Timer object
A link stores its timer: what it counts to and how. The email only carries a short URL, so you can change the timer after the email is sent — a moved deadline or a new schedule shows in every copy already delivered. Recipients see the change once caches expire: within about 5 minutes for web embeds and 1 to 5 minutes for email images.
Every link has a timer: timer is required when you create one and cannot be removed
later (null is rejected with timer_required). The object has a type and the fields of that type. Responses return it normalized: every field of the
type is present, dates are ISO in UTC.
The dashboard sets up fixed deadlines, evergreen timers and daily or weekly schedules. Count-up, a custom interval and a deadline taken from a merge tag are available only through the API; the dashboard shows such a link's timer read-only.
Common fields
typestring required- Which kind of timer this is.
deadlineevergreenrecurringcountup langstring | null- Label language, one of the template languages.
null— the template default. Alangin the URL always wins over this one.
{
"type": "deadline",
"source": "fixed",
"deadline": "2026-11-27T17:00:00Z",
"lang": null
}Deadline
Counts down to a moment: the end of a sale, a webinar start. With source: "recipient" each subscriber has their own deadline — a trial end or renewal date stored in your email platform
— passed in the URL as target.
sourcestringfixed(default) — one deadline for everyone, stored here.recipient— each recipient brings their own deadline in the URL astarget, from a merge tag.fixedrecipientdeadlinestring | integer required for fixed- Unix seconds or an ISO date with a time zone (
2026-11-27T17:00:00Z,…-05:00). Returned as ISO in UTC. A date without a time zone is rejected withdeadline_without_timezone; past dates are allowed, dates more than 10 years away are not.
{
"type": "deadline",
"source": "fixed",
"deadline": "2026-11-27T17:00:00Z",
"lang": null
}{
"type": "deadline",
"source": "recipient",
"lang": null
}Evergreen
Counts down a fixed length of time from when the email is opened — “24 hours from now”. A
personal evergreen (per_recipient: true) remembers each recipient's first open, so
reopening the email shows the same countdown.
duration_secondsinteger required- Countdown length, from 60 to 31,536,000 (365 days).
per_recipientbooleantrue— each recipient gets their own clock, started on their first open and kept on reopens; the URL needsuid.false(default) — the countdown starts from full on every open or page load.
{
"type": "evergreen",
"duration_seconds": 86400,
"per_recipient": true,
"lang": null
}Recurring
Counts down to the next reset, then starts again — a daily deal at 5 PM, a weekly drop on
Monday. A schedule follows the local clock of timezone; an interval counts exact
seconds.
repeatstring requiredday,weekdays(Monday to Friday) andweekreset at a local time;intervalresets everyinterval_seconds.dayweekdaysweekintervaltimezonestring required for day, weekdays, week- IANA time zone of the reset, such as
Europe/Berlin. The reset stays at the same local time when daylight saving starts or ends — that day is 23 or 25 hours long. atstring- Reset time,
HH:MMon a 24-hour clock. Default00:00. onstring | null- Reset day for
week. Defaultmon. Returned asnullfor other schedules.montuewedthufrisatsun interval_secondsinteger required for interval- Cycle length, from 60 to 31,536,000. Counts exact seconds, so it drifts against local time across daylight-saving changes.
anchorstring | integer | null- Where the interval cycles start, same format as
deadline.null(default) — cycles line up with 00:00 UTC.
{
"type": "recurring",
"repeat": "weekdays",
"at": "17:00",
"on": null,
"timezone": "America/New_York",
"lang": null
}{
"type": "recurring",
"repeat": "week",
"at": "10:00",
"on": "mon",
"timezone": "Europe/Berlin",
"lang": null
}{
"type": "recurring",
"repeat": "interval",
"interval_seconds": 21600,
"anchor": "2026-10-01T00:00:00Z",
"lang": null
}Count-up
Counts up from a moment: time since launch, days without an incident. Available only through the API.
sourcestringfixed(default) — counts up fromstart.recipient— from atargetin the URL.fixedrecipientstartstring | integer required for fixed- The moment to count up from. Same format as
deadline.
{
"type": "countup",
"source": "fixed",
"start": "2026-01-01T00:00:00Z",
"lang": "de"
}{
"type": "countup",
"source": "recipient",
"lang": null
}Stored timer vs the URL
A timer URL can carry query parameters. What the image shows depends on the link:
| Link | What is read from the URL |
|---|---|
| Stored timer (default) | Only recipient parameters are read: uid, sig, lang, v, and target when the deadline comes from the recipient. Anything else is ignored. |
Stored timer with allow_query_override: true | If the URL has any timing parameter (target, duration, cycle, countup, every, at, on, tz), the timing in the URL replaces the stored timer as a whole. Otherwise as above. |
Legacy link (timer: null) | Everything comes from the URL, exactly as before. |
langin the URL always wins over the timer'slang.allow_query_overrideisfalseby default. With it off, nobody can put their own date into your design by editing the URL.- Links created before stored timers are legacy links (
timer: null) and keep working unchanged, reading URL parameters. When you first set a timer on one,allow_query_overridebecomestrueunless you pass it — so emails already sent with parameters in their URLs keep showing what they showed.
To see how a particular URL will be read, validate it.
Short URLs and recipient parameters
Every link has a short URL for email, https://t.tikio.io/e/<link-id>.gif, returned
in urls.gif. It needs no parameters: the timer is stored in the link. Put it in an <img>, or take ready-made code from embed_code (?include=embed_code): required recipient parameters
come in it as placeholders like {{deadline}} and {{recipient_id}}, to replace with your platform's merge tags.
urls.html is a live embed for web pages, and urls.click is the smart
link: it sends clicks to before_url until zero and to after_url after.
Some timers need something only the email platform knows about each recipient. Those go into the
URL as merge tags, which the platform fills in per recipient when it sends. The link's recipient_params lists exactly which ones it reads.
Recipient parameters
uidmerge tag personal evergreen- The recipient ID, as your email platform’s merge tag —
*|UNIQID|*in Mailchimp. A personal evergreen starts this recipient’s clock on their first open. Other timers ignore it. targetmerge tag source: recipient- The recipient’s own deadline or count-up start, from a date field of your platform. Must render as unix seconds or ISO with a time zone.
sigstring uid_sig_strict- Signature of
uid. Withuid_sig_stricton, auidwithout a validsigis ignored. See Signed uid. langstring- Label language for this email, one of the template languages. Overrides the timer’s
lang; an unknown value falls back to the template default. vstring- Cache-buster: any per-send value (a campaign ID merge tag works). Mail image proxies at Apple and Gmail cache hard; a new value makes them fetch a fresh frame.
Merge tags must be inserted literally — *|UNIQID|*, not %2A%7CUNIQID%7C%2A. Validate a URL copied from a test send to make sure every tag was filled in.
# Fixed deadline — nothing to add
https://t.tikio.io/e/5f1c2a9e-8b3d-4c61-9a0e-2d7b4e8f1a36.gif
# Personal evergreen (Mailchimp)
https://t.tikio.io/e/5f1c2a9e-8b3d-4c61-9a0e-2d7b4e8f1a36.gif?uid=*|UNIQID|*
# Deadline from the recipient (Klaviyo)
https://t.tikio.io/e/5f1c2a9e-8b3d-4c61-9a0e-2d7b4e8f1a36.gif?target={{ person.trial_end }}[
{
"name": "target",
"required": true,
"description": "This recipient's moment: a merge tag that renders ISO-8601 with a time zone (2026-11-27T18:00:00+01:00) or unix seconds. Without it the countdown shows a placeholder."
},
{
"name": "lang",
"required": false,
"description": "Display language; overrides timer.lang. An unknown value falls back to the template's first language."
},
{
"name": "v",
"required": false,
"description": "Cache-buster per send (any value): email proxies then fetch a fresh frame instead of an old one."
},
{
"name": "lang",
"required": false,
"description": "Display language; overrides timer.lang. An unknown value falls back to the template's first language."
},
{
"name": "v",
"required": false,
"description": "Cache-buster per send (any value): email proxies then fetch a fresh frame instead of an old one."
}
]Signed uid
A personal timer is looked up by uid. Without signing, someone who guesses another
recipient's uid can see their countdown. When that matters — a personal discount, a
trial window — turn on uid_sig_strict with Update a link (there is no switch for it in the dashboard), and every URL must also carry sig:
sig = base64url( HMAC-SHA256( signing_key, uid ) ) // no = padding The key is per link: fetch it with Retrieve the signing key. It is base64url-encoded — decode it to bytes before using it as the HMAC key. Sign on your server when you build the email, never in the email platform's template.
A URL with a missing or wrong sig does not show an error in the email: the timer falls
back to the shared countdown for that open.
import { createHmac } from 'node:crypto';
const key = Buffer.from(process.env.TIKIO_SIGNING_KEY, 'base64url');
const sig = createHmac('sha256', key).update(uid).digest('base64url');
const url = `https://t.tikio.io/e/5f1c2a9e-8b3d-4c61-9a0e-2d7b4e8f1a36.gif?uid=${encodeURIComponent(uid)}&sig=${sig}`;Links
A link is one timer placement: a template, a stored timer, and settings for what happens at zero and where clicks go. Create a link per campaign, put its short URL in the email, and change the timer or the settings any time — the change reaches emails already sent.
The Link object
idstring- Unique identifier (UUID).
objectstring- Always
link. namestring- Your name for the link; recipients never see it.
template_idstring- The template the timer is drawn with.
activebooleanfalseswitches the timer off in every email: it shows a placeholder.valid_untilstring | null- After this moment the link shows a placeholder, as if switched off.
timerTimer | null- The stored timer.
nullonly on legacy links, which read their timing from the URL. allow_query_overrideboolean- Whether timing parameters in the URL may replace the stored timer.
uid_sig_strictboolean- Whether a
uidcounts only with a validsig. See Signed uid. expired_actionstring- What the timer shows at zero:
zeros— 00:00:00,hide— nothing,image—expired_image_url.zeroshideimage expired_image_urlstring | null- Image shown at zero when
expired_actionisimage. before_urlstring | null- Where the smart link sends clicks before zero.
after_urlstring | null- Where the smart link sends clicks after zero.
urlsobjectgif— the short URL for email,html— the web embed,click— the smart link (nulluntilbefore_urlorafter_urlis set). None of them carry timing.recipient_paramsarray- The parameters this link reads from its URL, each with
name,requiredanddescription. Empty on a legacy link. embed_codeobject | null- Ready-to-paste
emailandwebcode. Only with?include=embed_code;nullon a legacy link. template_publishedboolean- Whether the template has been published. Until it is, the URLs show a placeholder.
created_atstring- When the link was created.
updated_atstring- When the link settings last changed.
{
"id": "5f1c2a9e-8b3d-4c61-9a0e-2d7b4e8f1a36",
"object": "link",
"name": "Black Friday",
"template_id": "b2e4f6a8-1c3d-4e5f-8a9b-0c1d2e3f4a5b",
"active": true,
"valid_until": null,
"timer": {
"type": "deadline",
"source": "fixed",
"deadline": "2026-11-27T17:00:00Z",
"lang": null
},
"allow_query_override": false,
"uid_sig_strict": false,
"expired_action": "zeros",
"expired_image_url": null,
"before_url": "https://shop.example/sale",
"after_url": null,
"urls": {
"gif": "https://t.tikio.io/e/5f1c2a9e-8b3d-4c61-9a0e-2d7b4e8f1a36.gif",
"html": "https://cdn.tikio.io/embeds/5f1c2a9e-8b3d-4c61-9a0e-2d7b4e8f1a36.html",
"click": "https://t.tikio.io/c/5f1c2a9e-8b3d-4c61-9a0e-2d7b4e8f1a36"
},
"recipient_params": [
{
"name": "lang",
"required": false,
"description": "Display language; overrides timer.lang. An unknown value falls back to the template's first language."
},
{
"name": "v",
"required": false,
"description": "Cache-buster per send (any value): email proxies then fetch a fresh frame instead of an old one."
}
],
"template_published": true,
"created_at": "2026-09-29T10:12:04Z",
"updated_at": "2026-09-29T10:12:04Z"
}Create a link
POST /v1/links
Creates a link with a stored timer and returns it with 201. Needs the tikio:publish scope. New links count against a daily budget shared with MCP.
Body parameters
template_idstring required- The template to draw the timer with.
timerTimer required- What the link counts. See the Timer object. Missing —
timer_required. namestring- Your name for the link, up to 200 characters.
allow_query_overrideboolean- Let timing parameters in the URL replace the stored timer. Default
false. expired_actionstring- What the timer shows at zero. Default
zeros.zeroshideimage expired_image_urlstring | null- Image for
expired_action: image, an absolute http(s) URL.
Returns the Link object: put urls.gif in your email, with the recipient_params it lists. The smart link (before_url, after_url), active, valid_until and uid_sig_strict are set with Update a link.
curl https://api.tikio.io/v1/links \
-H "Authorization: Bearer $TIKIO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"template_id": "b2e4f6a8-1c3d-4e5f-8a9b-0c1d2e3f4a5b",
"name": "Black Friday",
"timer": {
"type": "deadline",
"source": "fixed",
"deadline": "2026-11-27T17:00:00Z"
}
}'{
"id": "5f1c2a9e-8b3d-4c61-9a0e-2d7b4e8f1a36",
"object": "link",
"name": "Black Friday",
"template_id": "b2e4f6a8-1c3d-4e5f-8a9b-0c1d2e3f4a5b",
"active": true,
"valid_until": null,
"timer": {
"type": "deadline",
"source": "fixed",
"deadline": "2026-11-27T17:00:00Z",
"lang": null
},
"allow_query_override": false,
"uid_sig_strict": false,
"expired_action": "zeros",
"expired_image_url": null,
"before_url": null,
"after_url": null,
"urls": {
"gif": "https://t.tikio.io/e/5f1c2a9e-8b3d-4c61-9a0e-2d7b4e8f1a36.gif",
"html": "https://cdn.tikio.io/embeds/5f1c2a9e-8b3d-4c61-9a0e-2d7b4e8f1a36.html",
"click": null
},
"recipient_params": [
{
"name": "lang",
"required": false,
"description": "Display language; overrides timer.lang. An unknown value falls back to the template's first language."
},
{
"name": "v",
"required": false,
"description": "Cache-buster per send (any value): email proxies then fetch a fresh frame instead of an old one."
}
],
"template_published": true,
"created_at": "2026-09-29T10:12:04Z",
"updated_at": "2026-09-29T10:12:04Z"
}Retrieve a link
GET /v1/links/{id}
Returns a link. Add include=embed_code to get ready-to-paste code for email and
web. Needs tikio:read.
Query parameters
includestringembed_code— add theembed_codeobject to the response.embed_code
curl "https://api.tikio.io/v1/links/5f1c2a9e-8b3d-4c61-9a0e-2d7b4e8f1a36?include=embed_code" \
-H "Authorization: Bearer $TIKIO_API_KEY"{
"id": "5f1c2a9e-8b3d-4c61-9a0e-2d7b4e8f1a36",
"object": "link",
"name": "Black Friday",
"template_id": "b2e4f6a8-1c3d-4e5f-8a9b-0c1d2e3f4a5b",
"active": true,
"valid_until": null,
"timer": {
"type": "deadline",
"source": "fixed",
"deadline": "2026-11-27T17:00:00Z",
"lang": null
},
"allow_query_override": false,
"uid_sig_strict": false,
"expired_action": "zeros",
"expired_image_url": null,
"before_url": "https://shop.example/sale",
"after_url": null,
"urls": {
"gif": "https://t.tikio.io/e/5f1c2a9e-8b3d-4c61-9a0e-2d7b4e8f1a36.gif",
"html": "https://cdn.tikio.io/embeds/5f1c2a9e-8b3d-4c61-9a0e-2d7b4e8f1a36.html",
"click": "https://t.tikio.io/c/5f1c2a9e-8b3d-4c61-9a0e-2d7b4e8f1a36"
},
"recipient_params": [
{
"name": "lang",
"required": false,
"description": "Display language; overrides timer.lang. An unknown value falls back to the template's first language."
},
{
"name": "v",
"required": false,
"description": "Cache-buster per send (any value): email proxies then fetch a fresh frame instead of an old one."
}
],
"template_published": true,
"created_at": "2026-09-29T10:12:04Z",
"updated_at": "2026-09-29T10:12:04Z",
"embed_code": {
"email": "<a href=\"https://t.tikio.io/c/5f1c2a9e-8b3d-4c61-9a0e-2d7b4e8f1a36\"><img src=\"https://t.tikio.io/e/5f1c2a9e-8b3d-4c61-9a0e-2d7b4e8f1a36.gif\" alt=\"Black Friday\" width=\"520\" style=\"border:0;max-width:100%;height:auto;\" /></a>",
"web": "<iframe src=\"https://cdn.tikio.io/embeds/5f1c2a9e-8b3d-4c61-9a0e-2d7b4e8f1a36.html\" title=\"Black Friday\" width=\"520\" height=\"132\" frameborder=\"0\" scrolling=\"no\" style=\"border:0;\"></iframe>"
}
}Update a link
PATCH /v1/links/{id}
Changes the fields you pass and leaves the rest. Needs tikio:publish. A new timer or allow_query_override changes what every email already sent shows,
once caches expire (up to about 5 minutes).
Setting a timer on a legacy link also sets allow_query_override to true, unless you pass it, so the URLs already sent keep working.
Body parameters
timerTimer- A new timer. It replaces the old one in every email already sent, within a few minutes.
nullis rejected withtimer_required. namestring- Your name for the link.
activeboolean- Switch the timer on or off in every email.
valid_untilstring | null- Switch the link off at this moment.
null— never. allow_query_overrideboolean- Let timing parameters in the URL replace the stored timer.
uid_sig_strictboolean- Require a valid
signext to everyuid. See Signed uid. expired_actionstring- What the timer shows at zero. Default
zeros.zeroshideimage expired_image_urlstring | null- Image for
expired_action: image, an absolute http(s) URL. before_urlstring | null- Smart link destination before zero, an absolute http(s) URL.
after_urlstring | null- Smart link destination after zero, an absolute http(s) URL.
curl https://api.tikio.io/v1/links/5f1c2a9e-8b3d-4c61-9a0e-2d7b4e8f1a36 \
-X PATCH \
-H "Authorization: Bearer $TIKIO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"timer": {
"type": "deadline",
"source": "fixed",
"deadline": "2026-11-30T23:59:00-05:00"
},
"after_url": "https://shop.example/sale-ended"
}'{
"id": "5f1c2a9e-8b3d-4c61-9a0e-2d7b4e8f1a36",
"object": "link",
"name": "Black Friday",
"template_id": "b2e4f6a8-1c3d-4e5f-8a9b-0c1d2e3f4a5b",
"active": true,
"valid_until": null,
"timer": {
"type": "deadline",
"source": "fixed",
"deadline": "2026-12-01T04:59:00Z",
"lang": null
},
"allow_query_override": false,
"uid_sig_strict": false,
"expired_action": "zeros",
"expired_image_url": null,
"before_url": "https://shop.example/sale",
"after_url": "https://shop.example/sale-ended",
"urls": {
"gif": "https://t.tikio.io/e/5f1c2a9e-8b3d-4c61-9a0e-2d7b4e8f1a36.gif",
"html": "https://cdn.tikio.io/embeds/5f1c2a9e-8b3d-4c61-9a0e-2d7b4e8f1a36.html",
"click": "https://t.tikio.io/c/5f1c2a9e-8b3d-4c61-9a0e-2d7b4e8f1a36"
},
"recipient_params": [
{
"name": "lang",
"required": false,
"description": "Display language; overrides timer.lang. An unknown value falls back to the template's first language."
},
{
"name": "v",
"required": false,
"description": "Cache-buster per send (any value): email proxies then fetch a fresh frame instead of an old one."
}
],
"template_published": true,
"created_at": "2026-09-29T10:12:04Z",
"updated_at": "2026-11-20T15:31:40Z"
}List links
GET /v1/links
Returns your links, newest first, as a paginated list. Needs tikio:read.
Query parameters
template_idstring- Only links of this template.
activeboolean- Only switched-on (
true) or switched-off (false) links. limitinteger- Page size, 1 to 100. Default 20.
starting_afterstring- Cursor:
next_cursorof the previous page.
curl "https://api.tikio.io/v1/links?template_id=b2e4f6a8-1c3d-4e5f-8a9b-0c1d2e3f4a5b&active=true&limit=20" \
-H "Authorization: Bearer $TIKIO_API_KEY"{
"object": "list",
"data": [
{
"id": "5f1c2a9e-8b3d-4c61-9a0e-2d7b4e8f1a36",
"object": "link",
"name": "Black Friday",
"template_id": "b2e4f6a8-1c3d-4e5f-8a9b-0c1d2e3f4a5b",
"active": true,
"valid_until": null,
"timer": {
"type": "deadline",
"source": "fixed",
"deadline": "2026-11-27T17:00:00Z",
"lang": null
},
"allow_query_override": false,
"uid_sig_strict": false,
"expired_action": "zeros",
"expired_image_url": null,
"before_url": "https://shop.example/sale",
"after_url": null,
"urls": {
"gif": "https://t.tikio.io/e/5f1c2a9e-8b3d-4c61-9a0e-2d7b4e8f1a36.gif",
"html": "https://cdn.tikio.io/embeds/5f1c2a9e-8b3d-4c61-9a0e-2d7b4e8f1a36.html",
"click": "https://t.tikio.io/c/5f1c2a9e-8b3d-4c61-9a0e-2d7b4e8f1a36"
},
"recipient_params": [
{
"name": "lang",
"required": false,
"description": "Display language; overrides timer.lang. An unknown value falls back to the template's first language."
},
{
"name": "v",
"required": false,
"description": "Cache-buster per send (any value): email proxies then fetch a fresh frame instead of an old one."
}
],
"template_published": true,
"created_at": "2026-09-29T10:12:04Z",
"updated_at": "2026-09-29T10:12:04Z"
}
],
"has_more": true,
"next_cursor": "5f1c2a9e-8b3d-4c61-9a0e-2d7b4e8f1a36"
}Delete a link
DELETE /v1/links/{id}
Deletes a link for good: its URLs show a placeholder in every email already sent. Needs the tikio:delete scope, which only REST API keys can have and which is off by default.
To stop a timer without deleting it, update active to false instead.
curl https://api.tikio.io/v1/links/5f1c2a9e-8b3d-4c61-9a0e-2d7b4e8f1a36 \
-X DELETE \
-H "Authorization: Bearer $TIKIO_API_KEY"{
"id": "5f1c2a9e-8b3d-4c61-9a0e-2d7b4e8f1a36",
"object": "link",
"deleted": true
}Retrieve the signing key
GET /v1/links/{id}/signing_key
Returns the link's key for signing uids, base64url-encoded, with a
worked example. Needs tikio:publish. Keep the key on your server.
Response
objectstring- Always
signing_key. link_idstring- The link the key belongs to.
keystring- The HMAC key, base64url without padding. Decode it to bytes before use.
algorithmstring- How
sigis computed, in words. exampleobject- A
uidand itssig— test your signing code against it.
curl https://api.tikio.io/v1/links/5f1c2a9e-8b3d-4c61-9a0e-2d7b4e8f1a36/signing_key \
-H "Authorization: Bearer $TIKIO_API_KEY"{
"object": "signing_key",
"link_id": "5f1c2a9e-8b3d-4c61-9a0e-2d7b4e8f1a36",
"key": "k7Qm2xVd9pLr4TzWc8NbJ1sYhEo6GaUf3iKq5RvXwM0",
"algorithm": "HMAC-SHA256, base64url without padding",
"example": {
"uid": "user-42",
"sig": "Xo3Jm9TqV2bR7kLc1wYp8sNd4HfGu6Ea0zKiQrPvBtM"
}
}Retrieve link statistics
GET /v1/links/{id}/stats
Views and clicks of a link over the last days days, with the same totals for the
period before for comparison. Email views are opens of the GIF by mail clients; web views are
the HTML embed. Clicks are counted on the smart link. Needs tikio:read.
Query parameters
daysinteger- Window in days, 1 to 90. Default 30.
Response
objectstring- Always
link_stats. link_idstring- The link.
daysinteger- The window, in days.
totalinteger- Views: email opens of the GIF plus web views of the HTML embed.
email_viewsinteger- The part of
totalthat is email opens. clicksinteger- Smart link clicks. Not part of
total. prevobjecttotal,email_viewsandclicksfor the window of the same length before.dailyarray- Per UTC day:
date,gif,html,static(CDN web embed) andclicks. policiesarray- Views by how the countdown was shown, such as
absoluteorevergreen_personal. clientsarray- Views by email client or browser.
countriesarray- Views by country, ISO 3166-1 alpha-2.
hoursarray- 24 numbers: views by hour of the day, UTC.
curl "https://api.tikio.io/v1/links/5f1c2a9e-8b3d-4c61-9a0e-2d7b4e8f1a36/stats?days=30" \
-H "Authorization: Bearer $TIKIO_API_KEY"{
"object": "link_stats",
"link_id": "5f1c2a9e-8b3d-4c61-9a0e-2d7b4e8f1a36",
"days": 30,
"total": 18432,
"email_views": 16210,
"clicks": 1204,
"prev": {
"total": 9120,
"email_views": 8400,
"clicks": 610
},
"daily": [
{
"date": "2026-09-28",
"gif": 812,
"html": 40,
"static": 12,
"clicks": 61
},
{
"date": "2026-09-29",
"gif": 1033,
"html": 52,
"static": 9,
"clicks": 88
}
],
"policies": [
{
"policy": "absolute",
"views": 18432
}
],
"clients": [
{
"client": "apple-mail",
"views": 9800
},
{
"client": "gmail-proxy",
"views": 5100
}
],
"countries": [
{
"country": "US",
"views": 7400
},
{
"country": "DE",
"views": 2300
}
],
"hours": [
210,
160,
120,
90,
80,
110,
260,
540,
910,
1120,
1180,
1090,
1010,
980,
1040,
1100,
1150,
1210,
1280,
1320,
1190,
930,
640,
402
]
}Templates
A template is the design of a timer: fonts, colors, size and label languages. Design templates
in the editor or with an AI client over MCP; the
REST API reads them so your code can pick a template_id for new links.
The Template object
idstring- Unique identifier (UUID). Use it as
template_idwhen creating links. objectstring- Always
template. namestring- The template name.
folder_idstring | null- The folder it is in, if any.
layoutinteger- Units shown:
3— hours, minutes, seconds;4— with days. langsarray- Languages the labels are available in. The first is the default.
publishedboolean- Whether the template has been published. Links of an unpublished template show a placeholder.
published_atstring | null- When it was last published.
has_unpublished_changesboolean- Whether the editor has changes recipients do not see yet.
versioninteger- Draft version; grows with every design edit.
sizeobjectwidthandheightof the countdown in px — the GIF is rendered at this size.created_atstring- When the template was created.
updated_atstring- When the template was last changed.
{
"id": "b2e4f6a8-1c3d-4e5f-8a9b-0c1d2e3f4a5b",
"object": "template",
"name": "Black Friday — dark",
"folder_id": null,
"layout": 4,
"langs": [
"en",
"de"
],
"published": true,
"published_at": "2026-09-28T16:40:00Z",
"has_unpublished_changes": false,
"version": 7,
"size": {
"width": 520,
"height": 132
},
"created_at": "2026-09-20T08:00:00Z",
"updated_at": "2026-09-28T16:40:00Z"
}List templates
GET /v1/templates
Returns your templates, newest first, as a paginated list. Needs tikio:read.
Query parameters
folder_idstring- Only templates in this folder.
limitinteger- Page size, 1 to 100. Default 20.
starting_afterstring- Cursor:
next_cursorof the previous page.
curl "https://api.tikio.io/v1/templates?limit=20" \
-H "Authorization: Bearer $TIKIO_API_KEY"{
"object": "list",
"data": [
{
"id": "b2e4f6a8-1c3d-4e5f-8a9b-0c1d2e3f4a5b",
"object": "template",
"name": "Black Friday — dark",
"folder_id": null,
"layout": 4,
"langs": [
"en",
"de"
],
"published": true,
"published_at": "2026-09-28T16:40:00Z",
"has_unpublished_changes": false,
"version": 7,
"size": {
"width": 520,
"height": 132
},
"created_at": "2026-09-20T08:00:00Z",
"updated_at": "2026-09-28T16:40:00Z"
}
],
"has_more": false,
"next_cursor": null
}curl https://api.tikio.io/v1/templates/b2e4f6a8-1c3d-4e5f-8a9b-0c1d2e3f4a5b \
-H "Authorization: Bearer $TIKIO_API_KEY"{
"id": "b2e4f6a8-1c3d-4e5f-8a9b-0c1d2e3f4a5b",
"object": "template",
"name": "Black Friday — dark",
"folder_id": null,
"layout": 4,
"langs": [
"en",
"de"
],
"published": true,
"published_at": "2026-09-28T16:40:00Z",
"has_unpublished_changes": false,
"version": 7,
"size": {
"width": 520,
"height": 132
},
"created_at": "2026-09-20T08:00:00Z",
"updated_at": "2026-09-28T16:40:00Z"
}Validate a timer URL
POST /v1/timer_urls/validate
Checks a timer URL the way tikio will read it, without counting a view or changing anything:
which parameters are used and which are ignored, what is missing, which timer comes out, and
what the image shows right now. Paste a URL from a test send to catch unfilled merge tags and
dates without a time zone before the campaign goes out. Needs tikio:read; 60 checks
a minute per key, separate from other reads.
The response is 200 even when the URL is not valid — the verdict is in the body. 400 means the request itself is malformed (no url or link_id, both at once, or not a tikio timer URL), 404 that the link is not
yours or does not exist.
Body parameters
urlstring required unless link_id- A full timer URL, as it appears in the email or on the page:
…/e/<id>.gif,…/e/<id>.html,…/embeds/<id>.htmlor…/c/<id>, with its query. Merge tags may stay unfilled. link_idstring required unless url- Check this link with
paramsinstead of a URL. paramsobject- With
link_id: the URL query as a flat object of strings, such as{"uid": "*|UNIQID|*", "v": "bf-2026"}.
Response
objectstring- Always
timer_url_validation. validbooleantruewhenerrorsis empty. Warnings do not make a URL invalid.link_idstring- The link the URL belongs to.
url_kindstringgif— email image,html— live web counter,static_html— CDN web embed,click— smart link.gifhtmlstatic_htmlclickmodestringstored— the link has a timer and the URL adds recipient parameters;legacy_query— a legacy link that reads everything from the URL.storedlegacy_queryerrorsarray- Problems that make the timer show a placeholder or the wrong countdown, each with
severity,code,paramandmessage. warningsarray- Worth a look, but the timer works.
paramsobjectaccepted— the URL parameters that were used, as{name: value};ignored— each with anameand areason;missing— what the link needs but the URL lacks:target,uidorsig.effective_timerTimer | null- The timer this URL shows once the stored timer and the URL are combined, in the shape of
link.timer.null— nothing readable. effective_timer_sourcestring | nullstored— the link’s timer;query— timing from the URL (legacy link, or an override);stored+recipient— the stored type with the recipient’s owntarget.storedquerystored+recipientuidobjectpresent— the URL carries a usableuid(not empty, not an unfilled merge tag);signature—valid,invalid,missing, ornot_requiredunless the link hasuid_sig_stricton.shows_nowobject- What the URL shows at this moment, by
kind:countdownwithpolicy,lang,layout,digits,secondsandended;placeholderwith areason; orimage_after_zerowithimage_url.
Errors
| Code | Meaning |
|---|---|
target_without_timezone | A date in target has no time zone. |
bad_target | target is not a date. |
target_out_of_range | A date is more than 10 years away. |
bad_schedule | every, at, on or tz cannot be read. |
bad_duration | duration is not a whole, positive number of seconds. |
bad_interval | cycle is not a whole, positive number of seconds. |
countup_without_start | A count-up has no start moment. |
missing_recipient_target | The deadline comes from the recipient, but the URL has no target. |
no_timing | A legacy URL has nothing to count. |
link_inactive | The link is switched off. |
link_expired | The link is past valid_until. |
template_unpublished | The template has not been published yet. |
Warnings
| Code | Meaning |
|---|---|
unknown_param | Not a timer parameter; ignored. Your own utm_* parameters land here. |
override_ignored | A timing parameter the link does not let the URL override, or a uid on a shared evergreen. |
unfilled_merge_tag | A merge tag is still in the URL — expected in a template, wrong in a sent email. |
uid_missing | A personal timer without uid — everyone shares one countdown. |
sig_missing | The link requires signed uids and the URL has no sig. |
sig_invalid | The sig does not match the uid. |
lang_unknown | lang is not one of the template languages; the default is shown. |
wrong_host | The URL is not on a tikio timer host — the email would show a broken image. |
deadline_in_past | The deadline has already passed; the timer shows the after-zero state. |
Ignored parameters
| Reason | Meaning |
|---|---|
override_not_allowed | A timing parameter on a link with allow_query_override: false. |
unknown_param | Not a timer parameter, such as utm_source. |
not_per_recipient | uid on an evergreen timer with per_recipient: false. |
Placeholder reasons
When shows_now.kind is placeholder, reason says why. A timer
URL never answers with an error status — a broken image in a sent email is worse than a placeholder.
| Reason | Meaning |
|---|---|
link_missing | No such link. |
link_off | The link is switched off. |
link_expired | The link is past valid_until. |
not_published | The template has not been published. |
no_language | The template has no language to draw labels in. |
no_when | Nothing to count: no usable date, duration or schedule. |
countup_without_start | A count-up without a start moment. |
bad_anchor | The start of an interval cycle cannot be read. |
bad_schedule | The schedule cannot be read. |
hidden_after_zero | The countdown ended and the link hides it (expired_action: hide). |
rate_limited | Too many timer requests from one address. A validation never hits it. |
unknown | Anything else. |
curl https://api.tikio.io/v1/timer_urls/validate \
-H "Authorization: Bearer $TIKIO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://t.tikio.io/e/5f1c2a9e-8b3d-4c61-9a0e-2d7b4e8f1a36.gif?target=2026-11-27T17:00&utm_source=newsletter"
}'{
"object": "timer_url_validation",
"valid": false,
"link_id": "5f1c2a9e-8b3d-4c61-9a0e-2d7b4e8f1a36",
"url_kind": "gif",
"mode": "stored",
"errors": [
{
"severity": "error",
"code": "target_without_timezone",
"param": "target",
"message": "target=2026-11-27T17:00 has no time zone: add Z or an offset like +01:00, or pass unix seconds."
}
],
"warnings": [
{
"severity": "warning",
"code": "unknown_param",
"param": "utm_source",
"message": "utm_source is a tracking parameter; the timer ignores it."
}
],
"params": {
"accepted": {
"target": "2026-11-27T17:00"
},
"ignored": [
{
"name": "utm_source",
"reason": "unknown_param"
}
],
"missing": []
},
"effective_timer": null,
"effective_timer_source": "stored+recipient",
"uid": {
"present": false,
"signature": "not_required"
},
"shows_now": {
"kind": "placeholder",
"reason": "no_when"
}
}curl https://api.tikio.io/v1/timer_urls/validate \
-H "Authorization: Bearer $TIKIO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"link_id": "5f1c2a9e-8b3d-4c61-9a0e-2d7b4e8f1a36",
"params": {
"target": "2026-11-27T17:00:00Z"
}
}'{
"object": "timer_url_validation",
"valid": true,
"link_id": "5f1c2a9e-8b3d-4c61-9a0e-2d7b4e8f1a36",
"url_kind": "gif",
"mode": "stored",
"errors": [],
"warnings": [],
"params": {
"accepted": {
"target": "2026-11-27T17:00:00Z"
},
"ignored": [],
"missing": []
},
"effective_timer": {
"type": "deadline",
"source": "fixed",
"deadline": "2026-11-27T17:00:00Z",
"lang": null
},
"effective_timer_source": "stored+recipient",
"uid": {
"present": false,
"signature": "not_required"
},
"shows_now": {
"kind": "countdown",
"policy": "absolute",
"lang": "en",
"layout": 4,
"digits": "58:07:07:56",
"seconds": 5036876,
"ended": false
}
}URL parameters
Before stored timers, a link's timing lived in its URL: https://t.tikio.io/e/<link-id>.gif?target=…. Those links — legacy links, with timer: null — keep reading every parameter below, exactly as they always have. The
same parameters are read on a link with a stored timer only when it has allow_query_override: true, and then the timing in the URL replaces the stored
timer as a whole (see precedence). For anything new, store the
timer instead.
A link has two public addresses, and both take the same parameters: https://t.tikio.io/e/<link-id>.gif for email and https://t.tikio.io/e/<link-id>.html for a live web embed.
Timing parameters
targetunix | ISO- The deadline; the start for
countup; where acyclestarts. Unix seconds or an ISO date with a time zone (…Z,…+02:00). Without a time zone the timer shows a blank placeholder. durationseconds- Evergreen countdown length. With
uid— a personal evergreen, started on each recipient’s first open; without — it restarts on every open. everystring- Recurring on a local-time schedule:
weekdaysis Monday to Friday,weekresets once a week. Goes withat,onandtz.dayweekdaysweek atHH:MM- Reset time for
every. Default00:00. onstring- Reset day for
every=week. Defaultmon.montuewedthufrisatsun tzIANA zone- Time zone of the reset, e.g.
Europe/Berlin. Default UTC. The reset stays at that local time when the clocks change — that day is 23 or 25 hours long. cycleseconds- A custom recurring interval, such as
21600for every 6 hours, counted fromtargetor from 00:00 UTC. Exact seconds — it moves by an hour against local time when the clocks change. countup1- Count up from
targetinstead of down to it.
Recipient parameters
uidmerge tag- Recipient ID for a personal evergreen, e.g.
*|UNIQID|*. sigstring- HMAC signature of
uid, for links withuid_sig_stricton. See Signed uid. langstring- Label language.
EN,en-USandenall resolve toen. vstring- Per-send cache-buster: mail image proxies (Apple, Gmail) then fetch a fresh frame instead of one cached from an earlier send.
Dates and time zones
target is a moment. Use unix seconds, or an ISO date with a time zone. A date without one (2026-11-27T23:59) is
rejected and the timer shows a blank placeholder: the sender, the email platform and the
recipient can each be in a different time zone, so the moment would be ambiguous. Unix seconds
are safest in email — nothing in them needs escaping.
A recurring timer is set by the local clock instead: every=day&at=14:00&tz=America/New_York resets at 2 PM New York time every day, and stays at 2 PM when daylight saving starts or ends. An every with an unreadable at, on or tz shows the placeholder rather
than resetting at the wrong moment.
How the parameters are read
In a fixed order, and the first match wins: countup, then every, then cycle, then target, then duration. A URL with both countup=1 and duration is a count-up. duration and cycle must be whole, positive numbers of seconds. A URL with nothing usable shows the
placeholder image rather than a wrong countdown.
What the timer shows at zero and where clicks go are not parameters: they live in the link's settings, so you can change them after the email is sent.
# Fixed deadline
https://t.tikio.io/e/<link-id>.gif?target=1795823940
https://t.tikio.io/e/<link-id>.gif?target=2026-11-27T23:59:00-05:00
# Evergreen, 24 hours — restarts on every open
https://t.tikio.io/e/<link-id>.gif?duration=86400
# Personal evergreen, 24 hours
https://t.tikio.io/e/<link-id>.gif?duration=86400&uid=*|UNIQID|*
# Every day at 2 PM Berlin
https://t.tikio.io/e/<link-id>.gif?every=day&at=14:00&tz=Europe/Berlin
# Weekdays at 5 PM New York
https://t.tikio.io/e/<link-id>.gif?every=weekdays&at=17:00&tz=America/New_York
# Mondays at 10:00 Singapore
https://t.tikio.io/e/<link-id>.gif?every=week&on=mon&at=10:00&tz=Asia/Singapore
# Every 6 hours
https://t.tikio.io/e/<link-id>.gif?cycle=21600
# Count up
https://t.tikio.io/e/<link-id>.gif?countup=1&target=2026-01-01T00:00:00ZOpenAPI
The full API is described by an OpenAPI 3 document at https://api.tikio.io/v1/openapi.json. It needs no
key. Use it to generate a typed client, or import it into Postman or Insomnia.
curl https://api.tikio.io/v1/openapi.jsonMCP
AI clients such as Claude and ChatGPT can work with tikio through the Model Context Protocol
server at https://api.tikio.io/mcp: design templates, create links with stored timers and
build their URLs, in plain words. It uses the same API keys (or sign-in with tikio) and the same
scopes, except that AI clients can never delete. Setup for each client is on the MCP page.