Webhooks

Invo pushes a signed HTTPS request to your server the moment something happens on your game — 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 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.
purchase.failedA purchase attempt failed (card declined, abandoned 3-D Secure, cancelled). Card payments only.
purchase.refundedA purchase was refunded (full or partial); the player balance was debited.
purchase.disputedA card chargeback / dispute changed state (carries a dispute_status field, e.g. "lost"). Card payments only.
purchase.dispute_lostReserved — schema-defined but not currently emitted; a lost dispute arrives as 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). Omitting it is the most common integration gap.
transfer.claim_expiredAn unclaimed transfer expired
transfer.refundedAn expired/unclaimed transfer was refunded to the sender
item.purchasedA player spent currency on one of your in-game items
payout.status_changedA partner payout moved to a new lifecycle state
balance.updatedReserved — schema-defined but not currently emitted; balance changes surface through the purchase.* / transfer.* events above.
webhook.testA test event you triggered from the dashboard

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.

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": "97288428282",
      "source_game": "AtheraPlay",
      "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 — 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 game; 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. Changes on replay — 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 and changes on every retry and replay; the idempotency key is stable for the logical event. 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 (changes on 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"
    // ...event-specific fields per event_type
  }
}

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.

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

// IMPORTANT: capture the RAW body — 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)) {
      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

30s → 2m → 10m → 1h → 6h → 24h (6 attempts), each with a little jitter, then dead-letter.

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.