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

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": "5000073"
  }
}

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)
}

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_deposit
  • invoice.seen · invoice.confirming · invoice.confirmed
  • invoice.paid
  • invoice.expired · invoice.cancelled
  • invoice.underpaid · invoice.overpaid · invoice.late · invoice.manual_review
  • 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