Verify signatures
Check that a webhook request really comes from usend.
Anyone can send a request to your webhook endpoint. Every usend webhook is signed with your webhook's signing_secret, following the Standard Webhooks specification. Verify the signature before you trust the payload.
The algorithm
Each request carries three headers:
| Header | Example |
|---|---|
webhook-id | msg_2b7c4a51-1f0e-4f6e-9c8a-7d2f3e9a6b10 |
webhook-timestamp | 1760173967 |
webhook-signature | v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4= |
To verify:
- Take the signing secret, remove the
whsec_prefix and base64-decode the rest. This is the HMAC key. - Build the signed content:
{webhook-id}.{webhook-timestamp}.{body}, wherebodyis the raw request body, byte for byte. - Compute HMAC-SHA256 of the signed content with the key, and base64-encode it.
webhook-signaturecontains one or more space-separated signatures, eachv1,<base64>. The request is valid if one of thev1signatures equals yours. Use a constant-time comparison.- Reject requests whose
webhook-timestampis too far from your current time (for example, more than 5 minutes) to prevent replay attacks.
Verify against the raw body. If your framework parses the JSON and you re-serialize it, the bytes change and the signature will not match.
Using the SDK
The usend SDKs verify the signature and the timestamp for you, and return the parsed event. Pass the raw request body:
import express from 'express';
import { verifyWebhook } from '@usend/node';
const app = express();
app.post('/webhooks/usend', express.raw({ type: 'application/json' }), async (req, res) => {
let event;
try {
// Throws if the signature or timestamp is invalid.
event = await verifyWebhook({
payload: req.body.toString('utf8'),
headers: req.headers,
secret: process.env.USEND_WEBHOOK_SECRET,
});
} catch {
return res.status(400).send('Invalid signature');
}
// Store the event, then process it in the background.
res.sendStatus(200);
});import os
from flask import Flask, abort, request
from usend import UsendError, verify_webhook
app = Flask(__name__)
@app.post("/webhooks/usend")
def usend_webhook():
try:
# Raises UsendError if the signature or timestamp is invalid.
event = verify_webhook(
request.get_data(as_text=True),
request.headers,
os.environ["USEND_WEBHOOK_SECRET"],
)
except UsendError:
abort(400)
# Store the event, then process it in the background.
return "", 200Both reject timestamps more than 5 minutes away from your clock.
If you don't use the SDK, the examples below implement the same checks. Any Standard Webhooks library also works with your whsec_ secret.
Without the SDK: Node.js
import crypto from 'node:crypto';
import express from 'express';
const secret = process.env.USEND_WEBHOOK_SECRET; // whsec_...
function verify(rawBody, headers) {
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const signatures = headers['webhook-signature'];
if (!id || !timestamp || !signatures) return false;
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(age) || age > 5 * 60) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
const expected = crypto
.createHmac('sha256', key)
.update(`${id}.${timestamp}.`)
.update(rawBody)
.digest('base64');
return signatures.split(' ').some((entry) => {
const [version, signature] = entry.split(',');
if (version !== 'v1' || !signature) return false;
const a = Buffer.from(signature);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
});
}
const app = express();
app.post('/webhooks/usend', express.raw({ type: 'application/json' }), (req, res) => {
if (!verify(req.body, req.headers)) {
return res.status(400).send('Invalid signature');
}
const event = JSON.parse(req.body.toString('utf8'));
// Store the event, then process it in the background.
res.sendStatus(200);
});
app.listen(3000);Without the SDK: Python
import base64
import hashlib
import hmac
import json
import os
import time
from flask import Flask, abort, request
SECRET = os.environ["USEND_WEBHOOK_SECRET"] # whsec_...
app = Flask(__name__)
def verify(body: bytes, headers) -> bool:
msg_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp")
signatures = headers.get("webhook-signature")
if not msg_id or not timestamp or not signatures:
return False
try:
if abs(time.time() - int(timestamp)) > 5 * 60:
return False
except ValueError:
return False
key = base64.b64decode(SECRET.removeprefix("whsec_"))
signed = f"{msg_id}.{timestamp}.".encode() + body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
for entry in signatures.split(" "):
version, _, signature = entry.partition(",")
if version == "v1" and hmac.compare_digest(signature, expected):
return True
return False
@app.post("/webhooks/usend")
def usend_webhook():
body = request.get_data()
if not verify(body, request.headers):
abort(400)
event = json.loads(body)
# Store the event, then process it in the background.
return "", 200Rotate the secret
The signing secret is shown on the webhook in the dashboard and returned by GET /webhooks/{id}. To change it, create a new webhook for the same endpoint, update your application to accept the new secret, then delete the old webhook.