5. Webhooks

Instead of (or in addition to) polling /status, we can push state changes to your backend — 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 (Settings):

SettingDescription
Webhook URLYour backend URL to receive events (enables webhooks for the account).
Webhook signing secretHMAC key used to sign each delivery. Set it and verify signatures.

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.expiredWindow closed with no paymentno
intent.settle_failedAuto-settlement exhausted retries (needs attention)no

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

Payload shape

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

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

Headers

HeaderDescription
X-HDWallet-Event-IdA stable UUID for this event. Dedupe on it — a delivery can arrive more than once on retries.
X-HDWallet-Event-TypeSame as type in the body.
X-HDWallet-SignatureHex HMAC-SHA256 of the raw request body using your account's webhook signing secret (set 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):

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:

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)

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

  const eventId = req.header("X-HDWallet-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.expired":
    case "intent.refunded":
      cancelOrder(evt.intent.reference);
      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-HDWallet-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

Back to the Overview.