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.

Your money stays in your wallet. The API never holds funds or keys. Every call that moves USDC returns a Solana transaction; your wallet signs it, and only then does anything happen. Budgets sit in a public program on Solana that pays clippers per verified view and returns the rest to you.

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.
Base URL
URL
https://clips.example/api
Your first request
cURL
curl https://clips.example/api/v1/campaigns?sort=rate

Quickstart

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.

  1. 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.
  2. 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.
  3. Sign it with the wallet that holds the USDC. In a browser that is wallet.signTransaction(tx); on a server, tx.partialSign(keypair).
  4. 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.

launch.ts
TypeScript
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 await a list for one page, or for await it for every item across pages.
  • Errors are ClipsError with status, type, code, param and requestId.
  • Webhooks verifyWebhook(rawBody, signature, secret) checks the signature and returns the event, typed by its type.
  • Solana clips-sdk/solana signs what the API returns with a Keypair or any wallet adapter: launchCampaign creates, signs and activates in one call; fundCampaign, stopCampaign and withdrawFromCampaign likewise; createApiKey gets a key from a wallet signature.
Install
npm install clips-sdk @solana/web3.js
launch.ts
TypeScript
import { 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 signMessage does), 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.

Authenticated request
cURL
curl https://clips.example/api/v1/me \
  -H "Authorization: Bearer ck_live_9fK2mQ7xL4pV8nB1cR6tY3wZ5hJ0dS2a"
Revoke a key
cURL
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.

Sign on a server
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 until withdrawAfter (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.
States
Text
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 funder

Clips 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:

  1. 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 pending and is checked again for two days.
  2. 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 least minAudienceShare (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 is rejected. X reports no audience location, nor do TikTok accounts under 2,000 followers or Instagram under 100: their clips are rejected unless the campaign sets allowUnknownAudience. A just-connected account whose numbers have not arrived stays pending for up to two days.
  3. 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 rejected with a reason.
  4. 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.

Submit a clip
cURL
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" }'
Payout timing
Text
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 back

Webhooks

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.

EventSent whenTo
campaign.activateda campaign went live; data.campaign is the full objectfunder
campaign.endedits end date passed or it was stoppedfunder
submission.accepteda submitted link became a clipclipper, funder
submission.rejecteda link was turned down; data.submission.reason says whyclipper, funder
clip.paidviews were paid; amount in USDC, paidNow the part sent at onceclipper, funder
clip.vestedthe rest of earlier payouts was releasedclipper
clip.clawed_backunvested pay went back to the campaign (bought views)clipper, funder
campaign.ending_soonit ends within a day; data.campaign.endAt says whenfunder, clippers with clips in it
appeal.createda 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; id is unique, so ignore ids you have handled.
  • Testing. POST /v1/webhooks/:id/test sends a ping and tells you how your endpoint answered.
Verify a webhook
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()
})
clip.paid
JSON
{
  "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.

StatusTypeMeaning
400invalid_request_errorA parameter is missing or wrong, or a transaction failed on chain.
401authentication_errorNo key, or an unknown or revoked one.
403permission_errorThe key’s wallet may not do this (e.g. edit someone else’s campaign).
404not_foundNo such campaign, key, webhook or endpoint.
409conflictNot now: the campaign ended, it is too early to withdraw, an Idempotency-Key was reused differently.
429rate_limit_errorToo many requests; wait for the time in X-RateLimit-Reset.
5xxapi_errorOur fault. Retry with the same Idempotency-Key.
Error response
JSON
{
  "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.

Next page
cURL
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.

Safe retry
cURL
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: true

Rate 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.

Headers
HTTP
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 117
X-RateLimit-Reset: 1
X-Request-Id: req_3f9a1c2e7b4d5a60

API reference

OpenAPI JSON