ClipCard

ClipCard API

Base URL: https://api.clipcard.app (locally http://localhost:8080). All request and response bodies are JSON unless noted.

Developers authenticate with an API key and pay in credits (see API keys and credits). The web app's visitors are free and limited per IP address, unless its session cookie says who they are (see Accounts).

Quick start

Create a key on your account page, then:

curl -X POST https://api.clipcard.app/v1/images \
  -H "X-API-Key: cc_live_…" -H "Content-Type: application/json" \
  -d '{"url": "https://x.com/user/status/1840000000000000001", "theme": "dark"}' \
  -o card.png

Videos run as jobs: POST /v1/videos answers 202 with a job to poll at GET /v1/jobs/{id}, or pass webhookUrl to be told when it finishes.

Platforms

Every endpoint that takes a url accepts X posts (x.com, twitter.com and their mirrors) and TikTok posts (tiktok.com/@user/video/… and tiktok.com/@user/photo/… slideshows, and the vm.tiktok.com, vt.tiktok.com and tiktok.com/t/… short links). A bare numeric tweetId always means an X post.

Posts carry "platform": "x" or "platform": "tiktok". TikTok posts can be downloaded (/v1/downloads): the original MP4 without TikTok's watermark, the sound alone, or a slideshow's photos (see Download outputs). TikTok posts also carry a sound (title, author, url, durationMs) and TikTok's metrics.views and metrics.shares. Images and videos of TikTok posts are drawn as TikTok-styled cards: bold hashtags, the sound's name under the media, and views, likes, comments and shares. Every TikTok job fetches the post afresh, because TikTok's media links expire within hours.

Endpoints

MethodPathReturns
GET/v1/posts?url=<post URL>The post and its media, from X or TikTok
GET/v1/tweets?url=<post URL> or /v1/tweets/{id}The same; the original name, kept for existing clients
POST/v1/imagesPNG in the body; add ?response=url for JSON with a download link
POST/v1/videos202 and a job to poll
POST/v1/downloads202 and a job to poll (the original video, sound or photos; see Download outputs)
GET/v1/jobs/{id}Job status, and a download link once done
GET/v1/accountCredit balance, plan, costs and rate limit (API key or session)
GET/v1/limitsRenders left today, without using one
GET/v1/meWho is signed in, their plan, credits and limits
GET/v1/historyThe signed-in user's renders from the last 24 hours
/v1/auth/…, /v1/account/…Sign-in and account settings; see Accounts
GET/files/<key>?exp=…&name=…&sig=…A rendered file (signed link from a job; local storage only)
GET/media?url=<video.twimg.com link>An X video streamed through, for the web app's original-video download when X blocks the page from reading it (200 a day per IP)
GET/healthz{"ok": true} when Valkey (and Postgres, if used) are reachable

Render request

POST /v1/images, /v1/videos and /v1/downloads take the same body. Only url (or tweetId) is required.

{
  "url": "https://x.com/user/status/1840000000000000001",
  "format": "9x16",
  "theme": "dark",
  "background": "midnight",
  "show": { "metrics": true, "date": true, "verified": true, "quote": true },
  "media": { "from": "post", "index": 0 },
  "grid": false,
  "timeZone": "Africa/Nairobi",
  "quality": "hd",
  "webhookUrl": "https://example.com/clipcard-hook"
}
FieldValuesDefault
format9x16, 4x5, 1x19x16
themelight, dim, darkdark
backgroundsunset, ocean, forest, midnight, peach, lavender, slate, white, blackmidnight
show.*booleans; unset means shownall shown
mediafrom: post or quoted; index from the post's media listfirst video, then first photo
gridimages only: show 2–4 photos as a gridfalse
outputdownloads only: video, audio or photos; see Download outputsphotos for a post with photos and no video, else video
timeZoneIANA name for the date lineUTC
qualityfree (720p, watermark) or hd (needs a plan or an API key)hd with a plan or key, else free
webhookUrlAPI keys only: an https URL called when the job finishes; see Webhooksnone
turnstileTokenWeb app only: Cloudflare Turnstile token, also accepted in the CF-Turnstile-Response headerrequired when bot checks are on

Identical requests within 24 hours return the same job and file.

Download outputs

output picks what /v1/downloads makes. Every output costs the same, one daily render or 2 credits.

outputFileNotes
videoclipcard-<id>-download.mp4The video media picks, or the first one
audioclipcard-<id>-sound.m4aA video's sound, copied out without re-encoding. For a TikTok slideshow (no media), its own sound file in an M4A container. The plan's video length limit applies
photosclipcard-<id>-photos.zip: photo-01.jpg, photo-02.jpg, …, and sound.mp3 (or .m4a)The post's own photos in order, at the size the platform serves. The sound is left out if it cannot be fetched. No length limit

A post without the asked-for output answers no_video, no_audio or no_photos. The post's text, author, date and counts come from GET /v1/posts, not the download.

API keys and credits

Create keys on the account page (your email address must be confirmed first; up to 10 keys). A key is shown once: store it like a password, and call the API from your server only. Browsers cannot send keys, since the API does not allow the header across sites. Send it on every request:

X-API-Key: cc_live_…

Keys work on /v1/posts, /v1/tweets, /v1/images, /v1/videos, /v1/downloads, /v1/jobs/{id} and GET /v1/account. A missing key makes the request a free visitor's; a wrong or revoked key answers 401 invalid_api_key.

Every key request gets the best quality up to 1080p, no watermark and videos up to 10 minutes, and pays in credits instead of counting against a daily limit:

RequestCredits
POST /v1/images1
POST /v1/downloads2
POST /v1/videos5
Everything else0
  • A request is charged once it passes every check (bad input costs nothing), and the balance after it comes back in X-Credits-Remaining.
  • A job that fails is refunded automatically, within a few seconds, whether or not you poll it.
  • Asking again for a render you already paid for, within 24 hours, returns the same job at no charge, so retrying after a timeout is safe. A render cached from someone else's request costs the usual credits.
  • Without enough credits the request answers 402 insufficient_credits.
  • New accounts get 50 credits once; API plans add their monthly credits.
  • Each key may make 10 requests a second. Over that answers 429 rate_limited with a Retry-After header in seconds.

GET /v1/account:

{
  "user": { "id": "12", "email": "[email protected]", "name": "Ann" },
  "plan": { "id": "api_starter", "name": "API Starter", "kind": "api", "endsAt": "…" },
  "credits": 487,
  "costs": { "image": 1, "video": 5, "download": 2 },
  "rateLimit": { "perSecond": 10 }
}

Webhooks

Pass webhookUrl with a render and ClipCard sends a POST there when the job finishes, done or failed. The URL must be https on a public host. If you ask again for the same render with a new webhookUrl, that URL is called too.

{
  "id": "evt_q3m7…",
  "type": "job.done",
  "createdAt": "2026-09-30T19:05:08Z",
  "job": { "id": "job_k3v7q2m…", "type": "video", "status": "done", "download": { "url": "…" } }
}

type is job.done or job.failed; job is the same object as GET /v1/jobs/{id} returns. Answer with any 2xx status within 10 seconds. Anything else (including redirects) is retried 5 times, after 10 seconds, 1 minute, 5 minutes, 30 minutes and 2 hours. The same event can arrive more than once: use id (also in the ClipCard-Event-Id header) to ignore repeats. The account page lists recent calls and their errors.

Each call is signed with your account's webhook secret (whsec_…, on the account page):

ClipCard-Signature: t=1727723108,v1=5257a869e7…

v1 is the hex HMAC-SHA256 of the timestamp, a dot and the raw body, keyed with the secret. To check a call, compute it over the body exactly as received, compare in constant time, and reject timestamps more than 5 minutes old. In Node:

import { createHmac, timingSafeEqual } from 'node:crypto'

function verify(secret, header, rawBody) {
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')))
  if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false
  const want = createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex')
  return parts.v1?.length === want.length && timingSafeEqual(Buffer.from(parts.v1), Buffer.from(want))
}

Making a new secret on the account page takes effect at once, including for calls still being retried.

Jobs

{
  "job": {
    "id": "job_k3v7q2m…",
    "type": "video",
    "status": "done",
    "progress": 100,
    "createdAt": "2026-09-30T19:05:01Z",
    "finishedAt": "2026-09-30T19:05:07Z",
    "download": {
      "url": "https://….r2.cloudflarestorage.com/clipcard-files/2026/09/30/job_k3v7q2m….mp4?X-Amz-…",
      "previewUrl": "https://….r2.cloudflarestorage.com/clipcard-files/2026/09/30/job_k3v7q2m….mp4?X-Amz-…",
      "filename": "clipcard-1840000000000000001-video.mp4",
      "contentType": "video/mp4",
      "bytes": 444111,
      "expiresAt": "2026-10-01T19:05:01Z"
    }
  }
}

status is queued, running, done, failed or expired (the file was deleted after 24 hours). Poll every second or two until done or failed.

url saves the file under filename; previewUrl (images and videos only) shows it in the browser, for <img> and <video> tags. Both are signed, expire at expiresAt and must be used exactly as given: changing any part breaks the signature. They point at Cloudflare R2, which serves the file directly; a self-hosted install without R2 serves them from the API's /files/ path instead. Download links support HTTP range requests, so videos can seek.

Limits

API keys pay in credits instead; see above. The rest of this section is about the web app.

Free requests are limited per IP address per UTC day (10 by default), whether or not the caller is signed in. A signed-in user with a plan skips the bot check and has a per-user fair-use cap instead (500 a day by default), videos up to 10 minutes, and no watermark. Every render response carries:

  • X-RateLimit-Limit
  • X-RateLimit-Remaining
  • X-RateLimit-Reset (Unix time of the next reset)

Requests rejected for bad input do not count. A job that fails on our side is refunded the first time its failed status is read.

Accounts

These routes serve the web app. Signing in sets clipcard_session, an HttpOnly, SameSite=Lax cookie on the API's host, valid for 30 days and extended while in use. The web app and API share a parent domain, so the browser sends it with credentials: "include". State-changing requests count the cookie only when their Origin is the web app; /v1/auth/* and /v1/account/* refuse other origins with bad_origin.

Without a database (CLIPCARD_DATABASE_URL unset) the API runs with accounts off: /v1/me reports accounts.enabled: false and these routes answer accounts_disabled.

MethodPathBodyDoes
POST/v1/auth/signupemail, password (8–128 characters), name, turnstileTokenCreates an account, signs in, emails a link that confirms the address; 201 with the /v1/me body
POST/v1/auth/loginemail, password, turnstileTokenSigns in; the /v1/me body
POST/v1/auth/linkemail, next, turnstileTokenEmails a one-time sign-in link, valid 15 minutes; 202 whether or not the address has an account
POST/v1/auth/link/verifytokenUses the link's token (from the web page /auth/verify?token=…); returns user and next
POST/v1/auth/logoutEnds the session
GET/v1/auth/{google,github}/start?next=/pathRedirects to the provider; afterwards back to the web app at next, or to /sign-in?error=…
PATCH/v1/accountnameRenames the user
POST/v1/account/passwordcurrentPassword (when one is set), newPasswordSets or changes the password and signs out other sessions
GET/v1/account/creditsbalance and the latest ledger entries
GET/v1/account/keyskeys: id, name, prefix, createdAt, lastUsedAt
POST/v1/account/keysnameMakes a key; 201 with key (shown this once) and apiKey
DELETE/v1/account/keys/{id}Revokes a key
GET/v1/account/webhooksThe webhook secret (made on first use) and the latest deliveries
POST/v1/account/webhooks/secretReplaces the webhook secret; returns the new secret
POST/v1/billing/checkoutplan (pro, api_starter, api_growth, api_scale), billing (monthly or annual)Starts a Paystack payment; returns url (send the browser there) and the pending payment
POST/v1/billing/confirmreferenceChecks the user's payment with Paystack and applies it if paid; returns payment with status pending or paid

A payment buys one month or one year of a plan; plans never renew on their own. Buying the plan you have adds the time to the end of it; buying another replaces it from that day. An API plan's credits arrive with the payment (twelve months' worth for a year). Paystack returns the browser to the web app's /billing/return?reference=…, which calls confirm; Paystack's webhook (POST /v1/billing/paystack/webhook, signed) applies the payment too, so it counts even if the browser never comes back. Without Paystack configured these routes answer billing_disabled.

The admin dashboard's routes (/v1/admin/*) answer only users with the admin role and are not part of the public API.

All sign-in methods share one account per email address. Google and GitHub must report the address as verified. An account made with a password whose address was never confirmed belongs to whoever later proves the address (by Google, GitHub or an emailed link): its password is removed and its sessions end, so nobody can claim someone else's email ahead of them.

GET /v1/me works for everyone:

{
  "user": { "id": "12", "email": "[email protected]", "emailVerified": true, "name": "Ann", "role": "user",
            "createdAt": "…", "signInMethods": ["google", "password"] },
  "plan": { "id": "pro", "name": "Pro", "kind": "pro", "endsAt": "…" },
  "credits": 50,
  "limits": { "quality": "hd", "watermark": false, "maxVideoSeconds": 600,
              "dailyLimit": 500, "remaining": 498, "resetAt": "…" },
  "accounts": { "enabled": true, "methods": ["google", "github", "email", "password"] },
  "billing": { "enabled": true, "currency": "USD" }
}

user, plan and credits are absent or null for visitors and free users. GET /v1/history returns {"jobs": [...]}: each is a job plus requestedAt, tweet (id, author, text) and the render settings. Jobs whose files are gone show status: "expired".

Errors

Every error has the same shape:

{ "error": { "code": "video_too_long", "message": "This video is 5m00s; the limit on this plan is 2m00s." } }
CodeHTTPMeaning
invalid_request400Bad JSON or an unknown option value
invalid_url400Not an X or TikTok post link
tweet_not_found404The post does not exist or was deleted
tweet_unavailable403The post is not public
tweet_blocked451The post or its author is on the takedown list
banned403The account or network address is blocked
no_video422A video or download was asked for, but the chosen media is not a video
no_audio422output: "audio" on a post with no video and no sound, or the video has no sound track (job error)
no_photos422output: "photos" on a post with no photos of its own
media_not_found422media points at an item the post does not have
video_too_long422Longer than the plan allows
plan_required403hd quality needs a plan
bot_check_failed403Turnstile token missing or invalid
invalid_api_key401The API key is wrong or revoked
insufficient_credits402Not enough credits for this request
rate_limited429Today's free renders are used up, or a key's per-second limit was hit (see Retry-After)
source_unavailable503X or TikTok did not respond, or TikTok offered no video without its watermark
render_failed500Rendering failed on our side
job_not_found404Unknown or expired job
link_invalid403Download link tampered with or expired (/files/ links)
file_expired410The file was deleted after 24 hours (/files/ links)
sign_in_required401The route needs a signed-in user
invalid_credentials401Wrong email or password
invalid_email400Not an email address
weak_password400Password shorter than 8 or longer than 128 characters
email_taken409Sign-up for an address that has an account
wrong_password403The current password given to change it is wrong
sign_in_link_invalid400Emailed link used, expired or made up
bad_origin403Account route called from outside the web app
too_many_attempts429Too many sign-in attempts or emails; wait a few minutes
email_unverified403Creating an API key needs a confirmed email address
too_many_keys409The account already has 10 active keys
key_not_found404No such API key on this account
accounts_disabled503The server runs without a database
billing_disabled503Payments are not set up on this server
payment_not_found404No payment with that reference for this user
payment_mismatch409Paystack reports a different amount or currency than the plan's price
payment_unavailable503Paystack did not respond