# RavePilot API reference

> Create testimonial invitations, read submissions and clips, and receive signed webhooks.
> Base URL: `https://api.ravepilot.com` · Interactive version: https://ravepilot.com/docs · OpenAPI: https://api.ravepilot.com/openapi.json

## Authentication

Every request carries a workspace API key, either way:

```
Authorization: Bearer rp_live_a1b2c3…
X-API-Key: rp_live_a1b2c3…
```

Keys are scoped (`invitations`, `submissions`, `webhooks`), shown once at creation, and stored hashed. Workspace owners mint them in the admin portal at https://app.ravepilot.com. Everything a key touches is scoped to its workspace.

## Quickstart

1. **Create an invitation** — pass a customer's email; RavePilot mints their personal recording link on your branded portal and (by default) emails it to them.
2. **Listen** — register a webhook endpoint once: `clip.uploaded` when they record, `clip.approved` when review clears it, `render.succeeded` when the ad-ready cut is done.
3. **Read back** — `GET /v1/submissions` any time for full state.

```bash
curl https://api.ravepilot.com/v1/invitations \
  -H "Authorization: Bearer rp_live_…" \
  -H "Content-Type: application/json" \
  -d '{"customer_email": "marjorie@acme.com", "customer_name": "Marjorie Ellison"}'
```

## Invitations

### POST /v1/invitations

Create an invitation and, by default, email the customer their recording link. The customer's **email is their identity** — invitations are idempotent per lowercased email within your workspace, so the same customer always gets the same link and re-posting never creates duplicates. Scope: `invitations`. Fires `invitation.created`.

| Field | Required | Description |
|---|---|---|
| `customer_email` | yes | The customer's email address — their stable identity, and where the invitation goes |
| `customer_name` | no | Used in the recording flow and the rendered lower-third |
| `customer_phone` | no | Optional metadata only (10-digit North American number) |
| `send_email` | no (default `true`) | `false` mints the link without sending |
| `collector_id` | no | Target a specific collector (campaign); defaults to the workspace primary |

Returns `{ ok, link, invite_token, submission, reused? }` — `link` is the customer-facing recording URL on your branded host.

> Phone-number-based customer identity and third-party authentication are planned for the Enterprise plan. Today, email is the identity everywhere — the same input the hosted landing page takes.

### POST /v1/magic-link

Same creator, never sends the email — for embedding "record a testimonial" inside your own app or CRM. Same body minus the email behavior. Scope: `invitations`.

## Submissions

A submission is one customer's recording session. Status lifecycle:

```
in_progress → submitted → approved | partial_approved | rejected → paid
```

### GET /v1/submissions

List the workspace's submissions, newest first. Scope: `submissions`.

| Query | Description |
|---|---|
| `status` | Filter by lifecycle status |
| `limit` | Page size, default 50, max 200 |
| `offset` | Pagination offset, default 0 |

Returns `{ ok, submissions: [ { id, status, customer_phone, customer_email, customer_name, hubspot_contact_id, created_at, submitted_at, approved_at } ] }` (timestamps are unix seconds).

### GET /v1/submissions/:id

One submission with its clips.

Returns `{ ok, submission, clips: [ { id, script_key, status, duration_seconds, transcript, stream_video_uid, used_in_marketing, created_at } ] }`. Clip status: `recording | uploaded | approved | rejected | redo_requested`. `stream_video_uid` is the Cloudflare Stream UID for playback/download.

## Webhooks

Register an HTTPS endpoint; RavePilot pushes the pipeline to you, signed, logged, and retried.

### Event catalog

| Event | Fires when |
|---|---|
| `invitation.created` | An invitation was minted (API or portal) |
| `submission.submitted` | The customer finished and submitted their session |
| `clip.uploaded` | A clip finished uploading and encoding |
| `clip.approved` | A clip cleared review |
| `clip.rejected` | A clip was rejected with reviewer notes |
| `submission.approved` | The whole session was approved; rewards fire |
| `render.succeeded` | An ad-ready marketing cut finished rendering |
| `payout.sent` | The customer reward was sent |
| `payout.delivered` | The customer claimed their reward |
| `payout.failed` | A reward failed |

### POST /v1/webhooks

Register an endpoint. Scope: `webhooks`.

| Field | Required | Description |
|---|---|---|
| `url` | yes | Your HTTPS endpoint |
| `events` | no (default `["*"]`) | Array of event names from the catalog, or `"*"` |

Returns `{ ok, id, url, events, secret }`. **The `whsec_…` signing secret is returned only once** — store it.

### GET /v1/webhooks

Your registered endpoints plus the full list of valid event names.

### DELETE /v1/webhooks/:id

Remove an endpoint. Pending deliveries to it are dropped.

### GET /v1/webhook-deliveries

The 50 most recent deliveries with status, attempt count, and your endpoint's response code. Failed deliveries retry on a backoff of `1m → 5m → 30m → 2h → 6h` (5 attempts). After 25 consecutive failures the endpoint is automatically disabled — recreate it when your handler is healthy.

### Verifying signatures

Every delivery carries:

| Header | Description |
|---|---|
| `X-RavePilot-Event` | The event name, e.g. `clip.approved` |
| `X-RavePilot-Delivery` | Unique delivery id — use for idempotency |
| `X-RavePilot-Signature` | `t=<unix>,v1=<hex>` where `v1 = HMAC_SHA256(secret, t + "." + rawBody)` |

Compute the HMAC over the **raw** request body, compare in constant time, and reject stale timestamps (300 s window):

```js
import crypto from 'node:crypto';

function verify(rawBody, signatureHeader, secret) {
  const parts = Object.fromEntries(signatureHeader.split(',').map((p) => p.split('=')));
  const expected = crypto.createHmac('sha256', secret)
    .update(`${parts.t}.${rawBody}`).digest('hex');
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
  return fresh && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1 || ''));
}
```

```python
import hmac, hashlib, time

def verify(raw_body: bytes, signature_header: str, secret: str) -> bool:
    parts = dict(p.split("=", 1) for p in signature_header.split(","))
    expected = hmac.new(secret.encode(),
        f"{parts['t']}.".encode() + raw_body, hashlib.sha256).hexdigest()
    fresh = abs(time.time() - int(parts["t"])) < 300
    return fresh and hmac.compare_digest(expected, parts.get("v1", ""))
```

## Errors

Errors are JSON: `{ "error": "human-readable reason", "status": 4xx }`.

| Status | Meaning |
|---|---|
| 401 | Missing or malformed API key (keys start with `rp_`) |
| 403 | Valid key, wrong scope |
| 404 | The resource doesn't exist in your workspace |
| 400 / 409 | Validation problem — the `error` string says what to fix |
| 5xx | Our side; retry with backoff (invitations are idempotent per email) |
