Subscription webhooks
Every renewal, failure, authentication challenge, cancellation, expiry, refund and refund request 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",
"subscription.refund_requested", "subscription.refund_approved",
"subscription.refund_rejected", "purchase.completed",
"purchase.disputed"]}["*"]subscribes to everything.purchase.completedis in the list above only so your handler can recognise and skip the one a card renewal also sends (section 3); subscribe to it anyway if you sell currency.purchase.disputedis how you hear about a chargeback on a renewal (section 3).- 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",
"subscription.refund_requested", "subscription.refund_approved",
"subscription.refund_rejected", "purchase.completed",
"purchase.disputed"]}'
# -> 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, subscription.refunded and the three refund-request events 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, "is_trial": false,
"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": "0.00", "card_charged_usd": "9.99",
"rail": "card", "steam_charged_usd": "0.00",
"minted_coins": "99.90", "new_balance": "20.00",
"card_authorised_usd": "9.99", "tax_usd": "0.00", "tax_jurisdiction": null
},
"split": {
"total_usd": "9.99", "basis": "price",
"invo_fee_usd": "0.75", "partner_revenue_usd": "9.24",
"invo_fee_coins": "7.50", "partner_revenue_coins": "92.40",
"invo_fee_percent": "7.508",
"pass_through_fees_usd": "0.00", "pass_through_status": "none",
"international_card_fee_usd": "0.00"
},
"revenue_share_attribution": {
"recipient_player_id": 4242, "percent": "70.00", "base_usd": "9.24",
"attributed_amount_usd": "6.47", "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, "is_trial": false,
"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.48", "partner_revenue_usd": "6.42",
"invo_fee_coins": "4.80", "partner_revenue_coins": "64.20",
"invo_fee_percent": "6.957",
"pass_through_fees_usd": "0.00", "pass_through_status": "not_applicable",
"international_card_fee_usd": "0.00"
},
"revenue_share_attribution": null,
"metadata": {"guild_id": "42"}
}period_start/period_endare the period just paid;current_period_*andnext_charge_atare the new window.is_trialis on every delivery, on both rails. It istrueonly on the event for period 1 of a paid trial, whoseamount_usdis the trial price, andfalseon every other renewal. The subscription staystrialingafter that event; the regular price arrives as period 2 attrial_end. A free trial sends no event for period 1.amount_usdis the price of the period (a staged price change appears here from the period it applies to; the trial price on a paid trial’s period 1).amount_coinsis what the period was worth. It is the price that the fee split and your revenue are measured on. What the member’s card was authorised for isfunding.card_authorised_usd, with any tax Invo collected infunding.tax_usdandfunding.tax_jurisdiction. On the Open tier you are the seller and Invo collects no tax, sotax_usdis0.00and the authorised amount is the price. Reconcile a card statement againstcard_authorised_usd.- Card rail, card only (since 2026-09-17): the card pays the full price.
funding.balance_applied_coinsis always"0.00",card_charged_usdis the price,minted_coinsequalsamount_coins, andnew_balanceis the member’s unchanged balance (in the card example the member held20.00coins before and after).mint_order_idis nevernullon a card renewal; on Steam it isnullwhen the wallet covered the whole period. - Every paid card renewal also sends
purchase.completedfor the coins the charge minted. Recognise it bydata.metadata.source == "subscription_renewal"(withdata.metadata.subscription_id,period_seq,attempt_no,funding_rail, andfunding_model: "card_only"). Do not grant currency or access from it: those coins are spent by the same renewal. Act onsubscription.renewedonly. The two share the mint order id:order_idonpurchase.completedismint_order_idhere (whose ownorder_idis the spend). The event is kept for compatibility and will not be suppressed. - If a renewal’s card charge succeeds but Invo cannot complete the renewal in the same step, you may see
subscription.payment_failedwith a settlementfailure_code(for examplesettlement_error); the retry then completes the period without charging the card again, andsubscription.renewedfollows. - 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. - International card fee. On a card issued outside the United States, 1.5 percent of the listed price (before tax) is charged to you on top of
split.invo_fee_usd:split.partner_revenue_usdstays the gross figure and does not include it.split.international_card_fee_usdreports it, andsplit.pass_through_fees_usdcarries the same figure under its older name. They are one charge reported twice: subtract it once, never add the two together. Both read"0.00"on a US card. A $9.99 period on a card issued outside the US on the Open tier:invo_fee_usd0.75,partner_revenue_usd9.24,international_card_fee_usd0.15, so you keep9.09. revenue_share_attributionisnullwhen there is no share. The split figures in the card example are illustrative; read the fields.- Merchant of Record only: a card renewal also carries
receipt_numberandreceipt_url, the receipt Invo issued to the member for that charge. Absent on the Open tier and on Steam. See receipts.
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 of the period: the trial price on a paid trial (where the event also reads is_trial: true). Not necessarily what the card was authorised for; see funding.card_authorised_usd. |
subscription.amount_coins_estimate (trial_amount_coins_estimate on a paid trial) | 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.
Events about a paid trial’s trial period. subscription.payment_failed, subscription.past_due, subscription.authentication_required and subscription.expired for period 1 of a paid trial carry two extra fields, so the common block’s amount_usd (the regular price) is not mistaken for what was due:
{
...common block (amount_usd "29.00", period_seq 1)...,
"amount_due_usd": "1.00",
"is_trial": true,
"trial_amount_usd": "1.00",
...
}The two fields are absent on every other event of those types. When the card refused the trial charge, payment_failed arrives with retries_remaining: 0 and retry_at: null, followed by subscription.expired (below).
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": "9.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: since 2026-09-17 the full period price, the same figure as amount_due_usd (a challenge opened before that date can still show the older wallet-shortfall figure). 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.
After a full refund
{
...common block (status "canceled", cancel_at_period_end false, next_charge_at null)...,
"effective_at": "2026-10-20T09:12:44+00:00", "cancel_at_period_end": false,
"canceled_at": "2026-10-20T09:12:44+00:00", "ended_at": "2026-10-20T09:12:44+00:00",
"access_until": "2026-10-06T14:37:44+00:00", "paid_through": "2026-10-06T14:37:44+00:00",
"reason": "member request", "canceled_by": "partner", "cancel_cause": "full_refund"
}A refund that completes a period’s full amount cancels the subscription immediately and sends this event in the same step as subscription.refunded (sent together and may arrive in any order): the same payload as the cancel endpoint, plus the additive cancel_cause: "full_refund". cancel_cause is absent on an ordinary cancel. access_until (and paid_through) is the end of the last period that is still paid after the refund (the refunded period no longer counts). It can be in the past, which means revoke now, and it is null when no paid period remains.
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
}Card example (a paid trial whose trial charge was refused)
{
...common block (status "expired", amount_usd "29.00", funding_rail "card")...,
"period_seq": 1, "failed_period_start": "...", "failed_period_end": "...",
"final_attempt_no": 1, "attempts_used": 1,
"failure_code": "card_declined", "failure_message": "Your card was declined.",
"ended_at": "2026-09-23T10:00:07+00:00",
"final_period_end": null, "access_retained": false,
"expire_cause": "trial_payment_failed", "is_trial": true, "trial_amount_usd": "1.00"
}expire_cause is present only when the expiry was not the ordinary end of the retry ladder; today its one value is trial_payment_failed. The item is free again: subscribe the member again with a new client_request_id once they have a working card.
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 card-only 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": "card", "balance_delta_coins": "0.00", "card_refunded_usd": "9.99",
"processor_refund_adopted": false, "new_balance": "20.00", "invo_fee_retained": true,
"partner_revenue_reversed_usd": "9.24", "partner_revenue_reversal_mode": "full",
"processing_fee_usd": "0.00", "processing_fee_basis": "none",
"processing_fee_borne_by": "partner", "partner_total_reversed_usd": "9.99",
"card_fee_charged_to_partner_usd": "0.75", "partner_debited_usd": "9.99"
},
"revenue_share_attribution": {
"recipient_player_id": 4242, "percent": "70.00",
"original_attributed_amount_usd": "6.47", "refunded_attributed_amount_usd": "6.47",
"net_attributed_amount_usd": "0.00", "settled_by_invo": false
},
"metadata": {"guild_id": "42"},
"initiation": "self_serve", "refund_request_id": null, "subscription_canceled": true
}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.42", "partner_revenue_reversal_mode": "full",
"processing_fee_usd": "0.00", "processing_fee_basis": "none",
"processing_fee_borne_by": "partner", "partner_total_reversed_usd": "6.42",
"card_fee_charged_to_partner_usd": "0.00", "partner_debited_usd": "6.42"
},
"revenue_share_attribution": null,
"metadata": {"guild_id": "42"},
"initiation": "self_serve", "refund_request_id": null, "subscription_canceled": true
}- Additive since 2026-09-17:
refund.processing_fee_usd,refund.processing_fee_basis(actual,estimatedornone),refund.processing_fee_borne_by(partner),refund.partner_total_reversed_usd, and top-levelinitiation(self_serveorapproved_request),refund_request_idandsubscription_canceled. Older deliveries do not carry them. - Additive since 2026-09-28:
refund.card_fee_charged_to_partner_usd(Invo’s card fee on the refunded part, charged to you) andrefund.partner_debited_usd(your revenue reversed plus that fee, the whole refunded price before tax; the same value asrefund.partner_total_reversed_usd). A card refund no longer charges a separate payment processor fee:refund.processing_fee_usdreads"0.00"andrefund.processing_fee_basisnone. Before 2026-09-28 they carried the processor fee (actualorestimated). - A card-only period is refunded in cash, full or partial:
card_refunded_usdis the amount andbalance_delta_coinsis"0.00". Periods charged before 2026-09-17 keep the earlier coin behaviour. - When
subscription_canceledistrue(a full refund),subscription.canceledwithcancel_cause: "full_refund"is sent with it; the two are sent together and may arrive in any order. - Merchant of Record only: when money went back to a card, the event also carries
receipt_numberandreceipt_urlfor the refund receipt Invo issued to the member (a refund still pending when the event is sent carries no number yet). Absent on the Open tier. - Field meanings are on the refunds page.
subscription.refund_requested, subscription.refund_approved, subscription.refund_rejected
A refund over your game’s daily self-serve limit does not run: your /refund call answers 202 with status: "pending_approval" and it becomes a request Invo reviews. These three events describe that request. Their own shape, without the common block:
{
"request_id": "SRR20260917150211AB12CD", "status": "pending",
"subscription_id": "SUB_...", "item_id": "guild-42-membership", "period_seq": 2,
"client_request_id": "refund-2026-11-guild-42-member-7",
"amount_usd": "40.00", "reason": "member request",
"requested_at": "2026-09-17T15:02:11+00:00",
"daily_cap_usd": "25000.00", "executed_today_usd": "24980.00",
"player_email": "member@example.com", "identity_id": "idn_...",
"metadata": {"guild_id": "42"}
}subscription.refund_requested: the request was filed (status: "pending"). Nothing has moved.subscription.refund_approved: the same, withstatus: "approved",decided_atandtransaction_id(the refund). The refund has run.subscription.refunded(withinitiation: "approved_request") and, if the refund was full,subscription.canceledare sent in the same step; the three are sent together and may arrive in any order.subscription.refund_rejected: the same, withstatus: "rejected",decided_atanddecision_reason. Nothing moved; thatclient_request_idnow answers409 REFUND_REQUEST_REJECTED.reasonisnullwhen you sent none.amount_usdis the amount asked for.
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)
// d.is_trial === true: period 1 of a paid trial, charged at the trial price; still trialing
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
// d.expire_cause === "trial_payment_failed": the paid trial never started; subscribe again with a NEW key
break;
case "subscription.refunded":
// revenue reversed plus Invo's card fee: the whole refunded price. Older events carry
// partner_total_reversed_usd (same value) or, before 2026-09-17, only partner_revenue_reversed_usd
await reverseRevenue(d.subscription_id, d.period_seq,
d.refund.partner_debited_usd ?? d.refund.partner_total_reversed_usd ?? d.refund.partner_revenue_reversed_usd);
if (d.revenue_share_attribution) await correctAttribution(d.revenue_share_attribution);
break; // a full refund: subscription.canceled is sent too, any order
case "subscription.refund_requested":
case "subscription.refund_approved":
case "subscription.refund_rejected":
await trackRefundRequest(d.request_id, d.status, d.decision_reason); // nothing moves until approved
break;
case "purchase.completed":
if (d.metadata?.source === "subscription_renewal") break; // a card renewal's mint leg: grant NOTHING
await grantCurrency(d); // an ordinary currency purchase
break;
case "purchase.disputed":
if (d.order_type === "subscription_renewal" && d.dispute_status === "lost") {
// a lost chargeback on one period: your share plus Invo's card fee, as a refund; the subscription stays live
await reverseRevenue(d.subscription_id, d.period_seq,
d.partner_debited_usd ?? d.developer_revenue_reversed_usd);
}
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)
# d["is_trial"] True: period 1 of a paid trial, charged at the trial price; still trialing
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
# d.get("expire_cause") == "trial_payment_failed": the paid trial never started; new key to retry
elif t == "subscription.refunded":
# revenue reversed plus Invo's card fee: the whole refunded price. Older events carry
# partner_total_reversed_usd (same value) or, before 2026-09-17, only partner_revenue_reversed_usd
refund = d["refund"]
reverse_revenue(d["subscription_id"], d["period_seq"],
refund.get("partner_debited_usd")
or refund.get("partner_total_reversed_usd")
or refund["partner_revenue_reversed_usd"])
if d.get("revenue_share_attribution"):
correct_attribution(d["revenue_share_attribution"]) # a full refund: subscription.canceled is sent too, any order
elif t in ("subscription.refund_requested", "subscription.refund_approved",
"subscription.refund_rejected"):
track_refund_request(d["request_id"], d["status"], d.get("decision_reason")) # nothing moves until approved
elif t == "purchase.completed":
if (d.get("metadata") or {}).get("source") != "subscription_renewal": # skip a card renewal's mint leg
grant_currency(d) # an ordinary currency purchase
elif t == "purchase.disputed":
if d.get("order_type") == "subscription_renewal" and d.get("dispute_status") == "lost":
# a lost chargeback on one period: your share plus Invo's card fee, as a refund; the subscription stays live
reverse_revenue(d["subscription_id"], d["period_seq"],
d.get("partner_debited_usd") or d["developer_revenue_reversed_usd"])
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 split.international_card_fee_usd (the same figure as split.pass_through_fees_usd; subtract it once), minus refund.partner_debited_usd (revenue reversed plus Invo’s card fee: the whole refunded price) on any subscription.refunded. On refunds before 2026-09-28 read refund.partner_total_reversed_usd, and before 2026-09-17, which carry neither, refund.partner_revenue_reversed_usd. Also minus partner_debited_usd on any lost chargeback (purchase.disputed). For a windowed statement use reporting. |
Should I grant currency on a renewal’s purchase.completed? | No. When data.metadata.source is subscription_renewal, the coins are spent by the renewal itself. Grant on subscription.renewed. |
| Did a refund end the subscription? | subscription_canceled on subscription.refunded, and the subscription.canceled sent with it (cancel_cause: "full_refund"); the two are sent together and may arrive in any order. |
| Did my refund run? | A 200 from /refund, or subscription.refunded. A 202 (pending_approval) has not run: wait for subscription.refund_approved or subscription.refund_rejected. |
| Was this charge the trial price? | is_trial: true on subscription.renewed (period 1 of a paid trial). On the failure, authentication and expiry events for that period, is_trial: true with trial_amount_usd. |
| Did a paid trial fail to start? | subscription.expired with expire_cause: "trial_payment_failed", or first_charge.status: "failed" beside subscription.status: "expired" on the create response. |
| Did the member charge a renewal back? | purchase.disputed with order_type: "subscription_renewal" and dispute_status: "lost": subtract partner_debited_usd (your share reversed plus card_fee_charged_to_partner_usd, Invo’s card fee, exactly as a refund of that period costs) for that period_seq (0.00 when guarantee_covered is true: the chargeback guarantee covered it). processing_fee_usd reads 0.00. The subscription is not cancelled; cancel it yourself if you want to. See Refunds, chargebacks. |
| 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. |