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.

EventFires exactly whenAccess
subscription.renewedA 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_failedA 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_dueThe subscription entered past_due. Fires once, on the state transition, not once per attempt.retained
subscription.authentication_requiredThe 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.canceledA 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.expiredThe retry budget was exhausted and the subscription ended. This is the event that revokes.revoked
subscription.refundedYou 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.

FieldTypeNotes
event_idstringUnique per delivery row. Changes on replay — never de-duplicate on it.
idempotency_keystringStable for the logical event. Also sent as the X-Invo-Idempotency-Key header. De-duplicate on this.
event_typestringOne of the seven names above.
schema_versionstring"1.0" for every subscription event.
created_atstringISO 8601 with an explicit offset.
tenant_idstringYour game_id, stringified.
dataobjectThe body. Sections 3–6 document it per event.
Money is always a string. Every decimal in every subscription payload — 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.

FieldTypeNotes
subscription_idstringe.g. SUB_1786982400_A7K2M9QX. Not a credential — it is predictable, so never treat possession of one as authorisation.
item_idstringThe membership identifier you passed at subscribe time.
item_namestringHuman-readable name you supplied.
player_emailstring | nullThe member. Best-effort: an event is still delivered if the member record cannot be loaded.
identity_idstring | nullThe same opaque, stable cross-tenant identity the SDK uses. Correlate a member across events with this rather than the email.
statusstringtrialing | active | past_due | awaiting_authentication | canceled | expired.
amount_usdstring | nullThe subscription's current price. For what a specific cycle was billed, read amount_due_usd on the event.
interval, interval_countstring, intThe billing cadence.
period_seqintWhich cycle this is. Monotonic per subscription — the natural half of your idempotency key.
current_period_startstringISO 8601.
current_period_endstringISO 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_atstring | nullISO 8601. null once the subscription is finished.
cancel_at_period_endboolWhether a cancellation is pending at the end of the paid window.
metadataobjectYour 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

FieldTypeNotes
subscription_id, item_id, period_seqstring, string, int(subscription_id, period_seq) identifies one paid cycle. This is your business idempotency key.
transaction_idstringInvo's ledger handle for the spend leg.
order_idintThe spend order — where the renewal's reconciliation record lives.
mint_order_idint | nullThe 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_idstringThe member.
amount_usd, amount_coinsstringWhat this cycle was actually billed — the snapshot taken at charge time, not today's price.
period_start, period_endstringThe window this renewal paid for. period_end is the last instant of entitlement you just bought — grant to this.
current_period_start, current_period_endstringThe 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_atstringWhen the engine will attempt the next charge.
funding.balance_applied_coinsstringCoins the member already held that were spent on this renewal.
funding.card_charged_usdstringThe shortfall taken from the card on file. "0.00" for a wallet-funded renewal.
funding.minted_coinsstringCoins created by the top-up leg.
funding.new_balancestringThe member's balance after the renewal settled.
split.*objectThe 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_attributionobject | nullnull when no revenue share is configured for the subscription. Otherwise recipient_player_id, percent, base_usd, attributed_amount_usd, and settled_by_invo: false.
metadataobjectYours, 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
  }
}
FieldTypeNotes
attempt_noint | nullWhich charge attempt this was for the cycle. Increments on every attempt, including ones that consumed no retry budget.
period_seq, period_start, period_endint, string, stringThe cycle that failed.
amount_due_usdstring | nullThe 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.
outcomestringHow the attempt resolved.
failure_code, reasonstringThe machine-readable failure. reason carries the same value and exists for symmetry with the other events.
failure_messagestring | nullHuman-readable detail. Safe to log; think before showing it verbatim to a member.
first_failureboolFirst budget-consuming failure for this cycle. An authentication challenge does not make the next real decline a "second" failure.
retry_atstring | nullWhen the next retry runs. null once the budget is gone.
retries_remainingint | nullAttempts left. null if it could not be computed — emit the message without a deadline rather than not at all.
grace_period_endstring | nullThe 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_retainedboolAlways 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
  }
}
FieldTypeNotes
failed_period_start, failed_period_endstringThe cycle that was never paid — reported separately so you can reconcile the gap.
final_attempt_no, attempts_usedint | nullThe last attempt, and how much retry budget was consumed.
failure_code, failure_messagestring | nullWhy the last attempt failed.
ended_atstring | nullWhen the subscription was retired.
final_period_endstring | nullThe 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_retainedboolfalse.

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
  }
}
FieldTypeNotes
amount_due_usdstring | nullThe renewal price for this cycle.
card_amount_usdstring | nullWhat 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_urlstring | nullInvo-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_atstring | nullAfter this, the subscription returns to ordinary dunning rather than being stranded. Relay the link promptly.
reasonstringAlways "authentication_required".
access_retained, retry_consumedbooltrue / false. Stated explicitly because the natural — and wrong — reading of this event is "the payment failed".
Treat 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"
  }
}
FieldTypeNotes
cancel_at_period_endboolWhich mode was actually applied — read it, do not assume the flag you sent was honoured (see the callout below).
effective_atstringWhen the cancellation takes effect. In the future for an at-period-end cancel.
canceled_atstring | nullWhen the request was made.
ended_atstring | nullSet on an immediate cancel; null while an at-period-end cancel is still pending.
access_until, paid_throughstring | nullSame value: the paid-through boundary, derived from cycles that were actually charged. Revoke at this. Never at current_period_end.
reasonstring | nullWhatever 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" }
  }
}
FieldTypeNotes
transaction_idstringThe refund transaction, not the renewal's. Unique per refund — this is the right idempotency key for this event.
client_request_idstringEchoed from your refund call, so you can correlate.
amount_usdstringThis refund only.
total_refunded_amount_usdstringCumulative for the cycle, including this one.
period_amount_usdstringWhat the cycle was originally billed.
is_full_refundboolWhether this refund took the cycle to fully refunded.
period_statusstringpaid after a partial; refunded once fully refunded. A fully refunded cycle stops counting toward entitlement.
refund.funding_shapestringwallet, card or mixed — how the original cycle was funded.
refund.balance_delta_coinsstringCoins credited back to the member. Partial refunds settle entirely in coins.
refund.card_refunded_usdstringCash returned to the card. "0.00" for a wallet-funded cycle or a partial refund.
refund.new_balancestringThe member's balance after the credit.
refund.invo_fee_retainedboolAlways true: Invo's own fee stays booked on a refund and the platform bears it.
refund.partner_revenue_reversed_usdstringUSD of your revenue taken back. Named _usd because it is USD — do not compare it against a coin figure.
refund.partner_revenue_reversal_modestringnone, full or prorata.
revenue_share_attributionobject | nullThe 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 your purchase.completed handler grants something, it will now grant on renewals too. Correlate: the order_id on that purchase.completed is the same id as mint_order_id on the paired subscription.renewed. A wallet-funded renewal emits only subscription.renewed.
  • Cancellation fires once, at request time. There is no event when effective_at arrives. Schedule your own revocation from access_until.
  • Entitlement always derives from paid cycles. On renewed that is period_end; on canceled it is access_until; on expired it is final_period_end. It is never current_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 for renewed. It is not unique for refunded — one cycle can be refunded several times — so key that on the refund transaction_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

Node.js / Express — idempotent subscription processing
// 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 sawYou expectedWhat is actually happening
A purchase.completed alongside every renewalOnly subscription.renewedThe 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.renewedThe member to have been charged at signupCreating 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 silenceA second event on the end dateCancellation 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: trueThe flag to be honouredThe 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 challengeRetries to have resolved itStep-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 accessImmediate revocationBy 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: nullA date to revoke onNothing 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 renewalAn attribution objectNo 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 twiceExactly one deliveryAt-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_failedCause before consequenceWe 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 cycleOne per cycleRefunds 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 changeA price-change eventThat 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 intendedAccess to end at the paid boundaryYou 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