Subscription webhooks

Every renewal, failure, authentication challenge, cancellation, expiry and refund reaches you as a webhook, and several carry data you cannot fetch any other way. This page covers registering, the envelope and headers, the exact signature scheme, retries and dedupe, then every event with a card example and a Steam example, and finally which event to trust for which decision.

There is no subscription.created and no subscription.amount_changed

The /subscribe (or /steam/finalize) response is your creation signal; the first period’s subscription.renewed is the first event. The price a period was billed at appears on that period’s subscription.renewed. Do not wait for an event that will never arrive.

1. Subscribing

Register a webhook subscription for the title, either in the developer console (Webhooks) or with the API: PUT /api/dev/webhooks/games/<game_id> with X-Game-Secret-Key (or your console session).

PUT $BASE/api/dev/webhooks/games/<game_id>
X-Game-Secret-Key: <game secret>
Content-Type: application/json

{"target_url": "https://your.server/invo/webhooks",
 "subscribed_events": ["subscription.renewed", "subscription.payment_failed",
                       "subscription.authentication_required", "subscription.past_due",
                       "subscription.canceled", "subscription.expired", "subscription.refunded"]}
  • ["*"] subscribes to everything.
  • The response carries signing_secret once, on creation only; store it.
  • POST .../rotate-secret issues a new one with a 7-day dual-signing grace.
  • POST .../test queues a webhook.test delivery so you can prove your endpoint before real traffic.
  • GET .../deliveries lists deliveries; POST /api/dev/webhooks/deliveries/<delivery_id>/replay re-sends a failed or dead one.
  • GET /api/dev/webhooks/supported-events lists every event name.

The full management surface (retry policy, compression, metrics) is on Webhook Management.

curl, register then fire the test event
curl -sS -X PUT "$BASE/api/dev/webhooks/games/$GAME_ID" \
  -H "X-Game-Secret-Key: $GAME_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"target_url": "https://your.server/invo/webhooks",
       "subscribed_events": ["subscription.renewed", "subscription.payment_failed",
                             "subscription.authentication_required", "subscription.past_due",
                             "subscription.canceled", "subscription.expired", "subscription.refunded"]}'
# -> store "signing_secret" from the response; it is shown once

curl -sS -X POST "$BASE/api/dev/webhooks/games/$GAME_ID/test" \
  -H "X-Game-Secret-Key: $GAME_SECRET"
# -> a webhook.test delivery arrives at your target_url

Prove the endpoint before the first real subscription

$GAME_ID is your title’s id from the console (also subscription.game_id on any subscription object). The per-title routes (/games/<game_id>, .../test, .../deliveries, replay) accept X-Game-Secret-Key; the unscoped list GET /api/dev/webhooks/games takes a console session only. The test delivery is a real delivery: it is signed with your secret, carries the headers below, retries on the same ladder, and shows up in GET /api/dev/webhooks/games/<game_id>/deliveries. It is refused with 404 when the title has no active webhook subscription yet.

{
  "event_id": "…", "event_type": "webhook.test", "schema_version": "1.0",
  "created_at": "…", "tenant_id": "<your game_id>",
  "data": {
    "message": "This is a test event triggered from the dashboard. If your endpoint returned 2xx and validated the signature, your webhook is configured correctly.",
    "triggered_by_user_id": 123
  }
}
  1. Prove your verifier offline against the test vector in section 2, before anything is registered.
  2. Register (above) and store signing_secret.
  3. Fire the test event. Your handler must verify the signature over the raw bytes and answer 2xx within 10 seconds.
  4. Read GET .../deliveries (below): the webhook.test row’s items[].status should read succeeded. A failed or dead row carries your endpoint’s status code in last_response_code and the reason in last_error; fix and replay it with POST /api/dev/webhooks/deliveries/<delivery_id>/replay.
  5. Only then create a subscription. Events fired before a target is registered are not delivered to it later.
GET $BASE/api/dev/webhooks/games/<game_id>/deliveries?page=1&per_page=25
X-Game-Secret-Key: <game secret>

200
{"success": true,
 "items": [
   {"delivery_id": 9182, "event_id": "…", "event_type": "webhook.test",
    "status": "succeeded", "attempts": 1, "last_response_code": 200, "last_error": null,
    "next_retry_at": null, "created_at": "…", "last_attempted_at": "…", "succeeded_at": "…"}
 ],
 "pagination": {"page": 1, "per_page": 25, "total": 1, "total_pages": 1}}

items[].status is one of pending, in_progress, succeeded, failed, dead. per_page defaults to 25 and is capped at 100.

2. Envelope, headers, signature, retries

Every delivery is an HTTPS POST with Content-Type: application/json and this envelope:

{
  "event_id": "b6b1c5d4-...",
  "idempotency_key": "b6b1c5d4-...",
  "event_type": "subscription.renewed",
  "schema_version": "1.0",
  "created_at": "2026-10-06T14:38:01.220431+00:00",
  "tenant_id": "1234",
  "data": { ... }
}
HeaderValue
X-Invo-Signaturet=<unix seconds>,v1=<hex> (during a secret rotation, two v1= values)
X-Invo-Event-IdUnique per delivery attempt row; changes on every replay.
X-Invo-Idempotency-KeyStable across replays. Dedupe on this.
X-Invo-Secret-VersionInteger version of the signing secret in use.
User-Agentinvo-webhooks/1.0
Content-Encodinggzip, only if you opted into compression and the body is at least 1 KB. Decompress first, then verify.

Verifying the signature, exactly

  1. Take the raw request body bytes exactly as received (after decompression if gzip). Never re-serialise a parsed object.
  2. Split X-Invo-Signature on commas. Read t and every v1 value.
  3. Reject if abs(now - t) exceeds 300 seconds.
  4. Compute HMAC-SHA256(secret, "<t>." + raw_body) as lowercase hex, where the signed string is the timestamp, a literal ., then the body bytes.
  5. Accept if any v1 value equals the result, compared in constant time. During a rotation, try both your old and new secret.

Offline test vector

Feed these three values to your verifier before you register anything (with the timestamp tolerance disabled, or now pinned to 1789257600); it must accept. The body is the exact bytes below with no trailing newline.

secret:  whsec_test_0123456789abcdef
header:  X-Invo-Signature: t=1789257600,v1=f0b9502d3fd7d289fd30c81fbc147a617cb3e46262681574be04d97ce71c77f6
body:    {"event_id":"11111111-2222-3333-4444-555555555555","event_type":"webhook.test","schema_version":"1.0","created_at":"2026-09-13T00:00:00+00:00","tenant_id":"155963559928","data":{"message":"hello"}}

# i.e. HMAC-SHA256("whsec_test_0123456789abcdef", "1789257600." + body) as lowercase hex
Node, @invonetwork/web-sdk
import express from "express";
import { verifyWebhook } from "@invonetwork/web-sdk/server";

const app = express();

// raw body: the signature is over the exact bytes, so do not use express.json() here
app.post("/invo/webhooks", express.raw({ type: "*/*" }), async (req, res) => {
  let event;
  try {
    event = verifyWebhook(req.body, req.headers["x-invo-signature"], [
      process.env.INVO_WEBHOOK_SECRET!,
      process.env.INVO_WEBHOOK_SECRET_PREVIOUS,     // during a rotation; otherwise omit
    ].filter(Boolean) as string[]);
  } catch (err) {
    return res.status(400).end();                   // WEBHOOK_SIGNATURE_INVALID, WEBHOOK_TIMESTAMP_EXPIRED, ...
  }

  // dedupe on the idempotency key, which survives replays (event_id does not)
  if (await seen(req.headers["x-invo-idempotency-key"] as string)) return res.status(200).end();

  await handle(event);          // event.eventType, event.data (typed per event)
  res.status(200).end();        // any 2xx within 10 seconds is success
});
Node, raw HTTP
import { createHmac, timingSafeEqual } from "node:crypto";

function verify(rawBody: Buffer, signatureHeader: string, secrets: string[]): boolean {
  const parts = Object.create(null);
  const v1s: string[] = [];
  for (const kv of signatureHeader.split(",")) {
    const [k, v] = kv.split("=");
    if (k === "t") parts.t = v;
    if (k === "v1") v1s.push(v);
  }
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;

  const message = Buffer.concat([Buffer.from(String(t) + "."), rawBody]);
  for (const secret of secrets) {
    const expected = createHmac("sha256", secret).update(message).digest("hex");
    for (const sig of v1s) {
      if (sig.length === expected.length &&
          timingSafeEqual(Buffer.from(sig, "hex"), Buffer.from(expected, "hex"))) return true;
    }
  }
  return false;
}
Python, invonetwork
from flask import Flask, request
from invonetwork import verify_webhook, InvoError

app = Flask(__name__)

@app.post("/invo/webhooks")
def invo_webhooks():
    try:
        event = verify_webhook(
            request.get_data(),                             # raw bytes, never re-serialised
            request.headers.get("X-Invo-Signature"),
            [s for s in (os.environ["INVO_WEBHOOK_SECRET"],
                         os.environ.get("INVO_WEBHOOK_SECRET_PREVIOUS")) if s],   # both during a rotation
        )
    except InvoError:
        return "", 400

    key = request.headers.get("X-Invo-Idempotency-Key")     # stable across replays; dedupe on it
    if seen(key):
        return "", 200

    handle(event)            # event.event_type, event.data (typed per event)
    return "", 200           # any 2xx within 10 seconds is success
Python, raw HTTP
import hmac, hashlib, time

def verify(raw_body: bytes, signature_header: str, secrets: list[str]) -> bool:
    t, v1s = None, []
    for kv in signature_header.split(","):
        k, _, v = kv.partition("=")
        if k == "t":
            t = int(v)
        elif k == "v1":
            v1s.append(v)
    if not t or abs(time.time() - t) > 300:
        return False
    message = f"{t}.".encode() + raw_body
    for secret in secrets:
        expected = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
        if any(hmac.compare_digest(sig, expected) for sig in v1s):
            return True
    return False

Delivery and retries

  • Success is any 2xx response. Respond within 10 seconds (connect and read timeouts are 10 seconds each; you may set an override of 1 to 60 seconds through PATCH /api/dev/webhooks/games/<game_id>/retry-policy). Acknowledge first, do the work after.
  • On anything else Invo retries after roughly 30 s, 2 min, 10 min, 1 h, 6 h and 24 h (each with 20 percent jitter): six attempts over about 31 hours, then the delivery is dead. You may set max_attempts from 1 to 12 through the same retry-policy call.
  • A replayed delivery has a new event_id, the original idempotency_key, and data unchanged, plus a replay_of field naming the original event_id.
  • Process events idempotently and out of order. The envelope is delivered at least once.
  • Receivers should ignore unknown fields. Additive fields do not bump schema_version; a breaking change would.

3. Events

Every event except subscription.renewed and subscription.refunded carries this common block in data, with extras per event:

{
  "subscription_id": "SUB_...", "item_id": "guild-42-membership", "item_name": "Guild 42 membership",
  "player_email": "member@example.com", "identity_id": "idn_...",
  "status": "past_due", "amount_usd": "9.99", "interval": "month", "interval_count": 1,
  "period_seq": 2, "current_period_start": "...", "current_period_end": "...",
  "next_charge_at": "...", "cancel_at_period_end": false,
  "funding_rail": "card", "steam_agreement_status": null,
  "metadata": {"guild_id": "42"}
}

identity_id is an opaque, stable id for the member across Invo events; it is not the email. On a Steam subscription the common block reads funding_rail: "steam" and steam_agreement_status is pending, active or canceled.

subscription.renewed (the source of truth for entitlement)

Fires once per successfully charged period, including period 1. Extend the member’s access to current_period_start (equivalently period_end of the paid period), which is the new paid_through. The period_seq on this event is the period just paid; key your entitlement record on it.

Card example

{
  "subscription_id": "SUB_...", "item_id": "guild-42-membership",
  "period_seq": 2, "transaction_id": "TXN_...", "order_id": "ORD_...", "mint_order_id": "ORD_...",
  "player_email": "member@example.com", "identity_id": "idn_...",
  "amount_usd": "9.99", "amount_coins": "99.90",
  "period_start": "2026-10-06T14:37:44+00:00", "period_end": "2026-11-06T14:37:44+00:00",
  "current_period_start": "2026-11-06T14:37:44+00:00", "current_period_end": "2026-12-06T14:37:44+00:00",
  "next_charge_at": "2026-11-06T14:37:44+00:00",
  "funding": {
    "balance_applied_coins": "20.00", "card_charged_usd": "7.99",
    "rail": "card", "steam_charged_usd": "0.00",
    "minted_coins": "79.90", "new_balance": "0.00"
  },
  "split": {
    "total_usd": "9.99", "basis": "price",
    "invo_fee_usd": "1.30", "partner_revenue_usd": "8.69",
    "invo_fee_coins": "13.00", "partner_revenue_coins": "86.90",
    "invo_fee_percent": "10.000"
  },
  "revenue_share_attribution": {
    "recipient_player_id": 4242, "percent": "70.00", "base_usd": "8.69",
    "attributed_amount_usd": "6.08", "settled_by_invo": false
  },
  "metadata": {"guild_id": "42"}
}

Steam example (US member, partial wallet)

{
  "subscription_id": "SUB_...", "item_id": "guild-42-membership",
  "period_seq": 2, "transaction_id": "TXN_...", "order_id": "ORD_...", "mint_order_id": "ORD_...",
  "player_email": "member@example.com", "identity_id": "idn_...",
  "amount_usd": "9.99", "amount_coins": "69",
  "period_start": "...", "period_end": "...",
  "current_period_start": "...", "current_period_end": "...", "next_charge_at": "...",
  "funding": {
    "balance_applied_coins": "20", "card_charged_usd": "0.00",
    "rail": "steam", "steam_charged_usd": "7.00",
    "minted_coins": "49", "new_balance": "0"
  },
  "split": {
    "total_usd": "6.90", "basis": "steam_net",
    "invo_fee_usd": "0.69", "partner_revenue_usd": "6.21",
    "invo_fee_coins": "6.90", "partner_revenue_coins": "62.10",
    "invo_fee_percent": "10.000"
  },
  "revenue_share_attribution": null,
  "metadata": {"guild_id": "42"}
}
  • period_start / period_end are the period just paid; current_period_* and next_charge_at are the new window.
  • amount_usd is the price the period was billed at (a staged price change appears here from the period it applies to). amount_coins is what the period was worth.
  • mint_order_id is null when the wallet covered the whole period.
  • On Steam, split.total_usd is what the coins are worth (basis: "steam_net"), not the price, because Valve’s share and VAT come off before Invo’s split. The top-level amount_usd is always the price.
  • revenue_share_attribution is null when there is no share. The split figures in the card example are illustrative; read the fields.

Reconciling period 1 against the create response

The first subscription.renewed describes the same charge as the /subscribe 201 (or the /steam/finalize 200). They agree field for field; the pair to key on is the paid period.

On the create responseOn subscription.renewedNote
subscription.subscription_idsubscription_idJoin key.
first_charge.paid_period_seq (1, or 2 after a trial)period_seqThe period paid. Not subscription.period_seq, which already reads the next period.
first_charge.paid_through = subscription.paid_throughperiod_end = current_period_startThe entitlement boundary.
first_charge.amount_usdamount_usdThe price the period was billed at.
subscription.amount_coins_estimateamount_coinsWhat the period was worth.
subscription.current_period_start / _end, next_charge_atcurrent_period_start / _end, next_charge_atThe next window, on both.
(not on the response)transaction_id, funding, splitLedger handle and money figures exist only on the event (and on reporting).

subscription.payment_failed

Fires on every failed attempt, including the first, while the member still has access. Common block plus:

Card example

{
  ...common block (status "past_due", funding_rail "card")...,
  "attempt_no": 1, "period_seq": 2,
  "period_start": "...", "period_end": "...", "amount_due_usd": "9.99",
  "outcome": "card_declined", "reason": "card_declined", "failure_code": "card_declined",
  "failure_message": "Your card was declined.", "first_failure": true,
  "retry_at": "2026-11-08T14:37:44+00:00", "retries_remaining": 3,
  "grace_period_end": "2026-11-13T14:37:44+00:00", "access_retained": true
}

Steam example

{
  ...common block (status "past_due", funding_rail "steam", steam_agreement_status "canceled")...,
  "attempt_no": 2, "period_seq": 3,
  "period_start": "...", "period_end": "...", "amount_due_usd": "9.99",
  "outcome": "error", "reason": "steam_agreement_canceled", "failure_code": "steam_agreement_canceled",
  "failure_message": "The Steam agreement is no longer active.", "first_failure": false,
  "retry_at": "2026-12-11T14:37:44+00:00", "retries_remaining": 2,
  "grace_period_end": "2026-12-13T14:37:44+00:00", "access_retained": true
}

On the final failure retry_at is null, retries_remaining is 0, grace_period_end is null, first_failure is false, and subscription.expired follows. outcome is one of card_declined, insufficient_funds, error. The full list of failure_code values is on the renewals page.

subscription.past_due

Fires once when the subscription enters past_due (not on every retry). Common block plus:

Card example

{
  ...common block (status "past_due", funding_rail "card")...,
  "period_seq": 2, "amount_due_usd": "9.99",
  "grace_period_end": "2026-11-13T14:37:44+00:00",
  "retry_at": "2026-11-08T14:37:44+00:00", "retries_remaining": 3, "access_retained": true
}

Steam example

{
  ...common block (status "past_due", funding_rail "steam", steam_agreement_status "active")...,
  "period_seq": 3, "amount_due_usd": "9.99",
  "grace_period_end": "2026-12-13T14:37:44+00:00",
  "retry_at": "2026-12-08T14:37:44+00:00", "retries_remaining": 3, "access_retained": true
}

subscription.authentication_required

Not a failure. Common block (status awaiting_authentication) plus:

Card example

{
  ...common block (status "awaiting_authentication", funding_rail "card")...,
  "period_seq": 2, "attempt_no": 1, "period_start": "...", "period_end": "...",
  "amount_due_usd": "9.99", "card_amount_usd": "7.99",
  "confirmation_url": "https://<invo checkout host>/subscription-auth?token=...",
  "expires_at": "2026-11-09T14:37:44+00:00",
  "reason": "authentication_required", "access_retained": true, "retry_consumed": false
}

Card rail only. A Steam subscription never sends this event. card_amount_usd is what the card is being asked for (the wallet shortfall); amount_due_usd is the period price. Relay confirmation_url to the member before expires_at; do not log it beside identifiers you publish.

subscription.canceled

Fires once, at the moment cancellation is requested, in both modes. Common block plus:

Card example (at period end, by you)

{
  ...common block (status "active", cancel_at_period_end true, funding_rail "card")...,
  "effective_at": "2026-11-06T14:37:44+00:00", "cancel_at_period_end": true,
  "canceled_at": null, "ended_at": null,
  "access_until": "2026-11-06T14:37:44+00:00", "paid_through": "2026-11-06T14:37:44+00:00",
  "reason": "member request", "canceled_by": "partner"
}

Steam example (by the member, from their Steam account)

{
  ...common block (status "canceled", funding_rail "steam", steam_agreement_status "canceled")...,
  "effective_at": "2026-10-20T09:15:02+00:00", "cancel_at_period_end": false,
  "canceled_at": "2026-10-20T09:15:02+00:00", "ended_at": "2026-10-20T09:15:02+00:00",
  "access_until": "2026-11-06T14:37:44+00:00", "paid_through": "2026-11-06T14:37:44+00:00",
  "reason": null, "canceled_by": "steam"
}

canceled_by is partner for your API call and steam when the member cancelled the agreement from their Steam account (then cancel_at_period_end is false, effective_at is now, and access runs to access_until). Revoke at access_until in every case.

subscription.expired

The event that revokes entitlement. Common block (status expired) plus:

Card example (retry budget exhausted)

{
  ...common block (status "expired", funding_rail "card")...,
  "period_seq": 2, "failed_period_start": "...", "failed_period_end": "...",
  "final_attempt_no": 4, "attempts_used": 4,
  "failure_code": "card_declined", "failure_message": "Your card was declined.",
  "ended_at": "2026-11-13T14:40:02+00:00",
  "final_period_end": "2026-11-06T14:37:44+00:00", "access_retained": false
}

Steam example (never authorised)

{
  ...common block (status "expired", funding_rail "steam", steam_agreement_status "pending")...,
  "period_seq": 1, "failed_period_start": "...", "failed_period_end": "...",
  "final_attempt_no": null, "attempts_used": 0,
  "failure_code": "steam_authorization_abandoned", "failure_message": "The member did not authorise the agreement in Steam.",
  "ended_at": "2026-10-07T14:05:00+00:00",
  "final_period_end": null, "access_retained": false
}

final_period_end is the last instant the member paid for; null if nothing was ever collected (an expiry on period 1, or a Steam subscription that was never authorised). Revoke at final_period_end, not at ended_at. One expiry arrives without a preceding payment_failed: steam_authorization_abandoned (above). Handle subscription.expired on its own, never as “the fourth payment_failed”. A second such code, steam_containment, is no longer returned: currency is spendable in any title regardless of where it was bought, so no renewal is deferred or expired for that reason.

subscription.refunded

Its own shape, without the common block:

Card example (full refund of a mixed-funded period)

{
  "subscription_id": "SUB_...", "item_id": "guild-42-membership", "period_seq": 2,
  "transaction_id": "TXN_...", "client_request_id": "refund-2026-11-guild-42-member-7",
  "player_email": "member@example.com", "identity_id": "idn_...",
  "amount_usd": "9.99", "total_refunded_amount_usd": "9.99", "period_amount_usd": "9.99",
  "is_full_refund": true, "reason": "member request", "period_status": "refunded",
  "refund": {
    "funding_shape": "mixed", "balance_delta_coins": "20.00", "card_refunded_usd": "7.99",
    "processor_refund_adopted": false, "new_balance": "20.00", "invo_fee_retained": true,
    "partner_revenue_reversed_usd": "8.69", "partner_revenue_reversal_mode": "full"
  },
  "revenue_share_attribution": {
    "recipient_player_id": 4242, "percent": "70.00",
    "original_attributed_amount_usd": "6.08", "refunded_attributed_amount_usd": "6.08",
    "net_attributed_amount_usd": "0.00", "settled_by_invo": false
  },
  "metadata": {"guild_id": "42"}
}

Steam example (a period the wallet covered entirely; the only refundable kind on Steam today)

{
  "subscription_id": "SUB_...", "item_id": "guild-42-membership", "period_seq": 3,
  "transaction_id": "TXN_...", "client_request_id": "refund-2026-12-guild-42-member-7",
  "player_email": "member@example.com", "identity_id": "idn_...",
  "amount_usd": "9.99", "total_refunded_amount_usd": "9.99", "period_amount_usd": "9.99",
  "is_full_refund": true, "reason": "member request", "period_status": "refunded",
  "refund": {
    "funding_shape": "wallet", "balance_delta_coins": "69", "card_refunded_usd": "0.00",
    "processor_refund_adopted": false, "new_balance": "80", "invo_fee_retained": true,
    "partner_revenue_reversed_usd": "6.21", "partner_revenue_reversal_mode": "full"
  },
  "revenue_share_attribution": null,
  "metadata": {"guild_id": "42"}
}

Field meanings are on the refunds page.

4. A complete handler

Both SDKs hand you the verified envelope with the wire names unchanged (the body is the signed artefact): event.event_type, event.idempotency_key, and event.data with the snake_case fields shown above. The Node package types data per event; the Python package gives you a plain dict. The same handler therefore works whether you verified with the SDK or with the raw HMAC above.

Node, @invonetwork/web-sdk (typed events, wire names)
async function handle(event) {
  const d = event.data;
  switch (event.event_type) {
    case "subscription.renewed":
      // d.period_seq is the period JUST PAID; access runs to d.current_period_start (the new paid_through)
      await extendAccess(d.subscription_id, d.period_seq, d.current_period_start);
      await recordRevenue(d.subscription_id, d.period_seq, d.split.partner_revenue_usd);
      if (d.revenue_share_attribution) await accrueAttribution(d.revenue_share_attribution);  // you pay this, not Invo
      break;
    case "subscription.payment_failed":
      await notifyMember(d.player_email, d.failure_code, d.retry_at);      // access is retained
      break;
    case "subscription.past_due":
      await flagPastDue(d.subscription_id, d.grace_period_end);           // access is retained
      break;
    case "subscription.authentication_required":
      await sendLink(d.player_email, d.confirmation_url, d.expires_at);   // not a failure; do not revoke
      break;
    case "subscription.canceled":
      await revokeAt(d.subscription_id, d.access_until);                  // partner or steam
      break;
    case "subscription.expired":
      await revokeAt(d.subscription_id, d.final_period_end);              // null => nothing was ever paid
      break;
    case "subscription.refunded":
      await reverseRevenue(d.subscription_id, d.period_seq, d.refund.partner_revenue_reversed_usd);
      if (d.revenue_share_attribution) await correctAttribution(d.revenue_share_attribution);
      break;
    case "webhook.test":
      break;
    default:
      // ignore unknown events; new ones may be added without a schema bump
  }
}
Python, invonetwork (event.data is a dict, wire names)
def handle(event):
    d = event.data
    t = event.event_type
    if t == "subscription.renewed":
        # d["period_seq"] is the period JUST PAID; access runs to d["current_period_start"] (the new paid_through)
        extend_access(d["subscription_id"], d["period_seq"], d["current_period_start"])
        record_revenue(d["subscription_id"], d["period_seq"], d["split"]["partner_revenue_usd"])
        if d.get("revenue_share_attribution"):
            accrue_attribution(d["revenue_share_attribution"])           # you pay this, not Invo
    elif t == "subscription.payment_failed":
        notify_member(d["player_email"], d["failure_code"], d["retry_at"])     # access is retained
    elif t == "subscription.past_due":
        flag_past_due(d["subscription_id"], d["grace_period_end"])             # access is retained
    elif t == "subscription.authentication_required":
        send_link(d["player_email"], d["confirmation_url"], d["expires_at"])   # not a failure; do not revoke
    elif t == "subscription.canceled":
        revoke_at(d["subscription_id"], d["access_until"])                     # partner or steam
    elif t == "subscription.expired":
        revoke_at(d["subscription_id"], d["final_period_end"])                 # None => nothing was ever paid
    elif t == "subscription.refunded":
        reverse_revenue(d["subscription_id"], d["period_seq"], d["refund"]["partner_revenue_reversed_usd"])
        if d.get("revenue_share_attribution"):
            correct_attribution(d["revenue_share_attribution"])
    elif t == "webhook.test":
        pass
    # ignore unknown events; new ones may be added without a schema bump

5. Which event to trust

QuestionAnswer
Has the member paid for the period they are in?subscription.renewed for that period, or paid_through on GET.
Should I revoke access?Only on subscription.expired (at final_period_end) or on subscription.canceled (at access_until). Never on payment_failed, past_due or authentication_required.
Is the subscription still live?GET, or the status on the latest event.
What did I earn?split.partner_revenue_usd on subscription.renewed, minus partner_revenue_reversed_usd on any subscription.refunded. For a windowed statement use reporting.
Which period does an event belong to?Its own period_seq. On renewed that is the period just paid; on payment_failed, past_due, authentication_required and expired it is the period being attempted.