clips API
Launch clip campaigns, follow submissions and payouts, and pay clippers per view in USDC, from your own code.
The API does everything the website does. Brands create and fund campaigns, edit their brief, read the clips coming in, top up, stop and withdraw, and receive webhooks. Clippers submit clips and read their earnings. It is a plain JSON API over HTTPS; any language with an HTTP client works.
At a glance
- Base URL
https://clips.example/api, versioned under/v1. - Auth
Authorization: Bearer ck_live_…. Reading campaigns needs no key. - Formats JSON in and out; amounts in USDC as decimal numbers; times in ISO 8601.
- Network …
- OpenAPI the full description is at /api/v1/openapi.json: import it into Postman, Insomnia or a client generator.
https://clips.example/apicurl https://clips.example/api/v1/campaigns?sort=rateQuickstart
Launch a funded campaign in four calls. You need a Solana wallet holding the budget in USDC and a little SOL for the transaction fee.
- Get an API key. Ask for a challenge, sign it with the wallet, and exchange the signature for a key. Or create one in your dashboard.
- Create the campaign. Send the brief, budget and rate. Nothing is on chain yet; you get back a transaction that creates and funds the campaign.
- Sign it with the wallet that holds the USDC. In a browser that is
wallet.signTransaction(tx); on a server,tx.partialSign(keypair). - Activate. Send the signed transaction back. The API submits it, waits for confirmation, and publishes the campaign. Clippers can start right away.
From then on, follow submissions and payouts with GET /v1/campaigns/:id/clips or, better, a webhook.
import { Keypair, Transaction } from '@solana/web3.js'
import nacl from 'tweetnacl'
import bs58 from 'bs58'
const API = 'https://clips.example/api/v1'
const wallet = Keypair.fromSecretKey(/* your funding wallet */)
async function call(method: string, path: string, body?: unknown, key?: string) {
const res = await fetch(API + path, {
method,
headers: {
'Content-Type': 'application/json',
...(key ? { Authorization: `Bearer ${key}` } : {}),
},
body: body ? JSON.stringify(body) : undefined,
})
const json = await res.json()
if (!res.ok) throw new Error(`${json.error.code}: ${json.error.message}`)
return json
}
// 1. An API key, by signing a one-time message
const { message } = await call('POST', '/auth/challenge', {
wallet: wallet.publicKey.toBase58(),
})
const signature = bs58.encode(
nacl.sign.detached(new TextEncoder().encode(message), wallet.secretKey),
)
const { key } = await call('POST', '/keys', {
name: 'launch script',
wallet: wallet.publicKey.toBase58(),
message,
signature,
})
// 2. The campaign: its brief, budget and rate
const draft = await call('POST', '/campaigns', {
title: 'Acme',
content: [{ label: 'Stream', url: 'https://www.twitch.tv/videos/2901852985' }],
clipRequirements: ['Mention Acme in the on-screen caption'],
platforms: ['tiktok', 'instagram', 'youtube'],
audienceCountries: ['US', 'GB', 'CA'],
minAudienceShare: 0.5, // half of an account's audience there
budget: 500, // USDC
ratePer1k: 1, // USDC per 1,000 views
durationDays: 30,
}, key)
// 3. Sign the transaction it returned
const tx = Transaction.from(Buffer.from(draft.transaction.transaction, 'base64'))
tx.partialSign(wallet)
// 4. Activate: the API sends it and publishes the campaign
const campaign = await call('POST', `/campaigns/${draft.campaign.id}/activate`, {
signedTransaction: tx.serialize().toString('base64'),
}, key)
console.log(campaign.status, campaign.url) // live https://…TypeScript SDK
The official SDK wraps every endpoint with types generated from this API’s OpenAPI description, so your editor knows every field. It has no runtime dependencies and runs in Node 18+, browsers, Deno, Bun and edge runtimes.
- Resources mirror the reference:
clips.campaigns.create(),clips.submissions.create(),clips.webhooks.test(), … Every reference section below has an SDK tab. - Retries on network errors, 429 and 5xx, with backoff. Every POST carries an Idempotency-Key, so a retry never does the work twice.
- Paging
awaita list for one page, orfor awaitit for every item across pages. - Errors are
ClipsErrorwithstatus,type,code,paramandrequestId. - Webhooks
verifyWebhook(rawBody, signature, secret)checks the signature and returns the event, typed by itstype. - Solana
clips-sdk/solanasigns what the API returns with aKeypairor any wallet adapter:launchCampaigncreates, signs and activates in one call;fundCampaign,stopCampaignandwithdrawFromCampaignlikewise;createApiKeygets a key from a wallet signature.
npm install clips-sdk @solana/web3.jsimport { Keypair } from '@solana/web3.js'
import { Clips, isClipsError } from 'clips-sdk'
import { createApiKey, launchCampaign, withSigner } from 'clips-sdk/solana'
const wallet = Keypair.fromSecretKey(/* holds the budget */)
const { key } = await createApiKey({ signer: wallet, name: 'server' })
const clips = new Clips({ apiKey: key })
const campaign = await launchCampaign(clips, {
title: 'Acme',
content: [{ label: 'Stream', url: 'https://www.twitch.tv/videos/2901852985' }],
clipRequirements: ['Mention Acme in the on-screen caption'],
budget: 500,
ratePer1k: 1,
}, wallet)
for await (const clip of clips.campaigns.clips(campaign.id)) {
console.log(clip.url, clip.views, clip.earned)
}
try {
await withSigner(clips, wallet).withdraw(campaign.id)
} catch (e) {
if (isClipsError(e) && e.code === 'too_early') console.log(e.message)
}Authentication
Requests authenticate with an API key in the Authorization header. A key acts for the wallet that created it: as a brand, it can manage the campaigns that wallet funds; as a clipper, it submits clips paid to that wallet.
- Create keys in the dashboard, or programmatically: POST /v1/auth/challenge returns a message, the wallet signs it (ed25519, exactly what
signMessagedoes), and POST /v1/keys trades the signature for a key. A challenge is valid for ten minutes and works once. - Prefixes
ck_live_keys work on mainnet;ck_test_keys on test networks. - Secrecy a key is shown once; we store only its hash. Keep it on your server, never in a browser or an app.
- Rotation a wallet can hold 20 keys. Create the new one, deploy it, then revoke the old one; revocation takes effect at once.
A key cannot move money on its own: transfers always need the wallet’s signature on the transaction. A leaked key can edit your briefs and read your data, so revoke it if that happens.
curl https://clips.example/api/v1/me \
-H "Authorization: Bearer ck_live_9fK2mQ7xL4pV8nB1cR6tY3wZ5hJ0dS2a"curl -X DELETE https://clips.example/api/v1/keys/key_1a2b3c4d5e6f7a8b \
-H "Authorization: Bearer $CLIPS_API_KEY"Signing transactions
Four endpoints move money: create, fund, stop and withdraw. Each returns a transaction object instead of acting:
transaction: the serialized, unsigned Solana transaction, base64.signer: the wallet that must sign. It also pays the network fee (a fraction of a cent in SOL).lastValidBlockHeight: it expires after about a minute. If it does, ask for a new one.
Sign it and either send it back (activate for a new campaign, POST /v1/transactions for the rest) or submit it through any Solana RPC yourself. The API only relays transactions for the clips program, and only fully signed ones.
Every transaction can be inspected before signing: it calls the clips program (…) and, for deposits, moves USDC from your account into the campaign’s vault, a token account only the program controls.
import { Transaction } from '@solana/web3.js'
const { transaction } = await call('POST', `/campaigns/${id}/fund`, { amount: 250 }, key)
const tx = Transaction.from(Buffer.from(transaction.transaction, 'base64'))
tx.partialSign(wallet) // your Keypair
await call('POST', '/transactions', {
signedTransaction: tx.serialize().toString('base64'),
campaign: id,
}, key)Campaign lifecycle
A campaign is a vault of USDC with a rate, a per-clip ceiling and an end date, all on chain, plus a brief we keep off chain.
- Draft. Create checks the brief and builds the transaction. Nothing is public until you activate.
- Live. Clippers submit posts published after the start. Each accepted clip is paid at the rate as its views come in, up to
maxPerClip, until the budget runs out. Anyone can top up; the fee (5%) is taken from deposits, so clippers get exactly the advertised rate. - Editing. PATCH changes the texts, content, requirements, violations, platforms and audience. New requirements apply to clips checked from then on; platforms and audience to clips accepted from then on. Money, rate and dates live on chain and are fixed.
- Ended. After
endAt, or after you stop early, no new clips are taken. Clips already in keep earning untilwithdrawAfter(3 days later), so clippers are paid for views that were on their way. - Closed. From
withdrawAfter, withdraw returns what is left. Money still vesting to clippers stays with them.
POST /v1/campaigns → draft (awaiting_signature, private)
POST …/activate → live clips are taken and paid
endAt passes, or …/stop → ended no new clips; clips in are still paid
withdrawAfter passes → funder may withdraw the rest
POST …/withdraw → closed remaining budget back with the funderClips and payouts
Clippers post on their own TikTok, Instagram, YouTube or X accounts, connected once on the website, and submit the post’s link. No one approves clips by hand:
- Ownership. The post must be on one of the clipper’s connected accounts and published inside the campaign’s dates. A link that has not synced yet stays
pendingand is checked again for two days. - Platforms and audience. A post on a platform the campaign does not take is refused at once (
platform_not_allowed). When the campaign names countries, at leastminAudienceShare(default half) of the account’s audience must be in them, as the platform itself reports it: Instagram, YouTube and TikTok do, through the account connection. Below that the clip isrejected. X reports no audience location, nor do TikTok accounts under 2,000 followers or Instagram under 100: their clips are rejected unless the campaign setsallowUnknownAudience. A just-connected account whose numbers have not arrived stayspendingfor up to two days. - The brief. The video’s frames and the caption are checked against every clip and caption requirement, the logo if there is one, and the violations. Passing clips become clips; the others are
rejectedwith a reason. - Real views. Every hour each clip is judged on engagement, how its views arrive, the creator’s usual numbers and outside fake-view scores. Suspicious clips are
held; clips that stay suspicious, or whose views the platform purges, lose what has not vested yet.
There is no limit on what a clipper earns: the limits are the campaign’s (maxPerClip, maxPerDay, the budget). Trust decides how long views wait before they are paid, since platforms usually purge bought views within days: 7 days for a new wallet, 1 day after 4 clean weeks, 1 h after 8 clean weeks and $200 paid. A locked bond of $25 and Self verification (optional) each move a clipper one step up. A purged or held clip resets the clipper to new. Toward a campaign’s end the hold shortens, so late views are still paid within the grace. GET /v1/me shows a clipper’s tier, hold and what moves them up.
curl -X POST https://clips.example/api/v1/submissions \
-H "Authorization: Bearer $CLIPS_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "campaign": "J7xu…r9pR",
"url": "https://www.tiktok.com/@you/video/7412345678901234567" }'views counted every hour, paid once old enough
for both the clipper's trust and the clip's risk
new wallet views 7 days old
trusted 1 day (4 clean weeks)
established 1 h (8 clean weeks, $200 paid)
a bond or Self: one step up each
low risk views 1 h old
medium risk views a day old
high risk held while it lasts
each payout 30% to the clipper at once
the rest later later, unless clawed backWebhooks
Register an https endpoint with POST /v1/webhooks and we POST events to it as they happen: to the funder for their campaigns, and to the clipper for their clips. Clippers also find the ones about them in their notifications, webhook or not.
| Event | Sent when | To |
|---|---|---|
campaign.activated | a campaign went live; data.campaign is the full object | funder |
campaign.ended | its end date passed or it was stopped | funder |
submission.accepted | a submitted link became a clip | clipper, funder |
submission.rejected | a link was turned down; data.submission.reason says why | clipper, funder |
clip.paid | views were paid; amount in USDC, paidNow the part sent at once | clipper, funder |
clip.vested | the rest of earlier payouts was released | clipper |
clip.clawed_back | unvested pay went back to the campaign (bought views) | clipper, funder |
campaign.ending_soon | it ends within a day; data.campaign.endAt says when | funder, clippers with clips in it |
appeal.created | a clipper contested a rejection or clawback; data.appeal.note is their note (re-accept) | funder |
- Signatures. Each request carries
Clips-Signature: t=…,v1=…: an HMAC-SHA256 of"<t>.<raw body>"with your webhook’s secret. Check it against the raw body, and reject timestamps older than five minutes. - Retries. Answer with any 2xx within ten seconds. Otherwise we retry after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h and 24 h. Deliveries shows every attempt.
- Duplicates. An event can arrive more than once;
idis unique, so ignore ids you have handled. - Testing. POST /v1/webhooks/:id/test sends a
pingand tells you how your endpoint answered.
import { createHmac, timingSafeEqual } from 'node:crypto'
import express from 'express'
const app = express()
app.post('/clips/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const header = req.get('Clips-Signature') ?? '' // t=…,v1=…
const { t, v1 } = Object.fromEntries(header.split(',').map((p) => p.split('=')))
const expected = createHmac('sha256', process.env.CLIPS_WEBHOOK_SECRET!)
.update(`${t}.${req.body}`)
.digest('hex')
const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300
const a = Buffer.from(expected), b = Buffer.from(v1 ?? '') // timingSafeEqual throws on unequal lengths
if (!fresh || a.length !== b.length || !timingSafeEqual(a, b)) {
return res.status(400).end()
}
const event = JSON.parse(req.body.toString())
// event.id is unique: ignore ones you have seen
if (event.type === 'clip.paid') {
console.log(event.data.clip.id, event.data.amount, 'USDC')
}
res.status(200).end()
}){
"id": "evt_9a8b7c6d5e4f3a2b1c0d9e8f",
"type": "clip.paid",
"created": "2026-10-18T14:00:00.000Z",
"data": {
"clip": {
"id": "clip_42",
"campaign": "J7xu1BVoTgymFb1zHw2ckq3iwFQyTmRZSnf8XbXPr9pR",
"clipper": "9o4Zszv2Qb6yXJ5o2H7sV2kq9H9c4d8u3P6xg1Yb2ZcR",
"platform": "tiktok"
},
"paidViews": 48210,
"amount": 6.12,
"paidNow": 1.836,
"transaction": "3hE9…"
}
}Errors
Errors use HTTP status codes and one JSON shape. code is stable and meant for your program; message is for people; param names the field at fault; quote requestId when you contact us.
| Status | Type | Meaning |
|---|---|---|
| 400 | invalid_request_error | A parameter is missing or wrong, or a transaction failed on chain. |
| 401 | authentication_error | No key, or an unknown or revoked one. |
| 403 | permission_error | The key’s wallet may not do this (e.g. edit someone else’s campaign). |
| 404 | not_found | No such campaign, key, webhook or endpoint. |
| 409 | conflict | Not now: the campaign ended, it is too early to withdraw, an Idempotency-Key was reused differently. |
| 429 | rate_limit_error | Too many requests; wait for the time in X-RateLimit-Reset. |
| 5xx | api_error | Our fault. Retry with the same Idempotency-Key. |
{
"error": {
"type": "invalid_request_error",
"code": "parameter_invalid",
"message": "ratePer1k must be at least 0.000001",
"param": "ratePer1k",
"requestId": "req_3f9a1c2e7b4d5a60"
}
}Pagination
Lists return { object: "list", data, hasMore, nextCursor }. Ask for up to 100 items with limit (20 by default); while hasMore is true, pass nextCursor as cursor to get the next page.
curl "https://clips.example/api/v1/campaigns?limit=50"
# → { "data": [...], "hasMore": true, "nextCursor": "eyJvIjo1MH0" }
curl "https://clips.example/api/v1/campaigns?limit=50&cursor=eyJvIjo1MH0"Idempotency
Network failures happen. Send an Idempotency-Key header (any unique string, a UUID is ideal) with a POST, and a retry with the same key returns the first response instead of doing the work twice. Keys are remembered for 24 hours, per API key. Reusing a key for a different request is refused with idempotency_conflict.
Transactions are safe by themselves: a signed transaction can land on chain only once, and activating an active campaign returns it unchanged.
curl -X POST https://clips.example/api/v1/campaigns \
-H "Authorization: Bearer $CLIPS_API_KEY" \
-H "Idempotency-Key: 6f1d9c1e-launch-acme" \
-H "Content-Type: application/json" \
-d @campaign.json
# a retry with the same key returns the first response,
# with the header Idempotent-Replayed: trueRate limits
Each API key can make 120 requests a minute; requests without a key, 60 a minute per IP. Every response says where you stand in the X-RateLimit-* headers. Over the limit you get a 429; wait and retry. Need more? Get in touch.
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 117
X-RateLimit-Reset: 1
X-Request-Id: req_3f9a1c2e7b4d5a60