Public API Reference
The Tokei Public API lets you manage promotions, create entries programmatically, pull analytics and leaderboards, and receive webhooks when participants enter. All endpoints are versioned under /api/v1 and return JSON. A machine-readable OpenAPI 3.0 spec is also available.
Prefer not to write the HTTP calls yourself? tokei-agent wraps every endpoint below as a CLI and an MCP server, so Claude Code, Claude Desktop, OpenClaw and other AI agents can run your campaigns directly.
Quick Start
Getting Started
- Log in to your Tokei dashboard and go to Dashboard > Settings > API.
- Click Create New Key, name it, and copy the full key — it is shown only once.
- Verify the key works:
curl -X GET "https://tokei.io/api/v1/me" \
-H "Authorization: Bearer YOUR_API_KEY"Then list your promotions and create your first entry:
curl -X GET "https://tokei.io/api/v1/contests" \
-H "Authorization: Bearer YOUR_API_KEY"
curl -X POST "https://tokei.io/api/v1/contests/YOUR_CONTEST_ID/entries" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"email": "test@example.com", "name": "Test User", "points": 5}'Naming
Terminology
The API and the dashboard use different words for the same objects. They are not different things — this table is the whole mapping.
| API says | Dashboard / people say | Notes |
|---|---|---|
| contest | page, campaign, promotion | The core object. contestIdin a path is the page's id. |
| promotion | page, campaign | The same object again — POST /promotions creates it, and it reads back under /contests/:contestId. |
| entry | signup, subscriber, entrant | One person joining a page. |
| title | headline | The visible page headline — not the internal dashboard name (campaign_name, read-only). |
| settings.template | skin, design | basic-new, showcase or future. |
The CLI names commands after the dashboard words (pages:list) while the JSON it returns uses the API words.
Auth
Authentication
The base URL is https://tokei.io/api/v1. Every request must include your API key in the Authorization header:
Authorization: Bearer tokei_k_x8Kp2mNq4rT6vW9yB1dF3gH5jL7nP0sU- Keys start with
tokei_k_followed by 32 random characters. - Up to 5 active keys per account; keys access all promotions you own.
- The full key is shown only once at creation — store it securely.
- Revoke keys anytime from the dashboard; revocation is immediate.
- API access requires an active subscription or lifetime plan (trial accounts receive 403).
Errors
Error Handling
| Status | Code | Description |
|---|---|---|
| 200 | OK | Request succeeded. |
| 201 | CREATED | Resource created. |
| 400 | BAD_REQUEST | Invalid parameters. |
| 401 | UNAUTHORIZED | Missing, invalid, or revoked API key. |
| 403 | FORBIDDEN | Valid key but insufficient plan or missing scope. |
| 404 | NOT_FOUND | Promotion or resource not found. |
| 409 | CONFLICT | Duplicate entry (email already entered this promotion) or reused idempotency_key. |
| 409 | WINNERS_ALREADY_SELECTED | An end_date write — setting or clearing — on a promotion whose winners have already been drawn. The deadline is frozen once a draw has happened. |
| 413 | PAYLOAD_TOO_LARGE | Request body exceeds the 10KB limit. |
| 422 | VALIDATION_ERROR | Request body failed validation — see the details array. |
| 429 | RATE_LIMIT_EXCEEDED | Too many requests — see the Retry-After header. |
| 500 | INTERNAL_ERROR | Server error. |
Errors always use a structured body:
{
"success": false,
"error": {
"code": "RATE_LIMIT_EXCEEDED",
"message": "You have exceeded the rate limit of 60 requests per minute.",
"status": 429
}
}Validation failures (422) include a details array:
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Request body failed validation.",
"status": 422,
"details": [
{ "field": "email", "message": "Email is required." },
{ "field": "points", "message": "Points must be a positive integer." }
]
}
}Limits
Rate Limiting
| Plan | Read / min | Write / min | Daily cap |
|---|---|---|---|
| Subscriber | 60 | 30 | 10,000 |
| Lifetime | 120 | 60 | 50,000 |
Every response includes rate-limit headers; 429 responses also include Retry-After:
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 45
X-RateLimit-Reset: 1708200000The daily cap counts all requests (reads and writes) per account within a UTC calendar day. Exceeding it returns 429 RATE_LIMIT_EXCEEDED with Retry-After set to the seconds until UTC midnight.
- Cache responses when possible and fetch in pages.
- Implement exponential backoff on 429.
- Use webhooks instead of polling for real-time updates.
Lists
Pagination
All list endpoints accept page (default 1) and per_page (default 25, max 100) and return a pagination object:
{
"success": true,
"data": [ ... ],
"pagination": { "page": 1, "per_page": 25, "total_pages": 4, "total_count": 87 }
}Agents
CLI — tokei-agent
tokei-agent is the official command-line client for this API — a zero-dependency npm package that prints JSON to stdout, so you can pipe it into jq or hand it straight to an AI agent. Everything it does is available over plain HTTP too; the CLI just saves you writing the request. It also doubles as an MCP server.
npm install -g tokei-agent
# or run it without installing:
npx tokei-agent --helpRequires Node 22+. Published on npm; the agent landing page is at tokei.io/agent.
Configuration
| Variable | Required | Meaning |
|---|---|---|
| TOKEI_API_KEY | Yes | Sent as Authorization: Bearer <key>. Create one at Dashboard > Settings > API. |
| TOKEI_API_URL | No | Base URL override (default https://tokei.io). |
export TOKEI_API_KEY=tokei_k_... # bash / zsh
set -x TOKEI_API_KEY tokei_k_... # fish
$env:TOKEI_API_KEY = "tokei_k_..." # PowerShell
tokei-agent me # verify the key, see plan + API usage
tokei-agent pages:list --status active
tokei-agent stats <contestId>Output envelope and exit codes
stdout carries the API's JSON body exactly as documented on this page, plus a top-level rate_limit object built from the X-RateLimit-* headers (null when those headers were absent, e.g. a network failure). Read it and self-throttle rather than discovering a 429.
{ "success": true, "data": { ... },
"rate_limit": { "limit": 60, "remaining": 59, "reset": 1753000000 } }| Exit code | Meaning |
|---|---|
| 0 | Success (HTTP 2xx). |
| 1 | API or network error. The JSON error body is still printed on stdout; pure network failures print {"ok": false, "error": {"type": "network_error", ...}}. |
| 2 | Usage error (bad flags, missing argument, missing TOKEI_API_KEY) — printed as JSON on stderr. Nothing was sent to the API. |
Known issue — exit codes on Node 24 / Windows (fixed in 0.3.0). On 0.2.2 and earlier the CLI could print correct JSON and then abort during process exit, corrupting the exit code ($LASTEXITCODE read -1073740791 on success and failure alike). On an affected version, judge a run by the JSON on stdout — or upgrade. Note that the 0.3.0 package misreports --version as 0.2.2; if npm view tokei-agent version says 0.3.0, you have the fix regardless of what the CLI prints.
Agents
Command Reference
Every command maps to one endpoint on this page. Command names use the words humans use ("pages"); the JSON they return uses the API's words ("contest"/"promotion") — see Terminology. Read commands work with any key; write commands need a read+write key.
Read (any key)
| Command | Endpoint | Does |
|---|---|---|
| me | GET /me | Verify the key; account, plan, API usage |
| pages:list | GET /contests | List pages — --status, --mode, --page, --per-page |
| pages:get <contestId> | GET /contests/:contestId | One page in full (prizes, reward tiers, media, public URL) |
| stats <contestId> | GET /contests/:contestId/analytics | Aggregated analytics |
| leaderboard <contestId> | GET /contests/:contestId/leaderboard | Participants ranked by points |
| entries:list <contestId> | GET /contests/:contestId/entries | Signups — filter with --email |
| surveys:list <contestId> | GET /contests/:contestId/survey-responses | Survey responses |
| webhooks:list | GET /webhooks | List webhook subscriptions (no write scope needed) |
| templates:list | GET /templates | Named starting points, for pages:clone --template |
Write (read+write key)
| Command | Endpoint | Does |
|---|---|---|
| pages:clone | POST /promotions | Create a page from one you own, a named template, or the starter. 20/day cap |
| media:upload <file> | POST /media, then PUT to storage | Upload an image or video and get back a public_url. ≤5MB per file (video too) |
| pages:update <contestId> | PATCH /contests/:contestId | Update title, description, dates, prizes, reward tiers, appearance and media |
| pages:publish <contestId> | PATCH /contests/:contestId | Sugar for {"status": "active"} — needs a future end_date |
| pages:unpublish <contestId> | PATCH /contests/:contestId | Sugar for {"status": "draft"} — blocks new signups, but the page still renders publicly |
| entries:create <contestId> | POST /contests/:contestId/entries | Add a signup |
| webhooks:create | POST /webhooks | Subscribe an HTTPS endpoint — the whsec_ secret is shown once |
| webhooks:delete <webhookId> | DELETE /webhooks/:webhookId | Remove a subscription |
Write commands take simple fields as flags, and full or nested bodies via --data '<json>' or --data @file.json (flags win on conflict). Fields only reachable through --data: prizes, reward_thresholds, metadata, and explicit nulls. The CLI does no local schema validation — the API's 422 with per-field error.details is the validation.
# a full build, end to end
tokei-agent me # verify key + plan first
tokei-agent templates:list # never hardcode a slug
PAGE=$(tokei-agent pages:clone --title "Spring Launch Waitlist" \
--template product-hunt | jq -r '.data.id')
HERO=$(tokei-agent media:upload ./hero.png | jq -r '.data.public_url')
tokei-agent pages:update "$PAGE" \
--description "Join the list for early access." \
--template showcase --dark-mode true --primary-color "#7d78c6" \
--image-video "$HERO"
tokei-agent pages:publish "$PAGE" --data '{"end_date":"2026-09-01T00:00:00Z"}'
tokei-agent stats "$PAGE"Run tokei-agent --help for every flag.
Agents
MCP Server
tokei-agent mcp runs a local Model Context Protocol server over stdio, exposing all 17 commands as MCP tools for Claude Code, Claude Desktop, OpenClaw, and any other MCP client. No extra install — it is the same package.
claude mcp add tokei --env TOKEI_API_KEY=tokei_k_... -- npx -y tokei-agent mcpOr in a client's JSON config:
{
"mcpServers": {
"tokei": {
"command": "npx",
"args": ["-y", "tokei-agent", "mcp"],
"env": { "TOKEI_API_KEY": "tokei_k_..." }
}
}
}- Tool names swap
:for_—pages:listbecomespages_list. - Inputs use this API's wire field names directly (
contest_id,per_page,prizes, …), so nested bodies need no--data. - Results carry the same envelope (including
rate_limit) as text content, withisErrorset on API failures — the error semantics above apply unchanged.
Agents
Using Tokei from an AI Agent
The package ships an agent-oriented reference, SKILL.md, which agents like Claude Code and OpenClaw discover automatically — worked examples, error-handling guidance, and the gotchas below. These four are the ones that bite hardest, so they are worth knowing whether you drive the API by CLI, by MCP, or by raw HTTP.
- Check the key and plan first. API access requires an active subscription or lifetime plan — trial accounts get
403on every call, so a whole workflow can fail on its first request for a reason no other error explains. Note thatmereports the plan but notthe key's scope: you discover a read-only key by getting403 FORBIDDENon your first write. - Media must be uploaded through Tokei first. The seven media fields on
PATCH /api/v1/contests/:contestIdare guarded by a host allowlist, so local paths and third-party URLs are rejected. Upload, then patch thepublic_urlyou get back — see Upload Media. - List fields replace wholesale.
prizes(max 20) andreward_thresholds(max 50) are not merged — whatever array you send becomes the entire list. Always read, modify, then write the complete list back. - Unpublishing hides nothing. A draft page still renders publicly at its URL;
{"status": "draft"}only stops new signups. Tell your user this — they will assume otherwise.
Agent-safe keys, human in the loop
Tokei is built so agents draft and humans approve:
- Give monitoring and reporting agents a read-only key; reserve read+write keys for agents that genuinely need to change things.
- Set an expiry on keys you hand to an agent; an expired key returns 401.
- Revoke from Dashboard > Settings > API at any time — revocation is immediate.
- New webhook subscriptions created through the API trigger a security notification to the account owner.
- Have the agent create pages as drafts and leave publishing to you — a draft is fully readable and editable, and only
statusseparates it from live. - API responses can include entrant emails and survey answers. They land in the agent's conversation history — treat that output as the personal data it is.
More: the agent landing page, the npm package (README and SKILL.md are bundled), and the OpenAPI spec.
Account
GET /api/v1/me
Verifies your API key and returns account information including plan, today's API usage, and the number of active promotions (the active_contests field name is retained for backwards compatibility).
{
"success": true,
"data": {
"user_id": "usr_a1b2c3d4",
"email": "you@example.com",
"plan": "lifetime",
"api_usage": {
"requests_today": 142,
"daily_limit": 50000,
"rate_limit_per_minute": 120
},
"active_contests": 12
}
}Promotions
List & Retrieve Promotions
These read endpoints live under /api/v1/contests/… for backwards compatibility — they return your promotions, and the id returned by POST /api/v1/promotions works with all of them.
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/v1/contests | List all your promotions |
| GET | /api/v1/contests/:contestId | Retrieve a specific promotion |
The list endpoint supports status (draft, active, completed, deleted — exactly the values a promotion's status can hold) and mode (competition, gamification, sharing_only) filters plus pagination. A promotion object looks like:
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"title": "Win a Nintendo Switch",
"description": "Enter free for a chance to win a Nintendo Switch.",
"status": "active",
"mode": "competition",
"type": "promotion",
"contest_url": "win-nintendo-switch",
"public_url": "https://tokei.io/contest/win-nintendo-switch",
"start_date": "2026-02-01T00:00:00Z",
"end_date": "2026-03-01T23:59:59Z",
"days_left": 12,
"total_entries": 847,
"total_points_awarded": 4235,
"entry_methods": [
{ "label": "Enter with Email", "points": 5, "actionType": "email_signup" },
{ "label": "Follow us on Twitter", "points": 3, "actionType": "twitter_follow" }
],
"settings": { "template": "basic-new", "color": "#6366F1" },
"template": "basic-new",
"primary_color": "#6366F1",
"card_width": "max-w-2xl",
"image_video": "https://gjigbotdbcyymtaauogv.supabase.co/storage/v1/object/public/tokei-public/contest-images/hero.jpg",
"secondary_image": null,
"third_image": null,
"fourth_image": null,
"fifth_image": null,
"background_image": null,
"og_image": null,
"campaign_name": "Switch giveaway (internal)",
"project_name": "Free entry, ships worldwide",
"prizes": [{ "name": "Nintendo Switch", "winners": 1, "value": 299, "currency": "USD" }],
"reward_thresholds": [],
"daily_bonus_enabled": true,
"referral_enabled": true,
"dark_mode_enabled": false,
"created_at": "2026-01-25T10:30:00Z",
"updated_at": "2026-02-15T14:22:00Z"
}title is the visible page headline, not the internal dashboard name — project_name is the small subheading and campaign_name is the internal name; both are read-only. Appearance and media (primary_color, card_width, and the image fields) read back what the page actually renders. settings.color is a deprecated alias of primary_color.
status is the effective status — draft, active, completed or deleted — derived from the stored value and the dates: a promotion whose end_date has passed reads as completed, and one that has not started yet reads as draft. The ?status= filter matches that same derived value, so anything you read back is something you can filter on. Likewise days_left counts down from end_date on every read and is 0 once the promotion has ended, and each entry_methods[].points is what that action actually awards, including any per-action value the owner has configured.
total_entries counts entry actions (one row per completed entry method, not per person) — the same count as total_entries in the analytics response. total_points_awarded is the points total across those actions. If you want the number of distinct people— usually what “entries” means to a creator — use unique_participants from the analytics endpoint instead.
Promotions
Update a Promotion
| Method | Endpoint | Description |
|---|---|---|
| PATCH | /api/v1/contests/:contestId | Update copy, dates, prizes and reward thresholds (write scope) |
Send at least one field; unknown fields are rejected and the body is limited to 10KB. Body fields: title (1–100 chars — sets the dashboard name AND the visible page headline, so it reads back unchanged), description (≤2000 or null — basic rich-text HTML is accepted and sanitized on write, so anything outside the allowed tags is stripped), start_date (ISO 8601 or null — a future start pauses new entries until then), end_date (ISO 8601 in the future or null — recomputes days_left), prizes (array ≤20 of { name, winners, value?, currency? }), and reward_thresholds (array ≤50 of { id, points, rewardType, rewardDescription, rewardDetails?, isEnabled }), template ("basic-new" | "showcase" | "future" — the page skin, stored verbatim as settings.template; basic-new is the classic Gleam-style entry-list card and the default, showcase a two-column product-forward layout, future a dark game-style look), dark_mode_enabled (boolean — a creator-side toggle; there is no visitor prefers-color-scheme behavior), primary_color (hex colour only — 3, 4, 6 or 8 digits, e.g. #7d78c6, or null to reset to the template default; the value is interpolated into the server-rendered <style> tag, so other CSS colour formats are rejected), and card_width ("narrow" | "medium" | "wide", or the raw stored class "max-w-2xl" | "max-w-3xl" | "max-w-4xl" directly — friendly names map narrow→max-w-2xl, medium→max-w-3xl, wide→max-w-4xl, and reads always return the stored class, never the friendly name), and status ("draft" | "active" only — deleted, completed, ended and paused are rejected, so a promotion can never be destroyed or resurrected through this endpoint; publishing — a transition to active from any other status — requires an end_date in the future, either already stored or sent in this same request, otherwise 422 VALIDATION_ERROR, and re-sending active on an already-active promotion is a no-op that skips the check; unpublishing (active → draft) leaves entries and entrants untouched and only blocks new ones — a draft promotion still renders publicly at its URL, so unpublishing does not take the page down). prizes and reward_thresholds each replace the existing list wholesale; the VIP points threshold is recomputed from the first enabled vip_status tier. Setting or clearing end_date on a promotion whose winners have already been drawn returns 409 WINNERS_ALREADY_SELECTED — clear the selection in the dashboard first, or leave end_date out of the request. Clearing it on an active promotion returns 422 on field end_date: an active page must keep a deadline, so unpublish in the same request ({"status": "draft"}) or send a replacement date.
Seven media fields accept a URL or null to clear: image_video (the hero — image or video), secondary_image, third_image, fourth_image, fifth_image (additional layout block images), background_image, and og_image (social-share preview — falls back to the account-level image when null). Each is ≤500 chars and must be an https URL on your own Supabase public storage or res.cloudinary.com — the same allowlist as image_url on POST /api/v1/promotions below, and exactly what public_url from POST /api/v1/media returns — upload there, then PATCH it straight in.
fetch("https://tokei.io/api/v1/contests/CONTEST_ID", {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${API_KEY}`,
},
body: JSON.stringify({
title: "Win a Nintendo Switch 2",
description: "Enter free before the deadline.",
end_date: "2026-08-31T23:59:59Z",
prizes: [{ name: "Nintendo Switch 2", winners: 1, value: 449, currency: "USD" }],
}),
});200 returns the full updated promotion object:
{
"success": true,
"data": {
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"title": "Win a Nintendo Switch 2",
"description": "Enter free before the deadline.",
"status": "active",
"mode": "competition",
"type": "promotion",
"contest_url": "win-nintendo-switch",
"public_url": "https://tokei.io/contest/win-nintendo-switch",
"end_date": "2026-08-31T23:59:59Z",
"days_left": 42,
"total_entries": 847,
"total_points_awarded": 4235,
"settings": { "template": "basic-new", "color": "#6366F1" },
"prizes": [{ "name": "Nintendo Switch 2", "winners": 1, "value": 449, "currency": "USD" }],
"reward_thresholds": [],
"updated_at": "2026-07-20T09:00:00Z"
}
}Templates
List Templates
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/v1/templates | List the platform's named starting points |
Templates are platform content, not the caller's, so this listing is not ownership-scoped — every authenticated key sees the same list. No parameters, no pagination. Clone one by slug with POST /api/v1/promotions {"template": "<slug>"}. Each template: id, slug (pass as template to clone it), name, skin (page skin — basic-new, showcase, or future, same vocabulary as PATCH's template field), and entry_method_count.
{
"success": true,
"data": [
{
"id": "06743256-2e8e-4ede-a431-f17866fae1f6",
"slug": "competition-starter",
"name": "Starter — Gleam-style competition giveaway (X, Instagram, TikTok, Facebook entries)",
"skin": "basic-new",
"entry_method_count": 6
},
{
"id": "5f988c4e-3faf-49f0-9ee4-438cb5efc491",
"slug": "product-hunt",
"name": "Product Hunt launch — upvote & share drive (Product Hunt, X, LinkedIn actions)",
"skin": "basic-new",
"entry_method_count": 5
},
{
"id": "88fde228-8baf-4b31-9b0e-cc243b3cc83d",
"slug": "steam-promotion",
"name": "A futuristic Steam template for Adding to Steam Wishlists and Playing Steam Games.",
"skin": "future",
"entry_method_count": 6
}
]
}Promotions
Create a Promotion (Clone)
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/v1/promotions | Clone a promotion you own, with copy overrides (write scope) |
Creation is clone-based: keep one polished master promotion per page shape, then let the API copy its template, theme, entry methods and settings verbatim while you override the marketing copy. Body fields: source_promotion_id (optional — a promotion you own) or template (optional — a slug from GET /api/v1/templates, e.g. "product-hunt"); they're alternatives — sending both is a 422, a template slug matching no template is a 404, and omitting both clones the platform starter template. title (required — sets the dashboard name AND the public headline), description, prize (sets the prize — renames the first or creates one), end_date(defaults to the source's original duration), campaign_url, image_url (hero image — a Supabase storage or Cloudinary URL), status (draft default, active goes live immediately), and idempotency_key (reuse returns 409 CONFLICTwith the existing promotion's id). Limited to 20 API-created promotions per account per UTC day.
fetch("https://tokei.io/api/v1/promotions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${API_KEY}`,
},
body: JSON.stringify({
source_promotion_id: "MASTER_PROMOTION_ID",
title: "Win a Nintendo Switch 2 — launch week special",
prize: "Nintendo Switch 2 + 3 games",
end_date: "2026-07-31T23:59:59Z",
status: "active",
idempotency_key: "n8n-run-8823",
}),
});201 returns the live page URL directly — no follow-up lookup needed:
{
"success": true,
"data": {
"id": "9a1b2c3d-e5f6-7890-abcd-ef1234567890",
"title": "Win a Nintendo Switch 2 — launch week special",
"status": "active",
"mode": "competition",
"type": "promotion",
"contest_url": "x8Kp2m",
"public_url": "https://tokei.io/contest/x8Kp2m",
"edit_url": "https://tokei.io/dashboard/promotion/edit?id=9a1b2c3d-…",
"source_promotion_id": "MASTER_PROMOTION_ID",
"end_date": "2026-07-31T23:59:59Z",
"created_at": "2026-07-02T14:00:00Z"
}
}How automation uses this
The common flow is “a new product drops → launch a campaign”. Your automation (n8n, Zapier, Make) fires on a trigger, generates the copy and a hero image, then makes a single call. Omit source_promotion_id and the platform starter template is cloned for you — so a brand-new account with nothing to clone still works.

- Trigger fires (e.g. a new product in your store, an RSS item).
- AI drafts the
title,descriptionandprize. - The image is uploaded to your Supabase bucket (or Cloudinary) → you get its URL.
- One POST with
status: "active"creates a live promotion. - The 201 response's
public_urlis posted to your channels — no follow-up lookup.
// No source_promotion_id → clones the platform starter template
fetch("https://tokei.io/api/v1/promotions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${API_KEY}`,
},
body: JSON.stringify({
title: "Win a Nintendo Switch 2",
image_url:
"https://YOUR_PROJECT.supabase.co/storage/v1/object/public/tokei-public/media/switch.jpg",
prize: "Nintendo Switch 2",
description: "<p><b>Launch-week giveaway — enter free.</b></p>",
status: "active",
}),
});201 — the clone inherits the template's design (template, theme, entry methods, subheading) and applies your copy and image:
{
"success": true,
"data": {
"id": "0d1e2f3a-4b5c-6d7e-8f90-1a2b3c4d5e6f",
"title": "Win a Nintendo Switch 2",
"status": "active",
"type": "promotion",
"contest_url": "x8Kp2m",
"public_url": "https://tokei.io/contest/x8Kp2m",
"edit_url": "https://tokei.io/dashboard/promotion/edit?id=0d1e2f3a-…",
"source_promotion_id": "STARTER_TEMPLATE_ID",
"created_at": "2026-07-03T14:00:00Z"
}
}
Media
Upload Media (Two-Step)
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/v1/media | Get a signed upload URL for an image or video (write scope) |
Requires the write scope. This is a two-step upload, not a file upload endpoint: step 1 (this call) returns a short-lived ticket describing where and how to upload — nothing is stored yet, so success is 200, not 201; step 2 is a raw PUT of the file bytes to the URL the ticket gives you. Feed the resulting public_url into one of the seven media fields on PATCH /api/v1/contests/:contestId (image_video, secondary_image, third_image, fourth_image, fifth_image, background_image, og_image) — it is guaranteed to pass their host allowlist, so you can upload then PATCH it straight in.
Request fields
| Field | Type | Required | Description |
|---|---|---|---|
| filename | string, 1–255 chars | Yes | Validated (rejects path traversal and null bytes) and echoed back in the response, but not stored anywhere— the object's stored name is always a server-generated UUID. |
| content_type | enum, 7 values | Yes | image/jpeg, image/png, image/gif, image/webp, video/mp4, video/webm, video/quicktime. application/pdf is not accepted — this endpoint is images and video only. |
| size_bytes | integer, 100–5,242,880 | Yes | Declared size of the file you intend to upload. The tokei-public bucket's 5MB signed-upload limit applies to video as well as images — thin for video, so plan accordingly. This is only a declaration: an oversize or undersize value fails fast with 422 (field size_bytes) here, never a 413 — the file itself never reaches Tokei at this step. |
Abuse guard: max 50 tickets issued per account per UTC day — 429 RATE_LIMIT_EXCEEDED with Retry-After. The cap counts tickets issued, not completed uploads — abandoned or retried uploads still count.
Step 1 — request the ticket
const ticket = await fetch("https://tokei.io/api/v1/media", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${API_KEY}`,
},
body: JSON.stringify({
filename: "hero.jpg",
content_type: "image/jpeg",
size_bytes: 482113,
}),
}).then((r) => r.json());200 returns the ticket:
{
"success": true,
"data": {
"upload_url": "https://gjigbotdbcyymtaauogv.supabase.co/storage/v1/object/upload/sign/tokei-public/api-uploads/usr_a1b2c3d4/0f8fad5b-d9cb-469f-a165-70867728950e.jpg?token=eyJhbGciOi...",
"token": "eyJhbGciOi...",
"method": "PUT",
"headers": { "content-type": "image/jpeg", "x-upsert": "false" },
"bucket": "tokei-public",
"path": "api-uploads/usr_a1b2c3d4/0f8fad5b-d9cb-469f-a165-70867728950e.jpg",
"public_url": "https://gjigbotdbcyymtaauogv.supabase.co/storage/v1/object/public/tokei-public/api-uploads/usr_a1b2c3d4/0f8fad5b-d9cb-469f-a165-70867728950e.jpg",
"content_type": "image/jpeg",
"filename": "hero.jpg",
"max_bytes": 5242880,
"expires_in": 7200,
"expires_at": "2026-07-26T16:00:00Z"
}
}Step 2 — PUT the file bytes
PUT the raw bytes to data.upload_url with exactly data.headers. upload_url is a Supabase Storage host, not Tokei — send no Authorization header; the signed token embedded in its query string is the only credential this step needs.
# PowerShell
Invoke-WebRequest -Uri $ticket.data.upload_url `
-Method Put `
-InFile ".\hero.jpg" `
-Headers @{ "content-type" = "image/jpeg"; "x-upsert" = "false" }
# curl.exe
curl.exe -X PUT "UPLOAD_URL_FROM_STEP_1" -H "content-type: image/jpeg" -H "x-upsert: false" --data-binary "@hero.jpg"If the real file exceeds 5MB despite an honest size_bytes declaration, this step returns a 413 directly from Supabase Storage— not from Tokei, and not in Tokei's error format.
Errors specific to this endpoint
| Status | Code | Note |
|---|---|---|
| 413 | PAYLOAD_TOO_LARGE | Only for this call's own JSON body exceeding 10KB — never for an oversize file. An oversize declared size_bytes is a 422, not a 413. |
| 422 | VALIDATION_ERROR | Bad filename, content_type, or size_bytes — both too-small and too-large size_bytes report on the field size_bytes. |
| 429 | RATE_LIMIT_EXCEEDED | 50 tickets/day, counting tickets issued, not completed uploads. |
| 500 | INTERNAL_ERROR | Storage signing failed. |
Then PATCH it onto a promotion
Once step 2 succeeds, public_url is live and already allowlisted:
await fetch(`https://tokei.io/api/v1/contests/${CONTEST_ID}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${API_KEY}`,
},
body: JSON.stringify({ image_video: ticket.data.public_url }),
});Entries
List & Create Entries
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/v1/contests/:contestId/entries | List entries (filter with ?email=) |
| POST | /api/v1/contests/:contestId/entries | Create an entry (write scope) |
POST creates a participant programmatically. Body fields: email (required), name, action_type (default api_import), points, value, and metadata (custom key-value pairs). Creating a duplicate (same email and promotion) returns 409 CONFLICT.
fetch("https://tokei.io/api/v1/contests/CONTEST_ID/entries", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${API_KEY}`,
},
body: JSON.stringify({
email: "newuser@example.com",
name: "Alice Johnson",
action_type: "email_signup",
points: 5,
value: "Shopify Order #98765",
metadata: { order_total: "49.99", source: "shopify" },
}),
});Successful creation returns 201 with the new entry:
{
"success": true,
"data": {
"id": "e5f6a7b8-c9d0-1234-ef01-345678901234",
"email": "newuser@example.com",
"full_name": "Alice Johnson",
"entry_points": 5,
"action_type": "email_signup",
"referral_code": "qR5tUv",
"created_at": "2026-02-17T12:00:00Z"
}
}Analytics
GET /api/v1/contests/:contestId/analytics
Aggregated promotion analytics: totals, action breakdown, daily entry counts, top countries, and referral stats.
{
"success": true,
"data": {
"contest_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"total_entries": 847,
"unique_participants": 312,
"total_points_awarded": 4235,
"actions_breakdown": { "email_signup": 312, "twitter_follow": 198 },
"daily_entries": [ { "date": "2026-02-10", "count": 45 } ],
"top_countries": [ { "country_code": "US", "country_name": "United States", "count": 156 } ],
"referral_stats": {
"total_referral_clicks": 234,
"total_referral_conversions": 45,
"conversion_rate": 0.192
}
}
}Leaderboard
GET /api/v1/contests/:contestId/leaderboard
Participants ranked by points (descending), paginated.
{
"success": true,
"data": [
{
"rank": 1,
"full_name": "John Doe",
"email": "john@example.com",
"entry_points": 85,
"referral_code": "mN3pQr",
"country_code": "GB"
}
],
"pagination": { "page": 1, "per_page": 10, "total_pages": 32, "total_count": 312 }
}Surveys
GET /api/v1/contests/:contestId/survey-responses
Survey responses collected from gamified promotions, newest first, paginated.
{
"success": true,
"data": [
{
"id": "f6a7b8c9-d0e1-2345-f012-456789012345",
"contest_user_id": "c3d4e5f6-a7b8-9012-cdef-123456789012",
"response": "I love gaming and would play every day!",
"question_index": 0,
"points_awarded": 3,
"is_completed": true,
"created_at": "2026-02-12T16:45:00Z"
}
],
"pagination": { "page": 1, "per_page": 25, "total_pages": 5, "total_count": 109 }
}Webhooks
Webhooks
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/v1/webhooks | List webhook subscriptions |
| POST | /api/v1/webhooks | Create a webhook |
| DELETE | /api/v1/webhooks/:webhookId | Delete a webhook |
Available events
| Event | Trigger |
|---|---|
| entry.created | New participant enters (via widget or API). |
contest.ended, winner.selected and daily_bonus.claimed are reserved names that are not yet emitted. Subscribing to them returns 422 VALIDATION_ERROR rather than accepting a subscription that would never deliver.
Create a webhook
Provide an HTTPS url and at least one event. The response includes the signing secret — shown only once:
curl -X POST "https://tokei.io/api/v1/webhooks" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://yourserver.com/webhooks/tokei", "events": ["entry.created"]}'
{
"success": true,
"data": {
"id": "w1h2k3i4-d5e6-7890-abcd-ef1234567890",
"url": "https://yourserver.com/webhooks/tokei",
"events": ["entry.created"],
"secret": "whsec_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
"is_active": true,
"created_at": "2026-02-17T12:00:00Z"
}
}Payload format
{
"event": "entry.created",
"timestamp": "2026-02-17T12:00:00Z",
"data": {
"contest_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"entry_id": "e5f6a7b8-c9d0-1234-ef01-345678901234",
"email": "newuser@example.com",
"full_name": "Alice Johnson",
"points": 5,
"action_type": "email_signup"
}
}Signature verification
Every delivery is signed with HMAC-SHA256 in the X-TOKEI-Signature header. Verify it against the raw request body using your webhook secret:
const crypto = require("crypto");
function verifyWebhookSignature(payload, signature, secret) {
const expected = crypto
.createHmac("sha256", secret)
.update(payload, "utf8")
.digest("hex");
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}Delivery behavior
- Your endpoint must respond with a 2xx status within 10 seconds.
- Failed deliveries are retried with backoff: 5s, 30s, then 5 minutes.
- Subscriptions are auto-disabled after 10 consecutive failures.
- Webhook URLs must use HTTPS; redirects are treated as failures.
Reference
Action Types
| Action type | Platform | Description |
|---|---|---|
| email_signup | Enter with email | |
| twitter_follow | Twitter/X | Follow an account |
| twitter_like | Twitter/X | Like a tweet |
| twitter_retweet | Twitter/X | Retweet a tweet |
| twitter_view_post | Twitter/X | View a post on X |
| twitter_tweet | Twitter/X | Post a tweet on X |
| instagram_follow | Follow an account | |
| instagram_view_post | View a post | |
| instagram_share_photo | Share a photo | |
| tiktok_follow | TikTok | Follow an account |
| tiktok_watch | TikTok | Watch a video |
| facebook_visit_page | Visit a page | |
| facebook_view_post | View a post | |
| facebook_join_group | Join a group | |
| discord_join | Discord | Join a server |
| twitch_follow | Twitch | Follow a channel |
| twitch_subscribe | Twitch | Subscribe to a channel |
| linkedin_follow | Follow a profile | |
| linkedin_company_follow | Follow a company page | |
| linkedin_share | Share campaign content | |
| linkedin_post | Create a post | |
| steam_wishlist | Steam | Add a game to wishlist |
| steam_play_game | Steam | Play or own a game |
| steam_join_group | Steam | Join a group |
| producthunt_follow | Product Hunt | Follow on Product Hunt |
| producthunt_vote | Product Hunt | Upvote on Product Hunt |
| visit_website | Web | Visit a URL |
| referral_share | Referral | Share referral link |
| referral_conversion_bonus | Referral | Bonus for referred signups |
| user_upload | Upload | Upload an image |
| daily_bonus | Gamification | Claim daily bonus |
| api_import | API | Entry created via API |
| oauth_login | Auth | Read-only — appears in analytics, not settable via POST |
| social_share | Web | Read-only — generic share tracked automatically |
| survey | Gamification | Read-only — survey response, not settable via POST |
Examples
Integration Examples
Shopify: award an entry on purchase
async function handleShopifyOrder(order) {
const CONTEST_ID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
const response = await fetch(
`https://tokei.io/api/v1/contests/${CONTEST_ID}/entries`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${API_KEY}`,
},
body: JSON.stringify({
email: order.customer.email,
name: `${order.customer.first_name} ${order.customer.last_name}`,
points: 10,
value: `Shopify Order #${order.order_number}`,
metadata: { order_total: order.total_price, source: "shopify" },
}),
}
);
const result = await response.json();
console.log("Entry created:", result.data.id);
}Python: list active promotions
import requests
API_KEY = "tokei_k_..."
response = requests.get(
"https://tokei.io/api/v1/contests",
params={"status": "active"},
headers={"Authorization": f"Bearer {API_KEY}"}
)
for promotion in response.json()["data"]:
print(f"{promotion['title']} - {promotion['total_entries']} entry actions")Full documentation index: tokei.io/llms.txt