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
| Method | Path | Returns |
|---|---|---|
| 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/images | PNG in the body; add ?response=url for JSON with a download link |
| POST | /v1/videos | 202 and a job to poll |
| POST | /v1/downloads | 202 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/account | Credit balance, plan, costs and rate limit (API key or session) |
| GET | /v1/limits | Renders left today, without using one |
| GET | /v1/me | Who is signed in, their plan, credits and limits |
| GET | /v1/history | The 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"
}
| Field | Values | Default |
|---|---|---|
format | 9x16, 4x5, 1x1 | 9x16 |
theme | light, dim, dark | dark |
background | sunset, ocean, forest, midnight, peach, lavender, slate, white, black | midnight |
show.* | booleans; unset means shown | all shown |
media | from: post or quoted; index from the post's media list | first video, then first photo |
grid | images only: show 2–4 photos as a grid | false |
output | downloads only: video, audio or photos; see Download outputs | photos for a post with photos and no video, else video |
timeZone | IANA name for the date line | UTC |
quality | free (720p, watermark) or hd (needs a plan or an API key) | hd with a plan or key, else free |
webhookUrl | API keys only: an https URL called when the job finishes; see Webhooks | none |
turnstileToken | Web app only: Cloudflare Turnstile token, also accepted in the CF-Turnstile-Response header | required 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.
output | File | Notes |
|---|---|---|
video | clipcard-<id>-download.mp4 | The video media picks, or the first one |
audio | clipcard-<id>-sound.m4a | A 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 |
photos | clipcard-<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:
| Request | Credits |
|---|---|
POST /v1/images | 1 |
POST /v1/downloads | 2 |
POST /v1/videos | 5 |
| Everything else | 0 |
- 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_limitedwith aRetry-Afterheader 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-LimitX-RateLimit-RemainingX-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.
| Method | Path | Body | Does |
|---|---|---|---|
| POST | /v1/auth/signup | email, password (8–128 characters), name, turnstileToken | Creates an account, signs in, emails a link that confirms the address; 201 with the /v1/me body |
| POST | /v1/auth/login | email, password, turnstileToken | Signs in; the /v1/me body |
| POST | /v1/auth/link | email, next, turnstileToken | Emails a one-time sign-in link, valid 15 minutes; 202 whether or not the address has an account |
| POST | /v1/auth/link/verify | token | Uses the link's token (from the web page /auth/verify?token=…); returns user and next |
| POST | /v1/auth/logout | Ends the session | |
| GET | /v1/auth/{google,github}/start?next=/path | Redirects to the provider; afterwards back to the web app at next, or to /sign-in?error=… | |
| PATCH | /v1/account | name | Renames the user |
| POST | /v1/account/password | currentPassword (when one is set), newPassword | Sets or changes the password and signs out other sessions |
| GET | /v1/account/credits | balance and the latest ledger entries | |
| GET | /v1/account/keys | keys: id, name, prefix, createdAt, lastUsedAt | |
| POST | /v1/account/keys | name | Makes a key; 201 with key (shown this once) and apiKey |
| DELETE | /v1/account/keys/{id} | Revokes a key | |
| GET | /v1/account/webhooks | The webhook secret (made on first use) and the latest deliveries | |
| POST | /v1/account/webhooks/secret | Replaces the webhook secret; returns the new secret | |
| POST | /v1/billing/checkout | plan (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/confirm | reference | Checks 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." } }
| Code | HTTP | Meaning |
|---|---|---|
invalid_request | 400 | Bad JSON or an unknown option value |
invalid_url | 400 | Not an X or TikTok post link |
tweet_not_found | 404 | The post does not exist or was deleted |
tweet_unavailable | 403 | The post is not public |
tweet_blocked | 451 | The post or its author is on the takedown list |
banned | 403 | The account or network address is blocked |
no_video | 422 | A video or download was asked for, but the chosen media is not a video |
no_audio | 422 | output: "audio" on a post with no video and no sound, or the video has no sound track (job error) |
no_photos | 422 | output: "photos" on a post with no photos of its own |
media_not_found | 422 | media points at an item the post does not have |
video_too_long | 422 | Longer than the plan allows |
plan_required | 403 | hd quality needs a plan |
bot_check_failed | 403 | Turnstile token missing or invalid |
invalid_api_key | 401 | The API key is wrong or revoked |
insufficient_credits | 402 | Not enough credits for this request |
rate_limited | 429 | Today's free renders are used up, or a key's per-second limit was hit (see Retry-After) |
source_unavailable | 503 | X or TikTok did not respond, or TikTok offered no video without its watermark |
render_failed | 500 | Rendering failed on our side |
job_not_found | 404 | Unknown or expired job |
link_invalid | 403 | Download link tampered with or expired (/files/ links) |
file_expired | 410 | The file was deleted after 24 hours (/files/ links) |
sign_in_required | 401 | The route needs a signed-in user |
invalid_credentials | 401 | Wrong email or password |
invalid_email | 400 | Not an email address |
weak_password | 400 | Password shorter than 8 or longer than 128 characters |
email_taken | 409 | Sign-up for an address that has an account |
wrong_password | 403 | The current password given to change it is wrong |
sign_in_link_invalid | 400 | Emailed link used, expired or made up |
bad_origin | 403 | Account route called from outside the web app |
too_many_attempts | 429 | Too many sign-in attempts or emails; wait a few minutes |
email_unverified | 403 | Creating an API key needs a confirmed email address |
too_many_keys | 409 | The account already has 10 active keys |
key_not_found | 404 | No such API key on this account |
accounts_disabled | 503 | The server runs without a database |
billing_disabled | 503 | Payments are not set up on this server |
payment_not_found | 404 | No payment with that reference for this user |
payment_mismatch | 409 | Paystack reports a different amount or currency than the plan's price |
payment_unavailable | 503 | Paystack did not respond |