Blog / Tutorials / Node.js Webhook Receiver for YouTube Transcripts
Tutorials 8 min read â€ĸ November 13, 2026

Node.js Webhook Receiver for YouTube Transcripts

Founder

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:

HeaderContents
X-Scriptube-Signaturesha256=<hex digest>
X-Scriptube-TimestampUnix seconds when it was signed
X-Scriptube-Delivery-IDUnique 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 (or express.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 to register — 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

Full reference: API documentation.

Try Scriptube Free

Extract YouTube transcripts instantly. No credit card required.

Get Started

Related Articles

Tutorials

YouTube Transcript API: The Complete Guide

The Scriptube API lets you submit YouTube video, playlist, or channel URLs and get back transcripts and metadata as JSON, CSV, or JSONL, plus timed segments...

Tutorials

Python YouTube Transcript API Tutorial

You can fetch a YouTube transcript with Scriptube's API in about 15 lines of Python, using nothing but the requests library. This walks through the short...

Tutorials

Zapier, Make, and Pipedream: Connecting YouTube Transcripts Without a Native App

Scriptube does not have a native Zapier app, Make.com app, or Pipedream integration — say that plainly, because searching for "Scriptube Zapier integration"...