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.completed is 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.disputed is how you hear about a chargeback on a renewal (section 3).
  • 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",
                             "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_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, 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_end are the period just paid; current_period_* and next_charge_at are the new window.
  • is_trial is on every delivery, on both rails. It is true only on the event for period 1 of a paid trial, whose amount_usd is the trial price, and false on every other renewal. The subscription stays trialing after that event; the regular price arrives as period 2 at trial_end. A free trial sends no event for period 1.
  • amount_usd is 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_coins is 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 is funding.card_authorised_usd, with any tax Invo collected in funding.tax_usd and funding.tax_jurisdiction. On the Open tier you are the seller and Invo collects no tax, so tax_usd is 0.00 and the authorised amount is the price. Reconcile a card statement against card_authorised_usd.
  • Card rail, card only (since 2026-09-17): the card pays the full price. funding.balance_applied_coins is always "0.00", card_charged_usd is the price, minted_coins equals amount_coins, and new_balance is the member’s unchanged balance (in the card example the member held 20.00 coins before and after). mint_order_id is never null on a card renewal; on Steam it is null when the wallet covered the whole period.
  • Every paid card renewal also sends purchase.completed for the coins the charge minted. Recognise it by data.metadata.source == "subscription_renewal" (with data.metadata.subscription_id, period_seq, attempt_no, funding_rail, and funding_model: "card_only"). Do not grant currency or access from it: those coins are spent by the same renewal. Act on subscription.renewed only. The two share the mint order id: order_id on purchase.completed is mint_order_id here (whose own order_id is 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_failed with a settlement failure_code (for example settlement_error); the retry then completes the period without charging the card again, and subscription.renewed follows.
  • 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.
  • 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_usd stays the gross figure and does not include it. split.international_card_fee_usd reports it, and split.pass_through_fees_usd carries 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_usd 0.75, partner_revenue_usd 9.24, international_card_fee_usd 0.15, so you keep 9.09.
  • revenue_share_attribution is null when 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_number and receipt_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 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 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_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.

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, estimated or none), refund.processing_fee_borne_by (partner), refund.partner_total_reversed_usd, and top-level initiation (self_serve or approved_request), refund_request_id and subscription_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) and refund.partner_debited_usd (your revenue reversed plus that fee, the whole refunded price before tax; the same value as refund.partner_total_reversed_usd). A card refund no longer charges a separate payment processor fee: refund.processing_fee_usd reads "0.00" and refund.processing_fee_basis none. Before 2026-09-28 they carried the processor fee (actual or estimated).
  • A card-only period is refunded in cash, full or partial: card_refunded_usd is the amount and balance_delta_coins is "0.00". Periods charged before 2026-09-17 keep the earlier coin behaviour.
  • When subscription_canceled is true (a full refund), subscription.canceled with cancel_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_number and receipt_url for 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, with status: "approved", decided_at and transaction_id (the refund). The refund has run. subscription.refunded (with initiation: "approved_request") and, if the refund was full, subscription.canceled are sent in the same step; the three are sent together and may arrive in any order.
  • subscription.refund_rejected: the same, with status: "rejected", decided_at and decision_reason. Nothing moved; that client_request_id now answers 409 REFUND_REQUEST_REJECTED.
  • reason is null when you sent none. amount_usd is 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.

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)
      // 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
  }
}
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)
        # 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 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 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.