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#
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:
{ "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:
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:
- The auth header. Set
webhook_auth_header_nameandwebhook_auth_header_valueto a long random secret and compare it on arrival, as above. Sandchest stores the value encrypted and never returns it — the transcript only exposeswebhook_auth: trueandwebhook_auth_header_name. - Re-fetch the transcript. The payload carries no content, so the authoritative
answer is always
GET /v2/transcript/:idwith your API key. A forged callback cannot make a transcript exist.
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:
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}'{
"webhook_url": "https://api.example.com/hooks/sandchest",
"webhook_status_code": 200,
"webhook_auth": true,
"webhook_auth_header_name": "X-Sandchest-Token"
}Testing locally#
The hosted service refuses localhost and private addresses, so a tunnel is the
simplest path:
# any tunnel works — cloudflared, ngrok, tailscale funnel
cloudflared tunnel --url http://localhost:3000Then 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:
SANDCHEST_ALLOW_PRIVATE_WEBHOOK_URLS=trueThat 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:
curl -sS "https://stt-api.sandchest.com/v2/transcript/$ID" \
-H "Authorization: $SANDCHEST_API_KEY" | jq .webhook_status_code200A 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 — every endpoint, request and response in one place.