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

json
{
  "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

HeaderValue
Content-Typeapplication/json
Swap-Pay-Signaturet=<unix>,v1=<hex>
Swap-Pay-Event-IdUUID, dedupe key
Swap-Pay-Event-Typee.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

javascript
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);
}
python
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
<?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'] ?? '');
}
go
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&rsquo;d rather not pin IPs.

Firewall examples (replace the example address with the range support gives you):

nginx

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)

text
# 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: Block

Caddy

caddyfile
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_deposit
  • invoice.seen · invoice.confirming · invoice.confirmed
  • invoice.paid
  • invoice.product_delivered · invoice.product_delivery_review_required
  • invoice.expired · invoice.cancelled
  • invoice.underpaid · invoice.overpaid · invoice.late · invoice.review_required
  • invoice.refunded · invoice.partially_refunded · invoice.reorged
  • payout.requested · payout.approved · payout.rejected · payout.submitted · payout.confirmed · payout.failed
  • refund.requested · refund.approved · refund.rejected · refund.submitted · refund.confirmed · refund.failed
  • webhook.test