# SigmaShake Promo — agent onboarding guide

`promo.sigmashake.com` is a multi-streamer "outbid" sponsor-listing board. Any
Twitch or YouTube streamer onboards a **channel** (a board + an OBS
overlay). An advertiser creates a **listing** on that channel and pays with
**Stripe Checkout**, **Twitch bits**, or **YouTube Super Chat**. Rank is the
listing's **cumulative paid USD, held permanently** — there is no term, no
expiry, no rotation, no demand pricing; a listing is only displaced by
another listing's strictly higher total.

**Paying does not put a listing on screen.** A listing ranks the instant it
is paid, but shows on the board/overlay nowhere until the channel's own
owner or the platform operator approves it. Every route below that can move
a listing to `approved` requires the channel owner's own credential (session
or scoped API key) or the operator's admin token — there is no way around
this, by design.

This guide is for an autonomous agent (or a human driving one) doing
everything below with **only an API key** — no browser, no cookies. Every
step is a copy-pasteable `curl` call plus the equivalent call through
`@sigmashake/promo-sdk` (in-repo, `sigmashake-promo/sdk/`).

## Prerequisites

An API key cannot be minted by another API key — key management is
session-cookie-only (`POST /api/me/api-keys`, invariant 11 in `CLAUDE.md`).
**A human must do this once:**

1. Sign in at `https://promo.sigmashake.com/me` with Twitch or Google.
2. Open the API keys panel. It is on the dashboard (API keys card) and on
   `/me`. Defaults are `channel:read`, `channel:write`, `listings:read`,
   `listings:write`. `moderation:write` is an explicit checkbox for approving
   via the API, not a default. Create a key with the scopes the agent needs
   (see the scope table below) — the plaintext (`promo_live_…`) is shown
   **exactly once**. If you
   are an agent instructing a human, tell them precisely: "Sign in at promo.sigmashake.com/me, go to API keys,
   create a key named `<name>` with scopes `<scopes>`, and paste me the
   `promo_live_…` value it shows you."
3. Give the agent the plaintext key as an environment variable, e.g.
   `PROMO_API_KEY=promo_live_...`. Never log it, never paste it into a public
   channel — anyone holding it can act as that owner/buyer up to its scopes.

Every call below sends `Authorization: Bearer $PROMO_API_KEY`.

## Scopes

| Scope | Grants |
| --- | --- |
| `channel:read` | Read a channel's own settings/stats/rails/domain status |
| `channel:write` | Onboard a channel, edit its settings, manage its custom domain and payment rails |
| `listings:read` | List the caller's own listings/API-keys-visible queue reads |
| `listings:write` | Create/raise a claim, patch a listing revision |
| `moderation:write` | Approve/reject/remove a listing, or apply/reject its staged revision, in the owner queue |

`moderation:write` is a DISTINCT scope from `listings:write`: a key that can
create/edit listings cannot thereby moderate someone else's queue — a
`listings:write`-only key gets `403 insufficient scope` on an
approve/reject/remove call. An interactive owner session (no API key) is
never scope-checked and can always moderate its own channel.

A route missing the required scope on an otherwise-valid key is `403
insufficient scope` — never a 401 (the key itself is fine; it just cannot do
this). A key can be scoped to one channel at creation (`channel: "<slug>"`)
or left account-wide (every channel that owner owns) — **enforced**
server-side: a channel-scoped key gets `403 forbidden` on any OTHER
channel's owner route, and cannot call `POST /api/channels` to create a
brand-new one; `GET /api/me/api-keys` echoes the binding back (`channel:
"<slug>"` or `null`) so you can always see what a given key is actually
restricted to. **API keys can never manage other API keys** (mint/list/
revoke) — that stays session-only.

**One nuance:** this precise distinction only holds on **buyer** routes
(`POST /api/channels`, `POST .../claims`, `/api/me/listings*`,
`/api/me/contributions`). Every **owner** route under
`/api/me/channels/{slug}/*` (settings, stats, queue, listing actions,
rails, custom domain) shares its auth gate with the browser session flow,
which is DELIBERATELY binary: a wrong-scope key AND a revoked/unknown key
both come back as a plain `403 forbidden` there — it never tells an
unauthorized caller which of "wrong scope" or "wrong credential" applies.
If an owner-route call 403s, re-check both the key's validity and its
scope; a 401 on one of these means something else entirely (no
`Authorization` header at all).

## Step by step

All requests below go to `https://promo.sigmashake.com` (`$BASE`).

### 1. Create a channel

A **YouTube** channel can be onboarded directly with an API key — it starts
`pending` until the operator approves it, or until you connect the YouTube
rail (step 2 below), which itself proves ownership and activates it
immediately.

```sh
curl -s -X POST "$BASE/api/channels" \
  -H "Authorization: Bearer $PROMO_API_KEY" \
  -H "content-type: application/json" \
  -d '{"platform":"youtube","handle":"my-channel","channel_url":"https://youtube.com/@my-channel","owner_email":"you@example.com"}'
```

The 201 body is `{ channel: { slug, ... }, slug_degraded?, next_action? }`.
**YouTube API-key creates always return a temporary `*-yt-*` slug**
(`slug_degraded: true`) — never the handle. Capture it as `$SLUG` (or
`channel.slug` in TypeScript) and use that in every later URL. After the
owner completes Google rail-connect, re-GET `/api/me`: the slug may have
been promoted to the clean handle spelling.

**round7 audit P2-3 (contract B):** `owner_email` above is the ONLY moment
an API-key-created channel can ever set it — it is what
`sendPromoModerationPendingNotice` (the owner moderation-pending email) sends
to once this channel has its first paid, `pending_review` listing. Omit it
and the channel simply never gets that email (the in-app
`GET /api/me/notifications` feed is unaffected either way). A LATER
`PATCH /api/me/channels/{slug}/settings` call with an API key REJECTS
`owner_email` outright (`code: "owner_email_requires_session"`) — only the
channel owner's own signed-in browser session can read or change it after
creation, from the Dashboard's Settings → Owner email field.

**round7 audit P2-6 (SDK installation, CONFIRMED):** this package is
`"private": true` and not published to any registry this run — there is no
`bun add`/`npm install` form that resolves it, in this repo or your own.
The only two ways to actually use it are (1) import the relative path
straight from within this monorepo (`../sdk/src/index.js`, relative to
wherever your code lives under `sigmashake-promo/`), or (2) copy
`sigmashake-promo/sdk/src/index.ts` (a single dependency-free file, zero npm
dependencies) into your own project. `sdk/README.md`'s own "Install"
section documents exactly these two options and no others — every snippet
below writes `from "@sigmashake/promo-sdk"` purely as shorthand for
"whichever of those two you picked, under the name you gave it"; it is not
itself an installable package specifier.

```ts
import { AdsClient, isFullClaimStatus } from "@sigmashake/promo-sdk";
const client = new AdsClient({ baseUrl: BASE, apiKey: process.env.PROMO_API_KEY });
const { channel } = await client.createChannel({
  platform: "youtube",
  handle: "my-channel",
  channel_url: "https://youtube.com/@my-channel",
  owner_email: "you@example.com",
});
// YouTube: channel.slug is like "my-channel-yt-a1b2", NOT "my-channel".
const slug = channel.slug;
```

**Known limitation:** a **Twitch** channel can only auto-activate when the
caller's identity provably IS that Twitch login — proof only a live Twitch
OAuth session carries. An API key's identity is `"api_key"`, never
`"twitch"`, so `POST /api/channels` with `platform: "twitch"` always 403s
for an API-key caller. To onboard a Twitch channel, the human must sign in
once at `$BASE/auth/twitch` with the matching Twitch account; after that,
everything else in this guide works with the API key. A Twitch channel is
therefore already `active` by the time step 1 returns — step 2 below only
matters for a YouTube channel (needed to activate it), or for a Twitch
channel that also wants to accept bits (a channel has exactly ONE rail —
its own platform's — never both; see step 2's own "Connect ONLY the rail
matching this channel's own platform" note below).

**Mistyped a handle?** A channel STILL `pending` (never activated — no rail
connected yet, no operator approval yet) can be self-deleted to free the
handle for a retry — no operator ticket needed:

```sh
curl -s -X DELETE "$BASE/api/me/channels/$SLUG" -H "Authorization: Bearer $PROMO_API_KEY"
```

```ts
await client.me.deleteChannel(slug);
```

409s `{code: "not_pending"}` once the channel is `active`/`suspended` — at
that point the handle is permanently held; only the operator can release it.

### 2. Connect Twitch bits / YouTube Super Chat (browser step — do this BEFORE step 3)

**MUST happen before any of steps 3–7 below for a `pending` (YouTube)
channel** — `overlay`, `obs-scene.json`, the custom-domain route, and claim
creation all 404/403 on a non-`active` channel, and connecting the YouTube
rail is what activates one. A Twitch channel is already `active` from step
1, so this step is optional for it (only needed to actually accept bits).

**A channel may connect Twitch bits and YouTube Super Chat**, and card
checkout is available when Stripe is configured. Do not disconnect one
rail to connect the other. A channel's `platform` is fixed at onboarding
— it is the value you already passed as `platform` in step 1.
**round7 audit P2-9 (CONFIRMED): do NOT try to read it via `GET /api/channels/$SLUG`**
— that route is gated by the SAME active-channel requirement as every
other public board read (this guide's own note above: "pending channels
make active public routes fail"), so it 404s for a still-`pending` YouTube
channel.

**This is session-cookie-only, by design — never an API key**, because it
is an OAuth redirect entry point a browser must actually visit. An agent
cannot complete this step itself; hand the owner these URLs to open while
signed in:

- Twitch bits: `$BASE/auth/twitch/connect?channel=$SLUG`
- YouTube Super Chat: `$BASE/auth/google/connect?channel=$SLUG`

**round5 audit P1 (ADS-STR-R5-01): a YouTube channel activates ONLY when
the Google account completing this connect actually owns the handle typed
at step 1.** `channels.list?mine=true`'s proven `customUrl` (or channel id)
must match `handle`, case-insensitively — the self-declared `channel_url`
is never consulted here, it is corroborating evidence at onboarding time
only. A mismatch 302s to `/dashboard?auth_error=youtube_identity_mismatch&channel=<slug>&proven_handle=<the account's real handle>`
and activates nothing — no token is stored, the channel stays `pending`. If
this was a genuine typo (the streamer's real handle differs from what was
typed at onboarding), the recovery is: `DELETE /api/me/channels/<slug>`
(self-serve, `pending`-only — already available, no operator needed), then
re-run step 1 with the handle `proven_handle` actually names.

**round7 audit P2-9 (contract D): `/dashboard?auth_error=channel_reclaim_conflict&channel=<slug>`
is a DIFFERENT failure from `youtube_identity_mismatch` above — it means
the identity DOES match this handle, but someone ELSE already reclaimed
this exact channel row (by proving the same handle) in the moment between
your OAuth redirect starting and finishing** — a race, not a mismatch. It
also activates nothing; the channel keeps whatever owner won the race.
Recovery: re-fetch `GET /api/channels/$SLUG` (or the create response
from step 1) to see who owns it now — if it is genuinely you (the same
Google/Twitch account), just retry the connect URL from step 2, since a
retry after the race has settled reclaims cleanly; if it is a different
account entirely, this handle was already claimed and the only path
forward is picking a different one.

Once connected, `GET /api/me/channels/$SLUG/rails` (works with an API
key, `channel:read`) reports both connections' status — `null` means never
connected; only `status: "active"` means usable right now:

```sh
curl -s "$BASE/api/me/channels/$SLUG/rails" -H "Authorization: Bearer $PROMO_API_KEY"
```

If Twitch's EventSub subscription ever falls out of sync, an agent CAN
repair it without a browser:

```sh
curl -s -X POST "$BASE/api/me/channels/$SLUG/rails/twitch/resubscribe" -H "Authorization: Bearer $PROMO_API_KEY"
```

```ts
const rails = await client.me.getRails(slug);
await client.me.resubscribeTwitchRail(slug);
await client.me.pollYoutubeRail(slug); // manual Super Chat poll, 60s cooldown
```

### 3. Read the overlay URL and native size

```sh
curl -s "$BASE/api/channels/$SLUG/overlay"
```

The response's `size` field (from the channel's chosen layout plugin —
`bar` 1280×146, `sidebar` 320×900, `ticker` 1920×80) is the exact pixel
dimensions to size an OBS Browser Source to. The overlay itself is
public and served at `$BASE/c/$SLUG/overlay` — point the Browser
Source there. 404s until the channel is `active` (see step 2).

```ts
const overlay = await client.getOverlay(slug);
console.log(overlay.size, overlay.slots);
```

**"Installed in OBS" needs the ONE URL that actually carries the per-install
token.** `GET /api/me/channels/$SLUG/stats`'s `install_token` field is
embedded (`?src=<install_token>`) into `obs-scene.json`'s generated Browser
Source URL automatically — prefer downloading/importing that scene (step 5)
over hand-typing the bare `$BASE/c/$SLUG/overlay` URL above: a fetch
against the bare URL (or one with the wrong/missing `?src=`) still renders
correctly for a human/agent checking it, but does NOT advance
`last_overlay_poll_at`, since that signal is deliberately no longer
forgeable by any anonymous fetch of this public endpoint.

**Overlay renders blank and you cannot tell why.** Append `?debug=1` to the
overlay URL in a real browser (`$BASE/c/$SLUG/overlay?debug=1`) — it
surfaces the underlying fetch/render error inline instead of a silent blank
frame. This is diagnostic only; never ship `?debug=1` in the URL actually
handed to OBS.

**round7 audit P2-8: revoking a leaked API key does NOT revoke a leaked
`?src=<install_token>` URL — rotate it separately.** `POST
/api/me/channels/$SLUG/install-token/rotate` (`client.me.rotateInstallToken(slug)`)
mints a fresh token and immediately invalidates the old one; the overlay
itself keeps rendering under either URL (it never gates on this token —
invariant: never-down public GETs), but a poll under the OLD token stops
advancing `last_overlay_poll_at`/"Installed in OBS", indistinguishable from
a channel that was never installed. Re-download/re-import `obs-scene.json`
(step 5) after rotating — the OLD scene file's Browser Source URL is now
stale.

### 4. Set channel settings (layout, plugin, scroll, etc.)

```sh
curl -s -X PATCH "$BASE/api/me/channels/$SLUG/settings" \
  -H "Authorization: Bearer $PROMO_API_KEY" \
  -H "content-type: application/json" \
  -d '{"layout":"sidebar","plugin":"darkveil","animation":"slide","scrollSpeed":40}'
```

Requires `channel:write`. Any subset of `ChannelSettings` fields; see
`GET /api/plugins` for every valid `layout`/`plugin`(background)/`animation`
id and its parameters. Unlike overlay/scene/claim, this one does NOT require
an active channel.

```ts
const { settings } = await client.me.patchChannelSettings(slug, { layout: "sidebar", plugin: "darkveil", animation: "slide", scrollSpeed: 40 });
```

### 5. Download the OBS scene JSON

```sh
curl -s -o scene.json "$BASE/api/channels/$SLUG/obs-scene.json" \
  -H "Authorization: Bearer $PROMO_API_KEY"
```

Still 404s until the channel is `active` (see step 2). **Always send the
API key here, even though the route itself also answers an anonymous
request** — round6 audit P1-3: an anonymous fetch intentionally returns a
scene whose Browser Source URL carries NO per-install token (round4 audit
P2-9). OBS will still render the board from that URL, but
`last_overlay_poll_at` can never advance from it, so the install checklist
step never completes — silently, with nothing in the response to say why.
Only the channel's OWNER (session, or an API key scoped `channel:read` to
this channel) gets a URL with `?src=<install_token>` embedded, exactly as
step 3 above already notes about the bare overlay URL. A ready-to-import
OBS Scene Collection with one Browser Source pointed at that (tokened when
authenticated) overlay URL, sized to its saved or `?layout=`-overridden
layout. Import it in OBS via *Scene Collection → Import*.

```ts
// Sends `Authorization: Bearer <apiKey>` automatically — the client was
// built with one (step 1). `client.obsSceneUrl(...)` builds the same
// absolute URL without fetching, for handing to a human/OBS directly.
const scene = await client.getObsScene(slug);
```

### 6. Add a custom domain and read the DNS records

```sh
curl -s -X POST "$BASE/api/me/channels/$SLUG/domain" \
  -H "Authorization: Bearer $PROMO_API_KEY" \
  -H "content-type: application/json" \
  -d '{"hostname":"ads.my-own-domain.com"}'
```

Requires `channel:write`; flag-gated (`503 custom_domains_disabled` if the
operator has not turned this feature on for the board). The response's
`records` field is the CNAME/TXT the owner must add at their own DNS
provider. Poll status with:

**The claim surface is deliberately ABSENT on an active custom domain
(round4 audit P1-6).** `GET /claim` on the vanity hostname 302s to the SAME
board's `/c/:slug/claim` on the PLATFORM origin (`GET /api/channels/:slug`'s
`platform_claim_url` field is the same URL, pre-built) — the vanity host's
session cookie is host-only, so a claim can never actually authenticate
there. `GET /api/channels/:slug`'s `on_custom_host` field reports whether
THIS request resolved through the vanity host at all; build every claim/
sign-in/status link off the platform origin when it does.

```sh
curl -s "$BASE/api/me/channels/$SLUG/domain" -H "Authorization: Bearer $PROMO_API_KEY"
```

```ts
const created = await client.me.createDomain(slug, { hostname: "ads.my-own-domain.com" });
const status = await client.me.getDomain(slug);
```

### 7. Create a claim (buy or raise a listing)

Always `multipart/form-data`, even for a raise (only `image` is optional on
a raise — it is ignored). `rail` is REQUIRED and must be one this channel
actually supports right now — check `GET /api/channels/$SLUG`'s own
`rails` field (`{stripe, twitch_bits, youtube_superchat}`) first; `stripe`
is available whenever the operator has it provisioned, `twitch_bits`/
`youtube_superchat` only once step 2's connection is `active`. An API key
with `listings:write` is exempt from Turnstile (no browser to run the
challenge in) and is itself rate-limited per key. An `Idempotency-Key`
header makes a retried call from the SAME buyer within 24h replay the
original response instead of minting a duplicate claim — always send one.
**round7 workflow audit BUY-R7-01 (CONFIRMED): reuse a key ONLY when
`target`/`amount_cents`/`rail` are byte-identical to the original attempt**
— the server fingerprints those three fields when the key is first armed
and 409s `code: idempotency_conflict` if a retry under the SAME key
changed any of them, rather than silently replaying (or attaching to) the
wrong claim. Mint a brand-new key whenever any of those fields genuinely
changes, even if it is logically "the same" submission from the caller's
point of view.

**`target` is a full `https://`/`http://` URL, a BARE HOSTNAME with no
scheme (`example.com`, `example.com/path` — round4 audit P1-2: retried once
as `https://` before being rejected, so this ALWAYS resolves identically to
its explicit `https://` spelling), or a BARE `@handle`** (e.g. `@example`,
the leading `@` REQUIRED — a bare handle ALWAYS collapses onto
`https://x.com/example`, never any other platform). There is no way to
target a bare handle on YouTube/Instagram/TikTok/etc. through this
shorthand — spell those out as a full URL instead
(`https://youtube.com/@example`). The RESULTING identity — what actually
ranks and what a denylist entry matches against — is always the fully
resolved `x.com` URL, never the `@`-prefixed string typed in.

**`image` is REQUIRED for a NEW listing** (no existing listing yet at that
`target` on this channel — 800×360 px, 20:9 (±2% tolerance), larger 20:9
sizes accepted up to 4096px per axis, png/jpeg/gif/webp, <=2MB, magic-byte
sniffed, a declared Content-Type is never trusted) — still optional on a
raise, where it is ignored entirely.

**Before instructing anyone to pay:** `GET /api/config`'s `payout_disclosure`
field states who actually receives the money, by rail (card payments are
collected by SigmaShake, the operator — not the streamer; Twitch bits/
YouTube Super Chat pay the streamer directly). Every payment here is final
and non-refundable, and neither buying nor raising a listing guarantees
approval or that a rank will be held once paid — surface both of these to
whoever is actually paying, before they pay.

```sh
curl -s -X POST "$BASE/api/channels/$SLUG/claims" \
  -H "Authorization: Bearer $PROMO_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -F target=https://example.com/product \
  -F title="My Product" \
  -F description="A short pitch." \
  -F amount_cents=1000 \
  -F tos_accepted=1 \
  -F rail=stripe \
  -F image=@banner.png
```

```ts
const claim = await client.createClaim(slug, {
  target: "https://example.com/product",
  amountCents: 1000,
  tosAccepted: true,
  rail: "stripe",
  title: "My Product",
  description: "A short pitch.",
  image: imageBlob,
  imageFilename: "banner.png",
  idempotencyKey: crypto.randomUUID(),
});
// claim.code          — the PR-XXXX code: cheer/Super Chat this amount to pay with bits (rail !== "stripe")
// claim.listing_id    — round7 audit P1-3: SAVE this too. It is what step 8 below approves — never `listings[0]`.
// claim.checkout_url  — a Stripe Checkout link (rail === "stripe"); null for every other rail
// claim.channel_url   — round7 workflow audit BUY-R7-03: where to actually go cheer/Super Chat the code above (null only for a YouTube channel with no channel_url on record)
// claim.status_token  — round6 audit P1-1: SAVE this. It is what unlocks the FULL GET /api/claims/{code} body later.
```

**Claim #1 (Bidder agent recipe):** An advertiser or bidder agent can claim the top slot in one shot using `client.claimTop(slug, params)` or by sending `claim_top=1` with `amount_cents` omitted. The server automatically quotes the current leader and prices the claim at leader + $1 (100 cents), or the channel's suggested top floor if unoccupied.

```sh
curl -s -X POST "$BASE/api/channels/$SLUG/claims" \
  -H "Authorization: Bearer $PROMO_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -F target=https://example.com/product \
  -F title="My Product" \
  -F description="A short pitch." \
  -F claim_top=1 \
  -F tos_accepted=1 \
  -F rail=stripe \
  -F image=@banner.png
```

```ts
const topClaim = await client.claimTop(slug, {
  target: "https://example.com/product",
  tosAccepted: true,
  rail: "stripe",
  title: "My Product",
  image: imageBlob,
  imageFilename: "banner.png",
});
```

**Pay-first claims (migrations/0020_creative_optional.sql):** don't have a
target/title/image ready yet? Fund the claim first, pick the creative
after. Pass `creative=pending` (curl) / `creativePending: true` (SDK)
instead of `target`/`title`/`description`/`image` — all four are ignored.
Amount/rail/tos/turnstile/idempotency are completely unchanged; this is a
real, fully-validated payment, only the creative is deferred:

```sh
curl -s -X POST "$BASE/api/channels/$SLUG/claims" \
  -H "Authorization: Bearer $PROMO_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -F creative=pending \
  -F claim_top=1 \
  -F tos_accepted=1 \
  -F rail=stripe
```

```ts
const payFirst = await client.createClaim(slug, {
  creativePending: true,
  claimTop: true,
  tosAccepted: true,
  rail: "stripe",
});
// payFirst.listing_id — same field as an ordinary claim's response; SAVE it.

// ... once the real target/title/image are ready:
await client.me.patchListing(payFirst.listing_id, {
  target: "https://example.com/product",
  title: "My Product",
  image: imageBlob,
});
// the server detects this listing's own creative_status === "pending" and
// writes target/title/image DIRECTLY onto the row (no revision-approve
// step needed — it has never aired) — a target collision with another
// listing on this channel comes back `code: "target_conflict"` (409).
// Until this completes, step 8's approve call 409s `code: "creative_missing"`.
```

**Owner agents vs Bidder agents:** Owner agent = `createChannel` + configure (settings/rails/domain) + `moderation:write` approve/reject. Bidder agent = `claimTop()` (or `claim_top=1`). There are **no unpaid owner-authored listings** — owners also pay through a claim. Honest constraint: the first API-key mint (Prerequisites) and Twitch/YouTube channel proof (step 2) each still need one human browser step; after that the agent drives the rest. Nothing airs until approved in the queue.

Both payment paths credit the SAME claim if `rail` allows either — a
cheer/Super Chat can under-cover the charge and be topped up by a later
one, a Stripe Checkout always covers it in one shot.

**Polling `GET /api/claims/{code}` (`client.getClaimStatus(code, opts)`) —
round6 audit P1-1: the code alone only ever resolves a COARSE body.** The
`PR-XXXX` code is deliberately guessable-at-scale (32^4 ≈ 1M combinations
— see `lib/paycode.ts`'s own header comment; this is what authorizes
CREDITING a claim, anyone who reads it off a stream overlay may pay toward
it, never reading its full state back). Without `claim.status_token` (or
the owning buyer's own session), you get back `{access: "coarse", code,
status, claim_url}` — `status` here is a COARSE enum
(`pending_payment|paid|approved|rejected|expired`) that already blends in
the funded listing's moderation state, no amounts/timestamps/`kind`. Pass
the token to unlock the full body instead:

```ts
const full = await client.getClaimStatus(claim.code, { statusToken: claim.status_token ?? undefined });
if (isFullClaimStatus(full)) {
  // full.access === "full" — charge_cents/kind/created_at/paid_at/listing_status all typecheck here.
  console.log(full.status, full.listing_status);
}
```

`full.status` (the DIFFERENT, full-body enum: `pending_payment|paid|cancelled`)
goes `pending_payment` → `paid` (or `cancelled` if it expires unpaid); the
sibling `listing_status` only reaches `approved` once step 8 below
actually happens — `paid` alone never means the listing is visible. Check
`.access` (or use the exported `isFullClaimStatus` type guard) before
reading any full-body-only field — an expired/forgotten token silently
degrades to coarse rather than throwing.

**Once paid, `GET /api/me/buyer-notifications` (`client.me.buyerNotifications()`;
session or `listings:read` key) is the buyer's own push signal for the
terminal step** — one row per approve/reject/remove decision on that
buyer's own listings (round4 audit P2/rank 14; round6 audit P2-4 added a
`kind: "listing_removed"` row for an approved, already-live listing later
taken down; in-app only, no email yet — see the route's own openapi.yaml
entry).
If a paid listing sits `pending_review` past `GET /api/config`'s
`review_sla_hours`, escalate it as the SIGNED-IN BUYER who created it:
`POST /api/me/listings/{id}/report` (`client.me.reportListing(id, {
reason: "review_overdue" })`) with `reason: "review_overdue"` (round4
audit P1-4 / round6 audit P1-2). This route is buyer-identity-only
(session, or a `listings:write` API key scoped to this listing) and needs
NO Turnstile token — do NOT use the public, anonymous
`POST /api/listings/{id}/report` route for this: it still requires
Turnstile and exists for third-party reports, never a buyer escalating
their own stuck claim. Either way it lands in the SAME operator report
queue every other report reaches — the server folds the computed SLA
deadline into `details` automatically. (This is stored as
`reason: "other"` with a `[review_overdue]` tag in `details` — the
`reports` table's own reason column is a fixed six-value set this project
never widens — but the queue is the same one either way.)

### 8. Find the listing id, then approve it

**round7 audit P1-3 (CONFIRMED): match by the claim's own `listing_id`,
never by `listings[0]` of the owner queue.** `client.createClaim`'s
response (`claim` from step 6/7 above) carries `listing_id` — the
immutable id of the listing THAT CLAIM funded, whether it created a brand
new listing (`kind: "new"`) or raised an existing one (`kind: "raise"`):

```ts
const listingId = claim.listing_id; // from client.createClaim's own response above
```

If you lost that response (a crashed process, a different agent run),
recover it from `GET /api/me/claims` (`client.me.getClaims()`) — every row
there ALSO carries `listing_id`, keyed to the SAME claim `code` you still
have:

```ts
// Alternative to the line above, if you lost the original claim response:
const { claims: myClaims } = await client.me.getClaims();
const recoveredListingId = myClaims.find((c) => c.code === claim.code)?.listing_id;
```

**Do NOT locate the listing by reading the owner queue and assuming index
`[0]`.** `GET /api/me/channels/{slug}/queue` is oldest-first and is NOT
scoped to listings YOU created — on a channel with more than one pending
submission (from other buyers, or from an already-approved listing's own
staged `PATCH .../listings/{id}` revision sitting in the SAME queue), the
first row can be a completely unrelated ad. The queue is still useful for
a HUMAN/agent reviewing everything currently awaiting a decision, but it
is the wrong tool for "which listing did MY claim just fund" — use
`listing_id` for that instead:

```sh
curl -s "$BASE/api/me/channels/$SLUG/queue" -H "Authorization: Bearer $PROMO_API_KEY"
```

**Never approve sight-unseen.** Each queue row carries `target_url` and
`total_cents` already; to see the actual creative before approving, fetch it
through the owner-scoped image route (`listings:read`, NOT the admin-only
`/api/admin/*` route — that needs `PROMO_ADMIN_TOKEN`, which this key does not
have):

```sh
curl -s "$BASE/api/me/channels/$SLUG/listings/$LISTING_ID/image" -H "Authorization: Bearer $PROMO_API_KEY" -o preview.png
```

```ts
const imageUrl = client.me.ownerListingImageUrl(slug, listingId);
```

(`ownerRevisionImageUrl` is the same, for a staged `PATCH .../listings/{id}`
edit's not-yet-applied image.)

**Approval REQUIRES payment.** A listing with `total_cents <= 0` (nothing
credited yet) cannot be approved — the route 409s `{code: "unpaid"}` — see
the error catalogue. This is enforced server-side; it is not something the
queue view alone tells you to skip.

The channel owner (session or `moderation:write` API key — NOT
`listings:write`, which is a distinct scope for creating/editing listings,
not moderating them) or the operator:

```sh
curl -s -X POST "$BASE/api/me/channels/$SLUG/listings/$LISTING_ID/approve" -H "Authorization: Bearer $PROMO_API_KEY"
```

```ts
await client.me.ownerListingAction(slug, listingId, "approve");
```

Only an `approved` listing is ever visible on the board, the overlay,
`/img/:id`, or `/go/:id` — this is the platform's core invariant, and there
is no route that skips it.

### 9. Read the board

```sh
curl -s "$BASE/api/channels/$SLUG/board"
```

No auth required — public, cached, approved listings only, in permanent
rank order.

```ts
const board = await client.getBoard(slug);
```

## Error catalogue

Every error is a JSON body with an `error` string and usually a `code` or
`field`. This is not exhaustive — see `openapi.yaml` for the definitive,
per-route list — but covers what an agent will hit most often.

| HTTP | `error` / `code` | Next action |
| --- | --- | --- |
| 400 | `target is invalid (<reason>)` — reasons: `invalid`, `bad_scheme`, `private_host`, `chat_invite`, `shortener` | Fix the target URL/handle; do not retry unchanged |
| 400 | `amount_cents must be an integer` | Send a whole-cent integer |
| 400 (`code`) | `not_whole_dollars` / `below_min` / `above_max` / `below_top_increment` / `below_raise_increment` | Re-quote via `GET .../quote` and adjust `amount_cents` |
| 400 | `hostname is invalid (<reason>)` — reasons: `invalid`, `ip_literal`, `too_long`, `reserved`, `platform_domain` | Fix the hostname; a `sigmashake.com` subdomain or an IP literal is never accepted |
| 400 | `rail must be 'stripe', 'twitch_bits', or 'youtube_superchat'` | Send a valid `rail` — see `GET /api/channels/:slug`'s `rails` field |
| 400 (`code`) | `rail_not_supported` | This channel has not connected that rail (or the operator has not provisioned Stripe) — pick one `GET /api/channels/:slug`'s `rails` field marks `true` |
| 400 (`code`) | `quote_invalid_target` / `quote_invalid_amount` | round5 audit P2-7: `GET .../quote`'s own target/`amount_cents` validation failures — fix and re-quote |
| 400 (`code`) | `invalid_reason` | round5 audit P1-2 (contract A): `POST /api/me/listings/:id/report` (the signed-in buyer's own escalation) — unknown/missing `reason`, or a `details` validation failure |
| 400 (`code`) | `report_invalid_reason` / `report_invalid_details` | round5 audit P2-7: `POST /api/listings/:id/report` (the public, anonymous route) — same validation as above, split per field |
| 400 (`code`) | `domain_invalid_hostname` | round5 audit P2-7: `POST .../domain`'s own hostname-shape validation failure — fix and resubmit |
| 401 | `sign in required` (with `login_urls`) | No session/API key presented — mint or pass a key |
| 401 | `invalid api key` | The key is unknown or its hash does not match — re-check the plaintext |
| 403 | `insufficient scope` | The key is valid but missing the scope this route needs (e.g. `moderation:write` for approve/reject/remove, distinct from `listings:write`) — mint a new key with it |
| 403 | `forbidden` | Not this channel's/listing's owner, and not the operator — includes a CHANNEL-SCOPED key presented against a different channel than it was minted for; mint/use the right key. Also `POST /api/me/listings/:id/report` (round5 audit P1-2 contract A): not this listing's own buyer — a `channel:write`/owner-scoped key is NEVER sufficient here. |
| 403 | `target is not allowed` / `not allowed` / `hostname is not allowed` / `submission not allowed` (`code: denylisted`) | Denylisted — either operator-wide OR this ONE channel's own block list (round4 audit P2-11: the channel-scoped `user`/`ip_hash`/`host`/`listing_key` denylist is now enforced at claim creation, not just accepted) — will not succeed on retry |
| 403 (`code`) | `domain_denylisted` | round5 audit P2-7: `POST .../domain` — this hostname is on the operator's denylist; pick a different one, will not succeed on retry |
| 403 (`code`) | `provider_mismatch` | round5 audit P2 (STR-R5-02): `POST /api/channels` — onboarding a Twitch channel needs a browser session signed in through TWITCH (never Google), matching the exact handle. Carries `next_action` naming the required account by handle — sign out and back in with it, then resubmit; Twitch and Google sign-ins are permanently separate identities, so a channel never transfers between them. |
| 403 (`code`) | `owner_email_requires_session` | round5 audit AG-R5-adjacent: `PATCH /api/me/channels/:slug/settings` — an API-key identity (any scope) can never read OR set `owner_email`; only the owner's own browser session can. Drop `owner_email` from the request body when calling this route with an API key — every OTHER field patches normally. |
| 404 | `channel not found` / `listing not found` / `claim not found` | Check the slug/id; nothing to retry |
| 404 (`code`) | `not_found` | round5 audit P2-7: `GET .../quote` and `POST /api/me/listings/:id/report`'s own not-found responses — same posture as the bare-message rows above, now carrying an explicit `code` too |
| 404 (`code`) | `no_domain` (with `custom_domains_enabled:true`) | The custom-domains feature IS enabled, but this channel has never configured one — `POST` a hostname, do not retry the `GET` unchanged |
| 404 (`code`) | `custom_domains_disabled` (with `custom_domains_enabled:false`, from `GET .../domain`'s no-row path only) | The operator has the feature turned OFF board-wide — same operator-action-required posture as the 503 of the same name below, just on this one read-only, no-Cloudflare-call path |
| 409 (`code`) | `wrong_state` | The listing/claim/domain/API-key is not in the state this action expects (e.g. already approved, or already revoked) |
| 409 (`code`) | `unpaid` | Owner/operator tried to approve a listing with no payment credited yet (`total_cents <= 0`) — wait for a payment credit (or the buyer to complete Checkout), then retry the SAME approve call |
| 409 (`code`) | `target_unavailable` | The matched existing listing for this target is `removed`/`rejected` — permanently blocked; pick a different target, do not retry this one |
| 409 (`code`) | `idempotency_conflict` | Two distinct causes, same code: (1) round5 audit P1-1 — Stripe rejected a Checkout replay made under a frozen, byte-identical `Idempotency-Key`; a BUG SIGNAL, not an ordinary failure — do NOT retry with the same key, contact support with the claim code for manual reconciliation. (2) round7 workflow audit BUY-R7-01 — this `Idempotency-Key` was already used for a DIFFERENT `target`/`amount_cents`/`rail`; mint a brand-new key and resubmit — the fix here is simple, not a bug report. |
| 409 (`code`) | `domain_exists` / `target_conflict` | Remove the existing domain first / pick a different target |
| 409 (`code`) | `hostname_taken` | Another channel already has this exact hostname live — release it there first, or pick a different subdomain; never retries into success |
| 409 (`code`) | `handle_taken` | `POST /api/channels`: this platform/handle is already onboarded and VERIFIED — contact the operator, do not retry |
| 409 (`code`) | `handle_reserved_pending` | `POST /api/channels`: the handle is held by an UNVERIFIED `pending` YouTube reservation (nobody ever proved ownership) — the real owner can reclaim it by completing `GET /auth/google/connect?channel=<slug>` (the `channels.list?mine=true` proof transfers ownership automatically on success), rather than waiting out the 7-day expiry sweep |
| n/a — NOT a JSON body: a 302 redirect to `/dashboard?auth_error=channel_reclaim_conflict&channel=<slug>` | `channel_reclaim_conflict` | round7 audit P2-9 (contract D): `GET /auth/google/connect?channel=<slug>` (step 2) — the SAME reclaim race `handle_reserved_pending` above describes, but discovered a step later: your identity DID prove ownership of this handle, but a DIFFERENT reclaim attempt won the race for this exact channel row in between. Re-check who owns the channel now (`GET /api/channels/<slug>`, or the create response from step 1); if it is genuinely your own account, retry the SAME connect URL — a retry after the race has settled reclaims cleanly. |
| 422 | `tos_accepted is required` | Send `tos_accepted=1` |
| 429 (`code`) | `too_many_unpaid` | This buyer already has the max unpaid `pending_review` listings open on this channel — pay or wait for one to expire, then retry |
| 429 (`code`) | `api_keys_max_reached` | This account already holds the maximum ACTIVE API keys (`settings.api_keys_max_per_user`, 10 by default) — revoke one, or `PATCH /api/me/api-keys/:id` an existing key's name/scopes instead of minting a new one; no `retry_after_seconds`, waiting does not help |
| 429 | `try again later` / `rate limited` (`retry_after_seconds`) | Honor the `Retry-After` header/`retry_after_seconds` before retrying |
| 429 (`code`) | `cooldown_unavailable` | The D1 `cooldowns` table itself is unreachable (fails CLOSED — never a silent bypass) — this covers `PATCH /api/me/listings/:id`'s revision cooldown (`settings.revision_cooldown_seconds`, round4 audit rank 12) alongside claim/raise/report/onboarding/api_key |
| 429 (`code`) | `report_cooldown` / `domain_cooldown` | round5 audit P2-7: the per-buyer report-escalation cooldown (`POST /api/me/listings/:id/report`), or the per-channel domain-create cooldown (`POST .../domain`) — honor `retry_after_seconds` |
| 502 (claim create) | `checkout.error` — Stripe Checkout call failed | `rail=stripe` only; nothing was persisted (no listing/claim/upload survives), so it is genuinely safe to retry ONCE with the SAME `Idempotency-Key`, then back off. This is the ONLY 502 this API treats as safe-to-retry-as-is — do not generalize "502 → retry" to any other route. |
| 502 (domain create/delete) | Cloudflare rejected the call (`code: domain_upstream_failed` on create, round5 audit P2-7) | NOT automatically safe to retry — a create can leave an orphaned Cloudflare hostname on a losing race; check `GET .../domain` before retrying, and if it keeps failing the hostname/DNS records need operator attention, not more retries |
| 503 | `submission unavailable` (`PROMO_HASH_SALT` unresolvable) / `verification unavailable` (`TURNSTILE_SECRET` unresolvable) / "admin authentication unavailable" (`PROMO_ADMIN_TOKEN`) — tell the operator exactly which of these secrets the error names. `card payment unavailable` (`code: stripe_unavailable`) is different: the buyer-facing next_action is exactly "Card checkout is unavailable right now. Retry later." and does not name a secret. Do not tell the buyer a secret name. | NOT retryable by YOU. Retrying the same request will keep 503ing until the operator fixes it. This is never a rejection of your own credential. |
| 503 (`code`) | `checkout_persist_failed` | round5 audit P1-1: `POST .../claims` — Stripe created a real Checkout session but this service could not durably record it. **UNLIKE every other 503 in this table — RETRY, with the SAME `Idempotency-Key`.** The same frozen parameters replay Stripe's cached session rather than creating a second one; do not treat this as a generic "operator action required" 503. |
| 503 (`code`) | `code_exhausted` | `POST .../claims` — the `PR-XXXX` code space is momentarily exhausted; safe to retry |
| 503 (`code`) | `custom_domains_disabled` | round5 audit P2-7: now also carries this as an explicit `code` field, not just the `error` string (a caller branching on `err.code` alone previously never matched it). The operator has the custom-domains feature turned OFF board-wide — not a per-request failure; do not retry, tell the operator this feature needs enabling first |
| 503 (`code`) | `custom_domains_unavailable` | round5 audit P2-7: likewise now an explicit `code`. The feature is ON but misconfigured (`CF_SAAS_API_TOKEN`/`CF_ZONE_ID` not provisioned) — same as the vault-secret row above: operator action required, not a retry |
| 503 (`code`) | `upstream_unavailable` | round5 audit P2-4: a public never-down GET (`/api/channels/:slug`, `/board`, `/overlay`, ...) whose last-good KV snapshot fallback ALSO failed — both the live data source and its resilience snapshot are down right now; carries `Retry-After: 30` |

## Rate limits, cooldowns, and `Retry-After`

- **Per-key rate limit** (`RL_APIKEY`) applies to every API-key-authenticated
  call; a `429` always carries `Retry-After` (seconds) — read it, do not
  guess a backoff.
- **Cooldowns** (KV-backed, independent of the rate limiter): claim create,
  raise, report, channel onboarding, API key creation, custom-domain create
  (10 minutes), YouTube manual poll (60 seconds) — each returns `429` with
  `retry_after_seconds` in the body.
- Both are **availability controls**, never a payment or moderation
  boundary — they never cause a duplicate charge or a double-credit.

## Idempotency

- `POST .../claims` is idempotent WHEN — and only when — you send an
  `Idempotency-Key` header (or `idempotency_key` form field): the same key
  from the same signed-in buyer within 24h replays the ORIGINAL response
  (`200`, same `code`/`checkout_url`) instead of minting a duplicate claim.
  Always send one; a call with no key is NOT idempotent — a retried,
  actually-successful request without a key mints a second, independent
  claim. A concurrent retry with the SAME key while the original is still
  genuinely in flight gets `409 idempotency_in_progress` (retry the SAME key
  again after a short wait) rather than a duplicate — this can never mint a
  second claim/Checkout session. If the original request's Worker isolate
  crashed before ever finishing (rare), the reservation is recoverable: the
  SAME key, retried after the lease window
  (`settings.idempotency_lease_seconds`) has passed, re-wins the reservation
  and **replays whatever had already been frozen onto it** — the same
  `code`/quote, and the same `checkout_url`/Stripe session if one had
  already been created — rather than minting anything new. **round5 audit
  P1 (AG-R5-01): a recovery replay is never gated by a cooldown, including
  the API-key-scoped one** — every cooldown check is skipped entirely once
  the retry has re-won an already-frozen reservation, so it always returns
  the recovered claim immediately rather than a `429` that would otherwise
  force you to wait out that cooldown's own (typically longer) period on
  top of the lease — this holds regardless of how
  `settings.idempotency_lease_seconds` and any cooldown's period relate to
  each other, since the bypass is unconditional once a reservation carries
  a recoverable record. If NOTHING had
  been frozen onto the reservation yet (the crash happened before even
  that first, pre-Stripe write), the retry starts genuinely fresh under the
  same reservation identity — never permanently stuck either way.
- Payment credit itself IS idempotent on the far side regardless: a
  redelivered Stripe webhook or a re-sent cheer/Super Chat event never
  double-credits (unique on `(channel, event_id)`).

## Fail-closed 503s

A `503` here means "this service cannot authenticate or process anyone on
this path right now" — it is never a hidden way to bypass auth, and it
never means your specific credential was rejected; do not interpret it as a
401/403. That said, NOT every 503 is worth retrying on a short backoff — see
the error catalogue above for the exact split: a resolvable secret that is
merely slow/unreachable is a genuine transient outage (retry with backoff),
but an UNPROVISIONED secret or a board-wide-disabled feature
(`custom_domains_disabled`, or any "unresolvable"/"not provisioned" 503)
will keep 503ing on every retry until the OPERATOR configures it — surface
the exact secret/feature name the error gives you rather than looping.

## What an agent must never do

- Never scrape, guess, or brute-force another channel's owner-only routes
  (`/api/me/*` for a channel you do not own) — every one of them is
  authorization-checked server-side regardless of what a client sends.
- Never attempt to bypass Turnstile (irrelevant to an API-key caller anyway
  — you are already exempt, invariant 12) or fabricate a signature on the
  Stripe/Twitch/ingest webhook endpoints — those are for the real upstream
  services only.
- Never assume a listing is visible because it was paid — check `status`;
  only `approved` airs.
- Never log or transmit an `promo_live_…` API key anywhere other than the
  `Authorization` header of a request to `promo.sigmashake.com`.

---

