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.
@channelname), or URL. TubeHook registers and renews a WebSub subscription, then continuously reconciles the channel's RSS feed to recover notifications YouTube may miss.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.
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.
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 type | Label | Description |
|---|---|---|
| youtube.video.public_published | Public video | A video is confirmed public. |
| youtube.video.updated | Video updated | A known video emits another update. |
| youtube.video.deleted | Video deleted | YouTube sends a tombstone entry. |
| youtube.live.scheduled | Live scheduled | A live broadcast has upcoming metadata. |
| youtube.live.started | Live started | A live broadcast is active. |
| youtube.live.ended | Live ended | A live broadcast has ended and the VOD is being processed. |
| youtube.video.scheduled_release | Scheduled release | A future publish time is detected. |
| youtube.video.members_only_detected | Members-only | Public-flagged video appears gated by channel membership (oembed 401). |
| youtube.video.detected | Detected | A WebSub event has a video before enrichment completes. |
| youtube.unknown | Unknown | Parsing or classification cannot determine the type. |
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.
{
"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/..."
}
}
}{
"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/..."
}
}
}| Header | Value |
|---|---|
| content-type | application/json |
| user-agent | TubeHook Webhooks |
| tubehook-delivery-id | Stable ID for the delivery and its retries |
| tubehook-event-id | ID of the source YouTube event |
| tubehook-event-type | Same as payload type field |
| tubehook-signature-v2 | t=<unix-seconds>,v2=<hex> authenticated signature |
| tubehook-signature | Legacy 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.
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);
});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);
});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 "", 200import 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 "", 200Your signing secret is available in Endpoints → View secret. Rotate it any time — existing consumers must update before the next delivery.