Skip to content

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.

Open .md

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#

shell
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"
  }'
FieldRules
webhook_urlhttp 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_name1–1000 characters from A-Z a-z 0-9 _ -. Cannot be a reserved header (see below).
webhook_auth_header_valueAt 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:

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.
TypeScript
const transcript = await client.transcripts.get(transcript_id);
if (transcript.status !== "completed") return;

Retries#

BehaviourValue
AttemptsUp to 10
Retry spacingAbout 10 seconds between attempts
Request timeout10 seconds per attempt
SuccessAny 2xx
Permanent failureAny 4xx, or a destination that resolves to a non-public address (unsafe_destination) — Sandchest stops retrying
Retried5xx, timeouts, connection errors
RedirectsNever 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:

shell
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}'
expected output
{
  "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:

shell
# 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:

.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:

shell
curl -sS "https://stt-api.sandchest.com/v2/transcript/$ID" \
  -H "Authorization: $SANDCHEST_API_KEY" | jq .webhook_status_code
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 — every endpoint, request and response in one place.