Documentation

Everything you need to route TubeHook events into your stack or straight into a Discord channel.

Getting started

Three steps from zero to receiving events.

1Subscribe to a channel
Go to Channels and add any YouTube channel by ID, handle (@channelname), or URL. TubeHook registers and renews a WebSub subscription, then continuously reconciles the channel's RSS feed to recover notifications YouTube may miss.
2Create an endpoint
Go to Endpoints and add a destination URL. Pick Generic webhook (signed JSON to your HTTPS endpoint) or Discord (paste a Discord channel webhook URL). Filter by event type and scope to specific channel tags. TubeHook generates a unique signing secret per endpoint.
3Receive and verify events
When a subscribed channel publishes or updates a video, TubeHook sends a signed JSON POST to your endpoint. Verify the tubehook-signature-v2 header before processing. Failed deliveries are retried automatically.

Destination types

Each endpoint has a destination type that controls the outbound payload shape. Pick one when you create the endpoint; switch any time.

Generic webhook (JSON)
Default. Signed JSON POST to your HTTPS endpoint. Use for app backends, automation platforms, queues, and agent workflows.

Body is the payload documented under Payload format. Verify the tubehook-signature-v2 header before processing — see Webhook security.

Discord
Posts to a Discord channel webhook URL (https://discord.com/api/webhooks/...). Body is a single Discord { content } message. The YouTube link auto-embeds with thumbnail and title.

Setup: in Discord, open Channel Settings → Integrations → Webhooks → New Webhook, copy the URL, paste it into TubeHook with destination type set to Discord.

Event types

The type field in every payload is one of the following values.

Event typeLabel
youtube.video.public_publishedPublic video
youtube.video.updatedVideo updated
youtube.video.deletedVideo deleted
youtube.live.scheduledLive scheduled
youtube.live.startedLive started
youtube.live.endedLive ended
youtube.video.scheduled_releaseScheduled release
youtube.video.members_only_detectedMembers-only
youtube.video.detectedDetected
youtube.unknownUnknown

Payload format

Generic endpoints receive this JSON POST body. Fields inside data.video may be omitted when YouTube does not provide them for that event type. Discord endpoints receive a { "content": "..." } body instead — see Destination types.

Example payload
{
  "id": "jd7abc123def456",
  "type": "youtube.video.public_published",
  "occurredAt": "2026-05-10T14:30:00.000Z",
  "data": {
    "video": {
      "id": "dQw4w9WgXcQ",
      "title": "New video title",
      "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
      "thumbnailUrl": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg",
      "publishedAt": "2026-05-10T14:28:00.000Z",
      "updatedAt": "2026-05-10T14:30:00.000Z"
    },
    "channel": {
      "id": "UCBcRF18a7Qf58cCRy5xuWwQ",
      "title": "Channel Name",
      "handle": "@channelhandle",
      "url": "https://www.youtube.com/@channelhandle",
      "avatarUrl": "https://yt3.ggpht.com/..."
    }
  }
}
Request headers
Sent with every delivery.
HeaderValue
content-typeapplication/json
user-agentTubeHook Webhooks
tubehook-delivery-idStable ID for the delivery and its retries
tubehook-event-idID of the source YouTube event
tubehook-event-typeSame as payload type field
tubehook-signature-v2t=<unix-seconds>,v2=<hex> authenticated signature
tubehook-signatureLegacy sha256=<hex> body-only signature

Webhook security

Every request includes a tubehook-signature-v2 header, formatted as t=<unix-seconds>,v2=<hex>. Its HMAC SHA-256 binds the timestamp, delivery ID, event ID, event type, and exact raw request body to your endpoint's secret. Verify it with a timing-safe comparison and reject timestamps older than five minutes before processing the event.

The legacy tubehook-signature body-only header remains available for existing integrations, but it does not provide replay protection or authenticate the metadata headers. New integrations should use version 2. Discord endpoints receive both headers for parity, though Discord handles the request directly.

Node.js
import { createHmac, timingSafeEqual } from "node:crypto";

const SIGNATURE_TOLERANCE_SECONDS = 5 * 60;
const SIGNATURE_PATTERN = /^t=(\d+),v2=([0-9a-f]{64})$/;

function verifyTubeHookSignature(
  rawBody: string,
  headers: Record<string, string | string[] | undefined>,
  secret: string,
): boolean {
  const signature = headers["tubehook-signature-v2"];
  const deliveryId = headers["tubehook-delivery-id"];
  const eventId = headers["tubehook-event-id"];
  const eventType = headers["tubehook-event-type"];
  if (
    typeof signature !== "string" ||
    typeof deliveryId !== "string" ||
    typeof eventId !== "string" ||
    typeof eventType !== "string"
  ) return false;

  const match = SIGNATURE_PATTERN.exec(signature);
  if (!match) return false;
  const [, timestamp, providedDigest] = match;
  const signedContent = [
    "tubehook-webhook-v2",
    timestamp,
    deliveryId,
    eventId,
    eventType,
    rawBody,
  ].join("\n");
  const expectedDigest = createHmac("sha256", secret)
    .update(signedContent)
    .digest("hex");
  const expected = Buffer.from(expectedDigest);
  const provided = Buffer.from(providedDigest);
  if (
    expected.length !== provided.length ||
    !timingSafeEqual(expected, provided)
  ) return false;

  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
  return age <= SIGNATURE_TOLERANCE_SECONDS;
}

// Express example: keep the body raw until after verification.
app.post("/webhook", express.text({ type: "application/json" }), (req, res) => {
  if (!verifyTubeHookSignature(req.body, req.headers, process.env.TUBEHOOK_SECRET!)) {
    return res.status(401).send("Invalid signature");
  }
  const event = JSON.parse(req.body);
  // handle event.type ...
  res.sendStatus(200);
});
Python
import hashlib
import hmac
import re
import time

SIGNATURE_TOLERANCE_SECONDS = 5 * 60
SIGNATURE_PATTERN = re.compile(r"^t=(\d+),v2=([0-9a-f]{64})$")

def verify_tubehook_signature(raw_body: bytes, headers, secret: str) -> bool:
    signature = headers.get("tubehook-signature-v2", "")
    delivery_id = headers.get("tubehook-delivery-id", "")
    event_id = headers.get("tubehook-event-id", "")
    event_type = headers.get("tubehook-event-type", "")
    match = SIGNATURE_PATTERN.fullmatch(signature)
    if not match or not delivery_id or not event_id or not event_type:
        return False

    timestamp, provided_digest = match.groups()
    signed_content = b"\n".join([
        b"tubehook-webhook-v2",
        timestamp.encode(),
        delivery_id.encode(),
        event_id.encode(),
        event_type.encode(),
        raw_body,
    ])
    expected_digest = hmac.new(
        secret.encode(),
        signed_content,
        hashlib.sha256,
    ).hexdigest()
    if not hmac.compare_digest(expected_digest, provided_digest):
        return False

    return abs(int(time.time()) - int(timestamp)) <= SIGNATURE_TOLERANCE_SECONDS

# Flask example: keep the body raw until after verification.
@app.route("/webhook", methods=["POST"])
def webhook():
    raw_body = request.get_data()
    if not verify_tubehook_signature(raw_body, request.headers, TUBEHOOK_SECRET):
        return "Invalid signature", 401
    event = request.get_json(force=True)
    # handle event["type"] ...
    return "", 200

Your signing secret is available in Endpoints → View secret. Rotate it any time — existing consumers must update before the next delivery.