Skip to main content
Suby sends signed POST webhooks to the endpoints you register in dashboard settings. Each endpoint has its own whsec_… secret, shown once at creation and on rotation. Subscribe to specific events or to all of them, rotate the secret, and disable an endpoint from the same screen. Choosing where events go is an account setting, not an integration surface · but the events themselves are the backbone of the integration, so verify every one.

Events

Payments

There is one success event, and it is payment.succeeded. A hosted checkout completing, a /v3/crypto/charges deposit clearing and an off-session debit capturing all emit that same event, so one handler covers every way a payment can be taken · there is no separate checkout.* family to subscribe to. Nothing fires between the payer acting and the money being taken: every card charge captures immediately, so a payment is never left authorized-and-waiting. payment.succeeded is the acquirer confirming the charge, not the money reaching your bank. That second step is payout.completed.

Subscriptions

A plan change is announced when it lands, not when it is asked for. POST /v3/subscriptions/{id}/change-plan answers 202 and the swap happens later · on the upgrade charge completing, or at the next renewal for a period-end downgrade. subscription.updated fires at that moment, with productId naming the new plan. Read GET /v3/subscriptions/{id} in the meantime: while the change is queued it comes back under scheduledChange, and once applied that field is null. A period-end downgrade also announces itself as the subscription.renewed of the cycle that bills it. A cancellation is two events, not one. Scheduling one — from the dashboard, the customer’s /manage portal, or POST /v3/subscriptions/{id}/cancel with the default atPeriodEnd: true — fires subscription.updated straight away: the subscription is still ACTIVE and still entitles the customer, but cancel_at_period_end is true and expires_at is the last day they have. This is the churn signal · act on it here. subscription.canceled comes later, when the period actually ends and the access is revoked, and on an annual plan that is a year after the customer made up their mind. Cancelling with atPeriodEnd: false skips the wait: subscription.canceled only. Suby drives renewals · you never call the API for cycle N+1. Access never outlives the period that was paid for: when a renewal is not paid on its due date, the accesses it grants are revoked right away (access_grant.revoked), and given back automatically if a later payment for that cycle succeeds.
  • Card / wallets · off-session smart retry, all within 72h of the due date: monthly and yearly plans get 4 attempts (due date, +6h, +24h, +72h), weekly plans 3 (due date, +6h, +24h), daily plans 2 (due date, +6h). The access is revoked on the first refusal and comes back when a retry succeeds. Soft declines retry; hard declines (stolen card, closed account) end it immediately, whatever attempts remain · the credential is unusable, so a retry is a guaranteed second refusal. A third category, AUTHENTICATION_REQUIRED, means the issuer wants the cardholder to confirm the card: the schedule keeps running, but only the customer clears it · we email them a link to do so. State on the Subscription: renewalAttempt, nextRenewalAttemptAt, lastDeclineCategory.
  • Crypto · the customer re-deposits through a renewal link, sent by email and on Discord/Telegram 48h before the due date (24h for plans shorter than a month). With no deposit by the due date, the subscription moves to past_due and its accesses are revoked; it stays renewable for 2 more days, and paying in that window renews it in place and gives the accesses back. After that it expires.
  • Several purchases, one access · before revoking, Suby checks that no other live purchase grants the same access to the same person · including one made under another email with the same Discord or Telegram account. If one does, the entitlement ends but the role or membership stays, and the holder gets no renewal reminder, cooldown role or expiry notice for it · nothing they hold is at risk.

Disputes

A chargeback does not move the payment’s status · the sale did complete, and rewriting that would lose it. These events are the only notification you get. The payload is the dispute, not the payment: object: "dispute", with payment_id, status, reason (the scheme’s own code, e.g. fraudulent), amount_cents (which can be less than the payment on a partial chargeback), currency, evidence_due_by, resolved_at and fee_cents.
dispute.expired · a defense window missed · can be subscribed to but is not emitted yet. Treat a missed window as a dispute.lost today.

Access

The delivered material is never in the payload · a licence key or a single-use invite doesn’t belong in something stored, retried and logged. You get identifiers only: id, customer_id, source_ref, kind, status. source_ref is what the entitlement hangs off · pay_… for a one-off, sub_… for a subscription. Stable across renewals, so use it to correlate with your own order.
access_grant.revoked means the entitlement ended, not necessarily that the customer lost the access · they may still hold it through another live purchase. Read their remaining grants before cutting them off.

Customers, payment methods, payouts

Payload

Every delivery shares one envelope. The kind is type; the resource sits under data with its own object discriminator.
Branch on the envelope type (or the X-Webhook-Event header) · never on the data shape alone.

items · what was bought

Every payment.* event carries items, the basket as it was charged, frozen at that moment:
A single-product charge is a basket of one, so read items whatever the payment: product names only the simple case and stays null for a payment link that charges several products. product_id is null on an ad-hoc amount (a price and a label, no catalogue entry). Prices are in the payment’s currency, and a discount applies to the payment total · the lines keep their listed unit price, so they do not necessarily sum to amount_captured_cents.

fee_breakdown on a crypto payment

A cross-chain crypto sale is announced the moment the payer’s deposit is irreversible · the bridge fills and the on-chain split takes Suby’s cut minutes later. platform_fee_cents and net_cents are nonetheless in that event: they are computed at the rate you were quoted, which is the rate the split then executes. settled_at stays null until the funds land, and tax is always null on a crypto charge (nothing is added on top of your price, whatever your settlement mode).

metadata is yours alone

metadata returns exactly the keys you sent at checkout, and nothing else · Suby’s own routing ids are never mixed into it. What it used to carry has proper fields: checkout_session_id for the session that minted the payment, bundle_id for the payment link it came from.

payment_method · one flat shape

A wallet is a token over a card, so apple_pay and google_pay carry the underlying card’s brand and last4. An Amex arrives as type: "card" with brand: "amex" · the network is a brand, never a method of its own. payment_method is null in one case only: no instrument was ever bound, which is what a payment refused before authentication looks like.

payment_method.crypto · the payer’s side

On a crypto payment the crypto block describes what the payer sent: chain_id, token_symbol, token_decimals, token_amount, tx_hash and from_address are all theirs. Where the funds are heading is on the payment object itself · settlementChainId, settlementAsset, settlementChain · because on a converging account the two sides differ on almost every payment, and the destination amount does not exist yet when the sale is confirmed. Format token_amount with token_decimals; never with the destination asset’s.

receipt_url

The hosted receipt for a paid payment · the same page your buyer’s receipt email links to. Present on payment.succeeded, payment.refunded and payment.partially_refunded; null elsewhere, and null on a deployment with no hosted checkout configured.

Verifying

HMAC-SHA256 of `${timestamp}.${rawBody}`, keyed with your endpoint secret.
Use the raw body bytes · don’t re-serialize parsed JSON. In Express, use express.raw() on the webhook route or stash req.rawBody via verify.

Responding

Return 2xx in under 5s. Non-2xx is retried with backoff, so make your handler idempotent · the same event can arrive twice.

When to grant access

One event grants on both rails · that is the point of there being a single success event. Do not grant on payment.failed’s absence or on a checkout redirect landing: the payer reaching your successUrl means their browser followed a redirect, not that the charge captured. Revoke on dispute.opened, not on dispute.lost: the money is already gone when the chargeback is raised, and the defense can take months. If you win it, dispute.won is your signal to grant again. If you let Suby deliver the access (Discord, Telegram, licences, files, links), you do not need this table at all · access_grant.revoked already fires for every one of these cases, and the entitlement is cut on our side.