Skip to content

Verify Twitch EventSub webhooks

Verify that a webhook really came from Twitch EventSub before acting on it.

Identifier 'twitch'
Import import { verifyTwitch } from 'verihook/twitch'
Headers twitch-eventsub-message-signature
twitch-eventsub-message-id
twitch-eventsub-message-timestamp
twitch-eventsub-message-type
Signature HMAC-SHA256 of <message id><timestamp><raw body>, sent as sha256=<hex>.
Replay window 600 seconds (options.tolerance)
result.eventType the twitch-eventsub-subscription-type header, e.g. channel.follow

The secret you passed in transport.secret when creating the EventSub subscription (10–100 characters).

Store it in an environment variable (TWITCH_EVENTSUB_SECRET below) and never commit it.

import { verifyTwitch } from 'verihook/twitch';
export async function POST(request: Request) {
const result = await verifyTwitch(request, process.env.TWITCH_EVENTSUB_SECRET!);
if (!result.valid) {
console.warn(result.code, result.reason, result.hint);
return new Response('Invalid signature', { status: 401 });
}
console.log(result.eventType, result.event);
return Response.json({ received: true });
}

request can be a Fetch Request or { headers, body, url? } with the raw body. verifyWebhook('twitch', request, secret) from verihook does the same. Read why the raw body matters if verification fails behind a body parser.

app/api/webhooks/twitch/route.ts
import { createWebhookHandler } from 'verihook/next';
export const POST = createWebhookHandler('twitch', process.env.TWITCH_EVENTSUB_SECRET!, async (payload, result) => {
// Runs only for a valid signature
console.log(result.eventType, result.event);
});

Each adapter responds 401 to an invalid signature, 413 to a body over maxBodySize (2 MB by default) and 200 to a duplicate when you pass a dedupeStore.

After you create a subscription, Twitch sends a webhook_callback_verification message. Answer it with the challenge as plain text:

import { createWebhookHandler } from 'verihook/next';
export const POST = createWebhookHandler('twitch', process.env.TWITCH_EVENTSUB_SECRET!, async (payload, result) => {
const body = payload as { challenge?: string };
if (body.challenge) {
return new Response(body.challenge, { headers: { 'content-type': 'text/plain' } });
}
// handle result.eventType
});
  • Twitch retries with the same message ID. Use a dedupe store to ignore repeats.

signWebhook from verihook/testing builds a correctly signed Twitch EventSub request for your tests:

import { signWebhook } from 'verihook/testing';
import { verifyTwitch } from 'verihook/twitch';
const hook = await signWebhook('twitch', {
secret: 'twitch_eventsub_secret',
payload: { id: 'evt_test' },
});
const result = await verifyTwitch(
{ headers: hook.headers, body: hook.body, url: hook.url },
hook.secret,
);
// result.valid === true

From the command line, npx verihook simulate twitch --url http://localhost:3000/webhooks/twitch sends one to your local server.

See Testing your handlers for supertest and Fetch Request examples.

Failed results carry a code and often a hint with the likely cause. See Troubleshooting for each error code.

Twitch EventSub’s webhook documentation