Guide to Twitch Webhooks: Features and Best Practices
Twitch EventSub webhooks notify your application about channel activity: a stream goes live, someone follows or subscribes, a channel updates. If you're building a bot, an overlay, an alerting service, or any Twitch integration, EventSub over webhooks is how you react in real time without polling.
This guide covers how Twitch EventSub webhooks work, the message types you'll handle, the challenge handshake, how to verify the signature, the replay window, and the best practices for production.
What are Twitch EventSub webhooks?
EventSub is Twitch's unified eventing system. Over the webhook transport, Twitch POSTs to your callback URL, and the Twitch-Eventsub-Message-Type header tells you which of three kinds of message it is: webhook_callback_verification (the setup challenge), notification (an actual event), or revocation (Twitch has revoked the subscription). You create subscriptions via the Helix API with an app access token, setting a secret used to sign deliveries.
Twitch EventSub features
| Feature | Details |
|---|---|
| Configuration | POST /helix/eventsub/subscriptions (app access token) |
| Message types | notification, webhook_callback_verification, revocation |
| Signature header | Twitch-Eventsub-Message-Signature |
| Signature scheme | sha256= + hex HMAC-SHA256 over message-id + timestamp + raw body |
| Secret | Set per subscription, 10-100 ASCII characters |
| Callback | HTTPS on port 443 |
| Replay protection | Reject messages with a timestamp older than 10 minutes |
| Delivery | At-least-once; dedupe on message ID |
| Cost model | max_total_cost (typically 10,000); user-authorized subs cost 0 |
| SDKs | Community only (@twurple/eventsub-http, twitchAPI) |
Common subscription types
You subscribe to specific EventSub types. Common ones:
| Type | Fires when |
|---|---|
stream.online | A channel starts streaming |
stream.offline | A channel stops streaming |
channel.follow | Someone follows the channel (version 2, requires a moderator_user_id condition) |
channel.subscribe | Someone subscribes to the channel |
channel.update | A channel's category, title, or settings change (version 2) |
channel.cheer | Someone cheers with Bits |
Note that channel.follow and channel.update are version 2. The version is a separate field in the subscription create body, not part of the type string. Subscribe to what you need, and consult Twitch's EventSub subscription-types reference for the full list.
Setting up Twitch EventSub webhooks
Create a subscription via Helix with an app access token (user tokens are rejected for the webhook transport). Set the transport.secret (10-100 ASCII characters) and an HTTPS callback on port 443:
curl -X POST "https://api.twitch.tv/helix/eventsub/subscriptions" \
-H "Authorization: Bearer $APP_ACCESS_TOKEN" \
-H "Client-Id: $CLIENT_ID" \
-H "Content-Type: application/json" \
-d '{
"type": "stream.online",
"version": "1",
"condition": { "broadcaster_user_id": "1234" },
"transport": {
"method": "webhook",
"callback": "https://your-app.example.com/eventsub",
"secret": "your-subscription-secret"
}
}'
The challenge handshake
When you create a subscription, Twitch sends a webhook_callback_verification message. Respond with a 200 and the raw challenge string from the payload as the body (text/plain, not JSON-wrapped). Until you do, the subscription isn't enabled.
Securing Twitch EventSub webhooks
Verify the Twitch-Eventsub-Message-Signature header on every message: it's sha256= followed by a hex HMAC-SHA256 where the signed content is the concatenation of the Twitch-Eventsub-Message-Id header, the Twitch-Eventsub-Message-Timestamp header, and the raw request body, keyed with your subscription secret. Reject on mismatch, and reject any message whose timestamp is more than 10 minutes old to guard against replays.
const crypto = require("crypto");
const SECRET = process.env.TWITCH_EVENTSUB_SECRET;
function verify(headers, rawBody) {
const id = headers["twitch-eventsub-message-id"];
const timestamp = headers["twitch-eventsub-message-timestamp"];
const message = id + timestamp + rawBody;
const expected = "sha256=" +
crypto.createHmac("sha256", SECRET).update(message).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(headers["twitch-eventsub-message-signature"] || "");
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return false;
// Reject messages older than 10 minutes (replay protection)
return Date.now() - new Date(timestamp).getTime() < 10 * 60 * 1000;
}
app.post("/eventsub", express.raw({ type: "application/json" }), (req, res) => {
if (!verify(req.headers, req.body.toString("utf8"))) {
return res.sendStatus(403);
}
const type = req.headers["twitch-eventsub-message-type"];
const payload = JSON.parse(req.body);
if (type === "webhook_callback_verification") {
return res.status(200).type("text/plain").send(payload.challenge);
}
if (type === "revocation") {
// subscription revoked (user_removed, authorization_revoked, ...)
return res.sendStatus(200);
}
res.sendStatus(200); // notification: acknowledge fast
processQueue.add(payload.event); // process asynchronously
});
The same verification in Python:
import hashlib
import hmac
import os
from datetime import datetime, timezone
SECRET = os.environ["TWITCH_EVENTSUB_SECRET"].encode()
def verify(headers, raw_body: bytes) -> bool:
message = (headers["Twitch-Eventsub-Message-Id"]
+ headers["Twitch-Eventsub-Message-Timestamp"]).encode() + raw_body
expected = "sha256=" + hmac.new(SECRET, message, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, headers.get("Twitch-Eventsub-Message-Signature", ""))
Twitch EventSub limitations and pain points
Three message types on one endpoint
The Problem: The same callback receives verification challenges, notifications, and revocations. A handler that assumes every POST is a notification never enables the subscription (it ignores the challenge) and misses revocations.
Why It Happens: EventSub multiplexes lifecycle and delivery on one URL, distinguished by Twitch-Eventsub-Message-Type.
Workarounds:
- Branch on
Twitch-Eventsub-Message-Type: echo thechallengefor verification, handle revocation, process notifications. - Log revocations (
user_removed,authorization_revoked,notification_failures_exceeded,version_removed) so you can resubscribe.
How Hookdeck Can Help: Hookdeck can answer the verification challenge and verify signatures at the edge, forwarding only clean notifications to your app while surfacing revocations.
The 10-minute replay window
The Problem: Twitch signs a timestamp, and messages older than 10 minutes should be rejected as replays. A backlogged handler can see messages age out, and skipping the timestamp check leaves you open to replay.
Why It Happens: The freshness window is what makes the signature resistant to replay.
Workarounds:
- Check the
Twitch-Eventsub-Message-Timestampagainst a 10-minute window. - Acknowledge fast and process asynchronously so you verify inside the window.
How Hookdeck Can Help: Hookdeck verifies signatures and freshness at the edge and durably queues events, so a downstream backlog doesn't risk messages aging out.
Revocation and the cost model
The Problem: Twitch revokes subscriptions on repeated delivery failure (notification_failures_exceeded) and enforces a subscription cost model (max_total_cost, typically 10,000). A flaky endpoint loses subscriptions, and un-authorized subscriptions consume cost budget.
Why It Happens: Twitch prunes failing subscriptions and caps how many app-authorized subscriptions you can hold.
Workarounds:
- Keep the endpoint healthy and respond promptly to avoid failure-based revocation.
- Track your total cost and prefer user-authorized subscriptions (cost 0) where possible.
How Hookdeck Can Help: Hookdeck absorbs downstream failures and retries to your service, keeping your endpoint responsive to Twitch so subscriptions aren't revoked for delivery failures.
At-least-once delivery
The Problem: The same notification can arrive more than once, so acting on duplicates double-processes.
Why It Happens: EventSub prioritizes eventual delivery.
Workarounds:
- Dedupe on the
Twitch-Eventsub-Message-Id. - Make side effects idempotent.
How Hookdeck Can Help: Hookdeck deduplicates on the message ID at the edge, so retries don't reach your handler twice. See our guide to webhook idempotency.
Best practices
Verify signature and freshness
Compute sha256= + hex HMAC-SHA256 over message-id + timestamp + raw body, compare in constant time, and reject timestamps older than 10 minutes.
Handle all three message types
Echo the challenge for verification (text/plain), handle revocation, and process notifications. Branch on Twitch-Eventsub-Message-Type.
Acknowledge fast, process asynchronously
Respond within a few seconds and defer work to a queue. See why to process webhooks asynchronously.
Dedupe on the message ID
Use Twitch-Eventsub-Message-Id as an idempotency key so at-least-once delivery doesn't double-process.
Mind versions and cost
Use the right subscription version (channel.follow and channel.update are v2), and track your max_total_cost budget.
Make Twitch EventSub webhooks production-ready
Hookdeck answers the challenge, verifies signatures and freshness, deduplicates, and durably queues every event
Conclusion
Twitch EventSub delivers channel activity over webhooks, but the endpoint has to do more than accept notifications: it answers a webhook_callback_verification challenge, handles revocation messages, and verifies a signature computed over the message id, timestamp, and body, all while rejecting anything older than 10 minutes. Add at-least-once delivery and a subscription cost model, and there's real handling required.
That puts challenge handling, signature-and-freshness verification, deduplication, and subscription health on you. Hookdeck answers the challenge, verifies signatures and freshness, deduplicates, and durably queues every event at the edge, so your app only ever processes verified, unique notifications.
Get started with Hookdeck for free and handle Twitch EventSub webhooks reliably in minutes.