Verifying webhook signatures
Hapana signs every webhook delivery with HMAC-SHA256 using your webhook's signing secret. Verify the signature before you trust the payload — it proves the request came from Hapana and wasn't altered.
The signature header
Every delivery carries two headers you need:
X-Hapana-Timestamp: 1790982502
X-Hapana-Signature: t=1790982502,v1=48373c7bd723348cf7520b12718567bdd0950ea5f913dbc30177a0287c732c27X-Hapana-Signature is a comma-separated list of key=value pairs: t is the Unix timestamp (seconds) the attempt was signed at — the same value as X-Hapana-Timestamp — and v1 is the lowercase hex HMAC-SHA256 signature. Parse the pairs rather than relying on their position, and ignore keys you don't recognise.
How to verify
- Read the raw request body exactly as received. Don't parse and re-serialise the JSON first — any change to whitespace or key order breaks the signature.
- Parse
tandv1fromX-Hapana-Signature. - Build the signed payload:
${t}.${rawBody}— the timestamp, a literal period, then the raw body. - Compute
HMAC-SHA256of that payload, keyed with your signing secret, and hex-encode it. Use the whole secret string, including thewhsec_prefix, as UTF-8 bytes — don't strip the prefix or hex-decode it. - Compare your result with
v1using a constant-time comparison. - Reject the request if
tis more than 5 minutes from your server's clock. The timestamp is part of the signed payload, so it can't be altered without breaking the signature.
Respond 400 or 401 to a request that fails verification, and don't process it. Retries are re-signed with a fresh timestamp, so a legitimate retry never trips the 5-minute window.
Node.js
import crypto from 'node:crypto'
import express from 'express'
const SECRET = process.env.HAPANA_WEBHOOK_SECRET // whsec_...
const TOLERANCE_SECONDS = 300
function parseSignature(header) {
const parts = { t: null, v1: [] }
for (const pair of header.split(',')) {
const [key, value] = pair.trim().split('=', 2)
if (key === 't') parts.t = value
if (key === 'v1') parts.v1.push(value)
}
return parts
}
export function verifyHapanaSignature(rawBody, header, secret = SECRET) {
const { t, v1 } = parseSignature(header ?? '')
if (!t || v1.length === 0) return false
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(t))
if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false
const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.`)
.update(rawBody) // Buffer — the exact bytes received
.digest()
return v1.some((sig) => {
const received = Buffer.from(sig, 'hex')
return received.length === expected.length && crypto.timingSafeEqual(received, expected)
})
}
const app = express()
// express.raw keeps the body as a Buffer. express.json() would re-serialise it
// and break the signature, so don't mount it on this route.
app.post('/hapana/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifyHapanaSignature(req.body, req.get('X-Hapana-Signature'))) {
return res.status(400).send('invalid signature')
}
const event = JSON.parse(req.body.toString('utf8'))
enqueue(event) // process asynchronously
res.sendStatus(200)
})Python
import hashlib
import hmac
import os
import time
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["HAPANA_WEBHOOK_SECRET"] # whsec_...
TOLERANCE_SECONDS = 300
def verify_hapana_signature(raw_body: bytes, header: str, secret: str = SECRET) -> bool:
timestamp, signatures = None, []
for pair in header.split(","):
key, _, value = pair.strip().partition("=")
if key == "t":
timestamp = value
elif key == "v1":
signatures.append(value)
if not timestamp or not signatures:
return False
try:
if abs(int(time.time()) - int(timestamp)) > TOLERANCE_SECONDS:
return False
except ValueError:
return False
signed_payload = timestamp.encode() + b"." + raw_body
expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, sig) for sig in signatures)
@app.post("/hapana/webhooks")
def hapana_webhook():
# get_data() returns the raw bytes — verify before calling get_json()
raw = request.get_data()
if not verify_hapana_signature(raw, request.headers.get("X-Hapana-Signature", "")):
abort(400)
event = request.get_json()
enqueue(event) # process asynchronously
return "", 200Ruby
require 'json'
require 'openssl'
require 'rack/utils'
require 'sinatra'
SECRET = ENV.fetch('HAPANA_WEBHOOK_SECRET') # whsec_...
TOLERANCE_SECONDS = 300
def verify_hapana_signature(raw_body, header, secret = SECRET)
pairs = header.to_s.split(',').map { |pair| pair.strip.split('=', 2) }
timestamp = pairs.find { |key, _| key == 't' }&.last
signatures = pairs.select { |key, _| key == 'v1' }.map(&:last)
return false if timestamp.nil? || signatures.empty?
return false if (Time.now.to_i - Integer(timestamp, exception: false).to_i).abs > TOLERANCE_SECONDS
expected = OpenSSL::HMAC.hexdigest('SHA256', secret, "#{timestamp}.#{raw_body}")
signatures.any? { |sig| Rack::Utils.secure_compare(expected, sig) }
end
post '/hapana/webhooks' do
raw = request.body.read
halt 400, 'invalid signature' unless verify_hapana_signature(raw, request.env['HTTP_X_HAPANA_SIGNATURE'])
event = JSON.parse(raw)
enqueue(event) # process asynchronously
status 200
endGo
package webhooks
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
const toleranceSeconds = 300
var secret = []byte(os.Getenv("HAPANA_WEBHOOK_SECRET")) // whsec_...
func VerifyHapanaSignature(rawBody []byte, header string, secret []byte) bool {
var timestamp string
var signatures []string
for _, pair := range strings.Split(header, ",") {
key, value, ok := strings.Cut(strings.TrimSpace(pair), "=")
if !ok {
continue
}
switch key {
case "t":
timestamp = value
case "v1":
signatures = append(signatures, value)
}
}
if timestamp == "" || len(signatures) == 0 {
return false
}
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return false
}
if age := time.Now().Unix() - ts; age > toleranceSeconds || age < -toleranceSeconds {
return false
}
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(timestamp + "."))
mac.Write(rawBody)
expected := mac.Sum(nil)
for _, sig := range signatures {
received, err := hex.DecodeString(sig)
if err == nil && hmac.Equal(received, expected) {
return true
}
}
return false
}
func Handler(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "read body", http.StatusBadRequest)
return
}
if !VerifyHapanaSignature(body, r.Header.Get("X-Hapana-Signature"), secret) {
http.Error(w, "invalid signature", http.StatusBadRequest)
return
}
enqueue(body) // process asynchronously
w.WriteHeader(http.StatusOK)
}PHP
<?php
const TOLERANCE_SECONDS = 300;
function verify_hapana_signature(string $rawBody, string $header, string $secret): bool
{
$timestamp = null;
$signatures = [];
foreach (explode(',', $header) as $pair) {
$parts = explode('=', trim($pair), 2);
if (count($parts) !== 2) {
continue;
}
[$key, $value] = $parts;
if ($key === 't') {
$timestamp = $value;
} elseif ($key === 'v1') {
$signatures[] = $value;
}
}
if ($timestamp === null || !ctype_digit($timestamp) || !$signatures) {
return false;
}
if (abs(time() - (int) $timestamp) > TOLERANCE_SECONDS) {
return false;
}
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) {
return true;
}
}
return false;
}
$secret = getenv('HAPANA_WEBHOOK_SECRET'); // whsec_...
$raw = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_HAPANA_SIGNATURE'] ?? '';
if (!verify_hapana_signature($raw, $header, $secret)) {
http_response_code(400);
exit('invalid signature');
}
$event = json_decode($raw, true);
enqueue($event); // process asynchronously
http_response_code(200);Test vector
Use these values to unit-test your verifier. With your clock check disabled (or the clock pinned to the timestamp), it must accept:
| Input | Value |
|---|---|
| Secret | whsec_5f2b0c1d9e8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d2e1f0a9b8c7d6e5f4a3b2c |
| Timestamp | 1790982502 |
Raw body (a single line, no trailing newline):
{"id":"evt_7c1e1c52-3f0a-4a8e-9a43-2f5b1d0e6c11","object":"event","type":"booking.created","apiVersion":"2026-01-10","createdAt":"2026-10-02T23:08:22.481Z","livemode":true,"data":{"bookingId":"b3f1c2a4-5d6e-4f70-8a91-0b2c3d4e5f60"}}Expected header:
X-Hapana-Signature: t=1790982502,v1=48373c7bd723348cf7520b12718567bdd0950ea5f913dbc30177a0287c732c27You can reproduce it from a shell:
printf '%s.%s' "1790982502" '{"id":"evt_7c1e1c52-3f0a-4a8e-9a43-2f5b1d0e6c11","object":"event","type":"booking.created","apiVersion":"2026-01-10","createdAt":"2026-10-02T23:08:22.481Z","livemode":true,"data":{"bookingId":"b3f1c2a4-5d6e-4f70-8a91-0b2c3d4e5f60"}}' \
| openssl dgst -sha256 -hmac "whsec_5f2b0c1d9e8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d2e1f0a9b8c7d6e5f4a3b2c"Common mistakes
- Verifying a re-serialised body. Most frameworks parse JSON automatically. Capture the raw bytes for the webhook route (
express.raw,request.get_data(),php://input) and verify those. - Comparing the whole header. The header is
t=<timestamp>,v1=<signature>. Parse out thev1value; don't compare against the full string or strip a fixed prefix. - Signing only the body. The signed payload is
timestamp + "." + body, not the body alone. - Stripping the
whsec_prefix. The full secret string is the HMAC key. - Using
==to compare. Plain string equality leaks timing information. Usecrypto.timingSafeEqual,hmac.compare_digest,Rack::Utils.secure_compare,hmac.Equal, orhash_equals. - Skipping the timestamp check. Without it, a captured delivery could be replayed indefinitely. Keep your server clock in sync (NTP).
Secret rotation
Rotating a webhook's secret in Core takes effect immediately — the old secret stops signing deliveries straight away. Each webhook has its own secret, so if you run several webhooks into one endpoint, look the secret up per webhook (for example, by giving each webhook a distinct URL path). See Webhooks → Rotating the signing secret for a zero-downtime approach.
checkIn event uses the same headers and algorithm. Receivers built for v1 can ignore them, but we recommend verifying.