Webhooks
Register a destination — an https:// URL — and every finished run is POSTed to it, signed.
Three events, no others: run.completed, run.failed, run.canceled. Nothing fires for
intermediate states; a receiver that wants progress polls.
The sender runs every minute, so a delivery arrives within about a minute of the run finishing
and the retry ladder starts from there. If nothing has arrived two minutes after your own
poll shows the run terminal, read the run — nothing is lost, and GET /v1/destinations/{id}/deliveries
says what happened to the attempt.
Register a 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"}
secret is in this response and never again. Store it on the receiver as
SCRIPTRIP_WEBHOOK_SECRET. Supply your own with "secret": "…" (32–128 characters) or let one be
generated. events defaults to ["run.completed"] and may not be empty.
The URL must be https:// and must resolve to a public address. Loopback, private (10/8,
172.16/12, 192.168/16), link-local (169.254/16, which includes cloud metadata endpoints),
carrier-grade NAT and unique-local IPv6 addresses are refused with 403 forbidden at registration
and re-checked at every send — DNS is re-resolved each time, because a name that answered
publicly when you registered it can answer 169.254.169.254 an hour later. Redirects are never
followed: a 3xx from your endpoint is a failed attempt, not a hop. Up to 20 destinations per
account.
The delivery request
POST /hooks/script-rip HTTP/1.1
Host: bullshit.doctor
Content-Type: application/json
User-Agent: script-rip-webhooks/1
X-ScriptRip-Event: run.completed
X-ScriptRip-Delivery: 8c41d0aa-63e2-4a52-9f77-2b1c0e8d4a90
X-ScriptRip-Attempt: 1
X-ScriptRip-Signature: t=1773252597,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
X-ScriptRip-Delivery is stable across every retry of the same delivery, which makes it a second
de-duplication key beside (run_id, event).
The body
{"event":"run.completed","delivered_at":"2026-03-11T18:09:59Z","test":false,"run":{"id":"0195c8e4-8d02-7c19-b3e7-5a1f9d20c68b","source_type":"youtube","source_url":"https://www.youtube.com/watch?v=dQw4w9WgXcQ","title":"The Long Way South — Part 4","author_name":"Sailing Somewhere","author_url":"https://www.youtube.com/@sailingsomewhere","published_on":"2026-03-04","duration_seconds":2711.5,"thumbnail_url":"https://i.ytimg.com/vi/dQw4w9WgXcQ/mqdefault.jpg","status":"done","lane":"modal","engine":"whisperx large-v2","language":"en","name_speakers":true,"segment_count":412,"speaker_count":2,"word_count":6841,"speaker_labels":{"SPEAKER_00":{"name":"Matt","role":"host"},"SPEAKER_01":{"name":"Amy","role":"guest"}},"cached_from_run_id":null,"error":null,"error_class":null,"retry_after":null,"created_at":"2026-03-11T18:04:11Z","completed_at":"2026-03-11T18:09:57Z","transcript_url":"https://<host>/v1/runs/0195c8e4-8d02-7c19-b3e7-5a1f9d20c68b/transcript"}}
segments is never in a webhook body. The body is the run without its artifact; fetch
transcript_url with your own key (?format=json for the raw segments). Three reasons: a megabyte
posted to your endpoint five times over a retry ladder is your bandwidth spent on a delivery that
may be failing for another reason; many receivers cap bodies at 1 MiB, which would make long
transcripts the only undeliverable ones; and a pull is authenticated where a push is not.
run.failed and run.canceled carry the same object with status, error, error_class and
retry_after populated and transcript_url: null. A cached run fires run.completed with
lane: "cached" like any other; a receiver cannot tell and must not need to.
The signature
X-ScriptRip-Signature: t=<unix seconds>,v1=<hex hmac-sha256 of "<t>.<raw body>">
Keyed on the destination's secret. The signed string is the timestamp, a literal ., and the
exact bytes of the request body — not a re-serialisation of the parsed JSON. A framework that
parses the body before your handler sees it has already destroyed the thing being verified, and
that is the single most common cause of a signature that "does not work". t is inside the
signature, so the timestamp is unforgeable; reject a timestamp more than five minutes from now, in
either direction. Compare in constant time.
Verification in TypeScript
@script-rip/core/client exports this function; it is printed here so you can read it.
import { createHmac, timingSafeEqual } from "node:crypto";
/**
* @param rawBody the EXACT request body bytes. Not JSON.parse'd and re-stringified.
* @param header the X-ScriptRip-Signature value.
*/
export function verifyScriptRipSignature(
rawBody: Buffer | string,
header: string | null,
secret: string,
toleranceSeconds = 300,
): boolean {
if (!header) return false;
const fields = new Map<string, string>();
for (const part of header.split(",")) {
const i = part.indexOf("=");
if (i > 0) fields.set(part.slice(0, i).trim(), part.slice(i + 1).trim());
}
const t = fields.get("t");
const v1 = fields.get("v1");
if (!t || !v1) return false;
const ts = Number(t);
if (!Number.isFinite(ts)) return false;
// Replay protection. Both directions: a future timestamp is as suspicious as an old one.
if (Math.abs(Date.now() / 1000 - ts) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret)
.update(`${t}.`)
.update(rawBody)
.digest();
const given = Buffer.from(v1, "hex");
// Length must match before timingSafeEqual, which throws on a mismatch.
if (given.length !== expected.length) return false;
return timingSafeEqual(given, expected);
}
(a function; nothing is sent)
Getting the raw body in a Next.js route handler — the step that trips everyone:
export async function POST(req: Request) {
const raw = Buffer.from(await req.arrayBuffer()); // read ONCE, as bytes
const ok = verifyScriptRipSignature(raw, req.headers.get("x-scriptrip-signature"), process.env.SCRIPTRIP_WEBHOOK_SECRET!);
if (!ok) return new Response("bad signature", { status: 400 });
const body = JSON.parse(raw.toString("utf8")); // parse only after verifying
// ... handle
return new Response(null, { status: 204 });
}
HTTP/1.1 204 No Content
Verification in Python
import hashlib
import hmac
import time
def verify_scriptrip_signature(
raw_body: bytes,
header: str | None,
secret: str,
tolerance_seconds: int = 300,
) -> bool:
"""raw_body must be the EXACT request bytes — request.data, never request.json re-encoded."""
if not header:
return False
fields: dict[str, str] = {}
for part in header.split(","):
key, sep, value = part.partition("=")
if sep:
fields[key.strip()] = value.strip()
t = fields.get("t")
v1 = fields.get("v1")
if not t or not v1:
return False
try:
ts = int(t)
except ValueError:
return False
# Replay protection, both directions.
if abs(time.time() - ts) > tolerance_seconds:
return False
expected = hmac.new(
secret.encode("utf-8"),
f"{t}.".encode("utf-8") + raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, v1)
(a function; nothing is sent)
Flask:
@app.post("/hooks/script-rip")
def hook():
raw = request.get_data() # bytes, before any parsing
if not verify_scriptrip_signature(raw, request.headers.get("X-ScriptRip-Signature"), os.environ["SCRIPTRIP_WEBHOOK_SECRET"]):
return "", 400
payload = json.loads(raw)
...
return "", 204
HTTP/1.1 204 No Content
Retries, and how a destination is disabled
| Your endpoint answers | We do |
|---|---|
| 2xx | Delivered. Terminal |
| 410 Gone | Exhausted immediately — a receiver saying this endpoint is gone is believed the first time |
| Any other 4xx | Retried on the ladder; a 4xx is usually a bug on either side and both get a chance to deploy a fix |
| 3xx | Not followed. Retried on the ladder |
| 5xx, a timeout (10 s), a TLS failure, a DNS failure | Retried on the ladder |
The ladder is 1m, 5m, 30m, 2h, 12h and then exhausted: six attempts over roughly fifteen
hours. X-ScriptRip-Attempt counts them. Delivery is at-least-once.
A destination is disabled automatically after five consecutive exhausted deliveries. enabled
reads false and disabled_reason says why. Re-enable it with PATCH {"enabled": true}; nothing
is replayed. A receiver that missed a day catches up by reading what it missed and pulling, which is
what the next two endpoints are for.
Prove the plumbing
curl -sS -X POST "https://<host>/v1/destinations/6d2b8a10-1f47-4c8e-a2f0-cb9e4d3a7711/test" -H "Authorization: Bearer $SCRIPTRIP_KEY"
{"destination_id":"6d2b8a10-1f47-4c8e-a2f0-cb9e4d3a7711","event":"run.completed","delivered":true,"status_code":204,"latency_ms":183,"signature_header":"t=1773252601,v1=1f8ac10f23c5b5bc1167bda84b833e5c057a77d2ef2f3f2b6b0c1d4e5a6b7c8d","body_sha256":"9c1185a5c5e9fc54612808977ee8f548b2258d31f5a2a0e9b7c3d4e5f6a7b8c9","error":null,"message":"Delivered. The receiver answered 204 in 183 ms."}
The test is sent inline and its result is the response. Its body is a complete, correctly signed
payload carrying "test": true and a run whose id is 00000000-0000-0000-0000-000000000000; never
create a record for it. {"event": "run.failed"} tests another event's shape. A 500 from your
endpoint, a DNS failure or a timeout is still 200 here with delivered: false — the test
succeeded in telling you what happened. delivered: false with status_code: 400 almost always
means your signature check failed: a re-serialised body, a trimmed header, or a stale secret.
What we sent, and what came back
curl -sS "https://<host>/v1/destinations/6d2b8a10-1f47-4c8e-a2f0-cb9e4d3a7711/deliveries?status=exhausted" -H "Authorization: Bearer $SCRIPTRIP_KEY"
{"data":[{"id":"8c41d0aa-63e2-4a52-9f77-2b1c0e8d4a90","run_id":"0195c8e4-8d02-7c19-b3e7-5a1f9d20c68b","event":"run.completed","attempt":6,"status":"exhausted","status_code":503,"error":"The receiver answered 503.","next_attempt_at":null,"delivered_at":null,"created_at":"2026-03-11T18:09:58Z"}],"has_more":false,"next_cursor":null}
Every attempt to every destination, newest first, filterable by status and event. error says
in words why the last attempt failed — including which address check refused a URL and what the
name resolved to. last_delivery on the destination itself answers is it working in one field;
this list answers what did you send my receiver on Tuesday.
Ask for a run again
curl -sS -X POST "https://<host>/v1/runs/0195c8e4-8d02-7c19-b3e7-5a1f9d20c68b/deliver" \
-H "Authorization: Bearer $SCRIPTRIP_KEY" -H "Content-Type: application/json" \
-d '{"destination_id":"6d2b8a10-1f47-4c8e-a2f0-cb9e4d3a7711"}'
{"delivery_id":"8c41d0aa-63e2-4a52-9f77-2b1c0e8d4a90","destination_id":"6d2b8a10-1f47-4c8e-a2f0-cb9e4d3a7711","run_id":"0195c8e4-8d02-7c19-b3e7-5a1f9d20c68b","event":"run.completed","status":"pending","created":true,"message":"Queued for delivery. The sender runs every minute; the first attempt goes out on its next pass."}
Needs runs:read and destinations:write. It enqueues on the same ladder as the original;
event defaults to what the run's status implies. A run that is not finished, or a destination that
is disabled, is 409 conflict.
What a correct receiver does
- Read the raw body first, verify, then parse.
- Reject a timestamp outside five minutes, in both directions.
- Deduplicate on
(run_id, event). Delivery is at-least-once.X-ScriptRip-Deliveryworks too. - Answer within 10 seconds, and do the work afterwards. Enqueue and return
204. A receiver that fetches and analyses inside the request will time out, be retried, and do the work twice. - Ignore
test: trueexcept to confirm plumbing. Never create a record for the all-zero run id. - Ignore unknown fields and unknown events. New optional fields are not breaking.