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:
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
v1value 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:
| Stack | Raw body | Watch out for |
|---|---|---|
| PHP | file_get_contents('php://input') | Do not rebuild the body from $_POST |
| Laravel, Symfony | $request->getContent() | Middleware that trims or converts input |
| Node.js, Express | express.raw({ type: 'application/json' }) on the webhook route | A global express.json() runs first and consumes the body |
| Next.js route handler | Buffer.from(await request.arrayBuffer()) | Calling request.json() before verifying |
| Flask | request.get_data() | Reading request.json first |
| Django | request.body | Reading request.POST first |
| FastAPI, Starlette | await 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
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
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
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 FalseWith Flask, read the raw body before anything parses it:
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 "", 204A 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.
| Item | Value |
|---|---|
| Secret | whsec_dXBwdS1leGFtcGxlLXNpZ25pbmcta2V5LTMyYnl0ZXM= |
| Old secret | whsec_dXBwdS1leGFtcGxlLU9MRC1zaWduaW5nLWtleS0zMmI= |
webhook-id | 01jab3t6y8q7z0k5w9x2c4d6ef |
webhook-timestamp | 1760778902 (use it as "now" in tests) |
| Expected signature | v1,bkasCZjmHFOH+EYlzJiVUXX0aoEvGPmlqK69s6np8Ss= |
| Rotation header (both secrets verify) | v1,bkasCZjmHFOH+EYlzJiVUXX0aoEvGPmlqK69s6np8Ss= v1,x8egiBglmqEXbprO/7Z87AKPhtamIkB1DSxLS6Y6JkA= |
| Must fail | The 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.
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)) # FalseCommon mistakes
- Verifying parsed JSON. Always verify the raw bytes.
- Using the secret text as the key. Strip
whsec_and base64-decode it first. - 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.
- Checking only the first signature. During a rotation, the old secret's signature may be second.
- Doing the work before answering. Queue it; answer within the timeout.
- Treating a duplicate as an error. Answer 2xx to a repeat you have already processed, otherwise it will be retried.
- 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
- Standard Webhooks specification header names, signed content and secret format (accessed 22 October 2026)
- UPPU webhooks: events, delivery rules and verification event types, retry schedule, pull fallback
- UPPU API error codes (accessed 22 October 2026)