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):
| 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. 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:
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.settle_failed | Auto-settlement exhausted retries (needs attention) | no |
Each transition is delivered once (idempotent enqueue per intent+type).
No event fires on cancellation or timeout. When a checkout is cancelled or
its 10-minute window closes with no (complete) payment, the intent becomes
cancelled, but no webhook is sent. To react to abandoned checkouts, poll
GET /status (or GET /intents/status
with the client_secret), or watch the dashboard. A cancellation that returns a
partial SOL balance still emits the usual intent.refunded when the refund
settles.
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-Zuuppa-Event-Id | A stable UUID for this event. Dedupe on it: a delivery can arrive more than once on retries. |
X-Zuuppa-Event-Type | Same as type in the body. |
X-Zuuppa-Signature | Hex 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):
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-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:
- Verify the signature. Reject if it doesn't match.
- Dedupe on
X-Zuuppa-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).
Next: Swift SDK →, or back to the Overview.