Webhooks
SwapSS 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": "5000000"
}
}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)
}How to verify a delivery came from us
Use these layers together for defence in depth. The HMAC signature (Layer 3) is the one that matters most, so verify it on every request.
Layer 1: TLS certificate
Every delivery is sent over HTTPS from swapss.lol. Your server’s TLS stack verifies the certificate automatically. If the connection isn’t from the real swapss.lol origin, the TLS handshake fails before a byte of data is sent.
Layer 2: IP allowlist (optional)
Webhook deliveries come from a fixed set of outbound IPs. For an extra layer you can allowlist them at your firewall or reverse proxy and reject webhook POSTs from anywhere else. Request the current range from support, and we keep it up to date. The signature check (Layer 3) already protects you if you’d rather not pin IPs.
Firewall examples (replace the example address with the range support gives you):
nginx
# In your server { } block, restrict the webhook endpoint:
location /swap-pay-webhook {
allow 203.0.113.4; # replace with the SwapSS Pay outbound IP range
deny all;
proxy_pass http://localhost:3000;
}Cloudflare WAF (custom rule)
# Cloudflare → Security → WAF → Custom rules → Create rule
# Expression:
(http.request.uri.path eq "/swap-pay-webhook") and (not ip.src in {203.0.113.4})
# Action: BlockCaddy
yourdomain.example {
@swap-pay-webhook {
path /swap-pay-webhook
not remote_ip 203.0.113.4
}
respond @swap-pay-webhook 403
}Layer 3: HMAC signature
Even if an attacker spoofs the IP (requires BGP hijack + TLS break), they cannot forge the Swap-Pay-Signature header without your webhook secret. Always verify; see code samples below.
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 operator 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, so your handler must be idempotent.
Event catalog
invoice.created·invoice.awaiting_depositinvoice.seen·invoice.confirming·invoice.confirmedinvoice.paidinvoice.product_delivered·invoice.product_delivery_review_requiredinvoice.expired·invoice.cancelledinvoice.underpaid·invoice.overpaid·invoice.late·invoice.review_requiredinvoice.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