Webhooks
Swap Pay sends signed HTTPS POSTs to your endpoint on every
invoice/payout/refund state change. Verify the signature, dedupe
by event_id, return 2xx within 10 seconds.
Envelope
{
"event_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
"type": "invoice.paid",
"created_at": "2026-05-16T11:34:56Z",
"merchant_id": "MCH-AB12CDEF",
"data": {
"invoice_id": "INV-0123456789",
"status": "paid",
"credited": true,
"amount_raw": "5000073"
}
}Headers
| Header | Value |
|---|---|
Content-Type | application/json |
Swap-Pay-Signature | t=<unix>,v1=<hex> |
Swap-Pay-Event-Id | UUID — dedupe key |
Swap-Pay-Event-Type | e.g. invoice.paid |
Signature scheme
HMAC-SHA256 over the string t + "." + raw_body, keyed
by the webhook secret you got at creation. The header carries the
UNIX timestamp t and the hex MAC v1. Reject
if |now − t| > 300 seconds.
Verifying
import crypto from 'node:crypto';
export function verify(header, rawBody, secret, toleranceSec = 300) {
const parts = Object.fromEntries(
header.split(',').map((kv) => kv.trim().split('='))
);
const t = Number.parseInt(parts.t, 10);
if (!Number.isFinite(t)) return false;
if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const mac = crypto.createHmac('sha256', secret)
.update(`${t}.${rawBody}`)
.digest('hex');
// Constant-time compare
const a = Buffer.from(mac, 'hex');
const b = Buffer.from(parts.v1 ?? '', 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}import hmac, hashlib, time
def verify(header: str, raw_body: bytes, secret: bytes, tolerance: int = 300) -> bool:
parts = dict(p.strip().split("=", 1) for p in header.split(","))
try:
t = int(parts["t"])
except (KeyError, ValueError):
return False
if abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret, f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))<?php
function swap_pay_verify(string $header, string $rawBody, string $secret, int $tolerance = 300): bool {
$parts = [];
foreach (explode(',', $header) as $kv) {
[$k, $v] = array_map('trim', explode('=', $kv, 2));
$parts[$k] = $v;
}
$t = (int)($parts['t'] ?? 0);
if (!$t || abs(time() - $t) > $tolerance) return false;
$expected = hash_hmac('sha256', "$t.$rawBody", $secret);
return hash_equals($expected, $parts['v1'] ?? '');
}package swappay
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"math"
"strconv"
"strings"
"time"
)
func Verify(header, rawBody, secret string, tolerance int64) bool {
parts := map[string]string{}
for _, kv := range strings.Split(header, ",") {
if eq := strings.SplitN(strings.TrimSpace(kv), "=", 2); len(eq) == 2 {
parts[eq[0]] = eq[1]
}
}
t, err := strconv.ParseInt(parts["t"], 10, 64)
if err != nil {
return false
}
if math.Abs(float64(time.Now().Unix()-t)) > float64(tolerance) {
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
}
return hmac.Equal(expected, sig)
}Retry
On non-2xx, network error, or response > 1 KB / 10 s, we retry
with backoff [1m, 5m, 30m, 2h, 6h, 24h]. After 6 attempts
the delivery is marked dead and an admin alert fires. If
a webhook hits 20 consecutive failures across deliveries, the
endpoint auto-pauses; re-enable it from the cabinet.
Idempotency
Always dedupe by event_id. We resend the same event_id on retries. Same delivery can also be
manually re-queued from the cabinet — your handler must be
idempotent.
Event catalog
invoice.created·invoice.awaiting_depositinvoice.seen·invoice.confirming·invoice.confirmedinvoice.paidinvoice.expired·invoice.cancelledinvoice.underpaid·invoice.overpaid·invoice.late·invoice.manual_reviewinvoice.refunded·invoice.partially_refunded·invoice.reorgedpayout.requested·payout.approved·payout.rejected·payout.submitted·payout.confirmed·payout.failedrefund.requested·refund.approved·refund.rejected·refund.submitted·refund.confirmed·refund.failedwebhook.test