> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sakawave.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhook events

> The event catalog, the delivery envelope, and the payload each event family carries.

An active endpoint receives every event listed here for its business. Event filtering is not available, so route on `event` and ignore what you do not handle.

## Event catalog

| Domain | Events |
| - | - |
| Fiat deposits | `fiat.deposit.created`, `fiat.deposit.completed`, `fiat.deposit.failed` |
| Fiat withdrawals | `fiat.withdrawal.created`, `fiat.withdrawal.completed`, `fiat.withdrawal.failed` |
| Bill payments | `bills_payment.created`, `bills_payment.completed`, `bills_payment.failed` |
| Card transactions | `card.transaction.created`, `card.transaction.completed`, `card.transaction.failed`, `card.transaction.reversed` |
| Accounts | `account.created`, `account.updated` |
| Account details | `account.detail.created`, `account.detail.updated` |
| Cards | `card.created`, `card.updated` |
| Customers | `customer.created`, `customer.updated`, `customer.kyc.approved`, `customer.kyc.rejected`, `customer.kyc.suspended` |
| eSIM orders | `esim.order.created`, `esim.order.completed`, `esim.order.failed`, `esim.topup.completed` |
| eSIMs | `esim.profile.installed`, `esim.profile.depleted`, `esim.profile.expired`, `esim.profile.cancelled`, `esim.profile.revoked` |
| eSIM warnings | `esim.usage.low`, `esim.expiry.near` |
| Operations | `endpoint.test` |

<Note>
  Two events are **reserved**: `fiat.deposit.created` and `fiat.deposit.failed`. They are part of the catalog but are not currently emitted — current provider flows expose no durable local state to emit them from safely. Build handlers for them; do not wait on them.

  `fiat.deposit.completed` **is** emitted: it fires for the credit side of an internal transfer, so money arriving on one of your accounts from another Sakawave account does reach you.
</Note>

The customer KYC events follow a customer's verification for as long as it exists, not only its first verdict. Each is sent whenever its status is reached: `customer.kyc.suspended` when an approval is withdrawn, and `customer.kyc.approved` or `customer.kyc.rejected` again if a later review changes the outcome. Their `data` is the customer object plus a `kyc` object with `status`, `tier`, `failureReason` and `resolvedAt`. See [Identity and verification](/services/customers/identity-and-verification).

There are no `transfer.*` events. An outbound transfer reaches your endpoint as a `fiat.withdrawal.*` event whose `relatedOperation` describes the transfer.

## Envelope

Every request body has exactly two top-level fields.

```json theme={null}
{
  "event": "fiat.withdrawal.completed",
  "data": {}
}
```

The event identifier, timestamp, and attempt number travel as headers, not as body fields. Deduplicate on the `Sakawave-Webhook-Id` header — there is no `id` inside the body.

| Header | Meaning |
| - | - |
| `Sakawave-Webhook-Id` | Stable event ID. Unchanged across resends. |
| `Sakawave-Webhook-Timestamp` | Unix timestamp in seconds for this attempt. |
| `Sakawave-Webhook-Signature` | `v1=` followed by the hexadecimal HMAC-SHA-256 digest. |
| `Sakawave-Webhook-Attempt` | One-based attempt number. |

## Financial payloads

Deposits, withdrawals, bill payments, and card transactions carry the posted ledger transaction. `amount` and `fee` are strings of minor units; their `Formatted` counterparts, and converted amounts inside `relatedOperation`, are JSON numbers in major units.

```json theme={null}
{
  "event": "fiat.withdrawal.completed",
  "data": {
    "id": "01K2Z3AB4K5M6N7P8QRSTVWXYZ",
    "account": "01K2Z3AB4D5E6F7G8H9JKMNPQR",
    "amount": "2500000",
    "amountFormatted": 25000,
    "fee": "5000",
    "feeFormatted": 50,
    "currency": "NGN",
    "type": "debit",
    "category": "transfer",
    "subCategory": "outward",
    "status": "completed",
    "channel": "nip",
    "internalReference": "SKWV-TRF-000123",
    "externalReference": "payout_20260814_001",
    "groupReference": "01K2Z3AB4J5K6M7N8PQRSTVWXY",
    "reversalOfTransactionId": null,
    "narration": "Sandbox tutorial transfer",
    "createdAt": "2026-08-14T10:15:30.000Z",
    "relatedOperation": {}
  }
}
```

`status` is derived from the event name, so `fiat.withdrawal.failed` always carries `status: "failed"`.

### Related operations

`relatedOperation.type` is `transfer`, `bill_payment`, or `card_payment`.

* **`transfer`** — reference, destination type, recipient display name, institution name, last four digits, source and destination amounts and currencies, and nullable failure fields. Inbound movements add `direction: "inward"`, `sessionId`, and `senderName`. Bank resolution is best effort: `institutionName` is `null` when the NIP bank code cannot be resolved.
* **`bill_payment`** — biller and package names, customer name and address, the last four characters of the customer reference, vend status, token data where the biller returns one, and a nullable failure reason.
* **`card_payment`** — card ID, card label, masked PAN, merchant name, and merchant country.

## Resource payloads

`account.*`, `account.detail.*`, `card.*`, and `customer.*` events carry the same sanitized object their REST endpoint returns — byte for byte what a `GET` would give you.

| Event family | `data` is |
| - | - |
| `account.created`, `account.updated` | An account, as returned by `GET /v1/accounts/{accountId}` |
| `account.detail.created`, `account.detail.updated` | An account detail, naming its account in `account` |
| `card.created`, `card.updated` | A card |
| `customer.*` | A customer |

Provider identifiers, regulated identity values, full card numbers, CVVs, and ledger balances are never added to a webhook payload.

Because the payload *is* the resource, a resource event can be acted on directly without a follow-up `GET` — unlike a financial event, where the money detail sits in `relatedOperation` and the authoritative object is elsewhere.

### eSIM payloads

`esim.*` events follow the resource rule, with two shapes rather than one.

| Event family | `data` is |
| - | - |
| `esim.order.*`, `esim.topup.completed` | An order, as returned by `GET /v1/esims/orders/{orderId}` |
| `esim.profile.*`, `esim.usage.low`, `esim.expiry.near` | An eSIM, as returned by `GET /v1/esims/{profileId}` |

An order carries both money figures: `price` in USD, and `paid` in whatever currency the account was actually debited. Reconcile a bank statement against `paid`; reconcile the catalog against `price`.

```json theme={null}
{
  "event": "esim.order.completed",
  "data": {
    "id": "01K2Z3AB4S5T6V7W8XYZABCDEF",
    "status": "completed",
    "package": { "code": "PKG-US-5GB", "name": "United States 5GB" },
    "price": { "amount": "999", "currency": "USD", "display": "9.99" },
    "paid": { "amount": "1568430", "currency": "NGN", "display": "15,684.30" },
    "profileIds": ["01K2Z3AB4R5S6T7V8WXYZABCDE"]
  }
}
```

`profileIds` is what makes `esim.order.completed` actionable: fetch [the install details](/api-reference/esim/get-install-details) for each and put the QR in front of the customer.

<Note>
  Activation codes are never in a webhook payload. They install a working SIM, so they are only ever handed out by an authenticated request for one specific eSIM.
</Note>

`esim.usage.low` and `esim.expiry.near` fire **once per eSIM**, not on every usage sync. You can send them straight to a customer without keeping your own "already warned" flag.

### Deduplication is per revision

Resource events are staged with an idempotency key built from the event type, the resource id, and a revision taken from the object's `updatedAt`. Two updates that produce the same `updatedAt` are collapsed into one event. A `.created` event uses a fixed revision, so a resource is only ever announced once.

## Ordering and duplicates

Do not assume global ordering. A manual resend can deliver an older event after a newer one. Apply a change only when it is valid for your current state, and treat the REST resource as the source of truth when an event conflicts with it.

Deduplication must be atomic with recording receipt. A typical table uses the webhook ID as a unique primary key and stores the event name, received time, processing status, and a body hash. Return `2xx` for an event you have already processed.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.