Webhooks
Outbound Webhooks
Instead of (or in addition to) polling /status, Zuuppa pushes state changes to your backend, like Stripe webhooks. This is the recommended way to drive fulfillment: you're notified the instant a payment is paid, and again when it's swept with the exact settled amounts, without polling.
Every payload is self-contained: it names the owning account, the buyer, the asset, the amounts in both base units and exact decimal strings, and the on-chain signatures. A handler should never need a callback into the API to decide what to do.
Enabling#
Configure webhooks per account in the dashboard (Credentials page). There is no env-level or API-level webhook config.
| Setting | Description |
|---|---|
| Webhook URL | The backend URL to receive events. Setting it enables webhooks for the account. |
| Webhook signing secret | HMAC key used to sign each delivery. Generated server-side, revealed once in the dashboard, rotatable. |
Both must be non-empty for anything to be delivered. The target is snapshotted at enqueue time, so rotating the secret does not retro-sign events already queued.
Two cases resolve to a different target:
- A marketplace seller has no webhook of its own. Its payments notify the marketplace's URL, signed with the marketplace's secret.
- An archived account gets no webhook target at all. A late payment on an old deposit address still settles; it just doesn't notify a merchant who closed their account. An archived marketplace stops notifying for its sellers too.
Event types#
The type field in the body matches the X-Zuuppa-Event-Type header.
| type | Fired when | Extra block |
|---|---|---|
intent.created | An intent is created | none |
intent.token_selected | The buyer locks an asset and amount | none |
intent.underpaid | Received less than expected | none |
intent.paid | Received the expected amount | none |
intent.overpaid | Received more than expected | none |
intent.swept | Settled to your wallet | settlement |
intent.refunding | A partial balance is being returned (known sender) | none |
intent.refunded | A refund completed | none |
intent.cancelled | Nothing received (timeout, or explicit cancel with no funds) | none |
intent.wrong_token | An unaccepted asset was seen at the deposit address | wrong_token |
intent.token_refunded | A wrong-token refund completed | wrong_token |
intent.settle_failed | Automatic settlement/refund exhausted retries | none |
intent.expired appears in the event list published at GET /config but is never emitted: a timed-out checkout emits intent.cancelled, or intent.refunding when there's a partial balance to return.Subscription events relay through this same delivery pipeline, with the same X-Zuuppa-Signature and api_version and the same envelope shape. Their type values are subscription.* and charge.* (e.g. subscription.created, charge.confirmed, charge.failed, subscription.past_due) and they carry a subscription / charge resource block instead of intent. See Subscriptions API. Handle unknown type values as a no-op (see the rules below) so new event types never break your handler.
Ordering#
A completed payment: intent.created, intent.token_selected, intent.paid, intent.swept. An abandoned one: intent.created, intent.cancelled.
Ordering is not guaranteed — each event retries on its own schedule, so a retried intent.paid can land after intent.swept. Branch on type and on intent.status in the body, never on arrival order.
Idempotency#
Delivery is at least once: a retry can redeliver an event you've already processed. Dedupe on the event id (identical to the X-Zuuppa-Event-Id header). Zuuppa dedupes at the source too: one status event per intent per type, intent.token_selected keyed on the chosen mint, and each distinct wrong-token mint its own event.
Payload shape#
Every event has the same top-level shape. settlement and wrong_token are always present and null when they don't apply, so a receiver reads one stable structure.
{
"id": "8f2c9a10-4c1b-4a55-9c1e-1f0a2b3c4d5e",
"type": "intent.paid",
"api_version": "2026-08-20",
"created_at": "2026-08-20T14:03:11.412Z",
"account": {
"id": "d41a6e2f-93b7-4c8a-88a2-51f0c7e9a1b3",
"name": "Clay and Co",
"is_seller": false,
"external_id": null,
"marketplace_account_id": null
},
"intent": {
"id": "1c9e77aa-2f3d-4b21-9a6e-8c7d5b4f3a21",
"derivation_index": 42,
"address": "9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin",
"reference": "order-1001",
"status": "paid",
"price_usd_cents": 1250,
"mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
"mint_decimals": 6,
"ticker": "USDC",
"decimals": 6,
"expected_amount": 1250000,
"expected_amount_ui": "1.25",
"received_amount": 1250000,
"received_amount_ui": "1.25",
"shortfall_amount": null,
"shortfall_amount_ui": null,
"payer_address": "Fx3XX3BySVfF2ZQ2c5xtaCBnMTPD5s6bHqmVfWn9Ngsy",
"payment_signature": "5xYtx8kq...",
"marketplace_fee_bps": null,
"marketplace_fee_cents": null,
"created_at": "2026-08-20T13:58:02.109Z",
"updated_at": "2026-08-20T14:03:11.402Z",
"expires_at": "2026-08-20T14:08:02.109Z",
"customer_details": { "email": "buyer@example.com", "first_name": "Ada" },
"token_refunds": []
},
"settlement": null,
"wrong_token": null
}The payload is strictly additive
By policy, keys are never renamed, retyped, or removed.api_version is bumped only if that policy is ever broken, which is why you can branch on it rather than on field presence.Top level#
| Field | Type | Notes |
|---|---|---|
id | uuid | Stable id for this event, identical to X-Zuuppa-Event-Id. Dedupe on it. |
type | string | The event type. |
api_version | string | The payload contract version. |
created_at | RFC 3339 | When the event was generated (not delivered). |
account | object | The owning account. |
intent | object | The checkout (for payment events). |
settlement | object | null | Non-null on intent.swept. |
wrong_token | object | null | Non-null on the two wrong-token events. |
account#
| Field | Type | Notes |
|---|---|---|
id | uuid | For a marketplace payment, the seller id. |
name | string | Also the label the buyer's wallet shows. |
is_seller | boolean | True when this is a seller's payment. |
external_id | string | null | The marketplace's own id for the seller. Null for a business. |
marketplace_account_id | uuid | null | The marketplace above the seller. Null for a business. |
intent#
| Field | Type | Notes |
|---|---|---|
id | uuid | Intent id. |
derivation_index | integer | Per-account index of the deposit address. Not globally unique. |
address | string | The deposit address. |
reference | string | null | Your own order id, as passed at create. |
status | string | Status at the moment the event was generated. |
price_usd_cents | integer | null | USD price in cents. |
mint | string | null | Pay-in SPL mint, null for SOL. Null until token selection. |
mint_decimals | integer | null | Decimals of mint. Null for SOL. |
ticker | string | The asset's display symbol. |
decimals | integer | mint_decimals, or 9 for SOL. Use to interpret base units. |
expected_amount | integer / string | expected_amount / expected_amount_ui: amount owed, base units and exact decimal string. Null until token selection. |
received_amount | integer / string | received_amount / received_amount_ui: amount received, gross. |
shortfall_amount | integer / string | null | shortfall_amount / shortfall_amount_ui: expected - received when positive. |
payer_address | string | null | The external wallet that paid, and the refund destination. |
payment_signature | string | null | The incoming payment transaction. |
marketplace_fee_bps | integer | null | marketplace_fee_bps / marketplace_fee_cents: marketplace payments only. |
created_at | RFC 3339 | created_at / updated_at / expires_at: timestamps. |
customer_details | object | null | Buyer details verbatim, or null. |
token_refunds | array | One { mint, status } per unaccepted asset seen. Always present, usually empty. |
_ui fields are exact decimal strings computed with integer arithmetic — use them for reconciliation. The floats on /status are for display only.settlement, on intent.swept#
"settlement": {
"asset": "SOL",
"decimals": 9,
"destination_amount": 940000000,
"destination_amount_ui": "0.94",
"platform_fee_amount": 10000000,
"platform_fee_amount_ui": "0.01",
"marketplace_fee_amount": 50000000,
"marketplace_fee_amount_ui": "0.05",
"marketplace_fee_cents": 50,
"signatures": ["4bd9c1..."]
}Summed across every sweep for the intent. marketplace_fee_amount is always present and 0 for an ordinary payment. The three legs always sum to the gross received.
wrong_token, on the wrong-token events#
"wrong_token": {
"mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
"decimals": 6,
"amount": 2500000,
"amount_ui": "2.5",
"sender": "Fx3XX3BySVfF2ZQ2c5xtaCBnMTPD5s6bHqmVfWn9Ngsy",
"signature": "3aVh7q..."
}decimals and amount_ui are for the unaccepted asset. signature is the return transfer on intent.token_refunded, and the incoming transfer on intent.wrong_token (or null when the incoming signature isn't known).
Headers#
| Header | Description |
|---|---|
X-Zuuppa-Event-Id | Stable UUID for this event, identical to id in the body. Dedupe on it. |
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. |
Content type is application/json. The delivery timeout is 10 seconds.
Verifying the signature (required)#
Recompute the HMAC over the raw body and compare in constant time.
import crypto from "crypto";
function verify(rawBody: string, signatureHeader: string, secret: string): boolean {
const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(signatureHeader, "utf8");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Use the raw bytes of the request body
Do not re-serialize the parsed JSON, or whitespace and key-order differences will break the check. It is a plain HMAC-SHA256 with no timestamp or version prefix, so any stock library reproduces it.Known-answer test vector
Signingwhat do ya want for nothing? with the secret Jefe must produce 5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843. Use it to confirm your HMAC wiring before pointing it at real deliveries.Handling events on your side#
app.post("/zuuppa/webhook", express.raw({ type: "*/*" }), async (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 evt = JSON.parse(raw);
if (await alreadyProcessed(evt.id)) return res.status(200).end(); // dedupe
const orderId = evt.intent?.reference;
const sellerId = evt.account?.is_seller ? evt.account.id : null;
switch (evt.type) {
case "intent.paid":
case "intent.overpaid":
await fulfill(orderId, {
sellerId,
email: evt.intent.customer_details?.email,
txSignature: evt.intent.payment_signature,
});
break;
case "intent.swept":
await recordRevenue(orderId, evt.settlement);
break;
case "intent.underpaid":
await awaitMore(orderId, evt.intent.shortfall_amount_ui, evt.intent.ticker);
break;
case "intent.cancelled":
await releaseInventory(orderId);
break;
case "intent.refunding":
case "intent.refunded":
await cancelOrder(orderId);
break;
case "intent.wrong_token":
await noteWrongAsset(orderId, evt.wrong_token); // do NOT credit the order
break;
case "intent.token_refunded":
await noteWrongAssetReturned(orderId, evt.wrong_token.signature);
break;
case "intent.settle_failed":
await alertOps(orderId);
break;
// ignore unknown types (including subscription.* / charge.*) unless you handle them
}
await markProcessed(evt.id);
res.status(200).end(); // 2xx = delivered; anything else = retried
});Key rules:
- Verify the signature. Reject if it doesn't match.
- Dedupe on
id. It's stable across retries. - Return 2xx only after durably handling it. A non-2xx or a timeout triggers redelivery with backoff.
- Idempotent handlers. Processing the same event twice must be safe.
- Ignore unknown types. New events are added without an
api_versionbump, so an unrecognisedtypemust be a no-op. - Answer fast. Queue the work and reply. The timeout is 10 seconds.
Retry behavior#
A delivery that doesn't return 2xx (or times out) is retried with exponential backoff, up to 12 attempts spanning roughly an hour and a half, after which the event is given up on. If your endpoint is down that long, reconcile the missed events via GET /status (payments) or the subscription read endpoints. The maximum attempt count is also published at GET /config (webhooks.max_attempts).
Polling versus webhooks#
- Webhooks are the recommended primary mechanism: instant, no polling load, and the body carries everything needed to act.
/statuspolling is a fine fallback or for live buyer-facing UI. Using both is safe: the webhook drives fulfillment, the client polls for UX.