Walkthrough: bullshit.doctor
The end-to-end integration, written as the thing it is: a program that receives every finished transcript, verifies it came from us, and fetches the text. One page; it works when followed exactly.
Step 1 — a key with the scopes it needs
Account → API keys → New key, with runs:read (to fetch transcripts) and destinations:write
(to register itself). Not runs:write: a receiver that does not submit runs should not be able to.
export SCRIPTRIP_KEY=sk_live_…
Step 2 — stand up the receiver
A Next.js route handler. The raw-body read is the step everyone gets wrong: read the body once, as bytes, verify, then parse.
Buffer.from(await req.arrayBuffer()) — before anything touches the body. A framework that
parses JSON for you has already re-serialised the bytes the signature was computed over.
// app/hooks/script-rip/route.ts
import { verifyScriptRipSignature } from "@script-rip/core/client";
export async function POST(req: Request) {
const raw = Buffer.from(await req.arrayBuffer());
if (!verifyScriptRipSignature(raw, req.headers.get("x-scriptrip-signature"),
process.env.SCRIPTRIP_WEBHOOK_SECRET!)) {
return new Response("bad signature", { status: 400 });
}
const { event, test, run } = JSON.parse(raw.toString("utf8"));
if (test) return new Response(null, { status: 204 }); // rule 5
if (await alreadyHandled(run.id, event)) { // rule 3
return new Response(null, { status: 204 });
}
await enqueue({ runId: run.id, event, transcriptUrl: run.transcript_url, errorClass: run.error_class, retryAfter: run.retry_after });
return new Response(null, { status: 204 }); // rule 4 — under 10 s
}
HTTP/1.1 204 No Content
Deploy it at https://bullshit.doctor/hooks/script-rip.
Step 3 — register the destination
curl -sS -X POST https://<host>/v1/destinations \
-H "Authorization: Bearer $SCRIPTRIP_KEY" -H "Content-Type: application/json" \
-d '{"name":"bullshit.doctor","url":"https://bullshit.doctor/hooks/script-rip","events":["run.completed","run.failed"]}'
{"id":"6d2b8a10-1f47-4c8e-a2f0-cb9e4d3a7711","name":"bullshit.doctor","url":"https://bullshit.doctor/hooks/script-rip","events":["run.completed","run.failed"],"enabled":true,"disabled_reason":null,"created_at":"2026-03-01T09:14:02Z","updated_at":"2026-03-01T09:14:02Z","last_delivery":null,"secret":"whsec_7Kq2mVx9RnB4tLpC0eZaW1sYdH6gJ3fU"}
The response carries secret once. Put it in the receiver's environment as
SCRIPTRIP_WEBHOOK_SECRET before doing anything else, and keep the id as DEST.
Step 4 — prove the plumbing before trusting it
curl -sS -X POST "https://<host>/v1/destinations/$DEST/test" -H "Authorization: Bearer $SCRIPTRIP_KEY"
{"destination_id":"6d2b8a10-1f47-4c8e-a2f0-cb9e4d3a7711","event":"run.completed","delivered":true,"status_code":204,"latency_ms":214,"signature_header":"t=1773252601,v1=1f8ac10f23c5b5bc1167bda84b833e5c057a77d2ef2f3f2b6b0c1d4e5a6b7c8d","body_sha256":"9c1185a5c5e9fc54612808977ee8f548b2258d31f5a2a0e9b7c3d4e5f6a7b8c9","error":null,"message":"Delivered. The receiver answered 204 in 214 ms."}
The test body carries "test": true and the all-zero run id
00000000-0000-0000-0000-000000000000; the handler above answers 204 without creating a record,
which is rule 5.
delivered: false with status_code: 400 means the signature check failed. The three usual causes:
a re-serialised body (the framework parsed it first), a trimmed header (a proxy rewrote it),
a stale secret (rotated, or the wrong environment). delivered: false with no status code and
an error naming DNS, TLS, a timeout or a refused address is the network, not the code.
Step 5 — submit a run and watch it arrive
The same submission as the quickstart:
curl -sS -X POST https://<host>/v1/runs \
-H "Authorization: Bearer $SCRIPTRIP_KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"source":{"type":"youtube","url":"https://youtu.be/dQw4w9WgXcQ"}}'
{"id":"0195c8e4-8d02-7c19-b3e7-5a1f9d20c68b","status":"queued","…":"…"}
A few minutes later, the receiver's log — the X-ScriptRip-* headers landing:
POST /hooks/script-rip 204
x-scriptrip-event: run.completed
x-scriptrip-delivery: 8c41d0aa-63e2-4a52-9f77-2b1c0e8d4a90
x-scriptrip-attempt: 1
x-scriptrip-signature: t=1773252597,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
body: {"event":"run.completed","delivered_at":"2026-03-11T18:09:59Z","test":false,"run":{"id":"0195c8e4-8d02-7c19-b3e7-5a1f9d20c68b","status":"done",…}}
Step 6 — fetch the transcript from the webhook
In the queue worker, not the handler:
async function handle({ transcriptUrl }: { transcriptUrl: string }) {
const res = await fetch(transcriptUrl + "?format=json", {
headers: { Authorization: `Bearer ${process.env.SCRIPTRIP_KEY}` },
});
const { segments, speaker_labels } = await res.json();
// ... analysis
}
{"segments":[{"start":0.48,"end":4.02,"text":"Right, so this is day nine.","speaker":"SPEAKER_00"},{"start":4.02,"end":7.61,"text":"Day nine and we still haven't seen land.","speaker":"SPEAKER_00"},{"start":8.1,"end":11.44,"text":"I keep telling him that's the point.","speaker":"SPEAKER_01"}],"speaker_labels":{"SPEAKER_00":{"name":"Matt","role":"host"},"SPEAKER_01":{"name":"Amy","role":"guest"}},"language":"en","engine":"whisperx large-v2"}
The fetch is authenticated with your key, which is why the transcript is not in the webhook: anyone who ever compromised your endpoint would otherwise hold every transcript, including after the key was revoked.
Step 7 — handle the failures
run.failed arrives with an error_class. Three groups a receiver should treat differently:
| error_class | Do |
|---|---|
| premiere | The video is scheduled and has not aired. Submit a new run after retry_after — never mutate the failed one |
| gated, removed | Never retry. Tell the person: members-only, age-gated, private or deleted |
| everything else | An operator's problem — bot_wall, proxy, stalled, engine, … Log it with the run id |
async function onFailed(run: { id: string; source_url: string; error_class: string; retry_after: string | null }) {
if (run.error_class === "premiere" && run.retry_after) {
await scheduleAt(new Date(run.retry_after), () =>
sr.runs.create({ source: { type: "youtube", url: run.source_url } })); // a NEW run
} else if (run.error_class === "gated" || run.error_class === "removed") {
await tellTheUser(run.id, run.error_class);
} else {
log.error("run failed", { runId: run.id, errorClass: run.error_class });
}
}
(a function; nothing is sent)
An HTTP 201 from the submission followed by a failed run later is the normal shape, not an
inconsistency: the API error is about your request; error_class on the run is about the work.