Webhooks
PUT /v1/webhooks sets the account default endpoint and optional low-balance threshold. The response shows the new signing secret once; store it as carefully as an API key. Configure this default once before using a per-job webhook_url: that field overrides only the destination and keeps the account signing secret. A job override without an enabled account webhook is rejected before mutation with webhook_not_configured. GET /v1/webhooks returns only the current configuration without the secret; delivery state is included in job JSON. DELETE /v1/webhooks removes the default configuration and its signing secret. Already-enqueued deliveries retain the encrypted snapshot needed for their bounded retries.
Only public https: destinations are accepted. ScriptHaul rejects credentials in URLs, localhost, private, loopback, link-local, metadata, mapped-private IPv6, private DNS answers, and its own hostnames (scripthaul.com, api.scripthaul.com, bulktranscripts.com). Redirects are never followed: a 3xx response counts as a failed attempt. Each attempt has a 10-second timeout. A failed event receives no more than five total attempts (the initial attempt plus four retries), scheduled from one minute through twelve hours. Bodies are never logged.
Events are job.completed, job.failed, job.paused, job.resumed, and credits.low. Headers include X-ScriptHaul-Event, X-ScriptHaul-Delivery, X-ScriptHaul-Timestamp, and X-ScriptHaul-Signature. The signature is v1= followed by lowercase hex HMAC-SHA256 over the exact UTF-8 bytes ${timestamp}.${body}. Verify before parsing, compare in constant time, reject timestamps outside a short replay window (the examples use five minutes), and make the delivery ID idempotent.
Verify in Node
import { createHmac, timingSafeEqual } from "node:crypto";
const timestamp = request.headers.get("x-scripthaul-timestamp");
const supplied = request.headers.get("x-scripthaul-signature");
const body = await request.text();
const timestampSeconds = Number(timestamp);
const nowSeconds = Math.floor(Date.now() / 1000);
if (!Number.isSafeInteger(timestampSeconds) || Math.abs(nowSeconds - timestampSeconds) > 300) throw new Error("stale webhook");
const expected = createHmac("sha256", process.env.SCRIPTHAUL_WEBHOOK_SECRET)
.update(`${timestamp}.${body}`).digest("hex");
const candidate = supplied?.startsWith("v1=") ? supplied.slice(3) : "";
if (!/^[0-9a-f]{64}$/.test(candidate) || !timingSafeEqual(Buffer.from(candidate), Buffer.from(expected))) throw new Error("bad signature");
Verify in Python
import hashlib, hmac, os, time
timestamp = request.headers["X-ScriptHaul-Timestamp"]
body = request.get_data(cache=False)
try:
timestamp_seconds = int(timestamp)
except ValueError:
raise ValueError("bad timestamp")
if abs(int(time.time()) - timestamp_seconds) > 300:
raise ValueError("stale webhook")
expected = hmac.new(os.environ["SCRIPTHAUL_WEBHOOK_SECRET"].encode(), timestamp.encode() + b"." + body, hashlib.sha256).hexdigest()
supplied = request.headers.get("X-ScriptHaul-Signature", "")
candidate = supplied[3:] if supplied.startswith("v1=") else ""
if len(candidate) != 64 or not hmac.compare_digest(candidate, expected):
raise ValueError("bad signature")
Read configuration in four languages
Maintained runnable versions are in the examples directory.
curl https://api.scripthaul.com/v1/webhooks -H "Authorization: Bearer $SCRIPTHAUL_API_KEY"
import os, urllib.request
request = urllib.request.Request("https://api.scripthaul.com/v1/webhooks")
request.add_header("Authorization", f"Bearer {os.environ['SCRIPTHAUL_API_KEY']}")
print(urllib.request.urlopen(request).read().decode())
const response = await fetch("https://api.scripthaul.com/v1/webhooks", { headers: { Authorization: `Bearer ${process.env.SCRIPTHAUL_API_KEY}` } });
console.log(await response.json());
req, _ := http.NewRequest("GET", "https://api.scripthaul.com/v1/webhooks", nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("SCRIPTHAUL_API_KEY"))
response, err := http.DefaultClient.Do(req)
if err != nil { log.Fatal(err) }
defer response.Body.Close()
io.Copy(os.Stdout, response.Body)