5. Webhooks

Instead of (or in addition to) polling /status, we can push state changes to your backend, much like Stripe webhooks. This is the recommended way to drive fulfillment: you get notified the instant a payment is paid, and again when it's swept (with exact settled amounts), without polling.


Enabling#

Webhooks are configured per account in the dashboard (Credentials):

SettingDescription
Webhook URLYour backend URL to receive events (enables webhooks for the account).
Webhook signing secretHMAC key used to sign each delivery. We generate it. Reveal it in Credentials, copy it into your server's secrets, and verify signatures with it.

You can rotate the signing secret at any time from Credentials. Deliveries enqueued after a rotation are signed with the new secret, so deploy it promptly; events already queued keep the secret they were signed for.

With a webhook URL set, we POST an event to it on every notable transition of your intents. Delivery is durable: events are queued and retried with exponential backoff (up to 12 attempts) until your endpoint returns 2xx, so a transient outage never loses a notification.


Events#

We POST a JSON body for these transitions:

typeWhenIncludes settlement
intent.underpaidReceived less than expectedno
intent.paidReceived the expected amount (ready to fulfill)no
intent.overpaidReceived more than expectedno
intent.sweptFunds delivered to your treasury walletyes
intent.refundedFunds returned to the senderno
intent.settle_failedAuto-settlement exhausted retries (needs attention)no

Each transition is delivered once (idempotent enqueue per intent+type).

Payload shape#

json
{
  "type": "intent.paid",
  "intent": {
    "derivation_index": 42,
    "address": "9xQe...pump",
    "reference": "order-1001",
    "mint": null,
    "mint_decimals": null,
    "expected_amount": 500000000,
    "received_amount": 500000000,
    "status": "paid",
    "payer_address": "Fx3X...gsy",
    "payment_signature": "5xY...tx"
  },
  "settlement": null
}

payer_address is the external wallet the payment came from (use it as the refund destination and to attribute the payment to the payer). payment_signature is the incoming payment's on-chain transaction signature (link it as a receipt). Both may be null briefly if the sender wasn't yet known.

For intent.swept, settlement is populated with the exact delivered amounts:

json
{
  "type": "intent.swept",
  "intent": { "...": "...", "status": "swept" },
  "settlement": {
    "asset": "SOL",
    "decimals": 9,
    "destination_amount": 499995000,
    "platform_fee_amount": 0,
    "signatures": ["4bd..."]
  }
}

Headers#

HeaderDescription
X-Zuuppa-Event-IdA stable UUID for this event. Dedupe on it: a delivery can arrive more than once on retries.
X-Zuuppa-Event-TypeSame as type in the body.
X-Zuuppa-SignatureHex HMAC-SHA256 of the raw request body using your account's webhook signing secret (revealed in the dashboard). Verify it.

Verifying the signature (required for security)#

Recompute the HMAC over the raw body and compare, using the webhook signing secret you set for your account in the dashboard. Example (Node.js):

ts
import crypto from "crypto";

function verify(rawBody: string, signatureHeader: string, secret: string): boolean {
  const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
  // constant-time compare
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signatureHeader));
}

Rust example:

rust
use hmac::{Hmac, Mac};
use sha2::Sha256;
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
mac.update(raw_body.as_bytes());
let expected = hex::encode(mac.finalize().into_bytes());
// compare expected == signature_header (constant time)

Use the raw bytes of the request body for the HMAC. Don't re-serialize the parsed JSON, or whitespace/key-order differences will break the check.


Handling events on your side (best practice)#

ts
app.post("/webhooks/zuuppa", express.raw({ type: "*/*" }), (req, res) => {
  const raw = req.body.toString("utf8");
  const sig = req.header("X-Zuuppa-Signature") ?? "";
  if (!verify(raw, sig, process.env.ZUUPPA_WEBHOOK_SECRET!)) {
    return res.status(401).end();
  }

  const eventId = req.header("X-Zuuppa-Event-Id")!;
  if (alreadyProcessed(eventId)) return res.status(200).end(); // dedupe

  const evt = JSON.parse(raw);
  switch (evt.type) {
    case "intent.paid":
      fulfillOrder(evt.intent.reference);           // deliver the goods
      break;
    case "intent.swept":
      recordRevenue(evt.intent.reference, evt.settlement.destination_amount);
      break;
    case "intent.refunded":
      cancelOrder(evt.intent.reference);   // note: no event fires on plain cancel/timeout
      break;
    case "intent.settle_failed":
      alertOps(evt.intent.reference);
      break;
  }

  markProcessed(eventId);
  res.status(200).end();   // 2xx = delivered; anything else = retried
});

Key rules:

  1. Verify the signature. Reject if it doesn't match.
  2. Dedupe on X-Zuuppa-Event-Id. Retries can redeliver.
  3. Return 2xx only after you've durably handled it. A non-2xx (or timeout, 10s) triggers redelivery with backoff.
  4. Idempotent handlers. Processing the same event twice must be safe.

Polling vs. webhooks#

  • Webhooks are the recommended primary mechanism: instant, no polling load.
  • /status polling still works and is a fine fallback or for live UI. It's perfectly safe to use both (webhook drives fulfillment; the client polls for UX).

Next: Swift SDK →, or back to the Overview.