Webhook verification
Every delivery SwapSS Pay sends carries a Swap-Pay-Signature header. Verify it on every request. Skip verification and any server on the internet can send you fake payment confirmations.
The header format
Swap-Pay-Signature: t=1716000000,v1=a3f7b2c1d4e5...t: UNIX timestamp (seconds) when the delivery was signed.v1: lowercase hex HMAC-SHA256 of<t>.<raw_body>using your webhook secret as the key.
The signing formula
The signed payload is exactly:
HMAC-SHA256(key=webhook_secret, msg="{t}.{raw_request_body}")Important: raw_request_body means the body bytes as received, before any JSON parsing. Parsing then re-serialising changes whitespace and key order, which breaks the signature.
Replay protection
The timestamp t is embedded in the signature so an attacker cannot replay an old valid delivery. Reject any request where |now - t| > 300 seconds (5 minutes). All samples below enforce this window.
Event deduplication
Deliveries may be retried on network failure or non-2xx response. Each delivery carries the same event_id UUID. Store event_id in your database and skip processing if you’ve already handled it. Check signature first, dedupe second.
Verification samples
Node
// Node 18+ — no extra deps needed.
// rawBody must be the raw request body Buffer (not parsed JSON).
import crypto from 'node:crypto';
export function verifySwapPaySignature(
header: string,
rawBody: Buffer | string,
secret: string,
toleranceSec = 300,
): boolean {
const parts: Record<string, string> = {};
for (const kv of header.split(',')) {
const eq = kv.trim().indexOf('=');
if (eq > 0) parts[kv.slice(0, eq).trim()] = kv.slice(eq + 1).trim();
}
const t = Number.parseInt(parts['t'] ?? '', 10);
if (!Number.isFinite(t)) return false;
// Reject if timestamp is outside the tolerance window (replay protection).
if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const body = typeof rawBody === 'string' ? rawBody : rawBody.toString('utf8');
const expected = crypto
.createHmac('sha256', secret)
.update(t + '.' + body)
.digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(parts['v1'] ?? '', 'hex');
// Constant-time compare — prevents timing-side-channel attacks.
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express middleware example
app.post('/swap-pay-webhook', express.raw({ type: 'application/json' }), (req, res) => {
const sig = (req.headers['swap-pay-signature'] as string) ?? '';
if (!verifySwapPaySignature(sig, req.body, process.env.SWAP_PAY_WEBHOOK_SECRET!)) {
return res.status(401).json({ error: 'invalid signature' });
}
const event = JSON.parse(req.body.toString());
// Dedupe by event.event_id before processing.
res.status(200).json({ ok: true });
});Python
# Python 3.8+ — stdlib only.
# raw_body must be the raw request bytes (not decoded JSON).
import hmac
import hashlib
import time
def verify_swap_pay_signature(
header: str,
raw_body: bytes,
secret: bytes | str,
tolerance: int = 300,
) -> bool:
if isinstance(secret, str):
secret = secret.encode()
parts: dict[str, str] = {}
for kv in header.split(","):
kv = kv.strip()
eq = kv.find("=")
if eq > 0:
parts[kv[:eq].strip()] = kv[eq + 1:].strip()
try:
t = int(parts["t"])
except (KeyError, ValueError):
return False
# Reject stale events (replay protection: default 5-minute window).
if abs(time.time() - t) > tolerance:
return False
payload = (str(t) + ".").encode() + raw_body
expected = hmac.new(secret, payload, hashlib.sha256).hexdigest()
# constant-time compare
return hmac.compare_digest(expected, parts.get("v1", ""))
# Flask example
from flask import Flask, request, abort
import json
import os
WEBHOOK_SECRET = os.environ["SWAP_PAY_WEBHOOK_SECRET"].encode()
app = Flask(__name__)
@app.route("/swap-pay-webhook", methods=["POST"])
def webhook():
header = request.headers.get("Swap-Pay-Signature", "")
if not verify_swap_pay_signature(header, request.get_data(), WEBHOOK_SECRET):
abort(401)
event = json.loads(request.get_data())
event_id = event.get("event_id")
# Dedupe by event_id here before doing any work.
return {"ok": True}PHP
<?php
// PHP 8.0+ — no extensions beyond standard.
// $rawBody must be the raw request body string (not decoded).
function verifySwapPaySignature(
string $header,
string $rawBody,
string $secret,
int $tolerance = 300,
): bool {
$parts = [];
foreach (explode(',', $header) as $kv) {
$kv = trim($kv);
$eq = strpos($kv, '=');
if ($eq !== false) {
$parts[trim(substr($kv, 0, $eq))] = trim(substr($kv, $eq + 1));
}
}
$t = (int)($parts['t'] ?? 0);
if (!$t) return false;
// Reject if outside the tolerance window (replay protection).
if (abs(time() - $t) > $tolerance) return false;
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
// hash_equals performs constant-time comparison.
return hash_equals($expected, $parts['v1'] ?? '');
}
// Usage (raw PHP, no framework)
$rawBody = file_get_contents('php://input');
$header = $_SERVER['HTTP_SWAP_PAY_SIGNATURE'] ?? '';
if (!verifySwapPaySignature($header, $rawBody, getenv('SWAP_PAY_WEBHOOK_SECRET'))) {
http_response_code(401);
echo json_encode(['error' => 'invalid signature']);
exit;
}
$event = json_decode($rawBody, true);
// Dedupe by $event['event_id'], then handle $event['type']
echo json_encode(['ok' => true]);Go
// Go 1.21+ — stdlib only.
package swappay
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"math"
"net/http"
"strconv"
"strings"
"time"
)
// VerifySignature checks Swap-Pay-Signature against the raw body.
// toleranceSec should be 300 (5 minutes) in production.
func VerifySignature(header, rawBody, secret string, toleranceSec int64) bool {
parts := map[string]string{}
for _, kv := range strings.Split(header, ",") {
kv = strings.TrimSpace(kv)
if eq := strings.IndexByte(kv, '='); eq > 0 {
parts[strings.TrimSpace(kv[:eq])] = strings.TrimSpace(kv[eq+1:])
}
}
t, err := strconv.ParseInt(parts["t"], 10, 64)
if err != nil {
return false
}
// Reject events outside the replay-protection window.
if math.Abs(float64(time.Now().Unix()-t)) > float64(toleranceSec) {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(strconv.FormatInt(t, 10) + "." + rawBody))
expected := mac.Sum(nil)
sig, err := hex.DecodeString(parts["v1"])
if err != nil {
return false
}
// hmac.Equal performs constant-time compare.
return hmac.Equal(expected, sig)
}
// WebhookHandler is a net/http handler that verifies and dispatches events.
func WebhookHandler(secret string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "read error", http.StatusBadRequest)
return
}
sig := r.Header.Get("Swap-Pay-Signature")
if !VerifySignature(sig, string(body), secret, 300) {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
// Dedupe by event_id from JSON body, then process.
w.WriteHeader(http.StatusOK)
}
}curl / bash
#!/usr/bin/env bash
# Quick local test: verify the HMAC with openssl (no extra deps).
# Usage: SWAP_PAY_WEBHOOK_SECRET=<secret> ./verify.sh <header> <body_file>
HEADER="$1" # e.g. 't=1716000000,v1=abc123...'
BODY_FILE="$2" # path to saved raw request body
SECRET="${SWAP_PAY_WEBHOOK_SECRET:?set SWAP_PAY_WEBHOOK_SECRET}"
TOLERANCE=300
# Extract t= and v1= from header
T=$(echo "$HEADER" | grep -oP 't=\K[0-9]+')
V1=$(echo "$HEADER" | grep -oP 'v1=\K[0-9a-f]+')
if [ -z "$T" ] || [ -z "$V1" ]; then
echo "ERROR: could not parse Swap-Pay-Signature header" >&2; exit 1
fi
NOW=$(date +%s)
DRIFT=$(( NOW - T ))
ABS_DRIFT="${DRIFT#-}"
if [ "$ABS_DRIFT" -gt "$TOLERANCE" ]; then
echo "REJECT: timestamp drift ${DRIFT}s exceeds ${TOLERANCE}s window" >&2; exit 1
fi
# Signed payload is "<t>.<rawbody>"
PAYLOAD="$T.$(cat "$BODY_FILE")"
EXPECTED=$(printf '%s' "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')
if [ "$EXPECTED" = "$V1" ]; then
echo "OK: signature matches"
else
echo "FAIL: signature mismatch" >&2
echo " expected: $EXPECTED" >&2
echo " got: $V1" >&2
exit 1
fiOther headers on every delivery
| Header | Value | Use |
|---|---|---|
Swap-Pay-Signature | t=<unix>,v1=<hex> | Authenticate + replay-protect |
Swap-Pay-Event-Id | UUID | Deduplication key |
Swap-Pay-Event-Type | invoice.paid etc. | Route to the right handler without parsing body |
Content-Type | application/json | Always JSON; charset UTF-8 |
IP allowlist (defence in depth)
Pair signature verification with an IP allowlist at your firewall or reverse proxy. See the webhooks overview for nginx / Cloudflare / Caddy examples.
Checklist
- Read the raw body bytes before any JSON parsing.
- Parse
tas an integer; reject if non-numeric. - Verify
|now - t| ≤ 300. - Compute HMAC-SHA256 of
"{t}.{raw_body}"with your webhook secret. - Compare with constant-time equality (not
===or==). - Return 401 if check fails.
- Dedupe by
event_id; return 200 immediately on duplicate. - Return 2xx within 10 seconds or the delivery is retried.