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

  1. Read the raw body first, verify, then parse.
  2. Reject a timestamp outside five minutes, in both directions.
  3. Deduplicate on (run_id, event). Delivery is at-least-once. X-ScriptRip-Delivery works too.
  4. 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.
  5. Ignore test: true except to confirm plumbing. Never create a record for the all-zero run id.
  6. Ignore unknown fields and unknown events. New optional fields are not breaking.