Webhooks

Invo pushes a signed HTTPS request to your server the moment something happens on your title: a purchase clears, a transfer is received, an item is bought, a refund is issued. Webhooks let you react in real time instead of polling.

The receiver contract in one breath

Expose one HTTPS endpoint. On each request: verify the X-Invo-Signature, de-duplicate on X-Invo-Idempotency-Key, do your work, and return a 2xx quickly. Delivery is at-least-once. The same event may arrive more than once, so your handler must be idempotent. Anything that isn’t a 2xx is retried with exponential backoff.

1. Set up your endpoint

Register the HTTPS URL Invo should call, and the events you care about, using the same X-Game-Secret-Key: ivsdk_…your server already sends on every other call. No separate dashboard login is needed. We return a signing secret exactly onceat creation; store it securely (you can’t retrieve it again, only rotate it).

Create / update your webhook subscription (PUT)
# Register or update your endpoint + the events you subscribe to.
# RECOMMENDED: use ["*"] to receive every event type, then filter server-side.
# If you subscribe to a SUBSET, you MUST include "transfer.claim_pending" to be
# told an inbound transfer/send is awaiting your player's claim (the #1 gap:
# without it, your app never learns there's something "to collect").
curl -X PUT https://api.invo.network/api/dev/webhooks/games/<GAME_ID> \
  -H "X-Game-Secret-Key: ivsdk_<your_sdk_key>" \
  -H "Content-Type: application/json" \
  -d '{
    "target_url": "https://your-server.com/invo/webhooks",
    "subscribed_events": ["*"]
  }'

# Response: signing_secret is returned ONCE, on first creation. Save it now;
# POST .../rotate-secret later to roll it.
# {
#   "success": true,
#   "created": true,
#   "signing_secret": "<long-random-string>",   // store this securely
#   "subscription": { ... }
# }
Your target_url must be https and must resolve to a public address. Invo blocks delivery to internal / loopback / cloud-metadata addresses.

This is just the create call. For rotate-secret, tuning (timeouts, retries, rate limit, gzip), delivery history, and replay, see the full Webhook Management API, all callable server-to-server with the same X-Game-Secret-Key.

2. Event types

EventFires when
purchase.completedA currency / item purchase cleared on any payment method and the player was credited. A paid card subscription renewal also emits it, for the coins the charge minted, with data.metadata.source set to "subscription_renewal": do not grant currency on that one (the renewal spends those coins); use subscription.renewed. On a Merchant of Record card sale it also carries receipt_number and receipt_url (see /docs/tiers). On an INVO card purchase of coins it carries partner_fee_usd (the card fee charged to you) and international_card_fee_usd (the extra 1.5% on a card issued outside the US, "0.00" otherwise, including when the country of the card is unknown). Both are null only when the fee could not be booked at completion (the purchase succeeded and INVO books the fee shortly after), so type them as nullable strings; they are absent on purchases through a third-party storefront or payment rail.
purchase.failedA purchase attempt failed (card declined, abandoned 3-D Secure, cancelled). Card payments only.
purchase.refundedA purchase was refunded (full or partial; a Steam partial refund carries fully_refunded: false, rail and steam_transid). Only coins the player has not spent are debited, and never below zero. On a Merchant of Record account it carries the refund receipt's receipt_number and receipt_url.
purchase.disputedA chargeback / dispute changed state (carries a dispute_status field, e.g. "lost"). The open event carries order_id, transaction_id, payment_intent_id, charge_id, dispute_id, reason and usd_amount. Card payments, and Steam-rail chargebacks: a Steam chargeback carries the same keys as a lost card dispute with payment_intent_id, charge_id and dispute_id null, plus rail ("steam") and steam_transid. It takes back up to the purchase's coins from the available balance, never below zero. A lost dispute on a Platform Commerce card sale or a subscription charge carries guarantee_covered: true when the chargeback guarantee covers it (the cardholder says they did not authorize the payment), in which case your share is not taken back; a lost dispute not covered costs you what a refund of that charge does, and carries card_fee_charged_to_partner_usd (INVO's card fee charged to you) and partner_debited_usd (your share reversed plus that fee). A subscription dispute also carries processing_fee_usd, which reads "0.00" (see /docs/tiers). Coin purchase disputes carry none of these share and card-fee keys; a coin purchase close carries currency_debited, new_balance and spent_coins_charged_usd (the value of coins the player had already spent, charged to you at the price paid per coin before tax; "0.00" if none). From 2026-10-21, the close of a card dispute on either tier (coin purchase, Platform Commerce card sale or subscription charge) also carries dispute_fee_usd (string or null) and dispute_fee_charged_to_partner (boolean or null): the card network dispute fee, passed through to you at cost (none on a dispute the chargeback guarantee covers). A Steam chargeback never carries guarantee_covered or the dispute fee keys. There is no separate purchase.dispute_lost event: a lost dispute is purchase.disputed with dispute_status "lost".
purchase.fraud_warningA pre-dispute fraud warning was raised on a charge. Card payments only.
transfer.sentA cross-game transfer was initiated from a player
transfer.receivedA transfer was credited to the destination player
transfer.claim_pendingAn inbound transfer/send is awaiting your player’s claim. Subscribe to this to drive your "you have X to collect" notification. Fires to BOTH tenants (direction: outbound for the sender, inbound for the recipient); when sender and recipient are in the same title, that title receives both copies. Act on direction: inbound. Omitting it is the most common integration gap.
transfer.claim_expiredAn unclaimed transfer/send expired before collection. Fires like claim_pending: an outbound copy to the sending title and an inbound copy to the destination title, both copies to one title on a same-title move. Carries expiry_reason (see below).
transfer.refundedAn expired/unclaimed transfer was refunded to the sender (sending title only). reason is claim_expired or pin_expired; on an expiry it also carries expiry_reason, the precise cause.
item.purchasedA player spent currency on one of your items
payout.status_changedA partner payout moved to a new lifecycle state, including cancelled and returned. A creator payout carries payee_id (null for a payout to your own bank) and owner_confirmation_required, and never the creator’s bank or personal details. partner_message is a note INVO wrote for you, when there is one.
payee.status_changedA creator you registered for creator payouts was registered or changed status (invited, details_submitted, verified, rejected, suspended, expired, closed). Carries payee_id, partner_reference, status, previous_status, payee_type, country, created_via and status_changed_at; never the creator’s name or email. Subscribe to it by name.
player.phone_changedA player’s phone was changed through Invo (source: player, confirmed by the player; or support). Sent to every game the player is in. new_phone (full) only to the game that requested the change; everyone else gets new_phone_masked. Store the new identity_id, re-mint the token, and never push your stored number back (see /docs/player-identity#phone-changed-webhook).
identity.passkey_resetA player replaced a lost INVO passkey through recovery: on INVO’s approval or claim page, or in your app through the SDK recovery calls. Carries identity_id (the same opaque id as on player.phone_changed), reset_via (hosted_device, hosted_claim or sdk), cooldown_until (until when sending money out is paused for this player; receiving and collecting still work) and deactivated_count (passkeys switched off). Sent to the game the recovery ran in. Use it to tell the player, or flag the account, if they did not start it.
webhook.testA test event you triggered from the dashboard
subscription.*Subscription lifecycle: renewed, payment_failed, past_due, authentication_required, canceled, expired, refunded, refund_requested, refund_approved, refund_rejected. Payloads are on the Subscription webhooks page (/docs/subscriptions-webhooks).

Recipient attribution on inbound transfers/sends

When you are the destination tenant, the inbound transfer.claim_pending(direction: "inbound") and the GET .../{id}/statusresponse carry two fields so you can route a "you have X to collect" notice to the right user:

  • • to_phone: the recipient's phone (canonical). Match it to your player.player_phone to find the account to notify. This is your primary key. Always match on it first.
  • • to_identity_id: that player's opaque, stable identity (the same id the SDK uses), present only when the phone resolves to a single player. A convenience for an exact match; to_phone is the field you can always rely on.

How to read the two fields together:

  • • to_phone set + to_identity_id set → exactly one of your players owns this number. Badge that player.
  • • to_phone set + to_identity_id null → the recipient IS your player, but the number is shared by more than one of your accounts. Still surface it. Match to_phone to the logged-in player and show the "to collect" badge. (We withhold only the single identity, never the whole notification. Don't treat this as "nothing arrived.")
  • • both null → the recipient has no account on your tenant yet; they onboard via the claim code (no in-app session to badge).

⚠️ Common mistake: treating a null to_identity_id as "no recipient." If to_phone is present, a real transfer is waiting. Match on the phone. These fields are delivered to the destination tenant only, never the source.

Same-title moves deliver both directions

transfer.claim_pending and transfer.claim_expired go out as two copies: an outbound copy to the sending title and an inbound copy to the destination title. When both players are in your title, you receive both copies of each, one with direction: "outbound" and one with direction: "inbound". Since 2026-10-09 the inbound copy is delivered on same-title moves too; before that date a same-title move produced only the outbound copy. Cross-title moves are unchanged.

  • • Dedupe per copy, never on transaction_id. Each copy has its own event_id and its own X-Invo-Idempotency-Key. Both copies carry the same transaction_id, so deduping on it drops one direction.
  • • Act on direction. Drive the receiving player's UI (collect prompts, "expired, not received") from inbound only, and the sending player's UI (pending, refunded) from outbound only. A handler that ignores direction acts twice on a same-title move.
  • • Ignore event types you do not recognise. Return 2xx, log the raw body, and move on; new types and new fields are added without notice.
// transfer.claim_pending, inbound copy (the destination title)
{
  "event_id": "...",
  "event_type": "transfer.claim_pending",
  "tenant_id": "<your game_id>",
  "data": {
    "transaction_id": "TXN...",
    "order_id": "ORD_...",
    "amount": "100.00",
    "net_transfer_amount": "90.00",
    "claim_code_expires_at": "2026-10-10T12:00:00+00:00",
    "from_game_id": "123456789012",
    "to_game_id": "123456789012",       // the same title: you also get the outbound copy
    "from_player_id": "4242",
    "direction": "inbound",
    "to_phone": "+15555550123",        // inbound copy only
    "to_identity_id": "id_..."         // inbound copy only; null when the number is shared
  }
}

expiry_reason (additive) says precisely why a send or transfer timed out. It is on transfer.claim_expired, on transfer.refunded when the refund follows an expiry, and on GET /api/transfers/{id}/status and GET /api/currency-sends/{id}/status once it has expired:

  • • approval_expired: the sender did not approve in time (in-app, passkey or device approval).
  • • pin_expired: the sender did not enter the one-time code in time, on the legacy code flow. Rows that expired before 2026-10-09 also read pin_expired.
  • • claim_expired: the sender approved, but the recipient did not collect before claim_code_expires_at.

The reason on transfer.refunded keeps its existing values (claim_expired, pin_expired), so pin_expired there also covers a lapsed in-app approval. Read expiry_reason for the precise cause, and treat an unknown value as informational.

Discovering what's waiting: list the inbound pending claims

The webhook is a wake-up, not the source of truth. To render a "you have X to collect" badge you need to ENUMERATE what's waiting for a player. The webhook only tells you something changed, and GET .../{id}/status needs an id you already have. Use the list endpoint:

# Game-secret authed (server-to-server). Pass the player's email OR phone.
GET /api/transfers/inbound-pending?player_email=player@example.com
  -H "X-Game-Secret-Key: <your_secret>"

# → only LIVE, unclaimed transfers/sends addressed to THIS player at YOUR game:
{
  "inbound_pending": [
    {
      "transaction_id": "TXN...",
      "flow": "transfer",            // or "send"
      "amount": "10000.00",
      "net_amount": "9000.00",       // what the recipient receives
      "source_game_id": "123456789012",
      "source_game": "Example Game",
      "to_phone": "+15617675671",    // match to the logged-in player
      "to_identity_id": "id_...",    // null if the phone maps to >1 of your players
      "created_at": "...",
      "claim_code_expires_at": "..."
    }
  ]
}

The pattern (what every receiving integration should do):

  • 1. On transfer.claim_pending (or on app open / foreground), call this endpoint for the active player.
  • 2. Match to_phone to the logged-in player's number → render a "collect" item per row. (Don't require to_identity_id, since it's null when the number is shared.)
  • 3. The player collects via the claim code (or the in-app confirm-receipt). The claim code is never in this list. The claimer supplies it.

Returns only rows whose destination is your title; never another tenant's. A row drops off the list automatically once it's claimed or expires (24h → auto-refund to the sender).

3. Request shape

Every delivery is a POST with a JSON body and these headers:

HeaderMeaning
X-Invo-Signaturet=<unix_ts>,v1=<hmac>. Verify this (section 4).
X-Invo-Idempotency-KeyDe-dupe on this. Stable for a logical event, unchanged across retries and manual replays.
X-Invo-Event-IdUnique per delivery row. The same on every retry of that delivery; a replay gets a new one. Don’t dedupe on this.
X-Invo-Secret-VersionWhich signing-secret version signed this (increments on rotation).
Content-Encodinggzip only if you opted into compression. Decompress before verifying.
De-duplicate on X-Invo-Idempotency-Key, never on X-Invo-Event-Id. The event id is unique per delivery row: retries of a delivery keep it, but a replay gets a new one; the idempotency key equals the original event_id and stays the same across retries and replays. Keying on the event id would make a replay look like a brand-new event and you’d process it twice.
Example payload
{
  "event_id": "f3a1c9d2-...-b7",         // per-delivery id: same on retries, new on a replay
  "idempotency_key": "f3a1c9d2-...-b7",  // stable logical id; DEDUPE ON THIS
  "event_type": "purchase.completed",
  "schema_version": "1.0",
  "created_at": "2026-06-05T12:34:56.000000+00:00",
  "tenant_id": "123",                    // your game_id
  "data": {
    "transaction_id": "TXN...",
    "order_id": "ORD_...",
    "player_email": "player@example.com",
    "identity_id": "f3a1b8c0d4e5...",
    "usd_amount": "10.00",
    "currency_amount": "1000",
    "currency_name": "GOLD",
    "new_balance": "4200",
    "rail": "platform",           // settlement rail, provider-neutral; a Steam-channel
                                  // purchase carries "steam" here too (rail follows channel)
    "channel": "web",             // storefront: steam | ios | android | web | console
                                  // null = legacy game key ("unattributed", a real answer)
    "metadata": { "my_player_id": "p_123" }   // YOUR metadata from the purchase call, echoed verbatim
    // ...event-specific fields per event_type
  }
}

Correlation is metadata, not your idempotency key. The purchase_reference / client_request_id you sent is deliberately not echoed in webhook payloads. If your handler needs to attribute a credit to your own player or order records, pass a metadata object on the purchase call (purchase-currency, hosted checkout, or steam/init-purchase). It comes back at data.metadata on purchase.completed, on every payment method. Two caveats: the Steam rail merges its own context keys into the echoed object (steam_wallet_currency, steam_country,steam_account_status, vat_rate_pct), so avoid those names; and hosted-checkout metadata is best-effort (dropped, never blocking, if the short-lived session store misses).

4. Verify the signature

The signature is HMAC-SHA256 over the literal string <timestamp>.<raw_body>, keyed with your signing secret, hex-encoded (the standard signed-webhook scheme). Verify against the raw request bytes (never a re-serialized object), use a constant-time compare, and reject timestamps older than 5 minutes to stop replays. During a secret rotation the header carries multiple v1= values. Accept if any matches.

New signing secrets start whsec_live_ (production) or whsec_test_ (test mode). Use the whole secret string exactly as issued, prefix included, as the HMAC key; do not strip the prefix. Secrets issued before the prefix was added are unchanged and keep working.

Node.js / Express verification
const crypto = require('crypto');
const express = require('express');
const app = express();

// IMPORTANT: capture the RAW body; the signature is over the exact bytes.
app.post('/invo/webhooks',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    if (!verifyInvoSignature(req.body, req.get('X-Invo-Signature'), process.env.INVO_WEBHOOK_SECRET)) {
      // Any non-2xx makes INVO retry this delivery. That is what you want: a forged
      // request is not INVO's, so nothing retries it, and a genuine delivery that
      // fails verification (for example a secret you have not rotated yet) is
      // retried until your secret is right, instead of being lost.
      return res.status(400).send('bad signature');
    }
    const event = JSON.parse(req.body.toString('utf8'));

    // De-dupe on the stable idempotency key (survives retries + replays).
    if (alreadyProcessed(req.get('X-Invo-Idempotency-Key'))) {
      return res.status(200).send('duplicate');
    }

    // IMPORTANT: log the FULL raw envelope for ANY event you don't yet handle:
    // before you ACK. A 2xx tells Invo "received, don't retry", so an event your
    // handler doesn't recognize yet (deployed after it fired, or a type you don't
    // map) is GONE unless you logged it. Logging only event_type is the #1 way
    // teams silently lose an inbound "money arrived" event. Keep the raw body.
    if (!isHandled(event.event_type)) {
      logUnhandledWebhook({
        event_id: event.event_id,
        idempotency_key: req.get('X-Invo-Idempotency-Key'),
        event_type: event.event_type,
        raw: req.body.toString('utf8'),   // full payload, recoverable/replayable
      });
      return res.status(200).send('ok (logged unhandled)');
    }

    handle(event);            // do your work (ideally async; see section 6)
    return res.status(200).send('ok');   // 2xx = we won't retry
  });

function verifyInvoSignature(rawBody, header, secret, toleranceSec = 300) {
  if (!header || !secret) return false;
  const t = header.split(',').find(p => p.trim().startsWith('t='))?.split('=')[1];
  const sigs = header.split(',').filter(p => p.trim().startsWith('v1='))
                     .map(p => p.split('=')[1].trim());
  if (!t || sigs.length === 0) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > toleranceSec) return false; // replay guard
  const expected = crypto.createHmac('sha256', secret)
                         .update(Buffer.concat([Buffer.from(t + '.'), rawBody]))
                         .digest('hex');
  return sigs.some(s =>
    s.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)));
}

5. Retries & at-least-once delivery

If your endpoint doesn’t return a 2xx (timeout, 5xx, connection error), Invo retries with exponential backoff. After the schedule is exhausted the event is moved to a dead-letter queue, where it can be replayed manually once your endpoint is healthy.

Backoff schedule

Up to 6 delivery attempts by default: the first, then retries after about 30s → 2m → 10m → 1h → 6h, each with a little jitter. After the sixth failure the delivery is dead-lettered (replay it from the delivery log). A max_attempts above 6 adds retries a day apart.

Why dedupe matters

A retry, or an admin replay, re-sends the same logical event with the same X-Invo-Idempotency-Key. Keying your processing on it makes double-delivery a no-op.

6. Secret rotation

Rotating your signing secret returns a new one and keeps the old one valid for a 7-day grace window. During that window every delivery is signed with both secrets (two v1= values), so you can roll over with zero dropped events: deploy the new secret, then let the old one expire. The X-Invo-Secret-Version header tells you which version is current.

Receiver checklist

  • Serve the endpoint over HTTPS on a public address.
  • Verify X-Invo-Signature against the RAW body, constant-time, 5-min tolerance.
  • De-duplicate on X-Invo-Idempotency-Key (not X-Invo-Event-Id).
  • Return 2xx fast. Offload slow work to a queue; we time out around 10s.
  • Treat delivery as at-least-once: make your handler idempotent.
  • Store your signing secret securely; rotate it without downtime via the grace window.