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 game, 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_url2. 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.
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
});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.
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. Two expiries arrive without a preceding payment_failed: steam_authorization_abandoned (above) and steam_containment (seven consecutive daily deferrals because the wallet holds value that cannot be spent in this game; final_period_end is set). Handle subscription.expired on its own, never as “the fourth payment_failed”.
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
async function handle(event) {
const d = event.data;
switch (event.eventType) {
case "subscription.renewed":
// d.periodSeq is the period JUST PAID; access runs to d.currentPeriodStart (the new paid_through)
await extendAccess(d.subscriptionId, d.periodSeq, d.currentPeriodStart);
await recordRevenue(d.subscriptionId, d.periodSeq, d.split.partnerRevenueUsd);
if (d.revenueShareAttribution) await accrueAttribution(d.revenueShareAttribution); // you pay this, not Invo
break;
case "subscription.payment_failed":
await notifyMember(d.playerEmail, d.failureCode, d.retryAt); // access is retained
break;
case "subscription.past_due":
await flagPastDue(d.subscriptionId, d.gracePeriodEnd); // access is retained
break;
case "subscription.authentication_required":
await sendLink(d.playerEmail, d.confirmationUrl, d.expiresAt); // not a failure; do not revoke
break;
case "subscription.canceled":
await revokeAt(d.subscriptionId, d.accessUntil); // partner or steam
break;
case "subscription.expired":
await revokeAt(d.subscriptionId, d.finalPeriodEnd); // null => nothing was ever paid
break;
case "subscription.refunded":
await reverseRevenue(d.subscriptionId, d.periodSeq, d.refund.partnerRevenueReversedUsd);
if (d.revenueShareAttribution) await correctAttribution(d.revenueShareAttribution);
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.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.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. |