---
name: cypher-api
description: Use Cypher's public REST API for ad discovery, brand teardowns, swipefile/boards/spyder reads, and credit-aware agent workflows. Load when the user mentions the Cypher API, /developers, cypher_sk_, discovery ads, cypher_url, hosted MCP at cypherapp.io, or integrating an agent with Cypher's ad corpus.
---

# Cypher Public API

Stable URL: `https://cypherapp.io/skills/cypher-api/SKILL.md`.
Human docs: `https://cypherapp.io/developers`.
Machine spec: `https://cypherapp.io/api/openapi.yaml` (OpenAPI 3.1). This table is derived from that spec — do not invent paths.

## Mental model

Base URL `https://cypherapp.io`. Keyed REST lives under `/api/**`. Success bodies contain `data` + `metadata` (and some compatibility routes retain legacy top-level keys); error bodies add `error.message` and return `data: []`. Credits are recorded per key per UTC calendar month even when 402 enforcement is off. Media fields (`thumbnail` / `video` / `image`) are source URLs — this API never proxies bytes. `/api/mcp` is JSON-RPC Streamable HTTP, not a REST path in the spec.

## Auth

Mint a personal `cypher_sk_` key on `https://cypherapp.io/api-mcp` (signed-in). Send the secret in **one header**, never in the query string. Query aliases (`?api_key=`, `?token=`, `?secret=`) are rejected.

Accepted presentations, first match wins (same order as the spec):

1. `x-secret-token: cypher_sk_…`
2. `Authorization: Bearer cypher_sk_…`
3. `Authorization: cypher_sk_…` (scheme-less)
4. `x-api-key: cypher_sk_…`

Placeholder only: `cypher_sk_…` or `cypher_sk_YOUR_KEY`. Never commit a real secret.

## Personal vs system key

Three secret kinds are accepted:

- **Hashed `ApiKey`** (`cypher_sk_` prefix). Scopes and `monthlyCredits` come from the row. Revoked → 403 `API key has been revoked`. Expired → 403 `API key has expired`.
- **Legacy `User.apiSecretToken`** (`cypher_sk_`). Default scopes `discovery` + `library` + `account`. No expiry.
- **`ADS_SYSTEM_API_KEY`**. Scopes `discovery` + `account` only — **no `library`**, no bound user. `/api/usage` reports `user.id = "system"`.

Groups D–E (swipefile, boards, spyder) require a **personal** key bound to a user. A system key (or any key without `library`) gets:

- `This endpoint requires a personal API key`
- `Missing required scope: library`

Do not paste the system key into a library loop.

## Credits

`GET /api/usage` **before** a search. Read `remaining_credits` (and `total_credits`, `start_date`, `end_date`) from the body. Additive `payment.spend` reports the key's x402 spend ceiling for self-budgeting (`limit_usd_cents` / `window_days` / `spent_usd_cents` / `remaining_usd_cents`); it is not a Foreplay-required key. After every keyed response, read `X-Credits-Remaining` and `X-Credit-Cost`. Stop when remaining is low.

| Class | Cost |
| --- | --- |
| Ad list (`ad-list`) | 1 per ad returned |
| Brand list (`brand-list`) | 1 per brand returned |
| Ad lookup (`ad-lookup`) | 1 if an ad is found, else 0 |
| Flat listing (`flat-listing`) | 1 if the payload is non-empty, else 0 |
| Account (`/api/usage`) | free |
| Receipt fetch (`/api/payments/{txId}/receipt`) | free |

`PUBLIC_API_ENFORCE_CREDITS=true` turns a 0 remaining balance into **402** `Out of credits` before the handler runs. Default is **off**. Usage is still recorded — **do not loop just because 402 is absent**. A 200 is not an unlimited month.

When credits are exhausted **and** the x402 rail is mounted (`X402_ENABLED=true` plus facilitator config), the same 402 becomes a **payable challenge**: `PAYMENT-REQUIRED` is present (standard base64 (padded) x402 v2, `accepts[0].scheme` is always `"exact"`). The rail is live on Base mainnet (`eip155:8453`) when that flag is on. Covered callers (remaining credits > 0) never see a challenge. Decoded `PaymentRequired.extensions.bazaar` describes input/output (`info.input` / `info.output` / `schema`) on OD-3 REST and MCP twins; pay-and-retry should **echo** `extensions.bazaar` on the payment payload (`PAYMENT-SIGNATURE` / `_meta["x402/payment"]`) so CDP can catalog.

**Keyless pilot (flag-gated, default off):** when `X402_KEYLESS_ENABLED=true` **and** the x402 rail is mounted, a **no-secret** GET on the OD-3 discovery set (`/api/discovery/ads`, `/api/discovery/brands`, `/api/discovery/brands/explore`, `/api/ad`, `/api/ad/{adId}`, `/api/ad/duplicates/{adId}`, `/api/v1/brands`) skips the 401 and returns the same payable 402. Effective `limit` is hard-capped at **100**. Library / tenant surfaces are **never** keyless. A presented but invalid key stays 401. Facilitator OFAC/KYT on payer **and** recipient is the compliance gate for anonymous callers — there is no in-process screening. Kill the anonymous lane with `X402_KEYLESS_ENABLED=false` without touching keyed exhausted-caller 402s.

**Paid-lane rule for agents:** on 402 **with** `PAYMENT-REQUIRED`, decode the amount and pay-and-retry **once** only if that amount is ≤ the user's stated budget. Never auto-pay unbounded. If the challenge message contains `(facilitator_auth_failed)`, **stop** and tell a human — that is seller CDP auth, not a buyer funding error; do not pay-and-retry. On 402 **without** `PAYMENT-REQUIRED`, stop — `"Out of credits"`, `"duplicate_settlement"`, and `"spend_limit_exceeded"` are not payable. For `spend_limit_exceeded`, a human must raise the key's limit on `https://cypherapp.io/api-mcp`. For `duplicate_settlement`, do not send a new payment; retry the same request with the original `payment-identifier` to recover `PAYMENT-RESPONSE`.

On a paid 2xx, decode `PAYMENT-RESPONSE` (REST) or `_meta["x402/payment-response"]` (MCP). If `extensions.receipt` is present, verify the ES256 JWT against `https://cypherapp.io/developers/receipt-public-key.pem`. Keyed callers may also `GET /api/payments/{txId}/receipt` with scope `account`. Keyless / system-key rows are inline-only (the GET 404s them).

Credit headers are omitted on unauthenticated 401/403 and when the remaining-credits read fails.

## Cursor etiquette

Hold `limit` and `order` constant across pages. A cursor encodes its order; reusing it with a different `order` is **422**. Do not grow `limit` mid-walk. Offset endpoints use `offset` + `limit` instead of a cursor.

## cypher_url

Every public Ad DTO includes `cypher_url` (`https://cypherapp.io/discovery?ad={id}`). **Render it as a markdown link.** Do not invent `/ads/{id}` or a Meta URL. Do not omit it from user-facing output.

## Errors

Envelope on error:

```json
{
  "metadata": { "success": false, "message": "…", "status_code": 401, "processed_at": 0 },
  "error": { "message": "…" },
  "data": []
}
```

Client-fixable problems are 4xx. Cloudflare can replace 5xx bodies — always surface `X-Trace-ID`, and include `error.message` when the JSON envelope is present.

| Status | When | `error.message` (exact where locked) |
|---|---|---|
| 401 | missing / unknown key | `Unauthorized: Invalid or missing x-api-key header` |
| 403 | revoked | `API key has been revoked` |
| 403 | expired | `API key has expired` |
| 403 | system key on library | `This endpoint requires a personal API key` |
| 403 | missing `library` | `Missing required scope: library` |
| 403 | untracked spyder | `User not subscribed` |
| 402 | credits enforced, remaining 0, x402 rail off | `Out of credits` |
| 402 | credits enforced, remaining 0, x402 rail on | additive `Payment required: …` plus `PAYMENT-REQUIRED` (payable) |
| 402 | x402 rail on, seller CDP facilitator auth failed | additive `Payment required: … (facilitator_auth_failed)` plus `PAYMENT-REQUIRED` (not payable; seller `CDP_API_KEY_ID` / `CDP_API_KEY_SECRET`, not buyer payload/funding — stop and tell a human) |
| 402 | consumed payment nonce after verify | `duplicate_settlement` (not payable; no `PAYMENT-REQUIRED`) |
| 402 | keyed x402 caller over per-key spend ceiling | additive `spend_limit_exceeded` (not payable; no `PAYMENT-REQUIRED`; `error.detail` has `limit_usd_cents`, `window_days`, `spent_usd_cents`) |
| 422 | validation / bad cursor / missing required param | handler message |
| 404 | unknown ad / brand / board | handler message |
| 429 | 60 req / 60 s | `Rate limit exceeded` (`Retry-After`, `X-RateLimit-*`) |
| 500 | server | `Internal Server Error` |

## Endpoints

One row per OpenAPI path. Path templates use Next.js segment names (`{adId}`, `{brandId}`). There is no `/api/mcp` REST path, no OPTIONS, and no POST/DELETE `/api/boards` here.

| Method | Path | Scope | Credits | Pagination | Notes |
|---|---|---|---|---|---|
| GET | `/api/discovery/ads` | discovery | ad-list 1/ad | cursor (`limit` default 20, max 250) | Full filter matrix. `most_relevant` uses `ts_rank` when `query` is present. |
| GET | `/api/discovery/brands` | discovery | brand-list 1/brand | single page (`limit` default 10, max 100) | `query` / `q` / `domain` required (422 if missing). Honest `metadata.total_found`. |
| GET | `/api/discovery/brands/explore` | discovery | brand-list 1/brand | `limit` default 20, max 10000 | `days` 1–365 (default 30). Ranked by measured ad count. |
| GET | `/api/discovery/ads/impressions` | discovery | ad-list 1/ad | `limit` default 20, max 100 | **x-cypher-extension**. Sort by impressions high→low. Params: `brand`/`q`, `platform`, `min_days`. |
| GET | `/api/discovery/ads/impressions/by-brand` | discovery | ad-list 1/ad | offset + `limit` default 20, max 100 | **x-cypher-extension**. `brand`/`q` required → 400 if missing. |
| GET | `/api/ad` | discovery | ad-lookup 1 if found | none | Query is `ad_id` only — no `id` alias. Missing → 422. Unknown → 404. |
| GET | `/api/ad/{adId}` | discovery | ad-lookup 1 if found | none | Path form of get-by-id. Same resolver/DTO/404 as `GET /api/ad?ad_id=`. |
| GET | `/api/ad/duplicates/{adId}` | discovery | ad-list 1/ad | hard-capped 100 | Other ads sharing the anchor's non-null `mediaUrl`. Unknown anchor → 404. No media → 200 empty. |
| GET | `/api/brand/getAdsByBrandId` | discovery | ad-list 1/ad | cursor (`limit` ≤ 250) | `brand_ids` (alias `brand_id`) Brand cuids or `foreplayBrandId`s, max 50. `collect` accepted-and-ignored. |
| GET | `/api/brand/getAdsByPageId` | discovery | ad-list 1/ad | cursor (`limit` ≤ 250) | Numeric `page_id` required. Unknown numeric page → **200 empty** (not 404). |
| GET | `/api/brand/getBrandsByDomain` | discovery | brand-list 1/brand | `limit` default 10, max 10 | `domain` (aliases `query` / `q`) required. Matches `Brand.domain` / `websites`, not name ILIKE. |
| GET | `/api/brand/analytics` | discovery | ad-list 1/row (empty free) | none | `id` is Brand cuid or numeric page id (**not** `foreplayBrandId`). Unknown → 404. |
| GET | `/api/v1/brands` | discovery | brand-list 1/brand | cursor (`limit` default 20, max 100) | **x-cypher-extension**. Envelope superset of `{brands, nextCursor}`. Raw Prisma Brand rows. |
| GET | `/api/v1/brands/{brandId}/ads` | discovery | ad-list 1/ad | `limit` default 100, max 200 | **x-cypher-extension**. Matches `brandId` or case-insensitive `brandName`. Raw Prisma Ad rows. |
| GET | `/api/swipefile/ads` | library | ad-list 1/ad | offset + `limit` default 10, max 250 | Personal key. Orders `saved_newest` (default) / `newest` / `oldest` / `longest_running`. |
| GET | `/api/boards` | library | flat-listing 1 if non-empty | offset + `limit` (omitted = all) | Personal key. Keyed Foreplay envelope only. `folders=true` synthesizes a Default folder. |
| GET | `/api/board/brands` | library | flat-listing 1 if non-empty | offset + `limit` default 10, max 10 | Personal key. `board_id` required. Cross-tenant / unknown → 404. |
| GET | `/api/board/ads` | library | ad-list 1/ad | cursor (`limit` default 10, max 250) | Personal key. `board_id` required → 422. Cross-tenant / unknown → 404 before the items query. |
| GET | `/api/spyder/brands` | library | flat-listing 1 if non-empty | offset + `limit` default 10, max 10 | Personal key. Empty library → 200 `{data:[], count:0}`. |
| GET | `/api/spyder/brand` | library | flat-listing 1 | none | Personal key. `brand_id` required. Untracked / other-user / unknown → **403 `User not subscribed`** (never 404). |
| GET | `/api/spyder/brand/ads` | library | ad-list 1/ad | cursor (`limit` default 10, max 250) | Personal key. Tracked-check first → 403 `User not subscribed` **before** the ads query. |
| GET | `/api/usage` | account | free | none | UTC calendar month. Top-level `remaining_credits`. Additive `payment.spend` for x402 self-budgeting. System key: `user.id = "system"`. |
| GET | `/api/payments/{txId}/receipt` | account | free | none | Own settled rows only. Cross-tenant → 404. Keyless is inline-only. Verify JWT against the published PEM. |

Groups A–C authenticate but do not currently 403 on a missing `discovery` scope. Do not treat that as a spec bug.

## Quickstarts

### First search in 90 seconds

1. Check the current month's balance.
2. Run a small discovery search (`limit` defaults to 20, max 250 — keep it small).
3. Print `remaining_credits`, then link each ad's `cypher_url`.

```bash
curl -s https://cypherapp.io/api/usage \
  -H "Authorization: Bearer cypher_sk_YOUR_KEY"
```

```bash
curl -s "https://cypherapp.io/api/discovery/ads?query=running%20shoes&limit=5" \
  -H "Authorization: Bearer cypher_sk_YOUR_KEY"
```

Expect `data` + `metadata` on a successful search; failures also include `error.message` and return `data: []`. The search response stamps `X-Credits-Remaining` and `X-Credit-Cost`. Render each ad as `[headline or id](cypher_url)`.

### Competitor teardown

Resolve a domain → pull ads → analytics → expand duplicates of a winning creative. Hold `limit` constant if paging ads. `collect` is accepted and ignored (no on-demand scrape).

```bash
curl -s "https://cypherapp.io/api/brand/getBrandsByDomain?domain=example.com" \
  -H "Authorization: Bearer cypher_sk_YOUR_KEY"
```

```bash
curl -s "https://cypherapp.io/api/brand/getAdsByBrandId?brand_ids=BRAND_CUID&limit=20" \
  -H "Authorization: Bearer cypher_sk_YOUR_KEY"
```

`GET /api/brand/getAdsByPageId?page_id=…` is the page-id alternative.

```bash
curl -s "https://cypherapp.io/api/brand/analytics?id=BRAND_CUID" \
  -H "Authorization: Bearer cypher_sk_YOUR_KEY"
```

```bash
curl -s "https://cypherapp.io/api/ad/duplicates/{adId}" \
  -H "Authorization: Bearer cypher_sk_YOUR_KEY"
```

### Your library over MCP

A **personal** key is required (not the system key) so swipefile, boards, and spyder calls have a user and the `library` scope.

Hosted MCP (JSON-RPC, not REST):

```bash
claude mcp add --transport http cypher https://cypherapp.io/api/mcp
```

Authenticate with `Authorization: Bearer cypher_sk_YOUR_KEY`. No OAuth.

Hosted MCP exposes 17 tools from the same helpers as REST. `tools/list` is filtered by key scopes — a system key (`discovery` + `account`) never sees library tools. Calling a hidden tool returns a JSON-RPC tool-error whose text is the Foreplay 403 envelope (`Missing required scope: library`); HTTP stays 200. Each `tools/list` entry includes `_meta.x402` (`creditClass`, `creditsPerItem`, `microUsdPerCredit`, `minUsdCents`) for client-side spend controls.

When credits are exhausted **and** the x402 rail is mounted, `tools/call` stays **HTTP 200**. The JSON-RPC tool result is `isError: true` with the Foreplay 402 envelope in `content[0].text` and `_meta["x402/payment-required"]` (x402 v2 object, `accepts[0].scheme` is `"exact"`). Retry the same call with `_meta["x402/payment"]` (PaymentPayload object, or standard base64 or base64url string). Success attaches `_meta["x402/payment-response"]`. Same paid-lane rule as REST: decode the amount and pay-and-retry **once** only if that amount is ≤ the user's stated budget. A 402 envelope **without** `_meta["x402/payment-required"]` (`Out of credits`, `duplicate_settlement`, `spend_limit_exceeded`) is not payable. Local stdio (`npm run mcp`, process-trust `local-dev`) is never challenged.

When the keyless pilot is mounted (`X402_KEYLESS_ENABLED=true` plus the x402 rail), unauthenticated `initialize` and `tools/list` are allowed and `tools/list` returns **only** the five pilot tools (`discover_ads`, `search_brands`, `explore_brands`, `get_ad`, `get_ad_duplicates`). Pilot `tools/call` follows the same `_meta` pay-and-retry-once rule (`limit` capped at 100). Library tools and `analyze_creative` stay HTTP 401 pre-framing. Flag default off.

- Discovery: `discover_ads`, `search_brands` (name/domain ILIKE — not domain-exact), `explore_brands`
- Ads: `get_ad`, `get_ad_duplicates`
- Brands: `get_brand_ads`, `get_brands_by_domain` (exact host), `get_brand_analytics`
- Library: `get_swipefile_ads`, `list_boards`, `get_board_ads`, `get_board_brands`, `list_tracked_brands`, `get_tracked_brand`, `get_tracked_brand_ads`
- Account: `get_usage`
- Differentiator: `analyze_creative` (no REST twin)

Do not invent extra tool names. Agents that previously used `search_brands` as a domain lookup must switch to `get_brands_by_domain`.

REST fallbacks (personal key):

```bash
curl -s "https://cypherapp.io/api/swipefile/ads?limit=10" \
  -H "Authorization: Bearer cypher_sk_YOUR_KEY"
```

```bash
curl -s "https://cypherapp.io/api/boards" \
  -H "Authorization: Bearer cypher_sk_YOUR_KEY"
```

```bash
curl -s "https://cypherapp.io/api/board/ads?board_id=BOARD_CUID" \
  -H "Authorization: Bearer cypher_sk_YOUR_KEY"
```

```bash
curl -s "https://cypherapp.io/api/spyder/brands" \
  -H "Authorization: Bearer cypher_sk_YOUR_KEY"
```

## MCP and CLI

**MCP** is JSON-RPC Streamable HTTP at `POST https://cypherapp.io/api/mcp`. No OAuth. Hosted tools (17, scope-filtered): `discover_ads`, `search_brands`, `explore_brands`, `get_ad`, `get_ad_duplicates`, `get_brand_ads`, `get_brands_by_domain`, `get_brand_analytics`, `get_swipefile_ads`, `list_boards`, `get_board_ads`, `get_board_brands`, `list_tracked_brands`, `get_tracked_brand`, `get_tracked_brand_ads`, `get_usage`, `analyze_creative`. A system key never sees the 7 library tools; a hidden `tools/call` is a tool-error 403 envelope, not HTTP 403. REST fallbacks above remain valid. Exhausted keyed callers on the mounted x402 rail get a payable `_meta["x402/payment-required"]` challenge on `tools/call` (HTTP still 2xx); rail-off still HTTP 402 `"Out of credits"` at the transport. A 402 envelope without `_meta["x402/payment-required"]` includes `spend_limit_exceeded` (not payable; a human must raise the key's limit). Stdio local-dev is never challenged.

**CLI:** `npx @cypherai/cli` (bin `cypher`) is a thin HTTP client over this API. `npx @cypherai/cli login cypher_sk_YOUR_KEY`, then `cypher usage`. Local lane: `CYPHER_API_BASE_URL`. Credentials: `~/.cypher/credentials.json` mode `0600`. `--output table|json|quiet`. Exit 0 / 1 / 2. Until the package is published (issue 15), run from a checkout: `npm --prefix packages/cli run build && node packages/cli/dist/index.js`.

## Rate limits

60 requests / 60 seconds per key (`X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`). On 429 honor `Retry-After` with **one** retry. Surface `X-Trace-ID` + `error.message` on failures.

## Do not

- Invent endpoints that are not in the table (no `/ads/{id}`, no session/admin routes, no REST `/api/mcp`).
- Put the key in a query string (`?api_key=`, `?token=`, `?secret=`).
- Use the system key for library routes (swipefile / boards / spyder).
- Change `order` mid-cursor (rejected with 422), or vary `limit` during a page walk.
- Proxy or download media bytes through this API — follow the source URLs.
- Treat `collect=true` as a scrape. It is accepted and ignored.
- Commit a real `cypher_sk_`.
