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

http
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:

text
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
javascript
// 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
# 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
// 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
// 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
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
fi

Other headers on every delivery

HeaderValueUse
Swap-Pay-Signaturet=<unix>,v1=<hex>Authenticate + replay-protect
Swap-Pay-Event-IdUUIDDeduplication key
Swap-Pay-Event-Typeinvoice.paid etc.Route to the right handler without parsing body
Content-Typeapplication/jsonAlways 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 t as 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.