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…| Part | Meaning |
|---|---|
t | Unix timestamp (seconds) when Teyra Pay signed this attempt. |
v1 | Hex-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
- Read the raw body before any JSON parsing. Re-serialized JSON won't match byte for byte.
- Split the header on
,and each part on the first=. Taketand everyv1value. - Reject the request if
tis more than 5 minutes away from your server's clock. This blocks replayed requests. - Compute the expected
v1and compare it with a constant-time comparison. - 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
==. Usecrypto.timingSafeEqual,hash_equalsorhmac.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.