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-signaturetwitch-eventsub-message-idtwitch-eventsub-message-timestamptwitch-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 |
Get your secret
Section titled “Get your secret”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.
Verify a request
Section titled “Verify a request”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.
Framework examples
Section titled “Framework examples”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);});import express from 'express';import { verihookExpress, type VerihookRequestAdditions } from 'verihook/express';
const app = express();
// Register before express.json(), or keep express.json() off this routeapp.post('/webhooks/twitch', verihookExpress('twitch', process.env.TWITCH_EVENTSUB_SECRET!), (req, res) => { const { eventType, event } = (req as typeof req & VerihookRequestAdditions).verihook!; res.json({ received: true });});import Fastify from 'fastify';import { verihookFastify, verihookRawBody, type VerihookFastifyRequest } from 'verihook/fastify';
const app = Fastify();await app.register(verihookRawBody);
app.post('/webhooks/twitch', { preHandler: verihookFastify('twitch', process.env.TWITCH_EVENTSUB_SECRET!) }, async (request) => { const { eventType, event } = (request as typeof request & VerihookFastifyRequest).verihook!; return { received: true };});import { Hono } from 'hono';import { verihookHono, type VerihookVariables } from 'verihook/hono';
const app = new Hono<{ Bindings: { TWITCH_EVENTSUB_SECRET: string }; Variables: VerihookVariables }>();
app.post('/webhooks/twitch', verihookHono('twitch', (c) => c.env.TWITCH_EVENTSUB_SECRET), (c) => { const { eventType, event } = c.get('verihook'); return c.json({ received: true });});// main.ts: NestFactory.create(AppModule, { rawBody: true })import { Controller, Post, Req, UseGuards } from '@nestjs/common';import { createVerihookGuard, type VerihookNestRequest } from 'verihook/nestjs';
@Controller('webhooks')export class WebhooksController { @Post('twitch') @UseGuards(createVerihookGuard('twitch', process.env.TWITCH_EVENTSUB_SECRET!)) handle(@Req() req: VerihookNestRequest) { const { eventType, event } = req.verihook!; }}import { createWebhookHandler } from 'verihook/h3';
export default defineEventHandler( createWebhookHandler('twitch', process.env.TWITCH_EVENTSUB_SECRET!, async (payload, result) => { console.log(result.eventType); }),);import { env } from '$env/dynamic/private';import { createWebhookHandler } from 'verihook/sveltekit';
export const POST = createWebhookHandler('twitch', () => env.TWITCH_EVENTSUB_SECRET, async (payload, result) => { console.log(result.eventType);});import { createWebhookHandler } from 'verihook/remix';
export const action = createWebhookHandler('twitch', process.env.TWITCH_EVENTSUB_SECRET!, async (payload, result) => { console.log(result.eventType);});import { createWebhookHandler } from 'verihook/astro';
export const prerender = false;export const POST = createWebhookHandler('twitch', import.meta.env.TWITCH_EVENTSUB_SECRET, async (payload, result) => { console.log(result.eventType);});import { createWebhookHandler } from 'verihook/lambda';
export const handler = createWebhookHandler('twitch', process.env.TWITCH_EVENTSUB_SECRET!, async (payload, result) => { console.log(result.eventType);});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.
Setup handshake
Section titled “Setup handshake”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});Things to know
Section titled “Things to know”- Twitch retries with the same message ID. Use a dedupe store to ignore repeats.
Testing
Section titled “Testing”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 === trueFrom 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.
Troubleshooting
Section titled “Troubleshooting”Failed results carry a code and often a hint with the likely cause. See Troubleshooting for each error code.