Verify webhook signatures

Check the Teyra-Signature header with HMAC-SHA256 to prove a webhook came from Teyra Pay and wasn't changed.

Anyone can send a POST to your webhook URL. Before you trust an event, verify the Teyra-Signature header with your endpoint's signing secret (whsec_…).

The signature header

Teyra-Signature: t=1730124420,v1=6c0f2b8e3c…
PartMeaning
tUnix timestamp (seconds) when Teyra Pay signed this attempt.
v1Hex-encoded HMAC-SHA256 of the signed payload, keyed with your endpoint secret.

The signed payload is the timestamp, a dot, and the raw request body, exactly as received:

signed_payload = "<t>.<raw body>"
v1             = hex( HMAC_SHA256( key = endpoint secret, message = signed_payload ) )

Use the whole secret string as the key, including the whsec_ prefix.

Verification steps

  1. Read the raw body before any JSON parsing. Re-serialized JSON won't match byte for byte.
  2. Split the header on , and each part on the first =. Take t and every v1 value.
  3. Reject the request if t is more than 5 minutes away from your server's clock. This blocks replayed requests.
  4. Compute the expected v1 and compare it with a constant-time comparison.
  5. Only then parse the JSON and process the event.

Code

import crypto from 'node:crypto';
import express from 'express';

const WEBHOOK_SECRET = process.env.TEYRA_WEBHOOK_SECRET; // whsec_...
const TOLERANCE_SECONDS = 300;

export function verifyTeyraSignature(rawBody, header, secret, now = Date.now()) {
  if (!header) return false;
  let timestamp = null;
  const signatures = [];
  for (const part of header.split(',')) {
    const i = part.indexOf('=');
    if (i === -1) continue;
    const key = part.slice(0, i).trim();
    const value = part.slice(i + 1).trim();
    if (key === 't') timestamp = value;
    else if (key === 'v1') signatures.push(value);
  }
  if (!timestamp || !/^\d+$/.test(timestamp) || signatures.length === 0) return false;
  if (Math.abs(Math.floor(now / 1000) - Number(timestamp)) > TOLERANCE_SECONDS) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.`)
    .update(rawBody) // Buffer with the exact bytes received
    .digest();

  return signatures.some((sig) => {
    const given = Buffer.from(sig, 'hex');
    return given.length === expected.length && crypto.timingSafeEqual(given, expected);
  });
}

const app = express();

// express.raw keeps the body as a Buffer. Don't use express.json() on this route.
app.post('/hooks/teyra', express.raw({ type: 'application/json' }), async (req, res) => {
  if (!verifyTeyraSignature(req.body, req.get('Teyra-Signature'), WEBHOOK_SECRET)) {
    return res.status(400).send('Invalid signature');
  }
  const event = JSON.parse(req.body.toString('utf8'));

  if (await alreadyProcessed(event.id)) return res.sendStatus(200);
  await queueEventForProcessing(event); // do slow work in the background
  res.sendStatus(200);
});

Common mistakes

  • Parsing before verifying. Frameworks that parse JSON automatically (express.json(), Laravel's $request->all(), Django REST Framework) change whitespace and key order. Verify against the raw bytes.
  • Using the wrong secret. Each endpoint has its own secret, and test-mode and live-mode endpoints are separate.
  • Comparing with ==. Use crypto.timingSafeEqual, hash_equals or hmac.compare_digest.
  • Server clock drift. Keep your server's clock in sync (NTP), or the 5-minute tolerance check rejects valid events.

Test your verification

Use Send test event in Developers → Webhooks to send a signed payment.succeeded event to your endpoint, then check the delivery's response status in the deliveries list. See Testing from the dashboard.