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.