Skip to content

Developer

How to verify Standard Webhooks signatures in PHP, Node.js and Python

A step-by-step guide to HMAC-SHA256 webhook verification: signed content, raw-body pitfalls, constant-time checks, timestamps, rotation and a test vector.

A webhook endpoint is a public URL that changes data in your systems. Anyone who finds it can post to it. Signatures prove that a request came from the sender and was not changed on the way. This guide shows how to verify signatures that follow the Standard Webhooks scheme, as UPPU's webhooks do, with working code in PHP, Node.js and Python.

Why sign webhooks?

An unsigned webhook can be forged: a request that looks like "message.status: delivered" or "contact.erased" could come from anyone. HTTPS protects the connection, but it does not tell you who sent the request. A shared-secret signature (HMAC) does:

  • only someone who knows the secret can produce a valid signature;
  • changing a single byte of the body breaks it;
  • a timestamp inside the signed content limits replays of old requests.

Checking the source IP address helps as a second layer (UPPU's webhooks come from 91.216.36.26, and changes are announced 30 days ahead), but the signature is the real control.

The scheme: id, timestamp and body

Each request carries three headers. The signature covers the event ID, the timestamp and the exact bytes of the body, joined with dots:

Signed content
webhook-id: 01jab3t6y8q7z0k5w9x2c4d6ef
webhook-timestamp: 1760778902
webhook-signature: v1,bkasCZjmHFOH+EYlzJiVUXX0aoEvGPmlqK69s6np8Ss=

signed content = webhook-id + "." + webhook-timestamp + "." + raw body bytes
signature      = base64( HMAC-SHA256( key = base64decode(secret without "whsec_"), signed content ) )
  • The key is the secret without its whsec_ prefix, base64-decoded into raw bytes. Using the secret text itself as the key is the most common mistake.
  • The signature is HMAC-SHA256 over the signed content, encoded as standard (padded) base64, with the version prefix v1,.
  • Several signatures can arrive in one header, separated by a single space. Accept the request if any v1 value matches.

Raw-body pitfalls per framework

Verify the bytes you received, before any parsing. If your framework parses JSON first and you serialise it again, key order, spacing or escaping can change, and a valid signature will fail. How to get the raw body:

Reading the raw request body before parsing
StackRaw bodyWatch out for
PHPfile_get_contents('php://input')Do not rebuild the body from $_POST
Laravel, Symfony$request->getContent()Middleware that trims or converts input
Node.js, Expressexpress.raw({ type: 'application/json' }) on the webhook routeA global express.json() runs first and consumes the body
Next.js route handlerBuffer.from(await request.arrayBuffer())Calling request.json() before verifying
Flaskrequest.get_data()Reading request.json first
Djangorequest.bodyReading request.POST first
FastAPI, Starletteawait request.body()Declaring a Pydantic body model on the route

Compare in constant time

An ordinary string comparison returns as soon as the first byte differs. In theory, the time difference leaks how much of a forged signature is right. Use your language's constant-time comparison: hash_equals in PHP, crypto.timingSafeEqual in Node.js (on equal-length buffers) and hmac.compare_digest in Python.

Check the timestamp

Reject a request whose timestamp is not a whole number of seconds or is more than 5 minutes (300 seconds) away from your clock. This stops an attacker from replaying a captured request days later. Keep your server clock in sync (NTP). Note that webhook-timestamp is the time of this delivery attempt, not the time of the event: a retried event gets a new timestamp, while the event's own time is in the body's created_at.

Rotate secrets without downtime

During a rotation the sender signs with both secrets for a while. With UPPU, the old secret keeps signing for 24 hours and both signatures are sent, newest first: v1,<new> v1,<old>. Your receiver should accept a list of secrets, as the functions below do: deploy the new secret next to the old one, rotate, then remove the old one once it is no longer used.

De-duplicate by webhook-id

Webhooks are delivered at least once. A network timeout after you processed an event leads to a retry of the same event. The webhook-id header (equal to the body's id) stays the same across retries and manual resends, so use it as an idempotency key: store processed IDs and skip repeats. With UPPU, an event can be retried for about 76 hours and resent from the panel within 30 days, so keep IDs for at least 35 days.

Answer with a 2xx status quickly, within 10 seconds, and do the work in a queue. A slow handler causes timeouts, and timeouts cause retries.

The code

PHP

PHP 8.0+no dependencies
<?php
function uppu_verify_webhook(array $secrets, string $id, string $timestamp, string $body, string $signatureHeader, int $tolerance = 300): bool
{
    if ($id === '' || !ctype_digit($timestamp) || abs(time() - (int) $timestamp) > $tolerance) {
        return false;
    }
    $signed = $id . '.' . $timestamp . '.' . $body;
    foreach ($secrets as $secret) {
        if (!is_string($secret) || strncmp($secret, 'whsec_', 6) !== 0) {
            continue;
        }
        $key = base64_decode(substr($secret, 6), true);          // raw bytes, not the text
        if ($key === false || $key === '') {
            continue;
        }
        $expected = base64_encode(hash_hmac('sha256', $signed, $key, true));
        foreach (explode(' ', $signatureHeader) as $part) {
            [$version, $signature] = array_pad(explode(',', $part, 2), 2, '');
            if ($version === 'v1' && hash_equals($expected, $signature)) {
                return true;
            }
        }
    }
    return false;
}

$body = file_get_contents('php://input');                        // raw body, unmodified
$h = array_change_key_case(getallheaders(), CASE_LOWER);
$secrets = array_filter([getenv('UPPU_WEBHOOK_SECRET') ?: '', getenv('UPPU_WEBHOOK_SECRET_OLD') ?: '']);
if (!uppu_verify_webhook($secrets, $h['webhook-id'] ?? '', $h['webhook-timestamp'] ?? '', (string) $body, $h['webhook-signature'] ?? '')) {
    http_response_code(401);                                     // the activation probe expects a non-2xx here
    exit;
}
$event = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
// 1) drop repeats by $event['id']  2) queue the work  3) answer quickly
http_response_code(204);

Node.js

Node.js 18+built-in modules only
import { createServer } from 'node:http';
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifyUppuWebhook(secrets, id, timestamp, rawBody, signatureHeader, toleranceSec = 300) {
  if (!id || !/^[0-9]+$/.test(timestamp ?? '')) return false;
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSec) return false;
  const signed = Buffer.concat([Buffer.from(`${id}.${timestamp}.`, 'utf8'), rawBody]);
  const given = (signatureHeader ?? '').split(' ').flatMap((part) => {
    const comma = part.indexOf(',');
    return comma > 0 && part.slice(0, comma) === 'v1' ? [Buffer.from(part.slice(comma + 1), 'utf8')] : [];
  });
  for (const secret of secrets) {
    if (typeof secret !== 'string' || !secret.startsWith('whsec_')) continue;
    const key = Buffer.from(secret.slice(6), 'base64');
    if (key.length === 0) continue;
    const expected = Buffer.from(createHmac('sha256', key).update(signed).digest('base64'), 'utf8');
    if (given.some((sig) => sig.length === expected.length && timingSafeEqual(sig, expected))) return true;
  }
  return false;
}

createServer((req, res) => {
  const chunks = [];
  req.on('data', (chunk) => chunks.push(chunk));
  req.on('end', () => {
    const raw = Buffer.concat(chunks);                            // raw bytes; do not re-serialise
    const ok = verifyUppuWebhook([process.env.UPPU_WEBHOOK_SECRET], req.headers['webhook-id'],
      req.headers['webhook-timestamp'], raw, req.headers['webhook-signature']);
    if (!ok) { res.writeHead(401).end(); return; }
    const event = JSON.parse(raw.toString('utf8'));
    // drop repeats by event.id, queue the work, then:
    res.writeHead(204).end();
  });
}).listen(8080);

Python

Python 3.9+standard library; checked against the test vector
import base64
import hashlib
import hmac
import re
import time


def verify_uppu_webhook(secrets, msg_id, timestamp, raw_body, signature_header, tolerance=300, now=None):
    if not msg_id or not timestamp or not re.fullmatch(r"[0-9]+", timestamp):
        return False
    if abs((time.time() if now is None else now) - int(timestamp)) > tolerance:
        return False
    signed = f"{msg_id}.{timestamp}.".encode("utf-8") + raw_body
    given = []
    for part in (signature_header or "").split(" "):
        version, _, signature = part.partition(",")
        if version == "v1" and signature:
            given.append(signature.encode("utf-8"))
    for secret in secrets:
        if not secret or not secret.startswith("whsec_"):
            continue
        key = base64.b64decode(secret[len("whsec_"):])
        expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest())
        if any(hmac.compare_digest(expected, signature) for signature in given):
            return True
    return False

With Flask, read the raw body before anything parses it:

Python · Flaskuse the raw body
import json
import os

from flask import Flask, request

app = Flask(__name__)


@app.post("/uppu/webhook")
def uppu_webhook():
    raw = request.get_data()  # raw bytes, read before any JSON parsing
    ok = verify_uppu_webhook(
        [os.environ["UPPU_WEBHOOK_SECRET"], os.environ.get("UPPU_WEBHOOK_SECRET_OLD", "")],
        request.headers.get("webhook-id", ""),
        request.headers.get("webhook-timestamp", ""),
        raw,
        request.headers.get("webhook-signature", ""),
    )
    if not ok:
        return "", 401
    event = json.loads(raw)
    # drop repeats by event["id"], queue the work, then answer quickly
    return "", 204

A test vector

Test your function with fixed values before you connect a real endpoint. The secrets below are invented for documentation; they are not real secrets.

Test vector for webhook signature verification
ItemValue
Secretwhsec_dXBwdS1leGFtcGxlLXNpZ25pbmcta2V5LTMyYnl0ZXM=
Old secretwhsec_dXBwdS1leGFtcGxlLU9MRC1zaWduaW5nLWtleS0zMmI=
webhook-id01jab3t6y8q7z0k5w9x2c4d6ef
webhook-timestamp1760778902 (use it as "now" in tests)
Expected signaturev1,bkasCZjmHFOH+EYlzJiVUXX0aoEvGPmlqK69s6np8Ss=
Rotation header (both secrets verify)v1,bkasCZjmHFOH+EYlzJiVUXX0aoEvGPmlqK69s6np8Ss= v1,x8egiBglmqEXbprO/7Z87AKPhtamIkB1DSxLS6Y6JkA=
Must failThe body with one extra space; the timestamp plus 301 seconds; an empty webhook-id

The body is 277 bytes of UTF-8. Note the Turkish letters in "UPPU test olayı": they make sure your code signs bytes, not characters.

Python · test vectorexpected result: True, then False
SECRET = "whsec_dXBwdS1leGFtcGxlLXNpZ25pbmcta2V5LTMyYnl0ZXM="
BODY = ('{"id":"01jab3t6y8q7z0k5w9x2c4d6ef","object":"event","type":"webhook.test","api_version":"v1",'
        '"created_at":"2026-10-18T09:15:02.481Z","team_id":"01j8x7tm5k2m4n6p8q0r2s4t6v","channel":null,'
        '"test":true,"data":{"note":"UPPU test olayı","endpoint_id":"01jab3ep5k2m4n6p8q0r2s4t6v"}}').encode("utf-8")

print(verify_uppu_webhook([SECRET], "01jab3t6y8q7z0k5w9x2c4d6ef", "1760778902", BODY,
                          "v1,bkasCZjmHFOH+EYlzJiVUXX0aoEvGPmlqK69s6np8Ss=", now=1760778902))   # True
print(verify_uppu_webhook([SECRET], "01jab3t6y8q7z0k5w9x2c4d6ef", "1760778902", BODY + b" ",
                          "v1,bkasCZjmHFOH+EYlzJiVUXX0aoEvGPmlqK69s6np8Ss=", now=1760778902))   # False

Common mistakes

  1. Verifying parsed JSON. Always verify the raw bytes.
  2. Using the secret text as the key. Strip whsec_ and base64-decode it first.
  3. Answering 2xx to a bad signature. Return 401 or 403. UPPU only activates an endpoint after a deliberately mis-signed test event gets a non-2xx answer.
  4. Checking only the first signature. During a rotation, the old secret's signature may be second.
  5. Doing the work before answering. Queue it; answer within the timeout.
  6. Treating a duplicate as an error. Answer 2xx to a repeat you have already processed, otherwise it will be retried.
  7. Following redirects. Configure the final HTTPS URL; UPPU does not follow redirects, and a 3xx counts as a failure.

Webhooks in UPPU

UPPU's webhooks are signed with Standard Webhooks (HMAC-SHA256). Events are delivered at least once and in order per contact; an event is delivered in up to 12 attempts over about 76 hours, and you can resend events from the panel within 30 days. Every request comes from 91.216.36.26. The webhook guide lists the event types, the retry schedule and the pull fallback.

Sources

  1. Standard Webhooks specification header names, signed content and secret format (accessed 22 October 2026)
  2. UPPU webhooks: events, delivery rules and verification event types, retry schedule, pull fallback
  3. UPPU API error codes (accessed 22 October 2026)