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_secret once, on creation only; store it.
  • POST .../rotate-secret issues a new one with a 7-day dual-signing grace.
  • POST .../test queues a webhook.test delivery so you can prove your endpoint before real traffic.
  • GET .../deliveries lists deliveries; POST /api/dev/webhooks/deliveries/<delivery_id>/replay re-sends a failed or dead one.
  • GET /api/dev/webhooks/supported-events lists every event name.

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

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

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

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.
Node, @invonetwork/web-sdk
import express from "express";
import { verifyWebhook } from "@invonetwork/web-sdk/server";

const app = express();

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

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

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

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

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

app = Flask(__name__)

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

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

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

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

Delivery and retries

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

3. Events

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

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

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

subscription.renewed (the source of truth for entitlement)

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

Card example

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

Steam example (US member, partial wallet)

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

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

Node, @invonetwork/web-sdk (typed events)
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
  }
}
Python, invonetwork (typed events)
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 bump

5. Which event to trust

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