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

EventFires when
user.createdA new player signed in for the first time
user.suspendedA user was suspended (compliance/admin)
wallet.createdA wallet was provisioned for a user
wallet.exportedA user exported a wallet to self-custody
wallet.migratedA wallet was migrated to a new address
compliance.passedA compliance check passed
compliance.failedA compliance check failed
compliance.review_requiredA compliance check needs manual review
deposit.receivedA treasury deposit confirmed on-chain
withdrawal.completedA withdrawal / treasury cash-out confirmed on-chain
withdrawal.failedA 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.

json
{
  "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:

HeaderValue
X-Solven-EventThe event type (e.g. wallet.created) — cheap to route on before parsing
X-Solven-SignatureThe 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.

Sign over the raw body
Use the exact bytes you received. Parsing the JSON and re-serializing it will change whitespace and key order and the signature will not match. Capture the raw body before your JSON body-parser runs.
TS
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):

TS
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.

AttemptDelay after previous
1 (initial)immediate
210 seconds
31 minute
45 minutes
530 minutes
(gives up)after 2 hours
Ack fast, be idempotent
Respond 2xx as soon as you have durably recorded the event, then do slow work asynchronously. Because retries can redeliver an event you already processed, key your handler on the envelope’s (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.

MethodPathPurpose
GET/v1/portal/app/:appId/webhooksList the app's subscriptions (url, events, disabled)
POST/v1/portal/app/:appId/webhooksCreate a subscription → returns the whsec_ secret (once)
DELETE/v1/portal/app/:appId/webhooks/:webhookIdDelete a subscription (204)

Create a subscription

bash
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"]
  }'
json
{
  "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.