> For the complete documentation index, see [llms.txt](https://docs.wynta.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.wynta.com/bonus/webhooks/wallet-update.md).

# Bonus Wallet Update Webhook

Every lifecycle transition in Wynta converges on a **single webhook contract** pushed to the PAM. **The PAM — the external client system — maintains the player Bonus Wallet and updates it based on these webhooks:** all credit-side changes (grant, release, expiry, forfeit) are applied to the client wallet exclusively from webhook events. The exceptions are `BONUS_CONSUMED` / `BONUS_CONSUMPTION_REVERTED`, which **confirm** debits/credits the PAM already applied locally (see [Bonus Consumption](/bonus/flows/consumption.md)) — the PAM matches these by `consume_txn_id` instead of re-applying the delta.

{% hint style="warning" %}
**Status: integration contract proposal.** Field vocabulary aligns with the Wynta API (`chip_type`, `consume_txn_id`, chunk fields); the envelope shape below is pending sign-off between the Wynta and PAM (SR) teams.
{% endhint %}

## Event types

| `event_type`                     | Emitted when                        | `pending_delta`      | `available_delta`      | PAM applies delta?            |
| -------------------------------- | ----------------------------------- | -------------------- | ---------------------- | ----------------------------- |
| `BONUS_GRANTED`                  | Grant trigger fires, chunks created | `+amount`            | 0                      | ✔ apply                       |
| `BONUS_RELEASED`                 | Chunk threshold crossed by wagering | `−chunk amount(s)`   | `+chunk amount(s)`     | ✔ apply                       |
| `BONUS_CONSUMED`                 | `bonus_consume_bonus` accepted      | 0                    | `−amount`              | ✖ confirm by `consume_txn_id` |
| `BONUS_CONSUMPTION_REVERTED`     | `bonus_revert_bonus_consumption`    | 0                    | `+amount`              | ✖ confirm by `consume_txn_id` |
| `BONUS_EXPIRED`                  | Chunk or bonus expiry job           | `−pending remainder` | `−available remainder` | ✔ apply                       |
| `BONUS_FORFEITED`                | Player/operator forfeit             | `−pending remainder` | `−available remainder` | ✔ apply                       |
| `WAGERING_PROGRESS` *(optional)* | Progress update for UX              | 0                    | 0                      | ✖ UX only                     |

## Payload schema

```json
{
  "event_id": "uuid",                    // idempotency key — PAM dedupes on this
  "event_type": "BONUS_RELEASED",
  "occurred_at": "2026-07-13T10:42:07Z",
  "player_id": "player-123",
  "site_id": 1,
  "txn_id": 15,                          // grant record ID (transactions API)
  "consume_txn_id": null,                // set on CONSUMED / REVERTED events
  "bonus_code": "WELCOME100",
  "chip_type": "cash",                   // "cash" | "in_app_purchase"
  "amount": "166.67",                    // decimal string, like the API
  "wallet_effect": {
    "pending_delta": "-166.67",          // signed decimal strings
    "available_delta": "166.67"
  },
  "chunks": [                            // chunk-level detail; releases may batch
    {
      "chunk_ref": "CHUNK_002",          // matches transaction-detail chunk_ref
      "sequence": 2,
      "chunk_count": 3,
      "amount": "166.67",
      "wager_amount": "600.0000",        // chunk wagering progress at event time
      "status": "RELEASE",
      "expires_at": "2025-05-08T08:00:00" // chunk expiry (UTC) — lets the PAM show countdowns
    }
  ],
  "resulting_balance": {                 // Wynta's authoritative view — field names
    "pending_bonus": "333.33",           // match GET /{user_id}/summary
    "bonus_balance": "166.67",
    "wagering_required": "1000.00"
  }
}
```

## Example event payloads

### `BONUS_GRANTED`

```json
{
  "event_id": "84d3e584-b5b8-4d16-b122-afb83bec1618",
  "event_type": "BONUS_GRANTED",
  "occurred_at": "2026-07-27T08:28:58Z",
  "player_id": "16773",
  "site_id": 217,
  "txn_id": 240,
  "consume_txn_id": null,
  "bonus_code": null,
  "chip_type": "cash",
  "amount": "100.00",
  "wallet_effect": {
    "pending_delta": "100.00",
    "available_delta": "0.00"
  },
  "chunks": [
    {
      "chunk_ref": "CH001",
      "sequence": 1,
      "chunk_count": 1,
      "amount": "100.00",
      "wager_amount": "0.0000",
      "status": "RELEASE",
      "expires_at": null
    }
  ],
  "resulting_balance": {
    "pending_bonus": "640.00",
    "bonus_balance": "460.80",
    "wagering_required": "640.00"
  }
}
```

### `BONUS_RELEASED`

```json
{
  "event_id": "e302e8ee-00bf-4dc9-a408-69ace706a4e2",
  "event_type": "BONUS_RELEASED",
  "occurred_at": "2026-07-14T12:10:47Z",
  "player_id": "P1007",
  "site_id": 217,
  "txn_id": 82,
  "consume_txn_id": null,
  "bonus_code": "BONUS_TEST_DEV_02",
  "chip_type": "cash",
  "amount": "50.00",
  "wallet_effect": {
    "pending_delta": "-50.00",
    "available_delta": "50.00"
  },
  "chunks": [
    {
      "chunk_ref": "CH001",
      "sequence": 1,
      "chunk_count": 2,
      "amount": "50.00",
      "wager_amount": "50.0000",
      "status": "PENDING",
      "expires_at": "2026-07-16T12:10:43"
    }
  ],
  "resulting_balance": {
    "pending_bonus": "450.00",
    "bonus_balance": "50.00",
    "wagering_required": "450.00"
  }
}
```

### `BONUS_EXPIRED`

```json
{
  "event_id": "417ecfda-0ecf-405c-8cb7-cfc30577df0d",
  "event_type": "BONUS_EXPIRED",
  "occurred_at": "2026-08-07T13:00:14Z",
  "player_id": "P1001",
  "site_id": 217,
  "txn_id": 301,
  "consume_txn_id": null,
  "bonus_code": "WEBHOOK-01",
  "chip_type": "cash",
  "amount": "40.00",
  "wallet_effect": {
    "pending_delta": "-40.00",
    "available_delta": "0.00"
  },
  "chunks": [
    {
      "chunk_ref": "CH005",
      "sequence": 5,
      "chunk_count": 5,
      "amount": "40.00",
      "wager_amount": "0.0000",
      "status": "EXPIRED",
      "expires_at": "2026-08-07T12:59:19"
    }
  ],
  "resulting_balance": {
    "pending_bonus": "0.00",
    "bonus_balance": "1390.00",
    "wagering_required": "0.00"
  }
}
```

### `BONUS_FORFEITED`

```json
{
  "event_id": "3b792646-0d83-46c4-b1df-949d62aee8f4",
  "event_type": "BONUS_FORFEITED",
  "occurred_at": "2026-08-07T10:03:12Z",
  "player_id": "P1001",
  "site_id": 217,
  "txn_id": 18,
  "consume_txn_id": null,
  "bonus_code": "REACTIVATE_TOP_PLAYERS-JULY-08-3-1400",
  "chip_type": "cash",
  "amount": "500.00",
  "wallet_effect": {
    "pending_delta": "0.00",
    "available_delta": "-500.00"
  },
  "chunks": [
    {
      "chunk_ref": "CH001",
      "sequence": 1,
      "chunk_count": 1,
      "amount": "500.00",
      "wager_amount": "0.0000",
      "status": "FORFEITED",
      "expires_at": "2026-08-07T09:44:35"
    }
  ],
  "resulting_balance": {
    "pending_bonus": "400.00",
    "bonus_balance": "1390.00",
    "wagering_required": "1000.00"
  }
}
```

### `BONUS_CONSUMED`

```json
{
  "event_id": "a1b2c3d4-1111-4a2b-9c3d-e5f6a7b8c9d0",
  "event_type": "BONUS_CONSUMED",
  "occurred_at": "2026-08-07T14:12:30Z",
  "player_id": "48299661165272424",
  "site_id": 217,
  "txn_id": 512,
  "consume_txn_id": "wager-20260807-001",
  "bonus_code": null,
  "chip_type": "cash",
  "amount": "50.00",
  "wallet_effect": {
    "pending_delta": "0.00",
    "available_delta": "-50.00"
  },
  "chunks": [
    {
      "chunk_ref": "CH001",
      "sequence": 1,
      "chunk_count": 2,
      "amount": "50.00",
      "wager_amount": "50.0000",
      "status": "CONSUMED",
      "expires_at": null
    }
  ],
  "resulting_balance": {
    "pending_bonus": "450.00",
    "bonus_balance": "0.00",
    "wagering_required": "450.00"
  }
}
```

### `BONUS_CONSUMPTION_REVERTED`

```json
{
  "event_id": "f6e5d4c3-2222-4b1a-8d9c-0a1b2c3d4e5f",
  "event_type": "BONUS_CONSUMPTION_REVERTED",
  "occurred_at": "2026-08-07T14:20:05Z",
  "player_id": "48299661165272424",
  "site_id": 217,
  "txn_id": 512,
  "consume_txn_id": "wager-20260807-001",
  "bonus_code": null,
  "chip_type": "cash",
  "amount": "50.00",
  "wallet_effect": {
    "pending_delta": "0.00",
    "available_delta": "50.00"
  },
  "chunks": [
    {
      "chunk_ref": "CH001",
      "sequence": 1,
      "chunk_count": 2,
      "amount": "50.00",
      "wager_amount": "50.0000",
      "status": "INIT",
      "expires_at": null
    }
  ],
  "resulting_balance": {
    "pending_bonus": "450.00",
    "bonus_balance": "50.00",
    "wagering_required": "450.00"
  }
}
```

## Delivery semantics

* **At-least-once** delivery with exponential-backoff retries for ≥ 24 h; undeliverable events go to a dead-letter queue and are swept into the next reconciliation run.
* **Deduplication** — the PAM applies each `event_id` exactly once.
* **Ordering** — events may arrive out of order. The PAM applies signed deltas and compares against `resulting_balance` after each apply; a mismatch flags the player for targeted reconciliation. If Wynta provides a per-player sequence number, ordered application is preferred.
* **Batching** — a single wager can release multiple chunks; the `chunks[]` array then carries several entries and `wallet_effect` holds the summed deltas. The PAM must handle 1..N chunks per event.

## Authentication

Every webhook request from Wynta to the PAM endpoint is authenticated with the following layers:

**1. Identity header.** Each request carries `X-Client-Id` identifying the Wynta site/tenant emitting the event (mirroring the header convention of the [User Bonus API](/bonus/api-reference/overview.md#authentication-client-id-header)), so a multi-brand PAM can route and scope events.

**2. HMAC-SHA256 signature (proposed).** The User Bonus API authenticates with the `X-Client-Id` header alone, but the inbound webhook endpoint on the PAM is exposed to receive calls — an unsigned identifier is not sufficient there. We propose signing every webhook with a shared **webhook secret** provisioned at onboarding:

```
X-Client-Id:  wynta-bonus
X-Timestamp:  1783927078                      // unix epoch seconds
X-Signature:  hex( HMAC-SHA256( webhook_secret,
                 "{client_id}\n{timestamp}\n{raw_body}" ) )
```

Verification on the PAM side:

1. Read the **raw** request body (before any JSON parsing/re-serialization).
2. Reject if `|now − X-Timestamp| > 300 s` (replay window).
3. Build `canonical = client_id + "\n" + timestamp + "\n" + raw_body`, compute `HMAC-SHA256(webhook_secret, canonical)`, and compare using a **constant-time comparison**.
4. Only then parse the body and process the event.

**3. Secret rotation.** Secrets rotate without downtime: during rotation the PAM verifies against both current and next secret and accepts if **either** matches, then drops the old secret after cutover.

**4. Transport controls.** HTTPS/TLS 1.2+ only; Wynta publishes egress IP ranges for PAM-side allow-listing; mTLS available per environment where the PAM requires it.

{% hint style="danger" %}
Never apply a wallet update from an unverified request. A failed signature or expired timestamp must return `401` **without** processing — and must not be retried into the wallet via logs or queues.
{% endhint %}

## PAM handler requirements

1. Authenticate: verify `X-Client-Id`, timestamp window, and the canonical-string HMAC signature on the raw body (constant-time compare).
2. Dedupe on `event_id`.
3. Apply `wallet_effect` to the chip-type bucket pair — except `BONUS_CONSUMED` / `BONUS_CONSUMPTION_REVERTED`, which are matched to the existing local debit/credit by `consume_txn_id`.
4. Compare local balance to `resulting_balance`; on drift, flag (do not auto-correct outside reconciliation).
5. Store the receipt; acknowledge with 2xx quickly and do heavy processing async. Non-2xx or timeout triggers Wynta's retry.
