# Apogee Webhooks

Webhooks are **optional but recommended** for analytics, leaderboards, and responsible-gambling signals. Your wallet endpoints alone are enough to stay balanced — webhooks give you visibility into round-level detail.

Configure the receiver URL in **Admin Panel → Integration → Webhook URL**. All events are POSTed with the same HMAC signing scheme as wallet calls (see [INTEGRATION.md §3](./INTEGRATION.md#3-signing-requests)).

---

## Delivery guarantees

- **At-least-once.** Apogee retries with exponential backoff (1s, 5s, 30s, 2m, 15m, 1h, 6h, 24h) until you return `2xx`.
- **Ordered per session**, unordered across sessions. Use `ts` and `sequence` to sort if you need a total order.
- **Idempotent on `eventId`.** Retries reuse the same id; dedupe by storing recent ids with a 48h TTL.
- Delivery window: **72 hours**. Events not acknowledged within 72h are dropped and surfaced in the admin dashboard.

Return `2xx` quickly (< 3s). If processing takes longer, enqueue and return immediately — Apogee does not wait for your downstream work.

---

## Common envelope

```json
{
  "eventId":   "evt_01HXYZABC",
  "type":      "round.ended",
  "ts":        "2026-04-11T12:30:05Z",
  "sequence":  42,
  "sessionToken": "sess_01HXXXXXX",
  "gameId":    "skyward",
  "playerId":  "p_1234",
  "data":      { /* event-specific */ }
}
```

---

## Event catalog

### `session.started`
Player opened the game.
```json
{ "type": "session.started", "data": {} }
```

### `session.ended`
Session was closed by player, timeout, or operator.
```json
{ "type": "session.ended", "data": { "reason": "timeout", "roundsPlayed": 42, "wagered": 4200, "returned": 3954 } }
```

### `round.started`
A new round entered its betting window.
```json
{ "type": "round.started", "data": { "roundId": "rnd_8fxk9", "bettingWindowMs": 6000 } }
```

### `bet.placed`
Player placed a bet. Fires once per bet (a player can have up to 2 bets/round in dual-bet games).
```json
{
  "type": "bet.placed",
  "data": {
    "roundId":    "rnd_8fxk9",
    "betId":      "bet_1",
    "txId":       "tx_3bcd1",
    "stake":      250,
    "autoCashout": 200
  }
}
```

### `bet.cashed`
Player cashed out before crash.
```json
{
  "type": "bet.cashed",
  "data": {
    "roundId":    "rnd_8fxk9",
    "betId":      "bet_1",
    "multiplier": 1.85,
    "payout":     463,
    "automatic":  false
  }
}
```

### `bet.lost`
Round crashed before cashout.
```json
{ "type": "bet.lost", "data": { "roundId": "rnd_8fxk9", "betId": "bet_1" } }
```

### `round.ended`
Round fully settled.
```json
{
  "type": "round.ended",
  "data": {
    "roundId":     "rnd_8fxk9",
    "crash":       2.43,
    "serverSeed":  "…",
    "clientSeed":  "apogee",
    "nonce":       1247,
    "totalWagered": 12450,
    "totalReturned": 8240,
    "playerCount":  37
  }
}
```

### `jackpot.won`
(Games with a jackpot pool only.)
```json
{
  "type": "jackpot.won",
  "data": { "poolId": "thermal-grand", "amount": 125000, "multiplier": 8734.12 }
}
```

### `responsible_gambling.alert`
Fired when Apogee detects at-risk patterns (long sessions, escalating stakes, chase-losses). You should react with a cool-off or lockout.
```json
{
  "type": "responsible_gambling.alert",
  "data": { "signal": "chase_loss", "severity": "medium", "sessionLossMinor": 12500 }
}
```

---

### `wallet.credit`
Apogee credited the player's wallet (fires on cashout settlements and refund flows).
```json
{
  "type": "wallet.credit",
  "data": {
    "amount":   1850,
    "currency": "EUR",
    "txId":     "tx_3bcd1.win",
    "refTxId":  "tx_3bcd1",
    "reason":   "cashout",
    "roundId":  "rnd_8fxk9"
  }
}
```

### `wallet.debit_failed`
A player bet was rejected by the operator wallet (insufficient balance, operator error, etc.). Useful for RG engines that want to spot struggling players.
```json
{
  "type": "wallet.debit_failed",
  "data": {
    "txId":   "tx_3bcd2",
    "amount": 500,
    "reason": "insufficient_balance",
    "operatorStatus": 402
  }
}
```

### `player.chase_detected` 🟡
Fires when server-side chase detection flags a pattern (consecutive losses + doubling stake). Operator-opt-in — email partners@apogeetech.net to enable. Planned Q3.
```json
{
  "type": "player.chase_detected",
  "data": { "consecutiveLosses": 8, "stakeMultiple": 4, "recommendedAction": "reality_check" }
}
```

### `player.reality_check_shown`
The reality-check modal was displayed to the player. Use to audit RG compliance.
```json
{
  "type": "player.reality_check_shown",
  "data": { "sessionDurationMs": 300000, "pnl": -450, "wagered": 2200, "returned": 1750 }
}
```

### `player.session_limit_hit`
One of the `rgLimits` fired and auto-terminated the session.
```json
{
  "type": "player.session_limit_hit",
  "data": { "limit": "sessionLossMax", "configured": 10000, "actual": 10250 }
}
```

### `player.terminated`
Operator called `/v1/players/:playerId/terminate` — here's the result.
```json
{
  "type": "player.terminated",
  "data": { "reason": "self_excluded", "terminated": 2, "rolledBack": 1, "until": "2027-04-12T00:00:00Z" }
}
```

### `round.mult_verified` 🟡
Fires after server-side crash verification completes for a round. Useful for internal reconciliation.
```json
{
  "type": "round.mult_verified",
  "data": {
    "roundId":        "rnd_8fxk9",
    "claimedCrash":   2.47,
    "verifiedCrash":  2.47,
    "serverSeed":     "a3b7c9d2...",
    "serverSeedHash": "8f2a1c4d..."
  }
}
```

### `bonus.awarded` 🟡
Planned Q3 — an operator-granted bonus became active on a player. See `docs/BONUSES.md`.
```json
{
  "type": "bonus.awarded",
  "data": { "bonusId": "bns_abc", "type": "free_spins", "remaining": 10, "campaignId": "welcome_v3" }
}
```

### `bonus.progress` 🟡
Planned Q3 — a bonus round consumed a spin / bet. Fires per round while the bonus is active.

### `bonus.completed` 🟡
Planned Q3 — bonus wagering met; accrued winnings settled to real wallet via `/v1/wallet/credit`.

### `bonus.expired` 🟡 / `bonus.cancelled` 🟡
Planned Q3 — bonus ended without settlement.

### `merchant.rtp_drift` 🟡
Planned Q3 — RTP canary detected that rolling RTP for a merchant is > 2pp above target. Gives operator + Apogee ops a chance to intervene before bankroll is damaged. See `docs/HOUSE-PROTECTION.md §2.8`.

---

## Subscribing / unsubscribing

All events fire to the merchant's single `webhookUrl` by default.
Enterprise operators can configure per-event routing (e.g. send
`round.*` to the analytics stack and `player.*` to the RG engine) —
email `partners@apogeetech.net`.

## Signature verification (receiver side)

Your webhook endpoint verifies the same signature Apogee verifies on wallet calls:

```js
import crypto from "node:crypto";

function verifyApogeeWebhook(req) {
  const key  = req.headers["x-apogee-key"];
  const ts   = req.headers["x-apogee-timestamp"];
  const nonce= req.headers["x-apogee-nonce"];
  const sig  = req.headers["x-apogee-signature"];

  const secret = lookupSecretByKey(key);
  if (!secret) throw new Error("unknown_key");
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) throw new Error("stale");
  if (hasSeenNonce(nonce)) throw new Error("replay");
  rememberNonce(nonce, 300);

  const bodyHash = crypto.createHash("sha256").update(req.rawBody).digest("hex");
  const canonical = ["POST", req.path, ts, nonce, bodyHash].join("\n");
  const expected = crypto.createHmac("sha256", secret).update(canonical).digest("hex");
  if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) throw new Error("bad_sig");
}
```

Use the **raw request body** (not the parsed JSON) when computing `bodyHash`. JSON serializers reorder keys and break signatures.
