Verifying signatures
Every delivery carries Nexafon-Signature: t=<timestamp>,v1=<signature>, where the signature is the hex
HMAC-SHA256 of "<timestamp>.<raw body>" keyed with the endpoint’s secret (whsec_…). Check it before trusting
anything in the body.
- Read the raw body, exactly as received. Parsing and re-serialising JSON changes the bytes.
- Split the header on
,: taketand everyv1. - Reject the delivery if
tis more than 5 minutes from your clock (it stops replays of captured deliveries). - Compute HMAC-SHA256 of
t + "." + bodywith your secret; compare it with eachv1in constant time. - Answer
400if nothing matches. Then dedupe on the eventid.
With the helpers
Section titled “With the helpers”The server helpers do all of this in one call and return the parsed event.
if (req.method === 'POST' && req.url === '/hooks/nexafon') { try { // The raw body, exactly as received: parsing it first changes the bytes the signature covers. const event = verifyWebhook( req.headers, await rawBody(req), process.env.NEXAFON_WEBHOOK_SECRET ?? '', ) // Deliveries can repeat: skip an event.id you have already handled. console.log(event.type, event.id) res.writeHead(204).end() } catch (err) { if (!(err instanceof WebhookVerificationError)) throw err res.writeHead(400).end() } return}// The raw body, exactly as received: parsing it first changes the bytes the signature covers.$body = file_get_contents('php://input');try { $event = Webhook::verify(getallheaders(), $body, getenv('NEXAFON_WEBHOOK_SECRET'));} catch (WebhookVerificationException) { http_response_code(400); exit;}// Deliveries can repeat: skip an $event['id'] you have already handled.error_log($event['type'] . ' ' . $event['id']);http_response_code(204);if self.path == "/hooks/nexafon": # The raw body, exactly as received: parsing it first changes the bytes the signature covers. raw = self.rfile.read(int(self.headers.get("Content-Length", 0))) try: event = nexafon.verify_webhook(dict(self.headers), raw, os.environ["NEXAFON_WEBHOOK_SECRET"]) except nexafon.WebhookVerificationError: self.send_response(400) self.end_headers() return # Deliveries can repeat: skip an event["id"] you have already handled. print(event["type"], event["id"]) self.send_response(204) self.end_headers() returnfunc nexafonWebhook(w http.ResponseWriter, r *http.Request) { // The raw body, exactly as received: parsing it first changes the bytes the signature covers. body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20)) if err != nil { w.WriteHeader(http.StatusBadRequest) return } ev, err := nexafon.VerifyWebhook(r.Header, body, os.Getenv("NEXAFON_WEBHOOK_SECRET")) if errors.Is(err, nexafon.ErrInvalidWebhook) { w.WriteHeader(http.StatusBadRequest) return } if err != nil { w.WriteHeader(http.StatusInternalServerError) return } // Deliveries can repeat: skip an ev.ID you have already handled. log.Println(ev.Type, ev.ID) w.WriteHeader(http.StatusNoContent)}Without a helper
Section titled “Without a helper”Self-contained functions you can copy; Nexafon’s backend tests run each of them against real signed deliveries.
import { createHmac, timingSafeEqual } from 'node:crypto'
// Pass the raw request body exactly as received, before any JSON parsing.export function verifyNexafonWebhook(rawBody, signatureHeader, secret, toleranceSeconds = 300) { let timestamp = '' const signatures = [] for (const part of signatureHeader.split(',')) { const [key, value] = part.trim().split('=') if (key === 't') timestamp = value if (key === 'v1') signatures.push(value) } if (!/^\d+$/.test(timestamp)) return false if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) return false const expected = createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest() return signatures.some((signature) => { const given = Buffer.from(signature, 'hex') return given.length === expected.length && timingSafeEqual(given, expected) })}<?php
// Pass the raw request body exactly as received: file_get_contents('php://input').function verify_nexafon_webhook(string $rawBody, string $signatureHeader, string $secret, int $toleranceSeconds = 300): bool{ $timestamp = ''; $signatures = []; foreach (explode(',', $signatureHeader) as $part) { $pair = explode('=', trim($part), 2); if (count($pair) !== 2) { continue; } if ($pair[0] === 't') { $timestamp = $pair[1]; } elseif ($pair[0] === 'v1') { $signatures[] = $pair[1]; } } if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > $toleranceSeconds) { return false; } $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret); foreach ($signatures as $signature) { if (hash_equals($expected, $signature)) { return true; } } return false;}import hashlibimport hmacimport time
def verify_nexafon_webhook(raw_body: bytes, signature_header: str, secret: str, tolerance_seconds: int = 300) -> bool: """Pass the raw request body exactly as received, before any JSON parsing.""" timestamp = "" signatures = [] for part in signature_header.split(","): key, _, value = part.strip().partition("=") if key == "t": timestamp = value elif key == "v1": signatures.append(value) if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > tolerance_seconds: return False expected = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest() return any(hmac.compare_digest(expected, signature) for signature in signatures)package nexafon
import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "strconv" "strings" "time")
// VerifyWebhook checks the Nexafon-Signature header against the raw request body, exactly as received.func VerifyWebhook(rawBody []byte, signatureHeader, secret string, tolerance time.Duration) bool { var timestamp string var signatures []string for _, part := range strings.Split(signatureHeader, ",") { key, value, _ := strings.Cut(strings.TrimSpace(part), "=") switch key { case "t": timestamp = value case "v1": signatures = append(signatures, value) } } seconds, err := strconv.ParseInt(timestamp, 10, 64) if err != nil { return false } if age := time.Since(time.Unix(seconds, 0)); age > tolerance || age < -tolerance { return false } mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(timestamp + ".")) mac.Write(rawBody) expected := mac.Sum(nil) for _, signature := range signatures { given, err := hex.DecodeString(signature) if err == nil && hmac.Equal(given, expected) { return true } } return false}Rotating the secret
Section titled “Rotating the secret”POST …/webhook-endpoints/{endpointId}/rotate-secret returns a new secret. For keepPreviousHours (default 24, at
most 168) every delivery is signed with both secrets: the header then carries two v1 values, and a receiver
holding either one verifies it. Deploy the new secret within that window; the helpers accept a list of secrets
meanwhile.