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

  1. Log in to your Tokei dashboard and go to Dashboard > Settings > API.
  2. Click Create New Key, name it, and copy the full key — it is shown only once.
  3. 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 saysDashboard / people sayNotes
contestpage, campaign, promotionThe core object. contestIdin a path is the page's id.
promotionpage, campaignThe same object again — POST /promotions creates it, and it reads back under /contests/:contestId.
entrysignup, subscriber, entrantOne person joining a page.
titleheadlineThe visible page headline — not the internal dashboard name (campaign_name, read-only).
settings.templateskin, designbasic-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

StatusCodeDescription
200OKRequest succeeded.
201CREATEDResource created.
400BAD_REQUESTInvalid parameters.
401UNAUTHORIZEDMissing, invalid, or revoked API key.
403FORBIDDENValid key but insufficient plan or missing scope.
404NOT_FOUNDPromotion or resource not found.
409CONFLICTDuplicate entry (email already entered this promotion) or reused idempotency_key.
409WINNERS_ALREADY_SELECTEDAn end_date write — setting or clearing — on a promotion whose winners have already been drawn. The deadline is frozen once a draw has happened.
413PAYLOAD_TOO_LARGERequest body exceeds the 10KB limit.
422VALIDATION_ERRORRequest body failed validation — see the details array.
429RATE_LIMIT_EXCEEDEDToo many requests — see the Retry-After header.
500INTERNAL_ERRORServer 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

PlanRead / minWrite / minDaily cap
Subscriber603010,000
Lifetime1206050,000

Every response includes rate-limit headers; 429 responses also include Retry-After:

X-RateLimit-Limit: 60
X-RateLimit-Remaining: 45
X-RateLimit-Reset: 1708200000

The 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 --help

Requires Node 22+. Published on npm; the agent landing page is at tokei.io/agent.

Configuration

VariableRequiredMeaning
TOKEI_API_KEYYesSent as Authorization: Bearer <key>. Create one at Dashboard > Settings > API.
TOKEI_API_URLNoBase 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 codeMeaning
0Success (HTTP 2xx).
1API or network error. The JSON error body is still printed on stdout; pure network failures print {"ok": false, "error": {"type": "network_error", ...}}.
2Usage 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)

CommandEndpointDoes
meGET /meVerify the key; account, plan, API usage
pages:listGET /contestsList pages — --status, --mode, --page, --per-page
pages:get <contestId>GET /contests/:contestIdOne page in full (prizes, reward tiers, media, public URL)
stats <contestId>GET /contests/:contestId/analyticsAggregated analytics
leaderboard <contestId>GET /contests/:contestId/leaderboardParticipants ranked by points
entries:list <contestId>GET /contests/:contestId/entriesSignups — filter with --email
surveys:list <contestId>GET /contests/:contestId/survey-responsesSurvey responses
webhooks:listGET /webhooksList webhook subscriptions (no write scope needed)
templates:listGET /templatesNamed starting points, for pages:clone --template

Write (read+write key)

CommandEndpointDoes
pages:clonePOST /promotionsCreate a page from one you own, a named template, or the starter. 20/day cap
media:upload <file>POST /media, then PUT to storageUpload an image or video and get back a public_url. ≤5MB per file (video too)
pages:update <contestId>PATCH /contests/:contestIdUpdate title, description, dates, prizes, reward tiers, appearance and media
pages:publish <contestId>PATCH /contests/:contestIdSugar for {"status": "active"} — needs a future end_date
pages:unpublish <contestId>PATCH /contests/:contestIdSugar for {"status": "draft"} — blocks new signups, but the page still renders publicly
entries:create <contestId>POST /contests/:contestId/entriesAdd a signup
webhooks:createPOST /webhooksSubscribe an HTTPS endpoint — the whsec_ secret is shown once
webhooks:delete <webhookId>DELETE /webhooks/:webhookIdRemove 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 mcp

Or 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:list becomes pages_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, with isError set 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.

  1. Check the key and plan first. API access requires an active subscription or lifetime plan — trial accounts get 403 on every call, so a whole workflow can fail on its first request for a reason no other error explains. Note that me reports the plan but notthe key's scope: you discover a read-only key by getting 403 FORBIDDEN on your first write.
  2. Media must be uploaded through Tokei first. The seven media fields on PATCH /api/v1/contests/:contestId are guarded by a host allowlist, so local paths and third-party URLs are rejected. Upload, then patch the public_url you get back — see Upload Media.
  3. List fields replace wholesale. prizes (max 20) and reward_thresholds (max 50) are not merged — whatever array you send becomes the entire list. Always read, modify, then write the complete list back.
  4. 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 status separates 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.

MethodEndpointDescription
GET/api/v1/contestsList all your promotions
GET/api/v1/contests/:contestIdRetrieve 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

MethodEndpointDescription
PATCH/api/v1/contests/:contestIdUpdate 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 narrowmax-w-2xl, mediummax-w-3xl, widemax-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

MethodEndpointDescription
GET/api/v1/templatesList 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)

MethodEndpointDescription
POST/api/v1/promotionsClone 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.

Automation flow: a product-launch trigger generates campaign copy and a hero image, then a single POST to /api/v1/promotions returns a live public page URL.
  1. Trigger fires (e.g. a new product in your store, an RSS item).
  2. AI drafts the title, description and prize.
  3. The image is uploaded to your Supabase bucket (or Cloudinary) → you get its URL.
  4. One POST with status: "active" creates a live promotion.
  5. The 201 response's public_url is 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"
  }
}
Anatomy of a generated promotion: the title becomes the public heading, image_url becomes the hero image, prize sets the prize, and the subheading is inherited from the starter template.

Media

Upload Media (Two-Step)

MethodEndpointDescription
POST/api/v1/mediaGet 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

FieldTypeRequiredDescription
filenamestring, 1–255 charsYesValidated (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_typeenum, 7 valuesYesimage/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_bytesinteger, 100–5,242,880YesDeclared 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

StatusCodeNote
413PAYLOAD_TOO_LARGEOnly 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.
422VALIDATION_ERRORBad filename, content_type, or size_bytes — both too-small and too-large size_bytes report on the field size_bytes.
429RATE_LIMIT_EXCEEDED50 tickets/day, counting tickets issued, not completed uploads.
500INTERNAL_ERRORStorage 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

MethodEndpointDescription
GET/api/v1/contests/:contestId/entriesList entries (filter with ?email=)
POST/api/v1/contests/:contestId/entriesCreate 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

MethodEndpointDescription
GET/api/v1/webhooksList webhook subscriptions
POST/api/v1/webhooksCreate a webhook
DELETE/api/v1/webhooks/:webhookIdDelete a webhook

Available events

EventTrigger
entry.createdNew 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 typePlatformDescription
email_signupEmailEnter with email
twitter_followTwitter/XFollow an account
twitter_likeTwitter/XLike a tweet
twitter_retweetTwitter/XRetweet a tweet
twitter_view_postTwitter/XView a post on X
twitter_tweetTwitter/XPost a tweet on X
instagram_followInstagramFollow an account
instagram_view_postInstagramView a post
instagram_share_photoInstagramShare a photo
tiktok_followTikTokFollow an account
tiktok_watchTikTokWatch a video
facebook_visit_pageFacebookVisit a page
facebook_view_postFacebookView a post
facebook_join_groupFacebookJoin a group
discord_joinDiscordJoin a server
twitch_followTwitchFollow a channel
twitch_subscribeTwitchSubscribe to a channel
linkedin_followLinkedInFollow a profile
linkedin_company_followLinkedInFollow a company page
linkedin_shareLinkedInShare campaign content
linkedin_postLinkedInCreate a post
steam_wishlistSteamAdd a game to wishlist
steam_play_gameSteamPlay or own a game
steam_join_groupSteamJoin a group
producthunt_followProduct HuntFollow on Product Hunt
producthunt_voteProduct HuntUpvote on Product Hunt
visit_websiteWebVisit a URL
referral_shareReferralShare referral link
referral_conversion_bonusReferralBonus for referred signups
user_uploadUploadUpload an image
daily_bonusGamificationClaim daily bonus
api_importAPIEntry created via API
oauth_loginAuthRead-only — appears in analytics, not settable via POST
social_shareWebRead-only — generic share tracked automatically
surveyGamificationRead-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