Skip to content

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.

  1. Read the raw body, exactly as received. Parsing and re-serialising JSON changes the bytes.
  2. Split the header on ,: take t and every v1.
  3. Reject the delivery if t is more than 5 minutes from your clock (it stops replays of captured deliveries).
  4. Compute HMAC-SHA256 of t + "." + body with your secret; compare it with each v1 in constant time.
  5. Answer 400 if nothing matches. Then dedupe on the event id.

The server helpers do all of this in one call and return the parsed event.

Node
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
}
PHP
// 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);
Python
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()
return
Go
func 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)
}

Self-contained functions you can copy; Nexafon’s backend tests run each of them against real signed deliveries.

verify.mjs
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)
})
}
verify.php
<?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;
}
verify.py
import hashlib
import hmac
import 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)
verify.go
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
}

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.