Public API Reference
The Tokei Public API lets you manage promotions, create entries programmatically, pull analytics and leaderboards, and receive webhooks when participants enter. Every endpoint returns JSON, and all of them are versioned under /api/v1 and need an API key — except GET /api/public/contests, the unauthenticated listing of what is live on Tokei right now. 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. contestId in 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).
Versioning
Versioning & Deprecation
Every authenticated endpoint lives under /api/v1. Breaking changes ship under a new version prefix — /api/v2 — rather than by mutating /api/v1, so an integration written against v1 keeps working.
If a version is ever retired, it will be announced ahead of time: the affected endpoints will carry a Sunset response header naming the retirement date, alongside a published migration timeline. No version has been deprecated to date. The one endpoint outside this scheme is GET /api/public/contests, which is unauthenticated and unversioned by design.
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 (PATCH /api/v1/contests/:contestId alone is raised to 64KB, to fit a full entry_methods array). |
| 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 }
}No API key required
GET /api/public/contests
A public, unauthenticated list of every promotion that is live on Tokei right now — built for directories, aggregators and agents that want to browse rather than manage. Send no Authorization header: this route sits under /api/public, outside the /api/v1 base every other endpoint on this page uses, and ignores API keys entirely.
“Live” means the same thing it means in sitemap.xml: an active promotion with either no end date or an end date in the future. Every live promotion is listed — there is no per-creator suppression, so this feed and sitemap.xml always agree. Responses are cached for about 15 minutes.
| Query parameter | Default | Description |
|---|---|---|
| page_type | all four | Comma-separated subset of contest, levelup, promote, discover. An unrecognised value is a 400, not an empty list. |
| limit | 50 | Rows per response, 1–100. |
| offset | 0 | Rows to skip. total is the unpaginated count, so page until offset >= total. |
url is the canonical public address — the same one sitemap.xml lists, so it never redirects. description is plain text (the stored value is creator HTML and is stripped here). image is the promotion's own picture where it has one, falling back to the creator's account-level image; a video is used only when it can be turned into a still. image and ends_at can each be null, and prizes is empty for promotions that are not giveaways.
Each prize is the same object the authenticated GET /v1/contests/{id} returns — name and winners are always present, value is a number and is omitted along with currency when the creator did not set them.
curl "https://tokei.io/api/public/contests?page_type=contest,levelup&limit=20"
{
"success": true,
"data": {
"contests": [
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"title": "Win a 1961 Denizen Chronograph Watch",
"description": "Enter for a chance to win a restored 1961 chronograph.",
"url": "https://tokei.io/contest/9CV9Pp",
"page_type": "contest",
"image": "https://cdn.tokei.io/og-images/watch.jpg",
"prizes": [
{
"name": "1961 Denizen Chronograph",
"winners": 1,
"value": 480,
"currency": "usd"
}
],
"created_at": "2026-08-01T09:12:00Z",
"updated_at": "2026-08-20T14:03:00Z",
"ends_at": "2026-09-30T23:59:00Z"
}
],
"total": 4,
"limit": 20,
"offset": 0,
"generated_at": "2026-08-27T11:00:00Z"
}
}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. (Historical labelling slip: the 0.3.0 tarball misreported --version as 0.2.2; 0.3.1 and later report correctly.)
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 |
| referrals:top <contestId> | GET /contests/:contestId/referrals | Top referrers, ranked by conversions |
| winners:list <contestId> | GET /contests/:contestId/winners | Selection-run history, newest first, with each run's winners |
| 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 |
| actions:catalog | GET /actions/catalog | Every entry-action type Tokei supports — filter with --type |
| events:catalog | GET /events/catalog | Every webhook event Tokei's delivery engine understands — filter with --type |
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 to one or more of the 5 events — 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 21 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 not the 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 50) 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://media.tokei.io/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 64KB on this endpoint only — every other write endpoint on this page stays at the shared 10KB limit; this one was raised to fit a full 30-row entry_methods array. 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" | "simple" — 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, and simple the Custom template — bare structural markup styled by custom_css), custom_css (string, ≤20,000 chars, or null — creator CSS for the Custom template, applied on both the hosted page and the widget embed via --tokei-* custom properties and .tokei-simple-* class hooks; server-sanitised on write, so an unsafe construct returns 422 VALIDATION_ERROR naming the reason; null clears it), 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" | "xl", or the raw stored class "max-w-2xl" | "max-w-3xl" | "max-w-4xl" | "max-w-7xl" directly — friendly names map narrow→max-w-2xl, medium→max-w-3xl, wide→max-w-4xl, xl→max-w-7xl, 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; never rendered on the page itself, and a GIF or video here is dropped in favour of the default preview image, so use a JPG, PNG or WEBP). 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.
entry_methods (array, max 30) replaces the whole array — like prizes, there is no per-row patch, so GET the promotion first, modify the array, then PATCH the complete list back; [] clears every entry method and omitting the field leaves the stored array untouched. Each element is either an action row — { id?, actionType, label, points?, config?, requireVerification? }, where actionType must be one of the 26 writable types from GET /api/v1/actions/catalog — or a link row with no actionType — { id?, label, points?, link, actionsRequired? }, the shape the dashboard calls a Custom Link: a plain http(s) button to any URL, for anything the catalog has no action for. actionsRequired (integer 0–20, link rows only) hides the button until the entrant has completed that many other actions; an action row's equivalent threshold is a contest setting keyed by actionType, so it is not writable per row and is dropped if sent. Unknown keys (icon, config.type, and the ten legacy <platform>Config duplicates such as productHuntConfig) are silently stripped rather than rejected, so an agent can GET a live row and PATCH it back unmodified. icon and config.type are always server-derived from actionType and cannot be set by the client — whatever you send for them is ignored and overwritten.
Row id. Both row shapes may carry an optional id (string, 1–64 chars, [A-Za-z0-9_-] only) — a stable per-row identity, useful once a promotion holds several rows of the same actionType so a later PATCH can target the right one. It is optional and never required — every row written before this field existed has none, and an unmodified round-trip works with or without it. Two rows sharing an id in the same array is rejected with 422 VALIDATION_ERROR: Duplicate entry method id "<id>" (already used at index <n>).
Per-actionType cap. At most 5 rows may share one actionType, and only for twitter_follow, instagram_follow and facebook_visit_page — the three types the dashboard currently renders as duplicated rows. Every other action type, including one that is otherwise duplicate-eligible, is capped at 1 row, same as before this feature. Exceeding the cap is 422 VALIDATION_ERROR: Too many "twitter_follow" entry methods (max 5 per promotion). for the three types above, or Only one "tiktok_follow" entry method is allowed per promotion. for everything else.
points is 0–100; 0 is treated as unset and the renderer falls back to the action type's default. GET already returns the effective points (any settings.<actionType>_entry_value override applied), so an unmodified GET → PATCH round-trip persists that effective value into the raw stored points, not the value originally configured. Product Hunt and Steam actions go further still: their displayed points are hard-substituted with the platform default at render time, regardless of what is stored or read back.
One reserved label on a link row: anything starting with the capitalised "Visit Our " is the promotion's own campaign-visit button, owned by the campaign_url field — the dashboard rewrites and re-asserts that row on every render, so a value you PATCH into it is silently reverted the next time the owner opens the page editor. Any other label is yours. The lowercase "Visit our …" spelling is deliberately not reserved. Every link row renders as a clickable button that opens its link and credits its points, on all four page templates, whether or not the promotion has a campaign_url.
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. A count of 0 does not always mean a stub — two templates report 0 because their action lives on the page rather than in entry_methods: secret-codes, whose single action is the secret code, and survey-system, which entries through a mandatory survey and a photo upload. Cloning secret-codes enables the code input but copies no codes — those are stored per page and never cloned, so the owner adds their own in the dashboard.
{
"success": true,
"data": [
{
"id": "0e4de06c-8915-4e1c-ba3f-48f6c9a098f2",
"slug": "collect-email-list",
"name": "Collect email list — registration-first opt-in subscriber page",
"skin": "future",
"entry_method_count": 0
},
{
"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": "c0cc71a0-1ff5-46ab-8a23-c801aec30337",
"slug": "instagram-engagement",
"name": "Instagram engagement — follow & share photo giveaway",
"skin": "showcase",
"entry_method_count": 3
},
{
"id": "6ca0bdbc-23d8-4c79-a894-857eea485fbe",
"slug": "secret-codes",
"name": "Secret Code — unlock entries with a code (QR codes, receipts, printed inserts, events)",
"skin": "basic-new",
"entry_method_count": 0
},
{
"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
}
]
}An excerpt — 15 templates are live today, sorted by slug. The other ten: discord-community, facebook-promotion, family-friends, prelaunch-vips, product-hunt, survey-system, tiktok-growth, twitch-growth, x-followers, youtube-contest. Always call the endpoint rather than hardcoding this list.
Actions
Entry-Action Catalog
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/v1/actions/catalog | Every entry-action type Tokei supports |
The catalog is static platform data, not the caller's, so — like GET /api/v1/templates — it is not ownership-scoped: every authenticated key sees the same catalog, and there is no database read at all. Optional ?type= returns just that one action type's entry (a value matching no action type is a 400 BAD_REQUEST listing every valid value); omitting it returns all 37 action types keyed by actionType. Only 26 of the 37 are writable as an entry_methods row (isEntryMethodRow: true) — the other 11 are enabled through a contest setting or a dedicated route instead, and needs documents any deployment prerequisite (most often participant OAuth) an entry method of that type requires.
This is the authoritative per-type reference for PATCH /api/v1/contests/:contestId's entry_methods — each entry's fields array is exactly what that endpoint accepts under config. Fields sharing a group are ordered renderer-read-first: write the first field of the group. The rest are claim-validator aliases that satisfy validation but render a dead button on their own — each carries a note saying so.
tokei-agent actions:catalog --type twitter_follow
# or: fetch("https://tokei.io/api/v1/actions/catalog?type=twitter_follow", {
# headers: { Authorization: `Bearer ${API_KEY}` },
# });{
"success": true,
"data": {
"label": "Follow on X",
"description": "Follow an account on X (Twitter)",
"requiresAuth": false,
"defaultPoints": 3,
"platform": "twitter",
"trustBased": false,
"apiVerification": true,
"manualVerifiable": true,
"entryValueSettingKey": "twitter_follow_entry_value",
"isEntryMethodRow": true,
"fields": [
{ "key": "username", "type": "string", "required": true,
"note": "X/Twitter handle. Checked for presence only — no format validation." }
],
"needs": "Requires participant X/Twitter OAuth to be configured on this deployment (TWITTER_CLIENT_ID and TWITTER_CLIENT_SECRET); automatic verification additionally requires TWITTER_API_VERIFICATION_ENABLED to be set to \"true\"."
}
}Events
Webhook Event Catalog
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/v1/events/catalog | Every webhook event Tokei's delivery engine understands |
The catalog is static platform data, not the caller's, so — like GET /api/v1/actions/catalog — it is not ownership-scoped: every authenticated key sees the same catalog, and there is no database read at all. Optional ?type= returns just that one event's entry (a value matching no event name is a 400 BAD_REQUEST listing every valid value); omitting it returns all 5 events keyed by event name. Each entry carries description, payloadSchema (the shape of the delivered data field — see Webhooks for the outer envelope), emitSites, and subscribable (true for all 5 this stage).
tokei-agent events:catalog --type winner.selected
# or: fetch("https://tokei.io/api/v1/events/catalog?type=winner.selected", {
# headers: { Authorization: `Bearer ${API_KEY}` },
# });Sample response — the winners item schema is abbreviated here for readability; the real response spells out all eight per-winner properties (id, contest_user_id, email, full_name, points, prize_tier, prize_description, prize_value) with its own required list. Call the endpoint for the authoritative shape.
{
"success": true,
"data": {
"description": "Winner selection was finalized and persisted for a contest.",
"payloadSchema": {
"type": "object",
"properties": {
"contest_id": { "type": "string" },
"selection_run_id": { "type": "string" },
"winners": { "type": "array", "items": { "type": "object" } }
},
"required": ["contest_id", "selection_run_id", "winners"]
},
"emitSites": ["src/app/api/promotion/[contestId]/selection/route.ts"],
"subscribable": true
}
}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 CONFLICT with 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://media.tokei.io/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
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/v1/media | Upload an image or video (write scope) |
Requires the write scope. Send the file as multipart/form-data with a file part; the object is stored, so success is 201. Feed the returned 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.
The file part
| Part | Required | Description |
|---|---|---|
| file | Yes | The bytes, with a filename and a content type. The filename is echoed back but never stored — the object's stored name is always a server-generated UUID, so path traversal in it can only be echoed, never acted on. |
Accepted content types: 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. The part's declared type is the sole source of the stored extension. For images and video/mp4, the bytes are checked against it — a file whose leading bytes contradict the declared type is a 422, not a silently mislabelled object. video/webm and video/quicktime are accepted but not byte-checked; only the declared type decides those two.
Size: 100 bytes to 5 MB. The 5 MB cap applies to video as well as images — thin for video, so plan accordingly. An oversize file is a 413 in Tokei's own error format.
Abuse guard: max 50 uploads per account per UTC day — 429 RATE_LIMIT_EXCEEDED with Retry-After. The cap is checked after validation, so a rejected request does not burn quota.
Upload
const form = new FormData();
form.append("file", fileBlob, "hero.jpg");
const uploaded = await fetch("https://tokei.io/api/v1/media", {
method: "POST",
headers: { Authorization: `Bearer ${API_KEY}` },
body: form, // do NOT set Content-Type yourself — the boundary must match
}).then((r) => r.json());201 returns the stored object:
{
"success": true,
"data": {
"bucket": "tokei-media",
"path": "api-uploads/usr_a1b2c3d4/0f8fad5b-d9cb-469f-a165-70867728950e.jpg",
"public_url": "https://media.tokei.io/api-uploads/usr_a1b2c3d4/0f8fad5b-d9cb-469f-a165-70867728950e.jpg",
"content_type": "image/jpeg",
"filename": "hero.jpg",
"size_bytes": 482113,
"backend": "r2"
}
}backend names the store the object landed in, and public_url is always absolute — read the URL from the response rather than assembling one from bucket and path, because the host is migrating.
Errors specific to this endpoint
| Status | Code | Note |
|---|---|---|
| 410 | GONE | The retired JSON signed-upload ticket shape. Send the file as multipart/form-data instead. |
| 413 | PAYLOAD_TOO_LARGE | File over 5 MB. |
| 422 | VALIDATION_ERROR | Missing file part, a content type outside the seven accepted values, a file under 100 bytes, or bytes that do not match the declared type. |
| 429 | RATE_LIMIT_EXCEEDED | 50 uploads/day per account. |
| 500 | INTERNAL_ERROR | The store rejected the write. |
Then PATCH it onto a promotion
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: uploaded.data.public_url }),
});Deprecated: the signed-upload ticket
Sending application/json with filename, content_type and size_bytes still returns a two-step signed upload ticket (200) for now. It cannot survive the move to the new media host — it mints a storage-specific token — and answers 410 once that move completes. Move to the multipart form above.
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 }
}Referrals
GET /api/v1/contests/:contestId/referrals
Top referrers, ranked by converted referrals then total referrals, paginated — plus whole-promotion totals. Every participant is issued a referral code, so entrants who have not actually referred anyone are left out. converted_referrals counts referred people who went on to complete an entry action other than sharing. bonus_points_earned is a count of bonus entries, not a points total.
{
"success": true,
"data": [
{
"rank": 1,
"referrer_id": "88941e21-0229-4d61-b639-ddbcae3b78ef",
"referral_code": "4IXgZbGo",
"full_name": "John Doe",
"email": "john@example.com",
"total_referrals": 3,
"converted_referrals": 3,
"bonus_points_earned": 3
}
],
"totals": {
"total_referrers": 5,
"total_referrals": 8,
"total_clicks": 279,
"converted_clicks": 7,
"click_conversion_rate": 2.5
},
"pagination": { "page": 1, "per_page": 25, "total_pages": 1, "total_count": 5 }
}Winners
GET /api/v1/contests/:contestId/winners
Read-only, no query parameters. Selection runs for this promotion, newest first, each with its persisted winners nested and the same entrant identity fields the dashboard already shows the owner. Finalizing a selection stays dashboard-only (human-approval policy) — this endpoint is how an agent looks back at what a run actually selected, not how it draws one. No pagination: contest-scale run and winner counts make a fixed cap (100 runs) sufficient headroom rather than a pagination story.
{
"success": true,
"data": [
{
"id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"created_at": "2026-08-01T09:00:00Z",
"seed": "8f2a1c",
"algorithm_version": "v1",
"status": "finalized",
"requested_by_email": "owner@example.com",
"finalized_at": "2026-08-01T09:00:05Z",
"total_candidates": 312,
"total_winners_selected": 1,
"winners_count": 1,
"winners": [
{
"id": "c3d4e5f6-a7b8-9012-cdef-123456789012",
"contest_user_id": "88941e21-0229-4d61-b639-ddbcae3b78ef",
"email": "john@example.com",
"full_name": "John Doe",
"entry_points": 85,
"created_at": "2026-07-15T10:00:00Z",
"country_name": "United Kingdom",
"city": "London",
"prize_tier": "grand_prize",
"prize_description": "AirPods Pro",
"prize_value": 249,
"selected_at": "2026-08-01T09:00:05Z",
"notified_at": null,
"notification_method": null,
"verified": false,
"claimed_at": null
}
]
}
]
}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 | A participant completed an entry action (email, OAuth, or a public-API entry write). |
| winner.selected | Winner selection was finalized and persisted for a contest. |
| contest.ended | A contest's end date passed, observed by the contest-lifecycle worker (polls every 5 minutes). |
| daily_bonus.claimed | A participant claimed their daily bonus entry for a contest. |
| referral.converted | A referred participant joined a contest and the referrer's conversion bonus was awarded. |
All 5 events are emitted and subscribable through this API. See GET /api/v1/events/catalog (also events:catalog in the CLI/MCP) for each event's full payload shape, live. Per-contest creator webhooks, configured from the Tokei dashboard rather than this API, subscribe to a fixed, curated trio — entry.created, winner.selected, contest.ended — with no event-picker UI; daily_bonus.claimed and referral.converted stay developer-API-only, reachable only through POST /api/v1/webhooks above.
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
Every delivery shares the same envelope — event, timestamp, data — with data's shape varying per event. entry.created below is one example; GET /api/v1/events/catalog is the authoritative, per-event reference for all 5.
{
"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",
"first_name": "Alice",
"last_name": "Johnson",
"entry_source": "email_signup",
"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 |
| youtube_visit_channel | YouTube | Visit 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 |
This table is a static summary. GET /api/v1/actions/catalog (also actions:catalog in the CLI/MCP) is the live, authoritative per-type reference — platform, default points, per-type config fields, and any deployment prerequisite.
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