feedhook · guide

YouTube WebSub (PubSubHubbub): how new-video push notifications actually work

Every YouTube channel feed is a WebSub (formerly PubSubHubbub) topic — which means YouTube will push you an HTTP request within seconds of a new upload, for free, no Data API quota. Almost nobody uses it because the handshake, lease renewals, and signature checks are fiddly and badly documented. This is the complete, accurate walkthrough — every detail below is what a working production implementation actually does.

1. The topic: a channel's Atom feed

WebSub subscriptions are to a topic URL. For a YouTube channel it's the XML feed, keyed by the channel's UC… id (not the @handle):

https://www.youtube.com/xml/feeds/videos.xml?channel_id=UCBJycsmduvYEL83R_U4JriQ

You need the UC… id. From an @handle or channel URL, fetch the channel page and read <link rel="canonical"> — it's the only reliable source; the first "channelId" string in the HTML is often an embedded channel, not the one you want. Resolve once and cache it. (Or use our free channel ID finder.)

2. The hub, and how you subscribe

YouTube delegates push to Google's public hub at https://pubsubhubbub.appspot.com/subscribe. You subscribe by POSTing a form-encoded request:

POST https://pubsubhubbub.appspot.com/subscribe
content-type: application/x-www-form-urlencoded

hub.callback=https://your.app/websub/abc123   # your public endpoint
hub.mode=subscribe
hub.topic=https://www.youtube.com/xml/feeds/videos.xml?channel_id=UC…
hub.verify=async
hub.secret=     # used to sign notifications
hub.lease_seconds=432000                       # request ~5 days (hub may grant less)

A 202 Accepted means "request received" — not subscribed yet. Verification happens next, out of band.

3. The verification handshake (the part everyone gets wrong)

Because you sent hub.verify=async, the hub now makes a GET to your hub.callback to prove you actually own that endpoint:

GET https://your.app/websub/abc123
  ?hub.mode=subscribe
  &hub.topic=https://www.youtube.com/xml/feeds/videos.xml?channel_id=UC…
  &hub.challenge=
  &hub.lease_seconds=432000

You must echo hub.challenge back verbatim as a 200 body — but only after checking that hub.topic matches a subscription you actually asked for. Echo blindly and anyone can subscribe your endpoint to anything. Record now + hub.lease_seconds as your expiry; the hub decides the real lease and it may be shorter than you requested.

// the callback handler
if (mode === "subscribe" && topicMatchesAKnownSub) {
  expiresAt = Date.now() + leaseSeconds * 1000;   // remember this — see §6
  return res.status(200).send(challenge);          // echo verbatim
}
return res.status(404).end();                       // unknown topic → refuse

4. Receiving a new video

From then on, every new upload (and some edits) arrives as a POST of Atom XML to your callback:

POST https://your.app/websub/abc123
content-type: application/atom+xml
x-hub-signature: sha1=<hmac>

<feed><entry>
  <yt:videoId>dQw4w9WgXcQ</yt:videoId>
  <yt:channelId>UC…</yt:channelId>
  <title>The video title</title>
  <published>2026-06-15T15:54:18+00:00</published>
</entry></feed>

Verify the signature

The hub signs the raw body with the hub.secret you supplied, using HMAC-SHA1 (the WebSub default; some hubs send sha256). Compute it over the bytes before any JSON/XML parsing, and compare in constant time:

// node — 'raw' is the unparsed request body Buffer
import { createHmac, timingSafeEqual } from "node:crypto";
const [algo, theirHex] = (req.headers["x-hub-signature"] || "").split("=");
const ours = createHmac(algo, HUB_SECRET).update(raw).digest();
const theirs = Buffer.from(theirHex, "hex");
const authentic = ours.length === theirs.length && timingSafeEqual(ours, theirs);
// per spec: if it doesn't verify, return 2xx and silently ignore — don't 4xx

Parsing gotchas

5. You need a public, always-up endpoint

WebSub is push, so your callback must be reachable from the public internet at all times. Push has no memory: if your endpoint is down when a video publishes, the hub retries for a while and then that notification is simply gone. To not lose videos you also need a fallback that polls the feed and reconciles what push missed — which quietly reintroduces the polling you were trying to avoid.

6. The silent killer: lease renewal

Subscriptions expire. The hub grants a lease (YouTube's is days, not weeks), and when it lapses the pushes just stop — no error, no warning. You must track each subscription's expiry and re-subscribe before it runs out (e.g. when under ~6 hours remain), which re-runs the whole handshake from §2–3. Get this wrong and everything works perfectly in testing and then dies a few days after you deploy, all at once.

So: do it yourself, or not?

Do it yourself if you're at hundreds of feeds, or you enjoy owning the always-up endpoint, the ~5-day renewal cron, signature verification, XML parsing, dedupe, and a polling reconciler for missed pushes. It's all standard; it's just a lot of moving parts that fail quietly.

Or skip all of it. Feedhook does this plumbing once and hands you a clean signed JSON POST per new video — handshakes, ~5-day renewals, HMAC, parsing, 8 retries over ~9h, an hourly missed-push sweep, and delivery logs included. Free for 1 channel. See one real delivery in ~5 seconds, no endpoint of your own required:

npx -y feedhook-quickstart @mkbhd

The honest three-way comparison · the webhook contract + code · recipes for Discord, Slack, and no-code (Zapier/Make/n8n).

← a wall on walls.sh