# Reapption Agent API — Proof of Human Attention

Send a payload video to a human and get back their recorded video reaction, with
capture metadata. Reapption verifies **capture** (a fresh human reaction was
recorded against this task); it does **not** judge sincerity, sentiment, or
payment — the calling agent reviews the returned video and decides.

- **Base URL:** `https://reapption.net`
- **Auth:** an agent API key (`agt_…`), sent on every agent-facing call.
- **Shape:** asynchronous. You create a task, hand a link to a human, and the
  reaction arrives later — poll or receive a webhook.
- **Machine-readable:** [`/openapi.json`](https://reapption.net/openapi.json)
  (OpenAPI 3.1), [`/llms.txt`](https://reapption.net/llms.txt), and this file
  at both `/docs/agent-api` and `/api/agent/docs`.
- **Test without a human:** create a `sandbox` task (§5). It completes itself,
  fires a real signed webhook and is never billed.
- **User-Agent:** any client works on the agent API, including Python's
  `urllib` defaults. The agent paths (`/api/agent/*`, `/llms.txt`, `/openapi.json`,
  `/docs/agent-api`, `/.well-known/jwks.json`, `/api/health`) are exempt from
  Cloudflare's browser-integrity check. A descriptive UA
  (`my-agent/1.0 (+https://example.com)`) is still appreciated.
- **Conventions:** JSON in and out (task creation is `multipart/form-data`,
  or JSON for sandbox tasks). Every error is JSON `{"code": "…", "error": "…"}`:
  branch on `code` (stable), show `error` (human text). All timestamps are
  ISO 8601 UTC (`2026-10-02T07:00:00Z`). A wrong method on a known route is
  `405` with an `Allow` header. CORS is open on the agent API, so a
  browser-based agent can call it from any origin.

### Endpoints at a glance

| Method & path | Auth | Purpose |
|---|---|---|
| `POST /api/agent/challenge` | — | get a registration challenge |
| `POST /api/agent/register` | — | register; returns `api_key` + `webhook_secret` (once) |
| `GET /api/agent/me` | key | check your key, quota and task counts |
| `GET /api/agent/billing` | key | plan, limits, credits, 24 h usage, recent credit movements |
| `POST /api/agent/billing/checkout` | key | buy a plan or credit pack → Stripe `checkout_url` (§9) |
| `POST /api/agent/billing/portal` | key | Stripe portal: invoices, card, cancel, switch plan |
| `GET /api/billing/plans` | — | public price list |
| `POST /api/agent/tasks` | key | create a task (real or `sandbox`) → `react_url` |
| `GET /api/agent/tasks` | key | list your tasks (`?status=pending,ready`) |
| `GET /api/agent/tasks/:id` | key | poll a task / retrieve the reaction |
| `GET /api/agent/tasks/:id/reaction.mp4` | key or signed link | the reaction video (Range supported) |
| `GET /api/agent/tasks/:id/thumbnail.jpg` | key or signed link | the reaction thumbnail |
| `GET /api/agent/tasks/:id/receipt` | key | signed receipt (Ed25519 JWS) for a ready task |
| `GET /.well-known/jwks.json` | — | public key(s) to verify receipts (also `/api/agent/jwks.json`) |
| `GET /api/health` | — | liveness: `{ok, service, time, db}`; `503` if the database is unavailable (also `/api/agent/health`) |
| `POST /api/agent/campaigns` | key | create a campaign link: one public URL, many reactions → `url` |
| `GET /api/agent/campaigns` | key | list your campaigns |
| `GET /api/agent/campaigns/:id` | key | a campaign's counts and every reaction it took |
| `PATCH /api/agent/campaigns/:id` | key | change `max_reactions` |
| `POST /api/agent/campaigns/:id/close` | key | stop taking reactions now |
| `GET /api/agent/campaigns/:id/receipt` | key | signed settlement receipt over every ready reaction |
| `GET /api/agent/webhooks` | key | your recent webhook delivery attempts |
| `GET /api/agent/tasks/:id/webhooks` | key | every delivery attempt for one task |
| `POST /api/agent/tasks/:id/webhook/replay` | key | send a task's webhook again, once |
| `POST /api/agent/webhooks/test` | key | signed test delivery to your receiver |
| `GET /api/agent/address/check` | key | `?address=` → EIP-55 form, or the capture page's 400 |
| `POST /api/agent/rotate-key` | key | new key; old one valid for a grace window |
| `POST /api/agent/revoke` | key | permanently disable this agent |
| `GET /api/agent/react/:token` | link token | (capture page) load the payload info |
| `GET /api/agent/react/:token/payload.mp4` | link token | (capture page) the payload video |
| `POST /api/agent/react/:token` | link token | (capture page) submit the reaction |
| `GET /api/agent/c/:slug` | campaign link | (capture page) load a campaign's payload info |
| `GET /api/agent/c/:slug/payload.mp4` | campaign link | (capture page) the campaign's payload video |
| `POST /api/agent/c/:slug` | campaign link | (capture page) submit a reaction to a campaign |

### Quick start (5 calls)

```bash
B=https://reapption.net
# 1. challenge → answer it → register (store api_key + webhook_secret)
curl -s -X POST $B/api/agent/challenge
curl -s -X POST $B/api/agent/register -H 'Content-Type: application/json' \
  -d '{"name":"my-agent","challenge_token":"…","challenge_answers":[{"id":1,"answer":"…"}]}'
KEY=agt_…
# 2. confirm the key
curl -s $B/api/agent/me -H "X-Agent-Key: $KEY"
# 3. dry run, no human needed: completes itself in ~5 s
T=$(curl -s -X POST $B/api/agent/tasks -H "X-Agent-Key: $KEY" \
  -H 'Content-Type: application/json' -d '{"sandbox":true}' | jq -r .task_id)
sleep 6; curl -s $B/api/agent/tasks/$T -H "X-Agent-Key: $KEY"   # status: ready
# 4. real task: upload a video, send react_url to your human, then poll / await the webhook
curl -s -X POST $B/api/agent/tasks -H "X-Agent-Key: $KEY" -F payload=@clip.mp4
```

---

## 1. Getting a key — self-serve

An agent registers itself over HTTP. No human, no dashboard, no server access.
Registration is gated by a **reasoning challenge** — trivial for a real agent,
a wall for a dumb bot. Two steps:

### Step 1 — get a challenge

```
POST /api/agent/challenge
```
**200:**
```json
{
  "token": "…",
  "questions": [ { "id": 1, "question": "Which word does not belong: banana, Rust, Java, Python?", "type": "odd-one-out" } ],
  "expires_in": 600
}
```
Answer each question (here: `banana`). The token is valid for `expires_in`
seconds. If the gate is disabled you'll get `{ "disabled": true }` — skip to
step 2 with no challenge fields.

### Step 2 — register with the answers

```
POST /api/agent/register
Content-Type: application/json

{
  "name": "my-agent",
  "email": "you@example.com",
  "challenge_token": "…",
  "challenge_answers": [ { "id": 1, "answer": "banana" } ]
}
```
`name` and `email` are optional; `challenge_token` + `challenge_answers` are
required while the gate is enabled.

**201:**
```json
{
  "agent_id": "…",
  "name": "my-agent",
  "email": "you@example.com",
  "api_key": "agt_XXXXXXXXXXXXXXXXXXXXXXXX",
  "webhook_secret": "YYYYYYYYYYYYYYYYYYYYYYYY",
  "daily_task_quota": 100,
  "docs": "https://reapption.net/docs/agent-api",
  "note": "Store api_key and webhook_secret now — only the key hash is kept and cannot be recovered."
}
```

```bash
# 1. get a challenge, answer it, then 2. register with the answers
CH=$(curl -s -X POST https://reapption.net/api/agent/challenge)
# … your agent reads CH.questions, works out the answers …
curl -s -X POST https://reapption.net/api/agent/register \
  -H 'Content-Type: application/json' \
  -d '{"name":"my-agent","challenge_token":"…","challenge_answers":[{"id":1,"answer":"banana"}]}'
```

Only the key's SHA-256 hash is stored, so copy `api_key` and `webhook_secret`
now — they cannot be recovered. To replace a key use `POST /api/agent/rotate-key`
(§3); to kill an agent use `POST /api/agent/revoke`.

- The challenge is rate-limited to **30 / hour per IP**; registration to
  **10 / hour per IP**.
- Wrong answers → `400 challenge_failed`; missing them → `400 challenge_required`.
- Each key has a **daily task quota** (default 100 tasks / 24 h). Exceeding it
  returns `429` on `POST /tasks`. Ask the operator to raise it if you need more.

> **Operator note.** The same key can be minted server-side for your own agents
> with `node scripts/mint-agent-key.js "name"`. An agent revokes itself with
> `POST /api/agent/revoke`; the operator can also set `active = 0` on the
> `agent_keys` row, and changes a quota there.
>
> **Toggling the challenge gate** (kill switch, no restart) — admin-authenticated:
> ```
> GET  /api/admin/agent-challenge                 -> {"enabled": true|false}
> POST /api/admin/agent-challenge {"enabled": false}   # turn the gate off
> ```
> The setting is stored in the `kv` table and takes effect immediately. The
> `AGENT_CHALLENGE_ENABLED` env var is only the boot default (on unless `=false`);
> the admin toggle overrides it. With the gate off, `/register` accepts no
> challenge fields and `/challenge` returns `{"disabled": true}`.

**Authentication header** (either form):

```
X-Agent-Key: agt_XXXX…
# or
Authorization: Bearer agt_XXXX…
```

Missing/invalid/inactive key → `401`.

**Check your key** — the cheap "am I authenticated, what's my quota" call:

```
GET /api/agent/me
```
```json
{
  "agent_id": "…",
  "name": "my-agent",
  "email": null,
  "created_at": "2026-10-02T07:00:00Z",
  "active": true,
  "key": { "presented": "current", "previous_key_valid_until": null },
  "quota":         { "limit": 100, "used": 3, "remaining": 97, "next_slot_at": "2026-10-03T06:10:00.000Z", "retry_after_seconds": 0 },
  "sandbox_quota": { "limit": 200, "used": 0, "remaining": 200, "next_slot_at": null, "retry_after_seconds": 0 },
  "tasks": { "pending": 1, "recorded": 0, "ready": 2, "expired": 0, "failed": 0 }
}
```
`quota` is a rolling 24 h window; `next_slot_at` is when the oldest counted task
ages out. `key.presented` is `previous` if you called with a key that was
rotated out but is still inside its grace window.

---

## 2. The flow

```
  agent                     Reapption                    human
    │  POST /api/agent/tasks     │                          │
    │───(payload video)─────────▶│  creates task (pending)  │
    │◀──{task_id, react_url}─────│                          │
    │                            │                          │
    │  ── hand react_url to the human, in your own channel ──▶
    │                            │   opens react_url        │
    │                            │◀──watches + records───────│
    │                            │   (recorded → ready)     │
    │◀═══ webhook: status=ready ═│                          │   (or poll)
    │  GET /api/agent/tasks/:id  │                          │
    │◀──{reaction_url, …}────────│                          │
    │  review video, decide      │                          │
```

**Lifecycle states:** `pending → recorded → ready`, plus two terminal states:
- `expired` — the human never reacted before the link's TTL elapsed.
- `failed` — the reaction was received but processing failed.

`expired` and `failed` are how you know to stop waiting (and withhold any
reward). A webhook fires on `ready` and on `failed`.

---

## 3. Endpoints

### `POST /api/agent/tasks` — create a task

Auth required. `multipart/form-data` with the video in `payload`, **or** a JSON
body with `payload_url` (an https link Reapption downloads itself; for agents
whose tools can't send a file).

| Field | Required | Notes |
|---|---|---|
| `payload` | one of these two | the video the human will watch (any common video; ≤ 100 MB) |
| `payload_url` | one of these two | `https` URL of the video; Reapption fetches it (≤ 100 MB, 60 s, up to 3 redirects). It must resolve to a public address, including after each redirect. The response must be a video (`video/*`, `audio/*` or `application/octet-stream`); anything else is `400 payload_not_video`. Sending both `payload` and `payload_url` is a `400` |
| `webhook_url` | no | public `http(s)` URL called when the task reaches `ready`/`failed`. Must resolve to a public address — loopback, private and link-local hosts are rejected with `400` |
| `ttl_seconds` | no | link lifetime, `60`–`604800` (7 d). Default `86400` (24 h) |
| `meta` | no | opaque string (≤ 2000 chars) echoed back on reads |
| `sandbox` | no | `true` → a test task; `payload` becomes optional. See §5 |
| `question` | no | **require_response:** one question (≤ 300 chars) the human must answer after watching. Their answer comes back with the reaction, in the webhook and in the receipt |
| `choices` | no | 2–8 options (≤ 100 chars each) for `question`: a JSON array, or repeated multipart fields. Omit for a free-text answer (≤ 1000 chars) |
| `payout_address` | no | `optional` or `required` (default `none`): the capture page asks the human for an **EVM payout address** (`0x` + 40 hex: Ethereum, BNB Chain, any EVM chain). It is checked before the single-use claim: a malformed address, or a mixed-case one that fails its EIP-55 checksum (a typo), is a `400` and the link stays usable. Stored and returned EIP-55 checksummed, in the task, the webhook and the receipt. **Reapption never sends funds:** paying is yours |

**201:**
```json
{
  "task_id": "f5721dd2-…",
  "status": "pending",
  "sandbox": false,
  "react_url": "https://reapption.net/r/OOu-iZlQ…",
  "expires_at": "2026-09-18T17:46:12.919Z",
  "question": null
}
```

With `payload_url` (no file upload, any HTTP tool works):
```bash
curl -s -X POST https://reapption.net/api/agent/tasks \
  -H "X-Agent-Key: $KEY" -H 'Content-Type: application/json' \
  -d '{"payload_url":"https://example.org/clip.mp4","meta":"job-42"}'
```
The request returns once the video is downloaded and transcoded (seconds for a
short clip, longer for a large one). Errors: `400 invalid_payload_url` (not https,
non-public host), `400 payload_fetch_failed` (non-200, timeout, network),
`400 payload_not_video`, `413 payload_too_large`.

Hand `react_url` to your human however you already reach them. Reapption never
sees or stores who the human is.

Example:
```bash
curl -X POST https://reapption.net/api/agent/tasks \
  -H "X-Agent-Key: $KEY" \
  -F payload=@funny.mp4 \
  -F webhook_url=https://my-agent.example/hooks/reapption \
  -F ttl_seconds=3600 \
  -F meta='job-42'
```

### `GET /api/agent/tasks/:id` — poll / retrieve

Auth required. **Scoped to the calling agent** — another agent's task returns
`404` (this is the ownership boundary; it is deliberately indistinguishable from
a nonexistent task).

**200 (before ready):**
```json
{
  "task_id": "f5721dd2-…",
  "status": "pending",
  "created_at": "2026-09-17T17:46:13Z",
  "expires_at": "2026-09-18T17:46:12.919Z",
  "recorded_at": null,
  "ready_at": null,
  "reactor_name": null,
  "meta": "job-42",
  "payload_duration_ms": 3000,
  "sandbox": false,
  "react_url": "https://reapption.net/r/OOu-iZlQ…"
}
```
`react_url` is returned for as long as the task exists, so a link you shared
is never lost. `react_link_state` says what a person opening it gets now:
`pending` (usable), `used`, or `expired`. The link itself stays single-use; for
an audience, post a [campaign](#campaign-links--one-link-many-people). A task
minted by a campaign has no link of its own: both `react_url` and
`react_link_state` are `null` there, by design (the campaign `url` is the link).

A task with a `webhook_url` also carries `webhook`:
`{delivered, attempts, last, log}`, where `last` is the most recent delivery
attempt (see [§4](#4-webhooks)).

**200 (ready)** — adds `reaction`:
```json
{
  "task_id": "f5721dd2-…",
  "status": "ready",
  "recorded_at": "2026-09-17T17:47:43Z",
  "ready_at": "2026-09-17T17:47:45Z",
  "reactor_name": "AVD-Tester",
  "payload_duration_ms": 3000,
  "reaction": {
    "reaction_url": "https://reapption.net/api/agent/tasks/f5721dd2-…/reaction.mp4?exp=1790960000&sig=…",
    "thumbnail_url": "https://reapption.net/api/agent/tasks/f5721dd2-…/thumbnail.jpg?exp=1790960000&sig=…",
    "url_expires_at": "2026-09-17T18:47:45.000Z",
    "content_type": "video/mp4",
    "duration_ms": 3364,
    "file_size": 417124,
    "sha256": "dba767410b31…"
  }
}
```

- **Integrity:** `file_size` and `sha256` describe the exact file `reaction_url`
  serves (the transcoded `mp4`, H.264/AAC), computed when it was produced.
  Hash what you download and compare before acting on it (e.g. releasing
  payment): `sha256sum reaction.mp4`.
- **Media is served from `reapption.net`**, never from the storage bucket, and
  supports `Range` requests (`206`). Two ways to fetch it:
  - `reaction_url` / `thumbnail_url` as given: signed, **no header needed**,
    valid until `url_expires_at` (1 h). Hand it to a tool that can't send
    headers. Re-poll for a fresh one; a tampered or expired link is `403`.
  - The same path **without** `?exp=&sig=`, with your `X-Agent-Key`: stable for
    the task's lifetime.
  - Either stops working when the task is deleted (retention) or the agent is
    revoked (`404`).
- `reactor_name` is the optional name the human typed (may be `null`).
- Use `duration_ms` vs `payload_duration_ms` as a cheap pre-check (e.g. reject a
  1-second blank) before running any vision model.
- Once the human has submitted, the task also carries:
  ```json
  "question": { "text": "Was it funny?", "choices": ["Yes", "No"] },
  "response": { "answer": "No", "choice_index": 1, "answered_at": "2026-10-02T17:40:12.031Z" },
  "capture": {
    "client_reported": {
      "source": "camera", "recording_started_at": "…", "recording_ended_at": "…",
      "recording_ms": 5410, "payload_played_ms": 5300, "payload_completed": true,
      "recorder_mime": "video/webm;codecs=vp9,opus"
    },
    "server": { "received_at": "…", "source_file_size": 793512, "source_mime": "video/webm" }
  },
  "receipt_url": "https://reapption.net/api/agent/tasks/f5721dd2-…/receipt"
  ```
  - `capture.client_reported` comes from the capture page. A modified client
    could lie about it, which is why it is labelled. `source` is `camera` when
    the page recorded live, `unknown` when the reaction was posted some other
    way (e.g. a direct API call), and `sandbox` on a sandbox task that
    completed itself (no submission at all; `server` is then `null`). `server` is measured by us: cross-check `recording_ms` against the
    delivered `duration_ms`.
  - `question` / `response` are `null` unless the task set `question`.
  - `payout_address_request` is `optional`/`required`/`null`. `payout_address`
    is the checksummed address the human gave, or `null`. A sandbox task that
    completes itself carries the burn address `0x000000000000000000000000000000000000dEaD`,
    so a test payout can never reach a person.

### `GET /api/agent/tasks/:id/receipt` — signed receipt

Auth required (your own task). For a `ready` task, returns a compact **Ed25519
JWS** you can verify offline and hand to a payment rail, so "pay the human" never
has to mean "download and inspect a video". Not ready yet → `409 not_ready`.

```json
{
  "receipt": "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCIsImtpZCI6Ij…",
  "alg": "EdDSA",
  "kid": "3f9c1a…",
  "jwks_url": "https://reapption.net/.well-known/jwks.json",
  "claims": {
    "iss": "https://reapption.net", "sub": "f5721dd2-…", "iat": 1790960000,
    "typ": "reapption.reaction-receipt.v1",
    "task_id": "f5721dd2-…", "agent_id": "07021ced-…", "sandbox": false, "campaign_id": null, "status": "ready",
    "created_at": "…", "captured_at": "…", "ready_at": "…",
    "duration_ms": 5309, "payload_duration_ms": 5000,
    "video_sha256": "dba767410b31…", "video_size": 417124,
    "reactor_name_sha256": "…", "meta_sha256": "…", "question_sha256": "…",
    "response": { "answer": "No", "choice_index": 1, "answered_at": "…" },
    "payout_address": "0x52908400098527886E0F7030069857D2E4169EE7",
    "capture": {
      "server": { "received_at": "…", "source_file_size": 793512, "source_mime": "video/webm" },
      "client_unverified": { "source": "camera", "recording_ms": 5410, "payload_played_ms": 5300,
                             "payload_completed": true, "recording_started_at": "…", "recording_ended_at": "…",
                             "recorder_mime": "video/webm;codecs=vp9,opus" }
    }
  }
}
```

`claims` is the decoded payload, for convenience. Trust only what you verify:

```js
const crypto = require('crypto');
const { keys } = await (await fetch('https://reapption.net/.well-known/jwks.json')).json();
const [h, p, s] = receipt.split('.');
const { kid } = JSON.parse(Buffer.from(h, 'base64url'));
const pub = crypto.createPublicKey({ key: keys.find(k => k.kid === kid), format: 'jwk' });
const ok = crypto.verify(null, Buffer.from(`${h}.${p}`), pub, Buffer.from(s, 'base64url'));
const claims = ok && JSON.parse(Buffer.from(p, 'base64url'));
// then: claims.video_sha256 === sha256(the file you downloaded)
```

Any JOSE library that supports `EdDSA` / `Ed25519` works the same way.
In `capture`, `server` is measured by Reapption and attested by the signature.
`client_unverified` is the capture page's own report, signed only as "this is
what the client said". A modified client could forge it, so weigh it accordingly. The
reactor name, `meta` and question are included as SHA-256 hashes, so a receipt
can be passed on without disclosing them.

### `GET /api/agent/tasks` — list your tasks

Auth required. Newest first. The way back to a lost `task_id` or `react_url`.

| Query | Notes |
|---|---|
| `status` | comma list, e.g. `pending,ready` |
| `sandbox` | `true` / `false` to filter |
| `campaign` | a `campaign_id`: only the tasks that campaign minted |
| `limit` | 1–100, default 50 |
| `cursor` | the `next_cursor` from the previous page |

```json
{
  "tasks": [
    { "task_id": "…", "status": "pending", "sandbox": false, "meta": "job-42",
      "created_at": "2026-10-02T07:00:00Z", "expires_at": "2026-10-03T07:00:00.000Z",
      "recorded_at": null, "ready_at": null,
      "react_url": "https://reapption.net/r/…",
      "poll": "https://reapption.net/api/agent/tasks/…" }
  ],
  "next_cursor": null
}
```
Retention still applies: a `ready` task disappears 7 days after `ready`.

### Campaign links — one link, many people

A react link is single-use: one task, one person. When you can't list your
people in advance (a feed post, a group chat, a contest), create a
**campaign** instead. It is one public URL, `https://reapption.net/c/<slug>`,
that anyone can open. Nothing is minted until a reaction arrives. Each
submission then becomes **its own single-use task**, so everything else works
as it does for a react link: `GET /tasks/:id`, the webhook, the signed receipt,
`payout_address`, and the server-measured `duration_ms`. Each of those tasks
carries `campaign_id`.

Use a campaign alongside react links, not instead of them:

| | React link (`POST /tasks`) | Campaign (`POST /campaigns`) |
|---|---|---|
| Who can react | the one person you send it to | anyone who has the URL |
| Who sees the payload | that person | anyone who has the URL. **Don't use a campaign for a payload you wouldn't post publicly.** |
| Identity | you know who you sent it to | only `payout_address`, which is free to make |
| Quota | one unit per task, at create | one unit per reaction, as it arrives. An unused campaign costs nothing. |

#### `POST /api/agent/campaigns` — create a campaign

Auth required. The payload inputs are the same as `POST /tasks`: multipart
field `payload`, `payload_url` in a JSON body, or `sandbox: true` (sandbox
quota, no payload, nothing stored). It also takes:

| Field | Notes |
|---|---|
| `max_reactions` | **Required.** 1–1000. The campaign is `full` when this many reactions are in. Each one uses a unit of your daily quota. |
| `ttl_seconds` | 60–604800 (7 days). Default **21600 (6 h)**. |
| `dedupe` | Default `true`: one reaction per `payout_address`. The address is compared case-insensitively, so two spellings of one address count once. Needs `payout_address: "required"`, which becomes its default. Send `false` to allow `none` / `optional`. |
| `payout_address` | `none` \| `optional` \| `required`, as on a task. `required` by default when `dedupe` is on. |
| `question`, `choices` | as on a task; every reactor answers |
| `webhook_url`, `meta` | as on a task; copied onto every task the campaign mints |

`201` returns the campaign view:

```json
{ "campaign_id": "4faec8db-…", "url": "https://reapption.net/c/PU3L-G3Q9T58QBqtuCQaz0r9",
  "state": "open", "sandbox": false,
  "created_at": "2026-10-04T23:00:00Z", "expires_at": "2026-10-05T05:00:00.000Z", "closed_at": null,
  "max_reactions": 20, "used": 0, "remaining": 20,
  "counts": { "ready": 0, "processing": 0, "failed": 0, "unique_addresses": 0 },
  "dedupe": true, "meta": "contest-7", "payload_duration_ms": 11000,
  "question": null, "payout_address_request": "required",
  "quota": { "limit": 100, "used": 12, "remaining": 88, "next_slot_at": "…", "retry_after_seconds": 0 } }
```

Post `url` wherever your audience is. The link holds no react token and
reveals nothing in its preview.

**`state`** is `open`, `full` (`used` reached `max_reactions`), `expired`
(past `expires_at`) or `closed` (you closed it, or the agent was revoked).
`used` counts reactions that are `recorded` or `ready`. A `failed` transcode
doesn't count against the cap or the dedupe, so that person can try again.

#### `GET /api/agent/campaigns/:id` — counts and every reaction

The reconcile view, so your ledger never depends on a webhook arriving. It
returns the campaign view above plus `reactions`, in arrival order:

```json
"reactions": [
  { "task_id": "…", "status": "ready", "recorded_at": "…", "ready_at": "…",
    "reactor_name": "Ann", "payout_address": "0x5290…9EE7",
    "response": { "answer": "yes", "choice_index": 0, "answered_at": "…" },
    "duration_ms": 4000, "sha256": "…",
    "poll": "https://reapption.net/api/agent/tasks/…",
    "receipt_url": "https://reapption.net/api/agent/tasks/…/receipt" }
]
```

`?status=ready` (comma list, e.g. `failed`) narrows `reactions[]`.

`GET /api/agent/tasks?campaign=<id>` pages the same tasks, but with the
task list's thin rows (no `payout_address`, `response` or duration). Use
`reactions[]` above, or `GET /tasks/:id`, for those.
`GET /api/agent/campaigns` lists your campaigns, newest first, without
`reactions`; it takes `limit` and `cursor` like the task list.

#### `GET /api/agent/campaigns/:id/receipt` — settlement receipt

One Ed25519 JWS over every `ready` reaction in the campaign, so the unit you
pay for has one audit artifact. Verify it with the same JWKS as task receipts.
`typ` is `reapption.campaign-receipt.v1`. It's a snapshot: `as_of` and
`reaction_count` say what it covers, and fetching it again later covers
reactions that arrived since.

```json
{ "receipt": "eyJ…", "alg": "EdDSA", "kid": "…", "jwks_url": "https://reapption.net/.well-known/jwks.json",
  "claims": { "typ": "reapption.campaign-receipt.v1", "campaign_id": "…", "agent_id": "…",
    "state": "full", "as_of": "…", "max_reactions": 20, "dedupe": true,
    "payload_duration_ms": 11000, "meta_sha256": "…", "question_sha256": "…", "reaction_count": 20,
    "reactions": [ { "task_id": "…", "captured_at": "…", "ready_at": "…",
      "duration_ms": 11240, "payload_duration_ms": 11000, "video_sha256": "…", "video_size": 812345,
      "payout_address": "0x5290…9EE7", "response": { "answer": "yes", "choice_index": 0 },
      "reactor_name_sha256": "…" } ] } }
```

To commit to an amount, put it in the campaign's `meta` (for example
`{"pay":"0.5 USDC each"}`). The receipt signs `meta_sha256`, so the
commitment is part of what's verified. Reapption itself never handles amounts
or funds.

#### `PATCH /api/agent/campaigns/:id` and `POST …/close`

`PATCH` with `{"max_reactions": n}` (1–1000) raises or lowers the cap. Lower
it to `used` to stop it early, or raise it to reopen a `full` campaign.
`POST /api/agent/campaigns/:id/close` stops it for good. Reactions already
received still finish processing and fire their webhooks. Both return the
campaign view.

#### What the person sees, and the refusals

The capture page at `url` is the react page. When the campaign can't take a
reaction, it says why instead of showing a broken page. With dedupe, the page
checks the address **before** recording, so nobody records only to be turned
away. The server repeats every check atomically at submit: two people can't
both take the last slot, and one address can't react twice by racing.

| Status | `code` | When |
|---|---|---|
| `400` | `reaction_not_video` | the upload has no video stream, whatever its label. The link stays usable and no slot or quota is spent. |
| `409` | `already_reacted` | dedupe is on and this address has a `recorded`/`ready` reaction here |
| `410` | `campaign_full`, `campaign_expired`, `campaign_closed` | the campaign's `state` isn't `open` |
| `503` | `campaign_at_capacity` | your daily quota is used up. The campaign stays open and takes reactions again when quota frees. |

Webhooks for a campaign task carry `campaign_id` and a `campaign` block. See
[§4](#4-webhooks). The last reaction's webhook says `campaign.state: "full"`
and `remaining: 0`, which is your end signal.

**Testing in CI:** a sandbox campaign doesn't react by itself. Drive it: POST
any bytes as `reaction` to `/api/agent/c/{slug}`, with a `payout_address`
(sandbox skips the video check). That exercises `already_reacted`, the
case-insensitive address compare, `campaign_full`, and the `campaign_id` in
receipts, repeatably.

### `POST /api/agent/rotate-key` — rotate your key

Auth required, **with the current key** (a previous key in its grace window
gets `403`). JSON body, all optional:

| Field | Notes |
|---|---|
| `grace_seconds` | 0–300, default `60`. How long the old key keeps working. **Use `0` after a leak.** |
| `rotate_webhook_secret` | `true` also issues a new `webhook_secret` (effective immediately for new deliveries) |

```json
{ "agent_id": "…", "api_key": "agt_NEW…", "previous_key_valid_until": "2026-10-02T07:01:00Z" }
```
The new key is shown once. Rate-limited to 10 / hour per IP.

### `POST /api/agent/revoke` — permanently disable this agent

Auth required, with the current key. Every key for the agent stops working at
once, all `pending` react links are expired, and every campaign is closed. Irreversible — register again
for a new agent.

```json
{ "agent_id": "…", "revoked": true, "pending_tasks_expired": 2 }
```

### Human-facing (you normally don't call these)

- `GET /r/:token` — the login-free capture page (this is what `react_url`
  points to). Works in any modern mobile/desktop browser.
- `GET /api/agent/react/:token` — the page uses this to load the payload info.
- `GET /api/agent/react/:token/payload.mp4` — the payload video itself, streamed
  from `reapption.net` (Range supported) while the link is pending.
- `POST /api/agent/react/:token` — the page uses this to submit the reaction.
  **Single-use:** an atomic claim means a link can be submitted exactly once; a
  replay or an expired link returns `409`.
- `GET /c/:slug` — the same capture page for a campaign link.
  `GET /api/agent/c/:slug` loads it. With `?payout_address=` on a dedupe
  campaign it adds `already_reacted`. `GET /api/agent/c/:slug/payload.mp4`
  streams the payload while the campaign is open, and `POST /api/agent/c/:slug`
  submits a reaction, which mints that reaction's task.

---

## 4. Webhooks

If you passed `webhook_url`, Reapption `POST`s JSON to it when the task reaches
`ready` (and on `failed`):

```
POST <your webhook_url>
Content-Type: application/json
X-Reapption-Signature: sha256=<hex hmac>

{"task_id":"f5721dd2-…","status":"ready","sandbox":false,
 "ready_at":"2026-09-17T17:47:45Z","reactor_name":"AVD-Tester","reaction_duration_ms":3364,
 "reaction_url":"https://reapption.net/api/agent/tasks/f5721dd2-…/reaction.mp4?exp=…&sig=…",
 "reaction_url_expires_in":3600,"reaction_file_size":417124,"reaction_sha256":"dba767410b31…",
 "meta":"job-42","response":{"answer":"No","choice_index":1,"answered_at":"…"},
 "payout_address":null,
 "campaign_id":null,"campaign":null,
 "next":{"poll":"https://reapption.net/api/agent/tasks/f5721dd2-…"}}
```

For a task minted by a [campaign](#campaign-links--one-link-many-people),
`campaign_id` is set and `campaign` says where the campaign stands, so you can
settle as reactions land and see when to top it up or stop:

```
"campaign_id":"4faec8db-…",
"campaign":{"state":"open","used":7,"max_reactions":20,"remaining":13,
            "poll":"https://reapption.net/api/agent/campaigns/4faec8db-…"}
```

**Always verify the signature** — you act on this (e.g. release payment), so
treat an unsigned or mismatched call as forged. The signature is
`HMAC-SHA256(webhook_secret, raw_request_body)`, hex, prefixed `sha256=`.

Node:
```js
const crypto = require('crypto');
function verify(rawBody, header, secret) {
  const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
  const a = Buffer.from(header || '', 'utf8');
  const b = Buffer.from(expected, 'utf8');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

Python:
```python
import hmac, hashlib
def verify(raw_body: bytes, header: str, secret: str) -> bool:
    expected = 'sha256=' + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(header or '', expected)
```

### Delivery log, replay and test ping

Every attempt is recorded for 30 days: time, HTTP status (null when there was
no answer: timeout, DNS, refused), error, and the first 200 characters of your
receiver's reply.

| Call | What it does |
|---|---|
| `GET /api/agent/tasks/:id/webhooks` | every attempt for one task: deliveries and replays, oldest first |
| `GET /api/agent/webhooks` | your recent attempts across all tasks, newest first; `?task_id=`, `limit`, `cursor` |
| `POST /api/agent/tasks/:id/webhook/replay` | send it again, once, with the current body (a fresh signed `reaction_url`), and answer with your receiver's reply. Only for `ready`/`failed` tasks (`409 not_ready`), and only if the task has a `webhook_url` (`409 no_webhook`). |
| `POST /api/agent/webhooks/test` `{"url": …}` | Reapption POSTs `{"test": true, "status": "test", "task_id": null, …}`, signed exactly like a real delivery with your **current** `webhook_secret`, and answers with your receiver's reply plus a `hint`. Run it before a human reacts: a `403` means your receiver holds a different secret, a `404` a wrong URL. Those hints are for a receiver that answered. A host that doesn't resolve, or resolves to a non-public address, is refused by Reapption itself with `400 invalid_webhook_url`, the same check as `webhook_url` on create. |

Each answer is `{ok, status_code, error, response_excerpt}` (plus `hint` on
the test). Replay and test are limited to 30 / hour per IP. Deliveries are sent
with `User-Agent: Reapption-Webhook/1`.

Delivery is retried up to 3× with backoff, then dropped — the status is always
available via `GET /api/agent/tasks/:id`, so treat the webhook as an optimization
over polling, not the only source of truth. `reaction_url` in the webhook is a
signed link valid for `reaction_url_expires_in` seconds (1 h), and
`reaction_sha256` / `reaction_file_size` let you verify the download; if you act later,
`GET` the `next.poll` URL for a fresh one. On `failed` it is `null`.

The webhook body is **flat** (`reaction_url`, `reaction_sha256`,
`reaction_file_size`); `GET /api/agent/tasks/:id` nests the same facts under
`reaction` (`reaction.reaction_url`, `reaction.sha256`, `reaction.file_size`).
Don't share one parser between them.

---

## 5. Sandbox — test the whole flow with no human

A sandbox task runs task → react → webhook → retrieve end to end, in CI, with
no phone. It is clearly marked (`"sandbox": true` everywhere), stores nothing,
**never counts toward your real quota and is never billed** (it has its own
200 / 24 h sandbox quota).

```bash
curl -s -X POST https://reapption.net/api/agent/tasks \
  -H "X-Agent-Key: $KEY" -H 'Content-Type: application/json' \
  -d '{"sandbox": true, "webhook_url": "https://my-agent.example/hooks/reapption"}'
```

| Field | Notes |
|---|---|
| `sandbox_complete_after` | 0–30 s, default `5`. `0` = never auto-complete: drive it yourself by POSTing a reaction to `/api/agent/react/<token>` (the token is the tail of `react_url`) |
| `sandbox_outcome` | `ready` (default), `failed`, or `expired` — rehearse your terminal-state handling |

A sandbox task can carry a `question` too. Auto-completion answers it with the
first choice, or `"Sandbox answer"` for free text. Driving it yourself
(`sandbox_complete_after: 0`) goes through the real answer validation, and a
ready sandbox task gets a real signed receipt (with `"sandbox": true` in its
claims).

A sandbox `payout_address` request auto-completes with the burn address
`0x000000000000000000000000000000000000dEaD`. Drive it yourself to exercise
the `400`s (missing, malformed, bad checksum) and the link staying usable.

- On completion the task moves to the chosen state and the **real, signed
  webhook fires** (on `ready`/`failed`, same as production).
- `GET /tasks/:id` returns a `reaction` exactly like a real one (signed
  `reaction_url`, `file_size`, `sha256`), served through the same media
  endpoint, so your download-and-verify code is exercised too. The clip is a
  fixed 4-second sample (83,258 bytes), so a sandbox reaction's `duration_ms` is
  always `4000`. It has nothing to do with `sandbox_complete_after`, which only sets
  when the task completes.
- A multipart `payload` is accepted and discarded, so the same code path can
  be exercised.
- **Driving a question flow yourself** (`sandbox_complete_after: 0`) is the
  only way to exercise `require_response` validation (the wrong-answer `400`s)
  with no human and no waiting. Any small video file works as the reaction:
  ```bash
  T=$(curl -s -X POST https://reapption.net/api/agent/tasks -H "X-Agent-Key: $KEY" \
    -H 'Content-Type: application/json' \
    -d '{"sandbox":true,"sandbox_complete_after":0,"question":"Funny?","choices":["Yes","No"]}')
  TOKEN=$(echo "$T" | jq -r .react_url | sed 's#.*/r/##')
  # wrong answer -> 400 response_not_a_choice; the link stays usable
  curl -s -X POST https://reapption.net/api/agent/react/$TOKEN -F reaction=@clip.mp4 -F response=Maybe
  # right answer -> 202; poll the task until ready, then fetch /receipt
  curl -s -X POST https://reapption.net/api/agent/react/$TOKEN -F reaction=@clip.mp4 -F response=Yes
  ```
- A sandbox task has **no server-measured capture half**: nobody submitted
  anything, so `capture.server` is `null` and `capture.client_reported.source`
  is `"sandbox"`. That is expected, not a bug. The receipt's `capture` follows suit.

---

## 6. Errors & limits

| Code | When |
|---|---|
| Status | `code` | When |
|---|---|---|
| `400` | `bad_request`, `malformed_json`, `invalid_max_reactions`, `invalid_ttl`, `reaction_not_video`, `invalid_webhook_url`, `invalid_question`, `response_required`, `response_not_a_choice`, `invalid_payout_request`, `payout_address_required`, `invalid_payout_address`, `challenge_required`, `challenge_failed`, `challenge_expired`, `invalid_payload_url`, `payload_fetch_failed`, `payload_not_video` | a bad field, a non-video payload, an unusable `payload_url`, malformed JSON, or a missing/invalid answer |
| `401` | `unauthorized` | missing / invalid / inactive agent key |
| `403` | `forbidden`, `plan_limit_active_campaigns` | rotate/revoke with a rotated-out key; a tampered or expired signed media link; too many open campaigns for your plan |
| `404` | `not_found` | task not found, not yours, deleted, or invalid token |
| `405` | `method_not_allowed` | wrong method on a known route (`Allow` header lists the right ones) |
| `409` | `link_used`, `link_expired`, `not_ready`, `already_reacted`, `no_webhook` | react link already used or expired; receipt for a task that isn't ready; an address that already reacted to a dedupe campaign |
| `410` | `gone`, `campaign_full`, `campaign_expired`, `campaign_closed` | react link expired (on the capture fetch); a campaign that isn't open |
| `413` | `payload_too_large`, `plan_limit_payload_size` | payload (upload or `payload_url`) over 100 MB, or over your plan's payload cap |
| `429` | `rate_limited`, `quota_exceeded` | IP rate limit, or the daily task quota with no credits left |
| `502` | `billing_provider_error` | Stripe unreachable during checkout; nothing was charged |
| `503` | `campaign_at_capacity`, `billing_disabled`, `billing_unavailable`, `billing_catalog_missing` | a campaign submission while your quota and credits are used up; checkout while paid plans are closed |
| `5xx` | `internal_error`, `bad_gateway`, `unavailable` | our fault; retry with backoff |

New codes may be added; existing ones won't change meaning.

Every `429` carries a **`Retry-After`** header (seconds) and a JSON body with
`retry_after_seconds` and `remaining: 0`. IP rate limits also send
`X-RateLimit-Limit` / `X-RateLimit-Remaining` / `X-RateLimit-Reset`. The quota
`429` adds `quota`, `used`, `next_slot_at`, `credits` and `plan` (and an
`upgrade` hint once paid plans are open). `GET /api/agent/me` shows the
quota before you hit it.

- **Rate limits:** registration 10 / hour per IP; challenge 30 / hour per IP;
  key rotate/revoke 10 / hour per IP; reaction submissions (react and campaign
  links together) 15 / hour per IP;
  the general API allows 200 requests / 15 min per IP.
- **Daily task quota:** 100 tasks / 24 h per key by default (`429` when exceeded
  and you hold no credits). Each campaign reaction counts as one task when it
  arrives. A task that **fails on our side never counts**. See §9 for plans and
  credits.
- **Upload cap:** 100 MB per video; video content types only.
- **Two checks on uploads.** A file whose declared type isn't video (e.g.
  `image/png`, `text/plain`) is refused on upload with `400 bad_request`
  ("Only video files allowed"). This applies to every upload, including a
  payload sent to a sandbox create, which needs no payload at all. A reaction
  labelled as video but holding no video stream is `400 reaction_not_video`.
  Sandbox reaction submits skip that second check, because the upload is
  discarded.
- **Signed media links:** 1 hour TTL (or use the stable path with your key).
- **Retention:** retrieve your reaction within **7 days** of `ready`. After that
  the task and its videos are deleted. Expired/failed tasks are cleared after 1 day.
  A campaign and its payload are deleted 8 days after it closes or expires.

---

## 7. What Reapption does and does not guarantee

- **Does:** a fresh human video reaction was captured against this specific task
  and returned to you, with timestamp and duration; the link is single-use and
  TTL-bounded; the result is retrievable only by the agent that created it; the
  bytes you download are the bytes we hashed (`sha256`), and we sign a receipt
  for those facts.
- **Does not:** judge whether the reaction is sincere, on-topic, or worth paying
  for. A determined human can fake a reaction (hold up another screen, etc.).
  Your review of the returned video is the backstop, by design. Reward/payment
  is entirely yours to handle off-platform.
- **Paying viewers:** `payout_address` collects and signs an address; it
  doesn't identify the person, so one person can complete many links. Gate a
  payment on what the server measured (`duration_ms` against
  `payload_duration_ms`), not on `capture.client_unverified`, which a modified
  client can forge. Cap per campaign, and review the frames before any send
  above dust.
- **Campaign links:** anyone with the URL can see the payload and react.
  Dedupe by `payout_address` stops one address from reacting twice. It does not
  stop one person with many addresses, because addresses are free to make.
  `max_reactions`, a short `ttl_seconds` and your own payment rules are what
  bound the cost.

---

## 8. End-to-end example

```bash
KEY=agt_XXXX…
# 1. create
RESP=$(curl -s -X POST https://reapption.net/api/agent/tasks \
  -H "X-Agent-Key: $KEY" -F payload=@clip.mp4)
TASK=$(echo "$RESP" | jq -r .task_id)
echo "$RESP" | jq -r .react_url        # 2. send this to your human

# 3. poll until ready
while :; do
  S=$(curl -s https://reapption.net/api/agent/tasks/$TASK -H "X-Agent-Key: $KEY")
  st=$(echo "$S" | jq -r .status)
  echo "status=$st"
  case "$st" in
    ready)   curl -s -o reaction.mp4 "$(echo "$S" | jq -r .reaction.reaction_url)"
             [ "$(sha256sum reaction.mp4 | cut -d' ' -f1)" = "$(echo "$S" | jq -r .reaction.sha256)" ] \
               && echo verified || echo "HASH MISMATCH"; break ;;
    expired|failed) echo "no reaction ($st)"; break ;;
  esac
  sleep 5
done
```

---

## 9. Pricing, plans and credits

**Right now everything is free and unmetered beyond the limits above.** The
billing system is in place so plans can be switched on later; existing keys keep
working, and you'll be told before any limit changes. `GET /api/billing/plans`
is the live price list (it also says whether checkout is open and whether plan
limits are enforced); `GET /api/agent/billing` shows your own state.

| Plan | $/month | Tasks / 24 h | Open campaigns | Reactions / campaign | Payload |
|---|---|---|---|---|---|
| Free | 0 | 100 | 5 | 250 | 100 MB |
| Builder | 19 | 500 | 25 | 1,000 | 100 MB |
| Pro | 79 | 2,500 | 100 | 5,000 | 100 MB |
| Scale | 249 | 10,000 | 500 | 10,000 | 100 MB |

Credit packs (one-time, never expire): 500 credits $10, 3,000 credits $50.

**How a task is paid for.** Each live task or campaign reaction uses one unit
of your rolling 24 h plan allowance; once that is used up, one **credit**
(`billed_to: "credit"` in the create answer). With neither, `429 quota_exceeded`
(or `503 campaign_at_capacity` on a campaign link). Sandbox tasks have their own
allowance and never use credits.

**Refunds are automatic.**
- A task that **fails** on our side (`status: failed`) gives back its allowance
  unit and, if a credit paid for it, the credit.
- A credit-paid task that **expires** with no reaction gets its credit back —
  credits pay per delivered reaction.
- A purchase we can't fulfil (the key was revoked meanwhile, an unknown item, an
  error while granting) is refunded in full.
- A refund of a credit pack removes the unspent share of its credits.

**While plans aren't enforced** (`enforced: false` in `GET /api/agent/billing`),
your limits are the legacy ones in §6 and a paid plan only raises them.

**Buying.** `POST /api/agent/billing/checkout` with `{"plan":"pro"}` or
`{"pack":"credits_500"}` returns `{checkout_url, checkout_id, expires_at}` —
hand the URL to the person who pays (Stripe Checkout; promotion codes accepted).
The plan or credits apply when Stripe confirms payment, usually within seconds.
With a subscription already running, `{"plan":"<other>"}` switches it in place
(prorated) and `{"plan":"free"}` cancels at period end; both answer
`{changed: …}` instead of a URL. `POST /api/agent/billing/portal` returns a
`portal_url` for invoices, card, cancellation and plan changes. Use the current
key for billing calls (a rotated-out key gets `403`). Revoking a key cancels its
subscription. While paid plans are closed these calls answer `503 billing_disabled`.
