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_secretonce, on creation only; store it. POST .../rotate-secretissues a new one with a 7-day dual-signing grace.POST .../testqueues awebhook.testdelivery so you can prove your endpoint before real traffic.GET .../deliverieslists deliveries;POST /api/dev/webhooks/deliveries/<delivery_id>/replayre-sends afailedordeadone.GET /api/dev/webhooks/supported-eventslists every event name.
The full management surface (retry policy, compression, metrics) is on Webhook Management.
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_urlProve 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
}
}- Prove your verifier offline against the test vector in section 2, before anything is registered.
- Register (above) and store
signing_secret. - Fire the test event. Your handler must verify the signature over the raw bytes and answer
2xxwithin 10 seconds. - Read
GET .../deliveries(below): thewebhook.testrow’sitems[].statusshould readsucceeded. Afailedordeadrow carries your endpoint’s status code inlast_response_codeand the reason inlast_error; fix and replay it withPOST /api/dev/webhooks/deliveries/<delivery_id>/replay. - 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": { ... }
}| Header | Value |
|---|---|
X-Invo-Signature | t=<unix seconds>,v1=<hex> (during a secret rotation, two v1= values) |
X-Invo-Event-Id | Unique per delivery attempt row; changes on every replay. |
X-Invo-Idempotency-Key | Stable across replays. Dedupe on this. |
X-Invo-Secret-Version | Integer version of the signing secret in use. |
User-Agent | invo-webhooks/1.0 |
Content-Encoding | gzip, only if you opted into compression and the body is at least 1 KB. Decompress first, then verify. |
Verifying the signature, exactly
- Take the raw request body bytes exactly as received (after decompression if gzip). Never re-serialise a parsed object.
- Split
X-Invo-Signatureon commas. Readtand everyv1value. - Reject if
abs(now - t)exceeds 300 seconds. - Compute
HMAC-SHA256(secret, "<t>." + raw_body)as lowercase hex, where the signed string is the timestamp, a literal., then the body bytes. - Accept if any
v1value 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 heximport 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
});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;
}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 successimport 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 FalseDelivery and retries
- Success is any
2xxresponse. Respond within 10 seconds (connect and read timeouts are 10 seconds each; you may set an override of 1 to 60 seconds throughPATCH /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 setmax_attemptsfrom 1 to 12 through the same retry-policy call. - A replayed delivery has a new
event_id, the originalidempotency_key, anddataunchanged, plus areplay_offield naming the originalevent_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_endare the period just paid;current_period_*andnext_charge_atare the new window.amount_usdis the price the period was billed at (a staged price change appears here from the period it applies to).amount_coinsis what the period was worth.mint_order_idisnullwhen the wallet covered the whole period.- On Steam,
split.total_usdis 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-levelamount_usdis always the price. revenue_share_attributionisnullwhen 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 response | On subscription.renewed | Note |
|---|---|---|
subscription.subscription_id | subscription_id | Join key. |
first_charge.paid_period_seq (1, or 2 after a trial) | period_seq | The period paid. Not subscription.period_seq, which already reads the next period. |
first_charge.paid_through = subscription.paid_through | period_end = current_period_start | The entitlement boundary. |
first_charge.amount_usd | amount_usd | The price the period was billed at. |
subscription.amount_coins_estimate | amount_coins | What the period was worth. |
subscription.current_period_start / _end, next_charge_at | current_period_start / _end, next_charge_at | The next window, on both. |
| (not on the response) | transaction_id, funding, split | Ledger 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.
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
}
}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 bump5. Which event to trust
| Question | Answer |
|---|---|
| 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. |