Verify a signed webhook
This guide explains how to authenticate an event received by webhook: reading the header, computing the signature, constant-time comparison, tolerance check and deduplication. Fictional Node.js example.
Updated on
The principle
Each event arrives with a Telqio-Signature header shaped like t=<unix timestamp>,v1=<hex signature>. The signature is an HMAC-SHA256 of the text “timestamp, dot, raw body”, computed with the endpoint secret.
Three checks are needed: the signature matches, the timestamp is within a 300-second tolerance, and the event id has not already been processed.
Before you start
- Node.js with its built-in modules. Nothing else to install.
- The endpoint secret, read from an environment variable. Never write it in code or expose it in the browser.
- Access to the raw request body, before any JSON parser touches it. A reformatted body changes the signature.
The verification function
The comparison uses timingSafeEqual so an attacker cannot guess the signature byte by byte by measuring response time. Lengths are checked first, because timingSafeEqual throws if they differ.
import crypto from 'node:crypto'
const TOLERANCE_SECONDS = 300
export function verifySignature(rawBody, header, secret, nowMs = Date.now()) {
if (typeof header !== 'string') return false
const parts = {}
for (const item of header.split(',')) {
const index = item.indexOf('=')
if (index > 0) parts[item.slice(0, index).trim()] = item.slice(index + 1).trim()
}
const timestamp = Number(parts.t)
if (!Number.isInteger(timestamp) || !parts.v1) return false
const ageSeconds = Math.abs(Math.floor(nowMs / 1000) - timestamp)
if (ageSeconds > TOLERANCE_SECONDS) return false
const signedPayload = Buffer.concat([Buffer.from(timestamp + '.'), rawBody])
const expected = crypto.createHmac('sha256', secret).update(signedPayload).digest()
const received = Buffer.from(parts.v1, 'hex')
return expected.length === received.length && crypto.timingSafeEqual(expected, received)
}The endpoint
The server below reads the raw body, verifies the signature, drops duplicates, then answers quickly. The in-memory Set is only for the example: in practice, keep processed identifiers in shared, durable storage.
import http from 'node:http'
import { verifySignature } from './verify.mjs'
const SECRET = process.env.TELQIO_WEBHOOK_SECRET
const seen = new Set()
http
.createServer((req, res) => {
if (req.method !== 'POST' || req.url !== '/webhook') {
res.writeHead(404).end()
return
}
const chunks = []
req.on('data', (chunk) => chunks.push(chunk))
req.on('end', () => {
const rawBody = Buffer.concat(chunks)
if (!verifySignature(rawBody, req.headers['telqio-signature'], SECRET)) {
res.writeHead(400).end('invalid signature')
return
}
const event = JSON.parse(rawBody.toString('utf8'))
if (seen.has(event.id)) {
res.writeHead(200).end('duplicate')
return
}
seen.add(event.id)
console.log('event received:', event.type, event.id)
res.writeHead(200).end('ok')
})
})
.listen(3000)Test it locally
- Export a test secret: export TELQIO_WEBHOOK_SECRET=secret_de_test_exemple, then run node server.mjs.
- In another terminal, sign a sample body with openssl and send it with curl.
- Run the same command again: the answer changes from ok to duplicate. Change one character of the body without recomputing the signature: the answer becomes invalid signature.
BODY='{"id":"evt_EXEMPLE_001","type":"call.completed","created":1794096000}'
T=$(date +%s)
SIG=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "secret_de_test_exemple" -hex | sed 's/^.* //')
curl -X POST http://localhost:3000/webhook \
-H "Content-Type: application/json" \
-H "Telqio-Signature: t=$T,v1=$SIG" \
-d "$BODY"Possible events
- call.ringing, call.connected, call.completed, call.failed
- message.sent, message.delivered, message.undelivered
- number.activated
Common errors
| Symptom | Likely cause | What to do |
|---|---|---|
| The signature is always rejected | The body was parsed and re-serialized by a JSON parser. | Verify against the bytes as received, before any parsing. |
| Rejected although the signature is right | Server clock drift: the gap exceeds 300 seconds. | Synchronize the clock (NTP). |
| Exception from timingSafeEqual | Different lengths, missing prior check. | Compare lengths first, as in the example. |
| Events processed twice | The delivery is replayed and the id is not deduplicated. | Store processed ids and answer 200 to duplicates. |
| Exposed secret | Secret in the repository, a log or the browser. | Replace it and read it on the server side only. |
What this guide does not do
- It does not prove your application is secure: this code is a starting point to review and test.
- It does not set the delivery retry policy, which will be bounded and visible in the console.
- It does not guarantee event order: a message.delivered may arrive before other events. Rely on identifiers and timestamps.
- No real webhook exists until a provider is configured.
Frequently asked questions
Why use the raw body?
The signature covers the exact bytes that were sent. A space or a key order changed by a JSON parser is enough to produce a different signature.
What should I answer for an event already processed?
A 200 status. The sender then treats the delivery as successful and stops replaying it, with no side effect on your side.