Subscription Webhooks
Invo owns the billing clock. Your server owns entitlement. The seven events on this page are the whole conversation between the two: they tell you when a member paid, when a charge failed and how long you have to fix it, when a card issuer wants the member to re-authenticate, and when access ends. Everything you grant or revoke should be driven from here — not from an API response.
This page assumes you already have a receiver
Delivery, signature verification, the X-Invo-* headers, the retry schedule and secret rotation are identical for every Invo event and are documented once, on Webhooks. Registering your endpoint, listing deliveries and replaying a dropped one live on Webhook Management. Nothing on this page repeats them — everything here is subscription-specific.
1. The seven events
Subscribe with ["*"] and you receive all of them without changing anything. There is no subscription.amount_changed event — advertising an event before it can fire would let you subscribe to something that never arrives. A staged price change surfaces as a changed amount_usd on the next subscription.renewed.
| Event | Fires exactly when | Access |
|---|---|---|
| subscription.renewed | A renewal settled: the period was charged, the wallet was debited, and the member is paid through period_end. The only event that grants access. | granted |
| subscription.payment_failed | A charge attempt failed. Fires on the FIRST failure — while the member still has access — and again on every subsequent failing attempt (first_failure tells them apart). | retained |
| subscription.past_due | The subscription entered past_due. Fires once, on the state transition, not once per attempt. | retained |
| subscription.authentication_required | The card issuer wants the cardholder to re-authenticate before the charge completes. NOT a failure — no retry was consumed. Carries a single-use confirmation URL and its expiry. | retained |
| subscription.canceled | A cancellation was REQUESTED, in either mode. Carries cancel_at_period_end and the effective date. There is no second event when the date arrives. | until access_until |
| subscription.expired | The retry budget was exhausted and the subscription ended. This is the event that revokes. | revoked |
| subscription.refunded | You called the refund endpoint on a billed period, full or partial. Carries the funding shape, how value was returned, and the corrected attribution figure. | reverse the period |
All seven carry schema_version: "1.0".
2. The envelope
Every Invo delivery — subscription or otherwise — has the same outer shape. The event-specific fields are always inside data.
| Field | Type | Notes |
|---|---|---|
event_id | string | Unique per delivery row. Changes on replay — never de-duplicate on it. |
idempotency_key | string | Stable for the logical event. Also sent as the X-Invo-Idempotency-Key header. De-duplicate on this. |
event_type | string | One of the seven names above. |
schema_version | string | "1.0" for every subscription event. |
created_at | string | ISO 8601 with an explicit offset. |
tenant_id | string | Your game_id, stringified. |
data | object | The body. Sections 3–6 document it per event. |
amount_usd, card_charged_usd, attributed_amount_usd — is serialised as a JSON string so your parser cannot silently turn it into a float. Parse it into whatever exact decimal type your language has, never into a Number. Coin figures are strings too, and are always named *_coins so a coin value can never be mistaken for a dollar value.3. The shared subscription block
Five of the seven events — payment_failed, past_due, authentication_required, canceled and expired — open data with an identical block, so you can write one decoder and switch on event_type for the extras.
Two events do not use it
subscription.renewed is emitted by the renewal engine and subscription.refunded by the refund endpoint, and each carries its own field set — documented in full in sections 4 and 6. Notably, neither one carries status, interval or item_name. Do not assume the shared block is present on them.
| Field | Type | Notes |
|---|---|---|
subscription_id | string | e.g. SUB_1786982400_A7K2M9QX. Not a credential — it is predictable, so never treat possession of one as authorisation. |
item_id | string | The membership identifier you passed at subscribe time. |
item_name | string | Human-readable name you supplied. |
player_email | string | null | The member. Best-effort: an event is still delivered if the member record cannot be loaded. |
identity_id | string | null | The same opaque, stable cross-tenant identity the SDK uses. Correlate a member across events with this rather than the email. |
status | string | trialing | active | past_due | awaiting_authentication | canceled | expired. |
amount_usd | string | null | The subscription's current price. For what a specific cycle was billed, read amount_due_usd on the event. |
interval, interval_count | string, int | The billing cadence. |
period_seq | int | Which cycle this is. Monotonic per subscription — the natural half of your idempotency key. |
current_period_start | string | ISO 8601. |
current_period_end | string | ISO 8601. A billing projection, not entitlement. It exists from the moment the subscription is created, before any money has moved. Never grant access to it. |
next_charge_at | string | null | ISO 8601. null once the subscription is finished. |
cancel_at_period_end | bool | Whether a cancellation is pending at the end of the paid window. |
metadata | object | Your own metadata from subscribe time, echoed back. {} if you sent none. |
4. subscription.renewed — the authoritative grant
Never grant entitlement from an API response
POST /api/subscriptions/subscribe returns a subscription in status active before any money has moved. Subscriptions bill in advance, and the very first charge is made by the renewal engine, not by the subscribe call. A member who has just subscribed has paid for nothing yet.
subscription.renewed is the only signal that grants or extends access. subscription.expired revokes it, and subscription.canceled tells you the date to revoke on. Everything else is informational. If you grant on the subscribe response you will be giving away cycles nobody paid for — including to cards that decline on the first attempt.
Fires once per settled cycle, inside the same transaction as the money — so there is no state in which the member was charged and you were not told, and none in which you were told about a charge that rolled back.
{
"event_id": "8f3a...c1",
"idempotency_key": "8f3a...c1",
"event_type": "subscription.renewed",
"schema_version": "1.0",
"created_at": "2026-08-01T09:14:02.418913+00:00",
"tenant_id": "97288428282",
"data": {
"subscription_id": "SUB_1786982400_A7K2M9QX",
"item_id": "guild_membership_gold",
"period_seq": 4,
"transaction_id": "TXN_1786982400_K3P9",
"order_id": 884213,
"mint_order_id": 884212,
"player_email": "member@example.com",
"identity_id": "f3a1b8c0d4e5...",
"amount_usd": "9.99",
"amount_coins": "99.90",
"period_start": "2026-07-01T09:00:00+00:00",
"period_end": "2026-08-01T09:00:00+00:00",
"current_period_start": "2026-08-01T09:00:00+00:00",
"current_period_end": "2026-09-01T09:00:00+00:00",
"next_charge_at": "2026-09-01T09:00:00+00:00",
"funding": {
"balance_applied_coins": "40.00",
"card_charged_usd": "5.99",
"minted_coins": "59.90",
"new_balance": "0.00"
},
"split": {
"total_usd": "9.99",
"invo_fee_usd": "0.65",
"partner_revenue_usd": "9.34",
"invo_fee_coins": "6.50",
"partner_revenue_coins": "93.40",
"invo_fee_percent": "6.50"
},
"revenue_share_attribution": {
"recipient_player_id": 40219,
"percent": "10.00",
"base_usd": "9.34",
"attributed_amount_usd": "0.93",
"settled_by_invo": false
},
"metadata": { "guild_id": "g_8812" }
}
}Fields
| Field | Type | Notes |
|---|---|---|
subscription_id, item_id, period_seq | string, string, int | (subscription_id, period_seq) identifies one paid cycle. This is your business idempotency key. |
transaction_id | string | Invo's ledger handle for the spend leg. |
order_id | int | The spend order — where the renewal's reconciliation record lives. |
mint_order_id | int | null | The top-up order, present only when a card leg was needed. This is the id that also appears as order_id on the duplicate purchase.completed — see section 7. |
player_email, identity_id | string | The member. |
amount_usd, amount_coins | string | What this cycle was actually billed — the snapshot taken at charge time, not today's price. |
period_start, period_end | string | The window this renewal paid for. period_end is the last instant of entitlement you just bought — grant to this. |
current_period_start, current_period_end | string | The clock after this renewal. current_period_start equals period_end; current_period_end is the projected end of the next, not yet paid cycle. Granting to it hands out a free month. |
next_charge_at | string | When the engine will attempt the next charge. |
funding.balance_applied_coins | string | Coins the member already held that were spent on this renewal. |
funding.card_charged_usd | string | The shortfall taken from the card on file. "0.00" for a wallet-funded renewal. |
funding.minted_coins | string | Coins created by the top-up leg. |
funding.new_balance | string | The member's balance after the renewal settled. |
split.* | object | The canonical two-leg split for this renewal: total_usd, invo_fee_usd, partner_revenue_usd, plus coin-denominated twins and invo_fee_percent. There are exactly two legs — Invo and you. |
revenue_share_attribution | object | null | null when no revenue share is configured for the subscription. Otherwise recipient_player_id, percent, base_usd, attributed_amount_usd, and settled_by_invo: false. |
metadata | object | Yours, echoed back. |
About revenue_share_attribution
settled_by_invo is false and always will be. This is a report of what a recipient earned on this renewal so you can accrue it in real time. Invo has paid that recipient nothing — you pay them, out of your own settlement. The same figure, aggregated and net of refunds, is available from the Subscription Reporting & Attribution API.
5. Dunning: payment_failed, past_due, expired
A failed renewal is retried on a schedule; the default is 3 retries over 7 days, then the subscription expires. The member keeps access for the whole grace window — only subscription.expired takes it away.
subscription.payment_failed
Fires on the first failing attempt, while the member still has access and can still fix their card — that is the entire point of a grace window. It fires on subsequent failures too; first_failure tells them apart, so you can escalate your messaging as the deadline approaches, or filter on it and only mail once.
{
"event_type": "subscription.payment_failed",
"schema_version": "1.0",
"data": {
// ...the shared subscription block (section 3)...
"status": "past_due",
"attempt_no": 1,
"period_seq": 5,
"period_start": "2026-08-01T09:00:00+00:00",
"period_end": "2026-09-01T09:00:00+00:00",
"amount_due_usd": "9.99",
"outcome": "failed",
"reason": "card_declined",
"failure_code": "card_declined",
"failure_message": "Your card was declined.",
"first_failure": true,
"retry_at": "2026-08-03T09:00:00+00:00",
"retries_remaining": 3,
"grace_period_end": "2026-08-08T09:00:00+00:00",
"access_retained": true
}
}| Field | Type | Notes |
|---|---|---|
attempt_no | int | null | Which charge attempt this was for the cycle. Increments on every attempt, including ones that consumed no retry budget. |
period_seq, period_start, period_end | int, string, string | The cycle that failed. |
amount_due_usd | string | null | The period's price, not the subscription's. A price change staged for the next cycle must not appear on a failure for the current one. |
outcome | string | How the attempt resolved. |
failure_code, reason | string | The machine-readable failure. reason carries the same value and exists for symmetry with the other events. |
failure_message | string | null | Human-readable detail. Safe to log; think before showing it verbatim to a member. |
first_failure | bool | First budget-consuming failure for this cycle. An authentication challenge does not make the next real decline a "second" failure. |
retry_at | string | null | When the next retry runs. null once the budget is gone. |
retries_remaining | int | null | Attempts left. null if it could not be computed — emit the message without a deadline rather than not at all. |
grace_period_end | string | null | The deadline to put in your email. When the last scheduled retry runs — past which the next failure expires the member. Computed live from the current schedule, so it is never a stale quote. |
access_retained | bool | Always true on this event. Do not revoke here. |
subscription.past_due
The state transition, not the attempt. Fires once, when the subscription actually enters past_due. Subscribe to this one if you want "tell me when a member goes into arrears" and want to ignore the per-attempt noise. Extras: period_seq, amount_due_usd, grace_period_end, retry_at, retries_remaining, and access_retained: true. Soft-degrade here; keep the row.
subscription.expired
The retry budget is exhausted. This is the event that revokes.
{
"event_type": "subscription.expired",
"schema_version": "1.0",
"data": {
// ...the shared subscription block (section 3)...
"status": "expired",
"period_seq": 5,
"failed_period_start": "2026-08-01T09:00:00+00:00",
"failed_period_end": "2026-09-01T09:00:00+00:00",
"final_attempt_no": 4,
"attempts_used": 4,
"failure_code": "card_declined",
"failure_message": "Your card was declined.",
"ended_at": "2026-08-08T09:03:11+00:00",
"final_period_end": "2026-08-01T09:00:00+00:00",
"access_retained": false
}
}| Field | Type | Notes |
|---|---|---|
failed_period_start, failed_period_end | string | The cycle that was never paid — reported separately so you can reconcile the gap. |
final_attempt_no, attempts_used | int | null | The last attempt, and how much retry budget was consumed. |
failure_code, failure_message | string | null | Why the last attempt failed. |
ended_at | string | null | When the subscription was retired. |
final_period_end | string | null | The last instant the member actually paid for. null when nothing was ever collected — i.e. the very first cycle failed. Revoke at this boundary, not at current_period_end. |
access_retained | bool | false. |
Ordering on the final failure
When the budget runs out we emit subscription.payment_failed (with retries_remaining: 0, retry_at: null, first_failure: false) and then subscription.expired — cause before consequence. They are enqueued in that order, but two separate deliveries can still arrive out of order over the wire. See section 7.
6. authentication_required, canceled, refunded
subscription.authentication_required — the one that will surprise you
This is not a failure event
No retry was consumed (retry_consumed: false), the member has not lost access (access_retained: true), and the subscription has not entered dunning — it is paused at awaiting_authentication waiting for the cardholder to satisfy their issuer. The common integration mistake is wiring this to the same handler as payment_failed and starting a cancellation email.
You must surface confirmation_url to the member. If you do not, they cannot complete it, and once the challenge ceiling is reached it converts to a decline and enters ordinary dunning — so a member who would happily have paid gets expired. Mostly UK/EU cardholders; this is regulatory, and retries cannot solve it.
{
"event_type": "subscription.authentication_required",
"schema_version": "1.0",
"data": {
// ...the shared subscription block (section 3)...
"status": "awaiting_authentication",
"period_seq": 6,
"attempt_no": 1,
"period_start": "2026-09-01T09:00:00+00:00",
"period_end": "2026-10-01T09:00:00+00:00",
"amount_due_usd": "9.99",
"card_amount_usd": "5.99",
"confirmation_url": "https://invo.network/subscription-auth?token=<opaque>",
"expires_at": "2026-09-04T09:00:00+00:00",
"reason": "authentication_required",
"access_retained": true,
"retry_consumed": false
}
}| Field | Type | Notes |
|---|---|---|
amount_due_usd | string | null | The renewal price for this cycle. |
card_amount_usd | string | null | What the card is actually being asked for — the wallet shortfall, not the renewal price. These differ whenever the member holds a balance. Quote this one if you show an amount. |
confirmation_url | string | null | Invo-hosted. Do not construct it yourself — use the one in the event. null means the challenge could not be surfaced; a half-formed link is worse than none. |
expires_at | string | null | After this, the subscription returns to ordinary dunning rather than being stranded. Relay the link promptly. |
reason | string | Always "authentication_required". |
access_retained, retry_consumed | bool | true / false. Stated explicitly because the natural — and wrong — reading of this event is "the payment failed". |
confirmation_url as a bearer capability. Anyone holding the link can complete the challenge. It stops opening anything the moment the renewal settles, and expires at expires_at. Relay it to the member over a channel you already trust with their account, and do not log it next to identifiers you publish elsewhere.subscription.canceled
Fires when the cancellation is requested, in both modes. An at-period-end cancel is one event with a future effective date — not a promise followed by a second event when the date arrives. Nothing else is coming; schedule the revocation yourself.
{
"event_type": "subscription.canceled",
"schema_version": "1.0",
"data": {
// ...the shared subscription block (section 3)...
"status": "active",
"cancel_at_period_end": true,
"effective_at": "2026-09-01T09:00:00+00:00",
"canceled_at": "2026-08-14T16:02:44+00:00",
"ended_at": null,
"access_until": "2026-09-01T09:00:00+00:00",
"paid_through": "2026-09-01T09:00:00+00:00",
"reason": "member_request"
}
}| Field | Type | Notes |
|---|---|---|
cancel_at_period_end | bool | Which mode was actually applied — read it, do not assume the flag you sent was honoured (see the callout below). |
effective_at | string | When the cancellation takes effect. In the future for an at-period-end cancel. |
canceled_at | string | null | When the request was made. |
ended_at | string | null | Set on an immediate cancel; null while an at-period-end cancel is still pending. |
access_until, paid_through | string | null | Same value: the paid-through boundary, derived from cycles that were actually charged. Revoke at this. Never at current_period_end. |
reason | string | null | Whatever reason was supplied on the cancel call. |
Cancelling a never-charged subscription
at_period_end: true on a subscription that has never been charged downgrades to an immediate cancellation: there is no paid period to preserve, and honouring the flag would hand out a free cycle nobody paid for. The event arrives with cancel_at_period_end: false and access_until at roughly now. The cancel API response flags the same thing with terminated_immediately and downgrade_reason: "NO_PAID_PERIOD". See Manage a Subscription.
subscription.refunded
You called the refund endpoint on a billed cycle. Fires per refund, so a cycle refunded in instalments produces one event each time. Reverse entitlement for the refunded period, and — importantly — take the recipient accrual back off your books using the corrected attribution figure.
{
"event_type": "subscription.refunded",
"schema_version": "1.0",
"data": {
"subscription_id": "SUB_1786982400_A7K2M9QX",
"item_id": "guild_membership_gold",
"period_seq": 4,
"transaction_id": "TXN_1786985511_B4W1",
"client_request_id": "refund-2026-08-14-0007",
"player_email": "member@example.com",
"identity_id": "f3a1b8c0d4e5...",
"amount_usd": "3.00",
"total_refunded_amount_usd": "3.00",
"period_amount_usd": "9.99",
"is_full_refund": false,
"reason": "goodwill",
"period_status": "paid",
"refund": {
"funding_shape": "mixed",
"balance_delta_coins": "30.00",
"card_refunded_usd": "0.00",
"new_balance": "30.00",
"invo_fee_retained": true,
"partner_revenue_reversed_usd": "2.81",
"partner_revenue_reversal_mode": "prorata"
},
"revenue_share_attribution": {
"recipient_player_id": 40219,
"percent": "10.00",
"original_attributed_amount_usd": "0.93",
"refunded_attributed_amount_usd": "0.28",
"net_attributed_amount_usd": "0.65",
"settled_by_invo": false
},
"metadata": { "guild_id": "g_8812" }
}
}| Field | Type | Notes |
|---|---|---|
transaction_id | string | The refund transaction, not the renewal's. Unique per refund — this is the right idempotency key for this event. |
client_request_id | string | Echoed from your refund call, so you can correlate. |
amount_usd | string | This refund only. |
total_refunded_amount_usd | string | Cumulative for the cycle, including this one. |
period_amount_usd | string | What the cycle was originally billed. |
is_full_refund | bool | Whether this refund took the cycle to fully refunded. |
period_status | string | paid after a partial; refunded once fully refunded. A fully refunded cycle stops counting toward entitlement. |
refund.funding_shape | string | wallet, card or mixed — how the original cycle was funded. |
refund.balance_delta_coins | string | Coins credited back to the member. Partial refunds settle entirely in coins. |
refund.card_refunded_usd | string | Cash returned to the card. "0.00" for a wallet-funded cycle or a partial refund. |
refund.new_balance | string | The member's balance after the credit. |
refund.invo_fee_retained | bool | Always true: Invo's own fee stays booked on a refund and the platform bears it. |
refund.partner_revenue_reversed_usd | string | USD of your revenue taken back. Named _usd because it is USD — do not compare it against a coin figure. |
refund.partner_revenue_reversal_mode | string | none, full or prorata. |
revenue_share_attribution | object | null | The corrected figure: original_attributed_amount_usd, refunded_attributed_amount_usd, and net_attributed_amount_usd — the last is what the recipient should actually be paid for this cycle. settled_by_invo: false. null when no revenue share is configured. |
7. Ordering, duplicates and idempotency
Delivery is at-least-once
A retry or a manual replay re-sends the same logical event. De-dupe on X-Invo-Idempotency-Key (stable across both), never on X-Invo-Event-Id (changes every delivery). Mechanics on Webhooks.
Order is emitted, not guaranteed
We enqueue cause before consequence — payment_failed then past_due/expired — but they are separate HTTP deliveries with independent retries. Your handler must tolerate arrival in either order.
The caveats worth writing down
- A card-funded renewal also emits
purchase.completed. Coins really were minted, through the same credit path every top-up uses, so the event is honest rather than spurious. If yourpurchase.completedhandler grants something, it will now grant on renewals too. Correlate: theorder_idon thatpurchase.completedis the same id asmint_order_idon the pairedsubscription.renewed. A wallet-funded renewal emits onlysubscription.renewed. - Cancellation fires once, at request time. There is no event when
effective_atarrives. Schedule your own revocation fromaccess_until. - Entitlement always derives from paid cycles. On
renewedthat isperiod_end; oncanceledit isaccess_until; onexpiredit isfinal_period_end. It is nevercurrent_period_end, which is a projection that exists before any money has moved. - Pick the right business key per event.
(subscription_id, period_seq)identifies one paid cycle and is correct forrenewed. It is not unique forrefunded— one cycle can be refunded several times — so key that on the refundtransaction_id. - Return 2xx quickly. Do the durable write, ACK, and do slow work (email, push) off a queue. A non-2xx is a request to retry; only send one for an error a retry could actually fix.
A worked handler
// Signature verification is identical for every Invo event; see /docs/webhooks
// for verifyInvoSignature(). This file is only about what to DO with the event.
app.post('/invo/webhooks',
express.raw({ type: 'application/json' }),
async (req, res) => {
if (!verifyInvoSignature(req.body, req.get('X-Invo-Signature'),
process.env.INVO_WEBHOOK_SECRET)) {
return res.status(400).send('bad signature');
}
const event = JSON.parse(req.body.toString('utf8'));
// LAYER 1 — transport de-dupe. Stable across retries AND manual replays.
if (await claimDeliveryKey(req.get('X-Invo-Idempotency-Key'))) {
return res.status(200).send('duplicate');
}
try {
await handleSubscriptionEvent(event);
} catch (err) {
// Non-2xx tells Invo to retry with backoff. Only do this for errors a
// retry can actually fix (your DB was down) — not for a bad payload.
logger.error(err);
return res.status(500).send('retry me');
}
return res.status(200).send('ok');
});
async function handleSubscriptionEvent(event) {
const d = event.data;
switch (event.event_type) {
case 'subscription.renewed': {
// LAYER 2 — business idempotency. One paid cycle is exactly
// (subscription_id, period_seq). Re-running this must be a no-op, not a
// second month of access and not a second accrual.
const cycleKey = d.subscription_id + ':' + d.period_seq;
await grantEntitlement({
cycleKey,
subscriptionId: d.subscription_id,
itemId: d.item_id,
member: d.identity_id,
// period_end is what THIS renewal paid for. current_period_end is the
// next, unpaid cycle — granting to it gives away a free month.
accessUntil: d.period_end,
nextChargeAt: d.next_charge_at,
});
// Accrue what you owe the recipient. Invo has paid them NOTHING:
// settled_by_invo is false. You pay them, out of your own settlement.
if (d.revenue_share_attribution) {
await accrueAttribution({
cycleKey,
recipientPlayerId: d.revenue_share_attribution.recipient_player_id,
amountUsd: d.revenue_share_attribution.attributed_amount_usd,
});
}
return;
}
case 'subscription.payment_failed':
// access_retained is TRUE. Do not revoke. Mail them the deadline.
if (d.first_failure) {
await emailFixYourCard({
member: d.identity_id,
amount: d.amount_due_usd,
deadline: d.grace_period_end, // may be null — send without a date
retryAt: d.retry_at,
});
}
return;
case 'subscription.authentication_required':
// NOT a failure. No retry consumed, access retained. The member must open
// this link before expires_at or they will be expired for nothing.
if (d.confirmation_url) {
await sendAuthChallenge({
member: d.identity_id,
url: d.confirmation_url, // never construct this yourself
amountUsd: d.card_amount_usd, // the CARD amount, not the price
expiresAt: d.expires_at,
});
}
return;
case 'subscription.past_due':
await softDegrade(d.subscription_id); // keep the row, dim the perks
return;
case 'subscription.canceled':
// Fires at REQUEST time. There is no second event on the effective date —
// schedule the revocation yourself.
await scheduleRevocation({
subscriptionId: d.subscription_id,
at: d.access_until, // paid-through, may be ~now
immediate: !d.cancel_at_period_end,
});
return;
case 'subscription.expired':
// The event that actually revokes.
await revokeEntitlement({
subscriptionId: d.subscription_id,
paidThrough: d.final_period_end, // null = never successfully charged
});
return;
case 'subscription.refunded': {
// NOT keyed on period_seq — a cycle can be refunded several times.
// The refund transaction id is unique per refund event.
await reverseCycle({
refundKey: d.transaction_id,
subscriptionId: d.subscription_id,
periodSeq: d.period_seq,
amountUsd: d.amount_usd, // THIS refund only
fullyRefunded: d.period_status === 'refunded',
});
if (d.revenue_share_attribution) {
await setAttributionNet({
cycleKey: d.subscription_id + ':' + d.period_seq,
amountUsd: d.revenue_share_attribution.net_attributed_amount_usd,
});
}
return;
}
default:
// Log the FULL raw envelope for anything you don't handle yet, BEFORE you
// ACK — a 2xx means "don't retry", and the event is gone otherwise.
await logUnhandled(event);
}
}8. Troubleshooting
| You saw | You expected | What is actually happening |
|---|---|---|
| A purchase.completed alongside every renewal | Only subscription.renewed | The renewal needed a card top-up, and minting coins fires purchase.completed on every rail. Correlate: that event’s order_id equals mint_order_id on the renewal. Wallet-funded renewals emit only subscription.renewed. |
| A subscription sitting active with no subscription.renewed | The member to have been charged at signup | Creating a subscription charges nothing — subscriptions bill in advance and the first charge is made by the renewal engine. Grant nothing until the first renewal settles. |
| subscription.canceled with a future effective_at, then silence | A second event on the end date | Cancellation fires once, at request time. The later retirement of the row is the fulfilment of that event, not a new one. Schedule your revocation from access_until. |
| cancel_at_period_end: false after you sent at_period_end: true | The flag to be honoured | The subscription had never been charged, so there was no paid period to preserve and honouring it would gift a free cycle. The cancel response says the same with terminated_immediately and downgrade_reason: NO_PAID_PERIOD. |
| A member expired after an authentication challenge | Retries to have resolved it | Step-up cannot be retried away — the cardholder must open confirmation_url. Once the challenge ceiling is reached it converts to a decline and enters ordinary dunning. Surface the link. |
| subscription.payment_failed but the member still has access | Immediate revocation | By design: access_retained is true for the entire grace window. Only subscription.expired revokes. Use grace_period_end as the deadline in your email. |
| subscription.expired with final_period_end: null | A date to revoke on | Nothing was ever successfully collected — the first cycle failed. There is no paid-through boundary because the member never paid for one. |
| revenue_share_attribution: null on a renewal | An attribution object | No revenue share was configured for that subscription at the time it renewed. That is a real answer: nobody earned anything on that renewal. |
| The same subscription.renewed twice | Exactly one delivery | At-least-once delivery, plus manual replay. De-dupe on X-Invo-Idempotency-Key and make the grant idempotent on (subscription_id, period_seq). |
| subscription.past_due arriving before subscription.payment_failed | Cause before consequence | We enqueue them in that order, but they are independent deliveries with independent retries. Reconcile on status and period_seq rather than on arrival order. |
| Two subscription.refunded events for one cycle | One per cycle | Refunds can be partial and repeated. Each event is one refund: amount_usd is that refund, total_refunded_amount_usd is the running total. Key on the refund transaction_id. |
| No subscription.amount_changed after staging a price change | A price-change event | That event does not exist. A staged change applies from the next period and shows up as a changed amount_usd on the next subscription.renewed. There is no proration. |
| Access ending a month later than you intended | Access to end at the paid boundary | You granted to current_period_end. That is a billing projection for the next, unpaid cycle. Grant to period_end on the renewal. |
9. Receiver checklist
- Subscribe with ["*"] so a new subscription event never silently misses you.
- Grant and extend access ONLY on subscription.renewed — never on an API response.
- Grant to period_end. Never to current_period_end.
- Revoke on subscription.expired, and at access_until for a cancellation you scheduled yourself.
- Treat subscription.authentication_required as an action item, not a failure — surface confirmation_url before expires_at.
- Do not revoke on payment_failed or past_due; access_retained is true.
- De-dupe on X-Invo-Idempotency-Key, then key your writes on (subscription_id, period_seq) — and on the refund transaction_id for refunds.
- Expect purchase.completed alongside card-funded renewals; correlate on mint_order_id.
- Parse every money field as an exact decimal, never a float; never compare a *_coins figure against a *_usd one.
- Log the full raw envelope for any event you do not handle yet, before you ACK.
Related
- Subscriptions Overview — the model, the billing clock, and who owns what.
- Create a Subscription — subscribing, idempotency, and capturing a card.
- Manage a Subscription — cancel, price changes, refunds.
- Renewals & Dunning — how a renewal is funded, retried and recovered.
- Subscription Reporting & Attribution — aggregate the per-renewal attribution into a payout run.
- Webhooks — signatures, headers, retries, secret rotation.
- Webhook Management — delivery history and replaying a dropped event.