Developers

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=48373c7bd723348cf7520b12718567bdd0950ea5f913dbc30177a0287c732c27

X-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

  1. 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.
  2. Parse t and v1 from X-Hapana-Signature.
  3. Build the signed payload: ${t}.${rawBody} — the timestamp, a literal period, then the raw body.
  4. Compute HMAC-SHA256 of that payload, keyed with your signing secret, and hex-encode it. Use the whole secret string, including the whsec_ prefix, as UTF-8 bytes — don't strip the prefix or hex-decode it.
  5. Compare your result with v1 using a constant-time comparison.
  6. Reject the request if t is 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 "", 200

Ruby

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
end

Go

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:

InputValue
Secretwhsec_5f2b0c1d9e8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d2e1f0a9b8c7d6e5f4a3b2c
Timestamp1790982502

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=48373c7bd723348cf7520b12718567bdd0950ea5f913dbc30177a0287c732c27

You 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

  1. 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.
  2. Comparing the whole header. The header is t=<timestamp>,v1=<signature>. Parse out the v1 value; don't compare against the full string or strip a fixed prefix.
  3. Signing only the body. The signed payload is timestamp + "." + body, not the body alone.
  4. Stripping the whsec_ prefix. The full secret string is the HMAC key.
  5. Using == to compare. Plain string equality leaks timing information. Use crypto.timingSafeEqual, hmac.compare_digest, Rack::Utils.secure_compare, hmac.Equal, or hash_equals.
  6. 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.

Legacy v1 check-ins are signed too. The checkIn event uses the same headers and algorithm. Receivers built for v1 can ignore them, but we recommend verifying.