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.

POST /v1/links
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

ScopeAllows
tikio:readAlways included. Read templates, links and statistics; validate timer URLs.
tikio:writeDraft changes recipients do not see: folders, templates, design. Used by MCP; the REST API does not need it yet.
tikio:publishEverything that changes live timers, including in emails already sent: create and update links, read signing keys.
tikio:deleteDelete 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.

Authorization header
curl https://api.tikio.io/v1/links \
  -H "Authorization: Bearer tikio_sk_..."
Response 403
{
  "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 as timer_required.
  • message — a human-readable explanation. It can change; do not parse it.
  • param — the field at fault, as a dotted path (timer.deadline), or null.
  • doc_url — a link to this reference.
TypeStatusMeaning
invalid_request_error400The request is malformed or a parameter is invalid. param names the field.
authentication_error401No API key, or an unknown, revoked or expired one.
permission_error403The key is valid but lacks a scope. required_scope names it.
not_found404The object does not exist or belongs to another account, or the URL is not an API route.
rate_limit_error429Too many requests. Wait for the number of seconds in Retry-After.
api_error500Something went wrong on our side. Retry later and send us the request_id.

Common codes

CodeReason
parameter_missingA required parameter is absent. param names it.
parameter_invalidA parameter has the wrong type, format or value.
invalid_jsonThe request body is not valid JSON.
missing_api_keyNo Authorization: Bearer … header.
invalid_api_keyThe key is unknown, revoked or expired — or it is an OAuth token, which only works for MCP.
insufficient_scopeThe key lacks the scope in required_scope.
resource_missingNo such link or template on your account (also template_id on create), or no such route.
rate_limitedOver the per-minute limit of the key.
daily_create_limitToo many links created today through the API and MCP together.
internal_errorOur bug. Quote the request_id.

Timer codes

A timer that cannot be read is rejected with 400 and one of these codes:

CodeReason
timer_requiredA link needs a timer: timer is missing on create, or null on update.
bad_timerThe timer object has an unknown type, source or lang, or misses a field.
deadline_without_timezoneA date has no time zone. Use unix seconds or ISO with Z / ±HH:MM.
bad_dateA date cannot be read at all.
target_out_of_rangeA date is more than 10 years away, or negative unix seconds.
bad_durationduration_seconds is not a whole number from 60 to 31,536,000 (365 days).
bad_intervalinterval_seconds is out of the same range.
bad_schedulerepeat, at, on or timezone of a recurring timer cannot be read, or timezone is missing.
countup_without_startA fixed count-up has no start.
Response 400
{
  "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.

Walk through all links
curl "https://api.tikio.io/v1/links?limit=100&starting_after=<next_cursor>" \
  -H "Authorization: Bearer $TIKIO_API_KEY"
Response 200
{
  "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

type string required
Which kind of timer this is. deadlineevergreenrecurringcountup
lang string | null
Label language, one of the template languages. null — the template default. A lang in the URL always wins over this one.
Timer 200
{
  "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.

source string
fixed (default) — one deadline for everyone, stored here. recipient — each recipient brings their own deadline in the URL as target, from a merge tag. fixedrecipient
deadline string | 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 with deadline_without_timezone; past dates are allowed, dates more than 10 years away are not.
Fixed deadline 200
{
  "type": "deadline",
  "source": "fixed",
  "deadline": "2026-11-27T17:00:00Z",
  "lang": null
}
Deadline from the recipient 200
{
  "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_seconds integer required
Countdown length, from 60 to 31,536,000 (365 days).
per_recipient boolean
true — each recipient gets their own clock, started on their first open and kept on reopens; the URL needs uid. false (default) — the countdown starts from full on every open or page load.
Personal evergreen, 24 hours 200
{
  "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.

repeat string required
day, weekdays (Monday to Friday) and week reset at a local time; interval resets every interval_seconds. dayweekdaysweekinterval
timezone string 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.
at string
Reset time, HH:MM on a 24-hour clock. Default 00:00.
on string | null
Reset day for week. Default mon. Returned as null for other schedules. montuewedthufrisatsun
interval_seconds integer required for interval
Cycle length, from 60 to 31,536,000. Counts exact seconds, so it drifts against local time across daylight-saving changes.
anchor string | integer | null
Where the interval cycles start, same format as deadline. null (default) — cycles line up with 00:00 UTC.
Weekdays at 5 PM New York 200
{
  "type": "recurring",
  "repeat": "weekdays",
  "at": "17:00",
  "on": null,
  "timezone": "America/New_York",
  "lang": null
}
Mondays at 10:00 Berlin 200
{
  "type": "recurring",
  "repeat": "week",
  "at": "10:00",
  "on": "mon",
  "timezone": "Europe/Berlin",
  "lang": null
}
Every 6 hours 200
{
  "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.

source string
fixed (default) — counts up from start. recipient — from a target in the URL. fixedrecipient
start string | integer required for fixed
The moment to count up from. Same format as deadline.
Count up, German labels 200
{
  "type": "countup",
  "source": "fixed",
  "start": "2026-01-01T00:00:00Z",
  "lang": "de"
}
Count up from the recipient's date 200
{
  "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:

LinkWhat 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: trueIf 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.
  • lang in the URL always wins over the timer's lang.
  • allow_query_override is false by 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_override becomes true unless 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

uid merge 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.
target merge 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.
sig string uid_sig_strict
Signature of uid. With uid_sig_strict on, a uid without a valid sig is ignored. See Signed uid.
lang string
Label language for this email, one of the template languages. Overrides the timer’s lang; an unknown value falls back to the template default.
v string
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.

Short URLs
# 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 }}
recipient_params — deadline from a merge tag 200
[
  {
    "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.

Sign a uid
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}`;

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

object string
Always signing_key.
link_id string
The link the key belongs to.
key string
The HMAC key, base64url without padding. Decode it to bytes before use.
algorithm string
How sig is computed, in words.
example object
A uid and its sig — test your signing code against it.
GET /v1/links/{id}/signing_key
curl https://api.tikio.io/v1/links/5f1c2a9e-8b3d-4c61-9a0e-2d7b4e8f1a36/signing_key \
  -H "Authorization: Bearer $TIKIO_API_KEY"
Response 200
{
  "object": "signing_key",
  "link_id": "5f1c2a9e-8b3d-4c61-9a0e-2d7b4e8f1a36",
  "key": "k7Qm2xVd9pLr4TzWc8NbJ1sYhEo6GaUf3iKq5RvXwM0",
  "algorithm": "HMAC-SHA256, base64url without padding",
  "example": {
    "uid": "user-42",
    "sig": "Xo3Jm9TqV2bR7kLc1wYp8sNd4HfGu6Ea0zKiQrPvBtM"
  }
}

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

id string
Unique identifier (UUID). Use it as template_id when creating links.
object string
Always template.
name string
The template name.
folder_id string | null
The folder it is in, if any.
layout integer
Units shown: 3 — hours, minutes, seconds; 4 — with days.
langs array
Languages the labels are available in. The first is the default.
published boolean
Whether the template has been published. Links of an unpublished template show a placeholder.
published_at string | null
When it was last published.
has_unpublished_changes boolean
Whether the editor has changes recipients do not see yet.
version integer
Draft version; grows with every design edit.
size object
width and height of the countdown in px — the GIF is rendered at this size.
created_at string
When the template was created.
updated_at string
When the template was last changed.
The Template object 200
{
  "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_id string
Only templates in this folder.
limit integer
Page size, 1 to 100. Default 20.
starting_after string
Cursor: next_cursor of the previous page.
GET /v1/templates
curl "https://api.tikio.io/v1/templates?limit=20" \
  -H "Authorization: Bearer $TIKIO_API_KEY"
Response 200
{
  "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
}

Retrieve a template

GET /v1/templates/{id}

Returns a template. Needs tikio:read.

GET /v1/templates/{id}
curl https://api.tikio.io/v1/templates/b2e4f6a8-1c3d-4e5f-8a9b-0c1d2e3f4a5b \
  -H "Authorization: Bearer $TIKIO_API_KEY"
Response 200
{
  "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

url string 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>.html or …/c/<id>, with its query. Merge tags may stay unfilled.
link_id string required unless url
Check this link with params instead of a URL.
params object
With link_id: the URL query as a flat object of strings, such as {"uid": "*|UNIQID|*", "v": "bf-2026"}.

Response

object string
Always timer_url_validation.
valid boolean
true when errors is empty. Warnings do not make a URL invalid.
link_id string
The link the URL belongs to.
url_kind string
gif — email image, html — live web counter, static_html — CDN web embed, click — smart link. gifhtmlstatic_htmlclick
mode string
stored — the link has a timer and the URL adds recipient parameters; legacy_query — a legacy link that reads everything from the URL. storedlegacy_query
errors array
Problems that make the timer show a placeholder or the wrong countdown, each with severity, code, param and message.
warnings array
Worth a look, but the timer works.
params object
accepted — the URL parameters that were used, as {name: value}; ignored — each with a name and a reason; missing — what the link needs but the URL lacks: target, uid or sig.
effective_timer Timer | 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_source string | null
stored — the link’s timer; query — timing from the URL (legacy link, or an override); stored+recipient — the stored type with the recipient’s own target. storedquerystored+recipient
uid object
present — the URL carries a usable uid (not empty, not an unfilled merge tag); signature — valid, invalid, missing, or not_required unless the link has uid_sig_strict on.
shows_now object
What the URL shows at this moment, by kind: countdown with policy, lang, layout, digits, seconds and ended; placeholder with a reason; or image_after_zero with image_url.

Errors

CodeMeaning
target_without_timezoneA date in target has no time zone.
bad_targettarget is not a date.
target_out_of_rangeA date is more than 10 years away.
bad_scheduleevery, at, on or tz cannot be read.
bad_durationduration is not a whole, positive number of seconds.
bad_intervalcycle is not a whole, positive number of seconds.
countup_without_startA count-up has no start moment.
missing_recipient_targetThe deadline comes from the recipient, but the URL has no target.
no_timingA legacy URL has nothing to count.
link_inactiveThe link is switched off.
link_expiredThe link is past valid_until.
template_unpublishedThe template has not been published yet.

Warnings

CodeMeaning
unknown_paramNot a timer parameter; ignored. Your own utm_* parameters land here.
override_ignoredA timing parameter the link does not let the URL override, or a uid on a shared evergreen.
unfilled_merge_tagA merge tag is still in the URL — expected in a template, wrong in a sent email.
uid_missingA personal timer without uid — everyone shares one countdown.
sig_missingThe link requires signed uids and the URL has no sig.
sig_invalidThe sig does not match the uid.
lang_unknownlang is not one of the template languages; the default is shown.
wrong_hostThe URL is not on a tikio timer host — the email would show a broken image.
deadline_in_pastThe deadline has already passed; the timer shows the after-zero state.

Ignored parameters

ReasonMeaning
override_not_allowedA timing parameter on a link with allow_query_override: false.
unknown_paramNot a timer parameter, such as utm_source.
not_per_recipientuid 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.

ReasonMeaning
link_missingNo such link.
link_offThe link is switched off.
link_expiredThe link is past valid_until.
not_publishedThe template has not been published.
no_languageThe template has no language to draw labels in.
no_whenNothing to count: no usable date, duration or schedule.
countup_without_startA count-up without a start moment.
bad_anchorThe start of an interval cycle cannot be read.
bad_scheduleThe schedule cannot be read.
hidden_after_zeroThe countdown ended and the link hides it (expired_action: hide).
rate_limitedToo many timer requests from one address. A validation never hits it.
unknownAnything else.
POST /v1/timer_urls/validate
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"
}'
Response — a date without a time zone 200
{
  "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"
  }
}
POST /v1/timer_urls/validate — by link_id
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"
  }
}'
Response — valid 200
{
  "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

target unix | ISO
The deadline; the start for countup; where a cycle starts. Unix seconds or an ISO date with a time zone (…Z, …+02:00). Without a time zone the timer shows a blank placeholder.
duration seconds
Evergreen countdown length. With uid — a personal evergreen, started on each recipient’s first open; without — it restarts on every open.
every string
Recurring on a local-time schedule: weekdays is Monday to Friday, week resets once a week. Goes with at, on and tz. dayweekdaysweek
at HH:MM
Reset time for every. Default 00:00.
on string
Reset day for every=week. Default mon. montuewedthufrisatsun
tz IANA 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.
cycle seconds
A custom recurring interval, such as 21600 for every 6 hours, counted from target or from 00:00 UTC. Exact seconds — it moves by an hour against local time when the clocks change.
countup 1
Count up from target instead of down to it.

Recipient parameters

uid merge tag
Recipient ID for a personal evergreen, e.g. *|UNIQID|*.
sig string
HMAC signature of uid, for links with uid_sig_strict on. See Signed uid.
lang string
Label language. EN, en-US and en all resolve to en.
v string
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.

Legacy URLs
# 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:00Z

OpenAPI

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.

GET /v1/openapi.json
curl https://api.tikio.io/v1/openapi.json

MCP

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.