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):
| Setting | Description |
|---|---|
| Webhook URL | Your backend URL to receive events (enables webhooks for the account). |
| Webhook signing secret | HMAC 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:
type | When | Includes settlement |
|---|---|---|
intent.underpaid | Received less than expected | no |
intent.paid | Received the expected amount (ready to fulfill) | no |
intent.overpaid | Received more than expected | no |
intent.swept | Funds delivered to your treasury wallet | yes |
intent.refunded | Funds returned to the sender | no |
intent.expired | Window closed with no payment | no |
intent.settle_failed | Auto-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
| Header | Description |
|---|---|
X-HDWallet-Event-Id | A stable UUID for this event. Dedupe on it — a delivery can arrive more than once on retries. |
X-HDWallet-Event-Type | Same as type in the body. |
X-HDWallet-Signature | Hex 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:
- Verify the signature. Reject if it doesn't match.
- Dedupe on
X-HDWallet-Event-Id. Retries can redeliver. - Return 2xx only after you've durably handled it. A non-2xx (or timeout, 10s) triggers redelivery with backoff.
- Idempotent handlers. Processing the same event twice must be safe.
Polling vs. webhooks
- Webhooks are the recommended primary mechanism — instant, no polling load.
/statuspolling 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).
Back to the Overview.