usend Docs
Webhooks

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:

HeaderExample
webhook-idmsg_2b7c4a51-1f0e-4f6e-9c8a-7d2f3e9a6b10
webhook-timestamp1760173967
webhook-signaturev1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=

To verify:

  1. Take the signing secret, remove the whsec_ prefix and base64-decode the rest. This is the HMAC key.
  2. Build the signed content: {webhook-id}.{webhook-timestamp}.{body}, where body is the raw request body, byte for byte.
  3. Compute HMAC-SHA256 of the signed content with the key, and base64-encode it.
  4. webhook-signature contains one or more space-separated signatures, each v1,<base64>. The request is valid if one of the v1 signatures equals yours. Use a constant-time comparison.
  5. Reject requests whose webhook-timestamp is too far from your current time (for example, more than 5 minutes) to prevent replay attacks.
Warning

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:

Node.js
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);
});

Both 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

Node.js (Express)
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

Python (Flask)
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 "", 200

Rotate 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.