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.
# 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": { ... }
# }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 fully cleared and the player was credited |
| purchase.failed | A purchase attempt failed (card declined, abandoned 3DS, etc.) |
| purchase.refunded | A purchase was refunded; the player balance was debited |
| purchase.disputed | A chargeback / dispute was opened (informational) |
| purchase.dispute_lost | A dispute resolved against the merchant; balance clawed back |
| purchase.fraud_warning | A pre-dispute fraud warning was raised on a charge |
| transfer.sent | A cross-game transfer was initiated from a player |
| transfer.received | A transfer was credited to the destination player |
| transfer.claim_pending | A sent transfer is awaiting the recipient’s claim |
| 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 | Catch-all: any change to a player balance |
| webhook.test | A test event you triggered from the dashboard |
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": {
"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.
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.