---
name: clipradar-api
description: >-
  Query and act on ClipRadar's live database of pay-per-view clipping
  campaigns aggregated from Whop, ContentRewards and a dozen more creator
  platforms — plus the key owner's own bookmarks, alerts, earnings journal,
  connected accounts and analyses. Use when the user asks about clipping
  campaigns, per-view payout rates, which campaigns are worth clipping right
  now, new campaign drops, wants a personalized shortlist, wants to log
  earnings or set up drop alerts, or wants to build automations around
  clipping-market data. Requires a ClipRadar API key in the CLIPRADAR_API_KEY
  environment variable (or pasted by the user).
license: Proprietary — data served under the ClipRadar terms of service
---

# ClipRadar API

ClipRadar aggregates live **pay-per-view clipping campaigns** — offers where
creators pay clippers a fixed rate (USD per 1,000 views) to post short-form
clips — from many source platforms into one searchable radar. This skill lets
you query that radar over REST, and (with the right scopes) act on the key
owner's own data: save campaigns, run alerts, log earnings, get a personalized
shortlist.

- **Base URL:** `https://clipradar.co`
- **Discovery index:** `GET /api/v1` (free, no key) — every endpoint, scope and
  cost. Call it if you're unsure what exists.
- **Machine-readable spec:** `GET /api/v1/openapi.json` (free)
- **Human docs:** https://clipradar.co/developers · webhooks: /developers then
  the webhooks section
- **Success:** `{ "data": …, "meta"?: … }`. **Errors:**
  `{ "error": { "code", "message", "details"? } }`.

The API is **~85 operations**. Do not try to hold them all — work from the
**tasks** below, each of which names the exact endpoint(s) and cost. Reads are
GET; writes are POST/PATCH/PUT/DELETE and are strict (see [Writing](#writing-data)).

If your client speaks MCP (Claude Desktop, Claude Code, Cursor), the read
endpoints are also exposed as tools: `npx -y clipradar-mcp`, same key and
budget. This file is for everything else, and for the write/alert/webhook
surface the MCP server doesn't cover.

## Authentication

Send the key on every request except `/ping` and `/openapi.json`:

```
Authorization: Bearer cr_live_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
```

(`x-api-key: cr_live_…` works too.) Keys are created at
`https://clipradar.co/dashboard/developer`. If you don't have a key, ask the
user for one — never invent or guess keys, and never print a key back into
the conversation beyond confirming its `cr_live_` prefix.

**Scopes gate what a key can do.** Each key carries a set of scopes chosen when
it was created. A call whose scope the key lacks returns
**`403 insufficient_scope`** with `error.details = { required, granted }`. This
is a **configuration problem, not a retryable error** — retrying the same key
will always fail. When it happens: tell the user which scope to add at
`/dashboard/developer` (or to issue a new key with it), and stop; don't loop.
Public reads (`campaigns:read`, `market:read`, `leaderboard:read`,
`account:read`) are usually present; the owner's private data
(`bookmarks`, `alerts`, `profit`, `social`, `analysis`, `profile`, `affiliate`,
`webhooks`, each `:read`/`:write`) often isn't unless the user granted it.
`GET /api/v1/me` (scope `account:read`) lists the key's actual scopes — check
there before assuming a private-data call will work.

## Budget model — read this before making many calls

The account has a request budget: first the monthly quota (membership
allowance + API tier, resets on the 1st, UTC), then prepaid pay-as-you-go
credits (never expire). **Costs are now per-operation, not a flat 1** — most
reads are 1, but one campaign by id is 3, a similar/timeseries/for-you is 2, a
profit sync is 10, a bulk-by-id read is 50, and an **account analysis is 25**
(and also spends the main app's usage credits — a separate wallet). The **CSV
export is priced per row**: 1 request per campaign returned plus 0.5 per
campaign for each filter applied, minimum 5 — a 5,000-row dump can cost more
than a whole monthly quota, so check `/usage` and narrow the export first.
Each response reports what it debited in **`X-Request-Cost`**. Check where the
account stands at any time **for free**:

```
GET /api/v1/usage
→ { "data": { "plan": { "tier": "dev", "tier_name": "Dev", "member_allowance_active": true },
               "month": { "quota": 10250, "used": 4310, "remaining": 5940,
                          "resets_at": "2026-08-01T00:00:00.000Z" },
               "credits": { "remaining": 1478 },
               "rate_limit_per_minute": 60,
               "total_remaining": 7418 } }
```

Etiquette you MUST follow:

1. **Check `/usage` first — it's free** (and never metered). Do it before batch
   work, and before anything that costs more than 1 (bulk 50, analysis 25,
   sync 10, campaign-by-id 3, and the per-row export — whose price you can't
   know until you know the row count), and warn the user when `total_remaining`
   is low relative to the job. Budget by cost, not by call count.
2. **Watch the meter headers** on every response: `X-Request-Cost` (what this
   call cost), `X-Quota-Remaining`, `X-Credits-Remaining`,
   `X-RateLimit-Remaining`.
3. **Poll with `since` / `since_id`**, not by re-reading full pages or deep
   `offset` paging — cursor/`since` polling is stable and cheap (see recipes).
4. **Prefer webhooks over polling** when the user can receive them: register a
   webhook once instead of polling `/campaigns` on a timer. If they can't accept
   inbound HTTP, poll `GET /api/v1/events` with `since_id`.
5. **`insufficient_scope` (403) is not retryable** — it's a missing scope on the
   key, a config fix, not a wait. Surface it and stop (see Authentication).
6. **On 429**: `rate_limited` → wait the `Retry-After` seconds, then resume
   slower; `quota_exhausted` → stop and tell the user their quota AND credits
   are spent (top up at `https://clipradar.co/dashboard/billing#api-plans`).
7. Use `limit` up to 100 — one request costs the same regardless of page size.

## Tasks

Pick the task, use the endpoint(s) named. Costs are per call. Anything with a
scope in brackets needs that scope on the key — if it's missing you'll get
`403 insufficient_scope` (a config problem; don't retry).

### Find campaigns matching a niche — `GET /api/v1/campaigns` (1) [`campaigns:read`]

The workhorse. See the [param table](#get-apiv1campaigns-query-parameters). Examples:

```
# best-paying live TikTok campaigns
GET /api/v1/campaigns?target_platform=tiktok&sort=rate&limit=10
# gaming OR fitness, paying $1+, still has budget, ending within 3 days
GET /api/v1/campaigns?category=gaming,fitness&min_rate=1&has_budget=1&ending_within_h=72&sort=score
# fetch only the fields you need (cheaper to parse)
GET /api/v1/campaigns?sort=score&fields=id,name,rate_per_1k,url,ends_at
```

One campaign: `GET /api/v1/campaigns/{id}` (3). Many at once:
`GET /api/v1/campaigns/bulk?ids=<uuid>,<uuid>` (50 flat — cheaper than singles
past ~17 ids, returns `missing[]` for ids that don't resolve). Like-this-one:
`GET /api/v1/campaigns/{id}/similar` (2, each result has `similarity` 0–1 and
the matched fields).

### Watch for new drops — poll `since`, or use a webhook

Polling (store the newest `first_seen_at` you've seen; next poll):

```
GET /api/v1/campaigns?since=2026-07-27T18:00:00Z&sort=oldest&limit=100
```

Process in order, remember the last `first_seen_at`, repeat. `sort=oldest`
keeps paging inside one window stable. Prefer a `cursor` (from
`meta.next_cursor`) over deep `offset` for large walks — offset paging past a
few thousand rows is slow and drifts as the feed changes; cursor is only
supported on `sort=newest|oldest`.

**Better, if the user can receive HTTP:** register a webhook once and stop
polling. `POST /api/v1/me/webhooks` (1) [`webhooks:write`] with an `https` url
and `events` from `campaign.created` · `campaign.budget_low` · `campaign.expired`
· `campaign.enriched`, optionally `filters` (same shape as the feed). The
response includes the signing `secret` **once** — save it. Verify every
delivery's `X-ClipRadar-Signature` (HMAC-SHA256 recipe in the webhooks doc).
Manage: `GET/PATCH/DELETE /api/v1/me/webhooks/{id}`, test with
`POST …/{id}/test` (2), debug with `GET …/{id}/deliveries`.

**If the user CAN'T receive HTTP:** poll the event log —
`GET /api/v1/events?since_id=<n>&limit=100` (1) [`market:read`]. Ordered
oldest-last; take `meta.last_event_id` as the next `since_id`.

### Set up drop alerts (delivered by ClipRadar) — `/me/alerts/*` [`alerts:*`]

Alerts are ClipRadar's own notification engine (Telegram/Discord/Slack/webhook/
email), distinct from your outbound webhooks. Create a channel
(`POST /me/alerts/channels`, 1, max 10), then a rule
(`POST /me/alerts/rules`, 1, max 20) whose `filters` use the **AlertFilters**
shape (a superset with `keywords`/`excludeKeywords`/`minViral`/
`budgetThresholdPct` — NOT the feed's FeedFilters). **Creating a rule also
spends main-app usage credits**, and needs a paid plan/credits → free users get
`402 payment_required`. Preview a rule's reach first with
`POST /me/alerts/preview` (2, members only → non-members `403 not_a_member`).
Test delivery: `POST /me/alerts/rules/{id}/test` (2). History:
`GET /me/alerts/deliveries` (1).

### Log earnings — `/me/profit/*` [`profit:*`]

`POST /api/v1/me/profit/entries` (1) logs one earnings entry (`amount` is a USD
number). List/edit/delete via `/me/profit/entries[/{id}]`; **only manual rows
are editable** — touching an auto-tracked row is `409 conflict`. Monthly
rollup: `GET /me/profit/summary` (1). Goals: `GET`/`PUT /me/profit/goals` (1;
`amount_cents` is an integer, `null` clears). Auto-tracking (reads each verified
account's recent videos and logs earnings on a schedule): `GET /me/profit/sync`
(1) for state, `PATCH /me/profit/sync` (1) to set `enabled` and
`interval_hours` (24 default / 12 / 6 / 3). There is no trigger — checking is
scheduled and the speed is the control; faster costs proportionally more
tracking credits per account, and a speed your credits can't fund is `402
payment_required`. CSV: `GET /me/profit/export` (2).

### Get a personalized shortlist — `/me/for-you`, `/me/picks`, analyses [`analysis:*`]

Fast, no LLM: `GET /api/v1/me/for-you` (2) ranks the live feed for the owner's
connected accounts; tune with `w_match`/`w_viral`/`w_rate`/`w_budget_left`
(0–2). `GET /me/picks` (1) is today's per-account picks.
`GET /me/recommendations` (2) ranks against a niche profile
(`social_account_id` XOR `categories`+`keywords`).

The deep one — `POST /api/v1/me/analyses` (**25**, and spends usage credits) —
runs an LLM read of a connected account. It returns **200** for both a fresh
and a cached run, with a `cost { api_requests, usage_credits, paid_from }`
object; a **cached read is free of usage credits unless `force=true`**, so
prefer the cached read and only `force` when the user explicitly wants a
refresh. Free users → `402 payment_required`; an account with nothing to
analyse → `422 no_signals`. List stored analyses with `GET /me/analyses` (1).

### Save / hide campaigns & filter presets — `/me/bookmarks`, `/me/hidden`, `/me/presets`

`POST /me/bookmarks` (1) [`bookmarks:write`] saves a campaign (201 new, 200 if
already saved); `DELETE /me/bookmarks/{campaignId}` un-saves (204 even if it
wasn't saved). `GET /me/bookmarks` (1) [`bookmarks:read`] hydrates them.
`/me/hidden` mirrors this (ids by default, `?hydrate=true` for full rows).
Presets: `GET/POST /me/presets` (1, max 8), `PATCH/DELETE /me/presets/{id}` — a
PATCH replacing the `state` blob must send **both** `filters` and `sort` (one
without the other is a 400); a rename alone is fine.

### Market & radar reports — `/stats`, facets, `/status`, `/estimate`

```
GET /api/v1/stats            # (1) [market:read] de-duplicated aggregates
GET /api/v1/platforms        # (1) [campaigns:read] counts + top/avg $/1K per source
GET /api/v1/categories       # (1) also: /tags /languages /content-types /target-platforms
```

Trend over time: `GET /api/v1/stats/timeseries` (2) [`market:read`]. Radar
freshness (last ingest per source): `GET /api/v1/status` (1). Projected payout
for a view count: `GET /api/v1/estimate?campaign_id=<id>&views=250000` (1) —
returns `payout`, `raw_payout`, and `capped_by` (`budget`/`max-payout`/
`per-clip`/null).

### Public leaderboard & profiles — `/leaderboard`, `/profiles/{username}` [`leaderboard:read`]

`GET /api/v1/leaderboard?period=7d&platform=tiktok` (2) and
`GET /api/v1/leaderboard/rising` (2); one creator:
`GET /api/v1/profiles/{username}` (1). Members-only → non-members
`403 not_a_member`. No user id ever appears in a response — only opted-in
public profiles are named, everyone else is ranked anonymously.

### Manage the key itself — `/me`, `/usage`, `/api/v1`

`GET /api/v1/usage` (free) — budget, covered above. `GET /api/v1/me` (1)
[`account:read`] — the key's owner, **its actual scopes**, plan, quota,
balances and usage-by-day; call it to learn what a key can do before assuming.
`GET /api/v1` (free, no key) — the whole endpoint/scope/cost catalog.

### `GET /api/v1/campaigns` query parameters

All optional; **invalid values silently fall back to defaults** rather than
erroring (the one exception: a `cursor` on an unsupported sort is a hard 400).

| Param | Type | Meaning |
|---|---|---|
| `status` | `live` (default) \| `expired` \| `all` | Campaign state |
| `platform` | slug(s) | Source platform — **comma-separated for OR** (values from `/platforms`) |
| `category` | slug(s) | Enriched category — comma-separated for OR (values from `/categories`) |
| `target_platform` | slug(s) | Where clips must be posted (`tiktok`, `youtube`, `instagram`, `x`, …) — comma-OR |
| `tag` | string(s) | Campaigns carrying this tag (e.g. `fortnite`) — comma-OR |
| `language` | ISO 639-1 or `multi` | Content language — comma-OR |
| `content_type` | slug(s) | Accepted content type (`clipping`, …) — comma-OR |
| `min_rate`, `max_rate` | number | Bounds on USD per 1,000 views |
| `min_viral` | 0–100 | Minimum viral/clip-potential score |
| `min_score` | 0–100 | Minimum opportunity score |
| `min_budget` | number | Minimum total budget (USD) |
| `min_budget_left_pct` | 0–100 | Minimum budget remaining |
| `ending_within_h` | number | Only campaigns ending within N hours |
| `has_budget` | `1`/`true` | Only campaigns that publish a budget |
| `new_today` | `1`/`true` | Only campaigns first seen in the last 24h |
| `since`, `until` | ISO 8601 | Bounds on `first_seen_at` — `since` is the polling primitive |
| `exclude` | csv | Drop campaigns mentioning any term in name/summary/description |
| `q` | string | Case-insensitive name search |
| `fields` | csv | Only these public columns (`id` always included; unknowns dropped) |
| `sort` | `newest` (default) \| `oldest` \| `rate` \| `budget` \| `viral` \| `score` \| `ends_soon` | Order |
| `limit` | 1–100, default 25 | Page size |
| `offset` | 0–10000, default 0 | Page start |
| `cursor` | opaque | Keyset cursor from `meta.next_cursor`; `sort=newest|oldest` only; wins over `offset` |

`meta` carries `{ total, limit, offset, returned, next_cursor }` — page until a
page comes back short, or follow `next_cursor` until it's null.

Campaign fields (never removed under `/v1`, only added):
`id`, `platform`, `name`, `url` (claim page), `status`, `rate_per_1k`,
`budget_total`, `budget_remaining_pct`, `description`, `content_types`,
`target_platforms`, `category`, `categories`, `tags`, `language`, `summary`,
`opportunity_score` (0–100), `viral_score` (0–100), `viral_reason`,
`ends_at`, `first_seen_at`, `last_seen_at`. Enrichment fields are `null`
until the LLM pass has labeled a campaign — always handle nulls.

**Judging if a campaign is worth clipping** — combine `rate_per_1k` (the pay),
`budget_remaining_pct` (room left before it drains), `viral_score` +
`viral_reason` (how clippable the subject is), and `ends_at` (time left). A high
rate with 5% budget remaining is usually worse than a mid rate with a fresh
budget. Link the user to `url` to claim; ClipRadar never handles claiming
itself.

## Writing data

Writes (POST/PATCH/PUT/DELETE) are **strict**, the opposite of the forgiving
reads: unknown or misspelled fields are rejected, not ignored. A bad body is
**`400 validation_failed`** with `error.details = [{ field, message }]` — read
it and fix the named field; don't retry blindly. Status conventions: `201`
create, `200` update / already-exists, `204` delete, `409 conflict` (semantic
clash) or `409 limit_reached` (at a per-account cap — waiting won't help).

For any **non-idempotent** write you might retry (a create, a payout, an
analysis run), send an **`Idempotency-Key`** header — a unique string per
logical operation. A replay with the same key returns the first result instead
of acting twice; the **same key with a different body is `409
idempotency_conflict`** (use a fresh key for a genuinely new request). Accepted
on the creates/actions: bookmarks, hidden, presets, alert rules, alert
channels, profit entries, analyses, payouts, webhooks.

## Errors

| HTTP | `code` | React by |
|---|---|---|
| 401 | `missing_key` / `invalid_key` | Ask the user for a valid `cr_live_…` key |
| 403 | `no_active_plan` | Key works but nothing funds it — send the user to `https://clipradar.co/dashboard/billing#api-plans` |
| 403 | `insufficient_scope` | **Not retryable.** The key lacks the scope in `error.details.required` — tell the user to add it at `/dashboard/developer` |
| 403 | `not_a_member` | A members-only surface (leaderboard, profiles, alert preview) — the account isn't a member |
| 402 | `payment_required` | A paid write (alert-rule create, analysis run) with no plan/credits — top up at the billing link |
| 400 | `validation_failed` | A write (or a bad `cursor`) — read `error.details[]`, fix the named field, don't retry blindly |
| 400 | `invalid_body` / 413 `body_too_large` | Body isn't JSON, or is over 128 KB |
| 409 | `conflict` / `limit_reached` | Semantic clash / at a per-account cap — waiting won't help; change the request |
| 409 | `idempotency_conflict` / `request_in_flight` | Reused an `Idempotency-Key` with a different body / a duplicate is still running (retry after `Retry-After`) |
| 422 | `no_signals` | `POST /me/analyses` on an account with nothing to analyse |
| 429 | `rate_limited` | Wait `Retry-After` seconds, then resume slower (also the profit-sync cooldown) |
| 429 | `quota_exhausted` | Stop; tell the user quota AND credits are spent (top up at the billing link above) |
| 400 | `invalid_id` | Ids are UUIDs from the list endpoints |
| 404 | `not_found` | The id doesn't exist, or isn't the caller's (indistinguishable, by design) |
| 5xx | `internal_error` / `not_configured` | Retry once after a few seconds, then report |

Blocked requests (401/403/429, and validation 400s) don't consume budget; only
successful responses do. `insufficient_scope`, `validation_failed`,
`payment_required` and the `409`s are **not** wait-and-retry — they're fix-then-
retry.

## Facts to keep straight

- Rates are **USD per 1,000 views**; `budget_total` is USD.
- `status=live` means the radar saw the campaign on its source within the
  last ingestion cycles; `expired` means it stopped appearing or ended.
- The radar refreshes roughly every 15 minutes — never promise real-time.
- The API can read the radar **and** act on the key owner's own data (save,
  alert, log earnings, run analyses) when the key has the scopes. It still
  **cannot claim campaigns, post clips, or move money** — link the user to a
  campaign's `url` to claim; don't imply the API does the clipping.
- **Costs vary per operation** (analysis 25, sync 10, export 5, most reads 1);
  `X-Request-Cost` reports each call's cost. `/usage` and `/api/v1` are free.
- Two independent meters: API requests (this budget) and, for alert-create and
  analysis, main-app **usage credits** — both charge the same call.
- Quotas reset on the **1st of each month, 00:00 UTC**; prepaid credits
  never expire and are only spent after the quota.
