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).
# 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": { ... }
# }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
| Event | Fires when |
|---|---|
| purchase.completed | A currency / item purchase cleared on any payment method and the player was credited. |
| purchase.failed | A purchase attempt failed (card declined, abandoned 3-D Secure, cancelled). Card payments only. |
| purchase.refunded | A purchase was refunded (full or partial); the player balance was debited. |
| purchase.disputed | A card chargeback / dispute changed state (carries a dispute_status field, e.g. "lost"). Card payments only. |
| purchase.dispute_lost | Reserved — schema-defined but not currently emitted; a lost dispute arrives as purchase.disputed with dispute_status="lost". |
| purchase.fraud_warning | A pre-dispute fraud warning was raised on a charge. Card payments only. |
| transfer.sent | A cross-game transfer was initiated from a player |
| transfer.received | A transfer was credited to the destination player |
| transfer.claim_pending | An 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_expired | An unclaimed transfer expired |
| transfer.refunded | An expired/unclaimed transfer was refunded to the sender |
| item.purchased | A player spent currency on one of your in-game items |
| payout.status_changed | A partner payout moved to a new lifecycle state |
| balance.updated | Reserved — schema-defined but not currently emitted; balance changes surface through the purchase.* / transfer.* events above. |
| webhook.test | A 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 yourplayer.player_phoneto 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_phoneis the field you can always rely on.
How to read the two fields together:
- •
to_phoneset +to_identity_idset → exactly one of your players owns this number. Badge that player. - •
to_phoneset +to_identity_idnull→ the recipient IS your player, but the number is shared by more than one of your accounts. Still surface it — matchto_phoneto 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_phoneto the logged-in player's number → render a "collect" item per row. (Don't requireto_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:
| Header | Meaning |
|---|---|
| X-Invo-Signature | t=<unix_ts>,v1=<hmac> — verify this (section 4). |
| X-Invo-Idempotency-Key | De-dupe on this. Stable for a logical event — unchanged across retries and manual replays. |
| X-Invo-Event-Id | Unique per delivery row. Changes on replay — don’t dedupe on this. |
| X-Invo-Secret-Version | Which signing-secret version signed this (increments on rotation). |
| Content-Encoding | gzip only if you opted into compression — decompress before verifying. |
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.{
"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.
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.