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
- Deletions look like notifications. A removed video sends
<at:deleted-entry> instead of <entry> — handle it or you'll
crash on a missing videoId.
- Re-sends and edits. The same videoId can arrive more than once (title edits, hub
retries). Dedupe on videoId; treat the feed as at-least-once, not exactly-once.
- Shorts and premieres. Everything comes through the same feed — there's no type flag.
Filter downstream if you only want long-form.
- XML entities. Titles contain
&, ', etc.
Decode them.
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