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 theSubscription: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_dueand 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’sstatus · 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 istype; the resource sits under data with its own object discriminator.
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:
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.
Responding
Return2xx 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.
