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.
# Use ["*"] to receive every event type.
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": ["purchase.completed", "purchase.refunded", "item.purchased"]
  }'

# 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 fully cleared and the player was credited
purchase.failedA purchase attempt failed (card declined, abandoned 3DS, etc.)
purchase.refundedA purchase was refunded; the player balance was debited
purchase.disputedA chargeback / dispute was opened (informational)
purchase.dispute_lostA dispute resolved against the merchant; balance clawed back
purchase.fraud_warningA pre-dispute fraud warning was raised on a charge
transfer.sentA cross-game transfer was initiated from a player
transfer.receivedA transfer was credited to the destination player
transfer.claim_pendingA sent transfer is awaiting the recipient’s claim
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.updatedCatch-all: any change to a player balance
webhook.testA test event you triggered from the dashboard

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": {
    "player_email": "player@example.com",
    "amount": "1000",
    "currency": "GOLD",
    "new_balance": "4200"
    // ...event-specific fields
  }
}

4. Verify the signature

The signature is HMAC-SHA256 over the literal string <timestamp>.<raw_body>, keyed with your signing secret, hex-encoded — the same scheme Stripe uses. 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');
    }

    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.