Webhooks
Register an endpoint and the wallet POSTs a signed JSON event whenever something happens in your app — a player signs up, a wallet is provisioned, a deposit lands. Webhooks let your backend react without polling. Every delivery is HMAC-signed so you can prove it came from us, and every delivery is retried with backoff until your endpoint returns a 2xx.
Event types
| Event | Fires when |
|---|---|
user.created | A new player signed in for the first time |
user.suspended | A user was suspended (compliance/admin) |
wallet.created | A wallet was provisioned for a user |
wallet.exported | A user exported a wallet to self-custody |
wallet.migrated | A wallet was migrated to a new address |
compliance.passed | A compliance check passed |
compliance.failed | A compliance check failed |
compliance.review_required | A compliance check needs manual review |
deposit.received | A treasury deposit confirmed on-chain |
withdrawal.completed | A withdrawal / treasury cash-out confirmed on-chain |
withdrawal.failed | A withdrawal failed |
A subscription with an empty event filter receives all types; supply a filter to receive only the ones you handle.
Delivery envelope
Each POST body is the same envelope regardless of type. sequence is a per-app monotonically increasing counter — track it to detect gaps or reordering. The data object’s shape depends on type.
{
"type": "wallet.created",
"createdAt": "2026-07-13T10:24:05.123Z",
"appId": "app_…",
"sequence": 4217,
"data": {
"id": "wlt_…",
"userId": "usr_…",
"address": "…",
"custody": "custodial",
"status": "active"
}
}Two request headers accompany every delivery:
| Header | Value |
|---|---|
X-Solven-Event | The event type (e.g. wallet.created) — cheap to route on before parsing |
X-Solven-Signature | The HMAC signature: t=<unix>,v1=<hex hmac-sha256> |
Verifying the signature
The signature header is Stripe-style: t=<unix>,v1=<hex>. The signed payload is `${t}.${rawBody}` — the timestamp, a literal dot, then the exact raw request body. Recompute the HMAC-SHA256 with your subscription’s signing secret and compare in constant time.
import crypto from "crypto";
/** Verify an inbound IAMGame wallet webhook. Returns true only if the signature
* and timestamp are both valid. Pass the RAW request body string. */
export function verifyWebhook(opts: {
rawBody: string;
signatureHeader: string; // value of X-Solven-Signature
secret: string; // whsec_… shown once at subscription create
toleranceSeconds?: number;
}): boolean {
const parts = Object.fromEntries(
opts.signatureHeader.split(",").map((kv) => kv.split("=") as [string, string])
);
const t = Number(parts.t);
const provided = parts.v1;
if (!t || !provided) return false;
// Reject stale deliveries to blunt replay (default 5 min).
const tolerance = opts.toleranceSeconds ?? 300;
if (Math.abs(Date.now() / 1000 - t) > tolerance) return false;
const expected = crypto
.createHmac("sha256", opts.secret)
.update(`${t}.${opts.rawBody}`)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(provided);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Wiring it into an Express endpoint (note the raw-body capture):
import express from "express";
const app = express();
// Give this route the raw body — do NOT put express.json() in front of it.
app.post(
"/webhooks/solven",
express.raw({ type: "application/json" }),
(req, res) => {
const raw = req.body.toString("utf8");
const ok = verifyWebhook({
rawBody: raw,
signatureHeader: req.header("X-Solven-Signature") ?? "",
secret: process.env.SOLVEN_WEBHOOK_SECRET!,
});
if (!ok) return res.status(400).send("bad signature");
const event = JSON.parse(raw);
// Handle idempotently — the same event may be delivered more than once.
// Ack fast; do slow work out of band.
res.status(200).send("ok");
}
);Retries & backoff
A delivery succeeds only on a 2xx response within a 10-second timeout. Anything else (non-2xx, timeout, or a network error) is retried with exponential backoff, up to 5 attempts, after which the event is marked failed and dropped.
| Attempt | Delay after previous |
|---|---|
| 1 (initial) | immediate |
| 2 | 10 seconds |
| 3 | 1 minute |
| 4 | 5 minutes |
| 5 | 30 minutes |
| (gives up) | after 2 hours |
(appId, sequence) (or the data id) and no-op duplicates.Managing subscriptions
Subscriptions are created in the developer portal, or via its API with your portal session. The signing secret (whsec_…) is returned once at creation — store it in your secret manager immediately; it is never shown again.
| Method | Path | Purpose |
|---|---|---|
GET | /v1/portal/app/:appId/webhooks | List the app's subscriptions (url, events, disabled) |
POST | /v1/portal/app/:appId/webhooks | Create a subscription → returns the whsec_ secret (once) |
DELETE | /v1/portal/app/:appId/webhooks/:webhookId | Delete a subscription (204) |
Create a subscription
curl -X POST https://api-wallet.iamgame.com/v1/portal/app/$APP_ID/webhooks \
-H "authorization: Bearer $PORTAL_TOKEN" \
-H "content-type: application/json" \
-d '{
"url": "https://your-game.example.com/webhooks/solven",
"events": ["user.created", "wallet.created", "deposit.received"]
}'{
"id": "whk_…",
"url": "https://your-game.example.com/webhooks/solven",
"secret": "whsec_…",
"message": "Save this signing secret — it is shown once."
}Omit events (or pass an empty array) to receive every event type. Deleting a subscription stops future deliveries and marks any still-pending events for it as failed.
Where to go next
- API reference — the endpoints that trigger these events.
- Treasury — deposits/withdrawals that fire deposit.received / withdrawal.completed.