# Webhooks

Get a POST when a transcript finishes instead of polling for it — the payload, the optional auth header, the retry schedule, and how to test locally.

Set `webhook_url` when you create a transcript and Sandchest posts to it once the
transcript reaches `completed` or `error`. The payload is deliberately tiny: it
tells you *which* transcript changed, and you fetch the rest. This page covers the
payload, authentication, retries, and testing against a local server.

## Set it up

```bash
curl -sS https://stt-api.sandchest.com/v2/transcript \
  -H "Authorization: $SANDCHEST_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "audio_url": "https://example.com/meeting.mp3",
    "webhook_url": "https://api.example.com/hooks/sandchest",
    "webhook_auth_header_name": "X-Sandchest-Token",
    "webhook_auth_header_value": "a-long-random-secret"
  }'
```

| Field | Rules |
| --- | --- |
| `webhook_url` | `http` or `https`, at most 8192 characters. No embedded credentials, no `#fragment`. The host may not be `localhost`, a `.localhost` name, or an IP literal in a loopback, link-local, private or otherwise non-routable range. |
| `webhook_auth_header_name` | 1–1000 characters from `A-Z a-z 0-9 _ -`. Cannot be a reserved header (see below). |
| `webhook_auth_header_value` | At most 1000 characters. |

Send both auth fields together with a URL, or none of them. Any other combination —
and any URL that fails the checks above — returns HTTP 400 with
`{"error":"Invalid webhook URL or authentication parameters."}`.

### Creation time vs delivery time

Those checks read the URL as written. They do **not** resolve the hostname, so a
name that points at a private address is accepted at creation and returns 201.

Where the host actually resolves is enforced at *delivery*. Sandchest resolves
the hostname on every attempt and pins the resulting addresses to the socket; if
any of them is loopback, link-local, private or otherwise non-routable, the
delivery fails **permanently**. It is recorded as `unsafe_destination`, it is not
retried, and no 400 was ever returned to warn you — the transcript itself still
completes normally.

So `https://webhook.internal.example.com` (resolving to `10.0.0.7`) passes
creation and then never delivers. The symptom is a `webhook_status_code` that
stays `null` long after the transcript reached `completed`: nothing was ever sent,
so there is no status to record.

Reserved header names, which `webhook_auth_header_name` may not be: `host`,
`content-length`, `transfer-encoding`, `connection`, `trailer`, `te`, `upgrade`,
`expect`, `content-type`, `content-encoding`, `accept-encoding`,
`proxy-authorization`, `proxy-connection`, `keep-alive`.

## The payload

Sandchest sends `POST` with `Content-Type: application/json`, a
`User-Agent: Sandchest-Webhook/1.0`, and exactly this body:

```json
{ "transcript_id": "tr_9k2mQvR7pKd3sTn1yBhL", "status": "completed" }
```

`status` is `"completed"` or `"error"`. There are no other fields, and there never
will be a transcript body in the payload — the callback tells you to go and fetch it.

Your handler should look like this:

```ts title="app/hooks/sandchest/route.ts"
export async function POST(request: Request) {
  if (request.headers.get("x-sandchest-token") !== process.env.SANDCHEST_WEBHOOK_SECRET) {
    return new Response("forbidden", { status: 403 });
  }

  const { transcript_id, status } = (await request.json()) as {
    transcript_id: string;
    status: "completed" | "error";
  };

  // Acknowledge fast, then do the work out of band.
  void handleTranscript(transcript_id, status);
  return new Response("ok", { status: 200 });
}
```

## Verifying a delivery

There is no HMAC signature. Verify a callback in one of two ways, and preferably
both:

1. **The auth header.** Set `webhook_auth_header_name` and
   `webhook_auth_header_value` to a long random secret and compare it on arrival, as
   above. Sandchest stores the value encrypted and never returns it — the transcript
   only exposes `webhook_auth: true` and `webhook_auth_header_name`.
2. **Re-fetch the transcript.** The payload carries no content, so the authoritative
   answer is always `GET /v2/transcript/:id` with your API key. A forged callback
   cannot make a transcript exist.

```ts
const transcript = await client.transcripts.get(transcript_id);
if (transcript.status !== "completed") return;
```

## Retries

| Behaviour | Value |
| --- | --- |
| Attempts | Up to 10 |
| Retry spacing | About 10 seconds between attempts |
| Request timeout | 10 seconds per attempt |
| Success | Any `2xx` |
| Permanent failure | Any `4xx`, or a destination that resolves to a non-public address (`unsafe_destination`) — Sandchest stops retrying |
| Retried | `5xx`, timeouts, connection errors |
| Redirects | Never followed |

Delivery is **at least once**. A crash after your server accepted but before
Sandchest recorded the acknowledgement can redeliver the same
`transcript_id`. Make your handler idempotent — deduplicate on `transcript_id`.

The transcript records the outcome. `webhook_status_code` is the HTTP status of the
most recent attempt, `null` if nothing has been attempted yet:

```bash
curl -sS "https://stt-api.sandchest.com/v2/transcript/$ID" \
  -H "Authorization: $SANDCHEST_API_KEY" \
  | jq '{webhook_url, webhook_status_code, webhook_auth, webhook_auth_header_name}'
```

```json title="expected output"
{
  "webhook_url": "https://api.example.com/hooks/sandchest",
  "webhook_status_code": 200,
  "webhook_auth": true,
  "webhook_auth_header_name": "X-Sandchest-Token"
}
```

> [!NOTE]
> Deleting a transcript clears its webhook configuration along with its content, so
> `webhook_url` on a deleted transcript reads `null`.

## Testing locally

The hosted service refuses `localhost` and private addresses, so a tunnel is the
simplest path:

```bash
# any tunnel works — cloudflared, ngrok, tailscale funnel
cloudflared tunnel --url http://localhost:3000
```

Then pass the public URL the tunnel prints as `webhook_url`.

If you are running Sandchest yourself, you can allow private destinations instead —
in development only:

```bash title=".env.local"
SANDCHEST_ALLOW_PRIVATE_WEBHOOK_URLS=true
```

That flag is ignored when `NODE_ENV=production`, so it cannot weaken a real
deployment.

## Verify

Point a webhook at a request-capture service, transcribe a short file, and confirm
the delivery was recorded:

```bash
curl -sS "https://stt-api.sandchest.com/v2/transcript/$ID" \
  -H "Authorization: $SANDCHEST_API_KEY" | jq .webhook_status_code
```

```text title="expected output"
200
```

A `null` means nothing has been delivered yet — the transcript is probably still
`queued` or `processing`. A `null` on a transcript that finished long ago means no
request was ever made: the host resolved to a non-public address and the delivery
was dropped as `unsafe_destination`. A `4xx` means your endpoint rejected the
callback and Sandchest has stopped retrying.

## Next

[API reference](/docs/api) — every endpoint, request and response in one place.
