Skip to content

Streaming

How Twitch EventSub signs webhooks

Twitch EventSub signs with sha256= HMAC-SHA256 over message id + timestamp + body, needs the challenge echoed as text/plain, and revokes slow endpoints.

Checked against Twitch EventSub's documentation on October 31, 2026. Twitch EventSub webhook docs

How the signature works

HeaderTwitch-Eventsub-Message-Signature: sha256={signature}
Signed content{Twitch-Eventsub-Message-Id}{Twitch-Eventsub-Message-Timestamp}{raw body}
AlgorithmHMAC-SHA256
Encodinghex
KeyThe secret you sent when creating the subscription: ASCII, 10 to 100 characters
Where to find itThe secret you passed in transport.secret when creating the EventSub subscription.
Replay protectionTwitch suggests rejecting messages whose timestamp is more than 10 minutes old, and remembering message ids.
  • The timestamp is RFC 3339 with nanoseconds, not Unix time.
  • Answer webhook_callback_verification with 200, Content-Type text/plain and the raw challenge as the body.

verified Source: dev.twitch.tv/docs/eventsub/handling-webhook-events/

Verification code

Generated from the scheme above and tested against a signed example request, a changed body and a wrong secret. Pass the raw request body, before any JSON or form parsing.

const crypto = require('node:crypto');

const TOLERANCE_SECONDS = 600;

/**
 * Verify a Twitch EventSub webhook. rawBody: the request body as a Buffer, exactly as received
 * (before any JSON parsing); headers: lower-case names, as in req.headers.
 */
function verifyWebhook(rawBody, headers, secret, url = '', method = 'POST') {
  rawBody = Buffer.from(rawBody);
  const header = headers['twitch-eventsub-message-signature'] || '';
  if (!header.startsWith('sha256=')) return false;
  const signatures = [header.slice(7)];
  const ts = headers['twitch-eventsub-message-timestamp'] || '';
  const signed = Buffer.concat([Buffer.from((headers['twitch-eventsub-message-id'] || '') + (headers['twitch-eventsub-message-timestamp'] || '')), rawBody]);
  const key = secret;
  const expected = crypto.createHmac('sha256', key).update(signed).digest('hex');
  const valid = signatures.some((sig) => {
    const a = Buffer.from(sig.toLowerCase());
    const b = Buffer.from(expected);
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  });
  if (!valid) return false;
  const signedAt = Date.parse(ts.replace(/(\.\d{3})\d+/, '$1')) / 1000;
  return Number.isFinite(signedAt) && Math.abs(Date.now() / 1000 - signedAt) <= TOLERANCE_SECONDS;
}

// Express: keep the raw body for this route
// app.post('/webhooks/twitch', express.raw({ type: '*/*' }), (req, res) => {
//   if (!verifyWebhook(req.body, req.headers, process.env.TWITCH_WEBHOOK_SECRET)) return res.sendStatus(401);
//   res.sendStatus(200); // then process the event asynchronously
// });
import base64
import datetime
import hashlib
import hmac
import re
import time

TOLERANCE_SECONDS = 600


def verify_webhook(raw_body: bytes, headers, secret: str, url: str = '', method: str = 'POST') -> bool:
    """Verify a Twitch EventSub webhook. raw_body: the request body exactly as received (bytes);
    headers: a mapping with lower-case names (Flask and Django headers are case-insensitive)."""
    header = headers.get('twitch-eventsub-message-signature', '')
    if not header.startswith('sha256='):
        return False
    signatures = [header[7:]]
    ts = headers.get('twitch-eventsub-message-timestamp', '')
    signed = (headers.get('twitch-eventsub-message-id', '') + headers.get('twitch-eventsub-message-timestamp', '')).encode() + raw_body
    key = secret.encode()
    digest = hmac.new(key, signed, hashlib.sha256).digest()
    expected = digest.hex()
    if not any(hmac.compare_digest(sig.lower(), expected) for sig in signatures):
        return False
    try:
        signed_at = datetime.datetime.fromisoformat(re.sub(r'(\.\d{6})\d+', r'\1', ts).replace('Z', '+00:00')).timestamp()
    except ValueError:
        return False
    return abs(time.time() - signed_at) <= TOLERANCE_SECONDS


# Flask: verify_webhook(request.get_data(), request.headers, SECRET)
<?php

const TOLERANCE_SECONDS = 600;

/**
 * Verify a Twitch EventSub webhook. $rawBody: file_get_contents('php://input'), before any decoding;
 * $headers: lower-case names, e.g. array_change_key_case(getallheaders()).
 */
function verify_webhook(string $rawBody, array $headers, string $secret, string $url = '', string $method = 'POST'): bool
{
    $header = (string) ($headers['twitch-eventsub-message-signature'] ?? '');
    if (! str_starts_with($header, 'sha256=')) {
        return false;
    }
    $signatures = [substr($header, 7)];
    $ts = (string) ($headers['twitch-eventsub-message-timestamp'] ?? '');
    $signed = ($headers['twitch-eventsub-message-id'] ?? '') . ($headers['twitch-eventsub-message-timestamp'] ?? '') . $rawBody;
    $key = $secret;
    $expected = hash_hmac('sha256', $signed, $key);
    $valid = false;
    foreach ($signatures as $sig) {
        $valid = $valid || hash_equals($expected, strtolower($sig));
    }
    if (! $valid) {
        return false;
    }
    $signedAt = strtotime(preg_replace('/(\.\d{6})\d+/', '$1', $ts));

    return $signedAt > 0 && abs(time() - $signedAt) <= TOLERANCE_SECONDS;
}
package webhooks

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"math"
	"net/http"
	"strings"
	"time"
)

const toleranceSeconds = 600

// VerifyWebhook checks a Twitch EventSub webhook. rawBody is the request body exactly as received
// (io.ReadAll(r.Body) before any decoding).
func VerifyWebhook(rawBody []byte, h http.Header, secret, url, method string) bool {
	header := h.Get("Twitch-Eventsub-Message-Signature")
	if !strings.HasPrefix(header, "sha256=") {
		return false
	}
	signatures := []string{strings.TrimPrefix(header, "sha256=")}
	ts := h.Get("twitch-eventsub-message-timestamp")
	signed := []byte{}
	signed = append(signed, h.Get("twitch-eventsub-message-id")...)
	signed = append(signed, h.Get("twitch-eventsub-message-timestamp")...)
	signed = append(signed, rawBody...)
	key := []byte(secret)
	mac := hmac.New(sha256.New, key)
	mac.Write(signed)
	expected := hex.EncodeToString(mac.Sum(nil))
	valid := false
	for _, sig := range signatures {
		if hmac.Equal([]byte(strings.ToLower(sig)), []byte(expected)) {
			valid = true
		}
	}
	if !valid {
		return false
	}
	t, err := time.Parse(time.RFC3339Nano, ts)
	if err != nil {
		return false
	}
	signedAt := float64(t.Unix())
	return math.Abs(float64(time.Now().Unix())-signedAt) <= toleranceSeconds
}

Headers Twitch EventSub sends

HeaderMeaning
Twitch-Eventsub-Message-IdUnique message id
Twitch-Eventsub-Message-RetryRetry count
Twitch-Eventsub-Message-Typenotification, webhook_callback_verification or revocation
Twitch-Eventsub-Message-Signaturesha256= HMAC
Twitch-Eventsub-Message-TimestampRFC 3339 send time
Twitch-Eventsub-Subscription-Typee.g. channel.follow
Twitch-Eventsub-Subscription-VersionSubscription version

Timeouts and retries

Response timeoutRespond within a few seconds (no exact figure is published).
Counts as success2xx.
RetriesAt-least-once delivery: Twitch resends when unsure you received a message. Too many slow or failed responses revoke the subscription (notification_failures_exceeded).
OrderingNot guaranteed.
DuplicatesExpected occasionally; drop repeats by Twitch-Eventsub-Message-Id.
DisablingSubscription revoked with reason notification_failures_exceeded.

verified Source: dev.twitch.tv/docs/eventsub/handling-webhook-events/

Event types

The event is in Twitch-Eventsub-Subscription-Type header (subscription.type in the body). Examples:

webhook_callback_verificationchannel.followstream.onlinechannel.subscribechannel.cheer

verified Source: dev.twitch.tv/docs/eventsub/eventsub-subscription-types/

Source IP addresses

Twitch EventSub doesn't publish the addresses it sends webhooks from, so verify the signature instead.

No source addresses are published.

An allow-list only filters traffic: proxies, CDNs and shared cloud addresses mean the signature is still the real proof of origin.

Common mistakes

  • Answering the verification challenge as JSON: return the bare challenge string as text/plain.
  • Parsing the timestamp as Unix time: it is RFC 3339 with nanoseconds.
  • Joining the signed parts with separators: id, timestamp and body are concatenated directly.
  • Slow handlers: repeated timeouts revoke the subscription.

Stuck? Point the webhook at a free webhook inspector bin: it detects Twitch EventSub, checks the signature with your secret in the browser and shows the expected value next to the received one.

Not confirmed in Twitch EventSub's documentation

  • The User-Agent shown is an example.

FAQ

How do I verify a Twitch EventSub webhook signature?

Compute HMAC-SHA256 over {Twitch-Eventsub-Message-Id}{Twitch-Eventsub-Message-Timestamp}{raw body} using subscription secret, encode it as hex and compare it in constant time with the value in Twitch-Eventsub-Message-Signature (format: sha256={signature}). Then reject requests whose timestamp is more than 10 minutes away from your clock.

How long does Twitch EventSub wait for a webhook response?

Respond within a few seconds (no exact figure is published). Success means: 2xx.

Does Twitch EventSub retry failed webhooks?

At-least-once delivery: Twitch resends when unsure you received a message. Too many slow or failed responses revoke the subscription (notification_failures_exceeded).

What IP addresses does Twitch EventSub send webhooks from?

No. No source addresses are published.

Why does my Twitch EventSub signature check fail?

Answering the verification challenge as JSON: return the bare challenge string as text/plain. Parsing the timestamp as Unix time: it is RFC 3339 with nanoseconds.

Other webhook providers

Stripe GitHub GitLab Bitbucket Cloud Shopify Slack Twilio Paddle Billing Zoom Linear Square PayPal SendGrid Mailgun Discord Svix Standard Webhooks Intercom HubSpot Jira Cloud Calendly Typeform