Instead of polling the API for batch status, register a webhook and Scriptube will push a signed HTTP request to your server when a batch finishes. This walks through receiving and verifying that request in Node with Express and TypeScript.
Project setup
npm init -y
npm install express
npm install -D typescript @types/express @types/node ts-node
Run the receiver below with ts-node server.ts during development, or compile with tsc and run the output with plain node in production. None of this is Scriptube-specific â it's a standard Express + TypeScript setup, included here so the webhook code below is a complete, runnable file rather than a fragment.
Register the webhook
curl -s -X POST https://scriptube.me/api/webhooks/register \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://yourapp.example.com/webhooks/scriptube",
"events": ["batch.completed", "transcript.ready"],
"secret": "a-long-random-secret-you-generate"
}'
Webhooks are available on Pro and above (5 webhooks on Pro, up to 25 on Unlimited). The full event list â batch.created, batch.started, batch.completed, batch.failed, item.completed, item.failed, transcript.ready, credits.low, credits.depleted, subscription.updated â is in the API guide. If you only care about one batch and don't want a standing webhook, pass callback_url on that submit instead â see webhooks vs. polling for when each makes sense.
How the signature works
Every delivery carries three headers:
| Header | Contents |
|---|---|
X-Scriptube-Signature | sha256=<hex digest> |
X-Scriptube-Timestamp | Unix seconds when it was signed |
X-Scriptube-Delivery-ID | Unique ID for this delivery attempt (a callback_url delivery sends X-Scriptube-Batch-ID instead) |
The digest is HMAC-SHA256, keyed with the secret you registered, over the string {timestamp}.{raw request body}. The important detail: verify against the raw bytes of the body as received, not a re-serialized version of the parsed JSON. If your framework parses the body and you reconstruct it with JSON.stringify(), key order or spacing can differ from what was signed and the check will fail even for a legitimate request.
The receiver
Express's default JSON body parser discards the raw bytes, so mount a raw-body parser on this route specifically:
import express, { Request, Response } from "express";
import crypto from "crypto";
const app = express();
const WEBHOOK_SECRET = process.env.SCRIPTUBE_WEBHOOK_SECRET as string;
function verifySignature(rawBody: Buffer, timestamp: string, signature: string): boolean {
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - Number(timestamp)) > 300) return false; // reject >5 min old
const message = `${timestamp}.${rawBody.toString("utf8")}`;
const expected =
"sha256=" +
crypto.createHmac("sha256", WEBHOOK_SECRET).update(message).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signature);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post(
"/webhooks/scriptube",
express.raw({ type: "application/json" }),
(req: Request, res: Response) => {
const signature = req.header("X-Scriptube-Signature") || "";
const timestamp = req.header("X-Scriptube-Timestamp") || "";
if (!verifySignature(req.body as Buffer, timestamp, signature)) {
return res.status(401).send("invalid signature");
}
const event = JSON.parse((req.body as Buffer).toString("utf8"));
if (event.event === "batch.completed") {
console.log("batch finished:", event.data.batch_id, event.data.status);
// fetch the export, update your own DB, notify a user, etc.
}
res.status(200).send("ok"); // ack fast; do slow work after responding
}
);
app.listen(3000);
Respond 200 quickly and do any slow work (exporting, writing to your database) afterward â a slow handler just makes Scriptube's delivery worker wait and eventually retry.
A few things to get right that are easy to get wrong:
- Mount
express.raw()on this route only. If you apply it (orexpress.json()) globally, other routes that expect parsed JSON bodies will break. - Keep the secret out of source control. Load it from an environment variable (
process.env.SCRIPTUBE_WEBHOOK_SECRET), the same secret you passed toregisterâ Scriptube never sends it back to you, so if you lose it you'll need to re-register. - Use
crypto.timingSafeEqual, not===. A plain string comparison leaks timing information an attacker could use to guess the signature byte by byte; it's a small change that closes a real side channel.
Retries and what stops them
A failing delivery (connection error, non-2xx, or a blocked URL) is retried up to four times at 1, 5, and 15 minutes. Ten consecutive failures deactivate the webhook â check GET /api/webhooks for is_active and failure_count if deliveries seem to have stopped, and re-register once your endpoint is healthy again.
Testing locally
Point the webhook at a tunnel (ngrok or similar) during development, or submit a batch with a one-off callback_url instead of registering a standing webhook â it's signed the same way and needs no registration step. Reject anything with a timestamp older than five minutes; that's replay protection, not a clock-sync bug on your end.
To exercise the verification logic without waiting on a real batch, sign a fake payload yourself using the identical scheme and POST it to your own endpoint with curl:
node -e "
const crypto = require('crypto');
const secret = process.env.SCRIPTUBE_WEBHOOK_SECRET;
const body = JSON.stringify({event: 'batch.completed', timestamp: new Date().toISOString(), data: {batch_id: 'test'}});
const ts = Math.floor(Date.now() / 1000);
const sig = 'sha256=' + crypto.createHmac('sha256', secret).update(ts + '.' + body).digest('hex');
console.log(JSON.stringify({ts, sig, body}));
"
Then send the printed body with matching X-Scriptube-Timestamp/X-Scriptube-Signature headers via curl -X POST and confirm your server accepts it â and that flipping one character in the body makes it reject.
Handling other event types
The handler above only branches on batch.completed. The same payload shape â {event, timestamp, data} â applies to every event you subscribed to, so a real handler is usually a small switch on event.event: transcript.ready for a per-video notification as soon as one item finishes (useful for showing progress before the whole batch is done), batch.failed if you want to alert on total failures, credits.low/credits.depleted if you want your own warning before a batch gets rejected for insufficient balance.
Next steps
- Webhooks vs. polling â when to use which.
- Post to Slack when a batch completes, as a concrete receiver example.
- The complete API guide.
Full reference: API documentation.