Skip to content
Zuuppa
Dashboard

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.

SettingDescription
Webhook URLThe backend URL to receive events. Setting it enables webhooks for the account.
Webhook signing secretHMAC 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.

typeFired whenExtra block
intent.createdAn intent is creatednone
intent.token_selectedThe buyer locks an asset and amountnone
intent.underpaidReceived less than expectednone
intent.paidReceived the expected amountnone
intent.overpaidReceived more than expectednone
intent.sweptSettled to your walletsettlement
intent.refundingA partial balance is being returned (known sender)none
intent.refundedA refund completednone
intent.cancelledNothing received (timeout, or explicit cancel with no funds)none
intent.wrong_tokenAn unaccepted asset was seen at the deposit addresswrong_token
intent.token_refundedA wrong-token refund completedwrong_token
intent.settle_failedAutomatic settlement/refund exhausted retriesnone
Note
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.

200 OKExample: intent.paid
JSON
{
  "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
}
Tip

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#

FieldTypeNotes
iduuidStable id for this event, identical to X-Zuuppa-Event-Id. Dedupe on it.
typestringThe event type.
api_versionstringThe payload contract version.
created_atRFC 3339When the event was generated (not delivered).
accountobjectThe owning account.
intentobjectThe checkout (for payment events).
settlementobject | nullNon-null on intent.swept.
wrong_tokenobject | nullNon-null on the two wrong-token events.

account#

FieldTypeNotes
iduuidFor a marketplace payment, the seller id.
namestringAlso the label the buyer's wallet shows.
is_sellerbooleanTrue when this is a seller's payment.
external_idstring | nullThe marketplace's own id for the seller. Null for a business.
marketplace_account_iduuid | nullThe marketplace above the seller. Null for a business.

intent#

FieldTypeNotes
iduuidIntent id.
derivation_indexintegerPer-account index of the deposit address. Not globally unique.
addressstringThe deposit address.
referencestring | nullYour own order id, as passed at create.
statusstringStatus at the moment the event was generated.
price_usd_centsinteger | nullUSD price in cents.
mintstring | nullPay-in SPL mint, null for SOL. Null until token selection.
mint_decimalsinteger | nullDecimals of mint. Null for SOL.
tickerstringThe asset's display symbol.
decimalsintegermint_decimals, or 9 for SOL. Use to interpret base units.
expected_amountinteger / stringexpected_amount / expected_amount_ui: amount owed, base units and exact decimal string. Null until token selection.
received_amountinteger / stringreceived_amount / received_amount_ui: amount received, gross.
shortfall_amountinteger / string | nullshortfall_amount / shortfall_amount_ui: expected - received when positive.
payer_addressstring | nullThe external wallet that paid, and the refund destination.
payment_signaturestring | nullThe incoming payment transaction.
marketplace_fee_bpsinteger | nullmarketplace_fee_bps / marketplace_fee_cents: marketplace payments only.
created_atRFC 3339created_at / updated_at / expires_at: timestamps.
customer_detailsobject | nullBuyer details verbatim, or null.
token_refundsarrayOne { mint, status } per unaccepted asset seen. Always present, usually empty.
Note
_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#

200 OKsettlement
JSON
"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#

200 OKwrong_token
JSON
"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#

HeaderDescription
X-Zuuppa-Event-IdStable UUID for this event, identical to id in the body. Dedupe on it.
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.

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);
}
Warning

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.
Tip

Known-answer test vector

Signing what 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#

TypeScript
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:

  1. Verify the signature. Reject if it doesn't match.
  2. Dedupe on id. It's stable across retries.
  3. Return 2xx only after durably handling it. A non-2xx or a timeout triggers redelivery with backoff.
  4. Idempotent handlers. Processing the same event twice must be safe.
  5. Ignore unknown types. New events are added without an api_version bump, so an unrecognised type must be a no-op.
  6. 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.
  • /status polling is a fine fallback or for live buyer-facing UI. Using both is safe: the webhook drives fulfillment, the client polls for UX.

© 2026 Zuuppa. All rights reserved.