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

# Webhooks

> Receive real-time event notifications via webhooks

Webhooks notify your server when events happen — user KYC status changes, card transactions, wallet deposits, and collateral updates.

## Setup

Configure your webhook endpoint using the [Set Webhook](/api-reference/profile/set-webhook) endpoint. You'll receive a `webhookSecret` (prefixed `whsec_`) that you'll use to verify webhook signatures.

## Events

### KYC Events

| Event            | Status              | Description                                                       |
| ---------------- | ------------------- | ----------------------------------------------------------------- |
| `user.kyc-event` | `approved`          | User KYC approved — user can now create cards                     |
| `user.kyc-event` | `denied`            | User KYC denied — includes `reason` with one or more denial codes |
| `user.kyc-event` | `needsVerification` | Additional verification required — includes `verificationLink`    |

### Transaction Events

| Event                    | Type                | Description                                           |
| ------------------------ | ------------------- | ----------------------------------------------------- |
| `card.transaction-event` | `debit`             | Card transaction approved                             |
| `card.transaction-event` | `debit` (declined)  | Card transaction declined — includes `declinedReason` |
| `card.transaction-event` | `cross-border`      | FX fee charged for non-USD merchant transactions      |
| `card.transaction-event` | `credit` (reversed) | Card transaction reversed                             |

### Wallet Events

| Event                            | Description                                                                                                                                             |
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `wallet.deposit.crypto`          | Stablecoin deposit received and credited to your wallet balance                                                                                         |
| `wallet.deposit.card-withdrawal` | Funds from a card withdrawal (or liquidation) landed in your partner wallet. `data.reference` echoes the value passed on `POST /partner/card/withdraw`. |
| `wallet.withdrawal.crypto`       | Withdrawal request reached a terminal state — `data.status` is `completed`, `cancelled`, or `rejected`                                                  |

### Card Events

| Event                 | Description                                                         |
| --------------------- | ------------------------------------------------------------------- |
| `card.deposit.crypto` | Stablecoin deposit received and credited to a crypto card's balance |

## Payload Format

All webhooks are delivered as `POST` requests with a JSON body.

### KYC Webhook

```json Approved theme={null}
{
  "event": "user.kyc-event",
  "timestamp": "2026-02-19T20:49:29.927Z",
  "data": {
    "serviceId": "acme-user-7mFQc5aCslAWtkd",
    "status": "approved",
    "email": "jane@example.com",
    "firstName": "Jane",
    "lastName": "Doe"
  }
}
```

```json Denied theme={null}
{
  "event": "user.kyc-event",
  "timestamp": "2026-02-19T20:49:29.927Z",
  "data": {
    "serviceId": "acme-user-7mFQc5aCslAWtkd",
    "status": "denied",
    "email": "jane@example.com",
    "firstName": "Jane",
    "lastName": "Doe",
    "reason": "WRONG_USER_REGION, REGULATIONS_VIOLATIONS"
  }
}
```

```json Needs Verification theme={null}
{
  "event": "user.kyc-event",
  "timestamp": "2026-02-19T21:02:49.083Z",
  "data": {
    "serviceId": "acme-user-G9KZgnhgbndDdJg",
    "status": "needsVerification",
    "email": "jane@example.com",
    "firstName": "Jane",
    "lastName": "Doe",
    "verificationLink": "https://kyc.getplu.com/verify?userId=e5508685-2771-48fd-8cbc-c8c3e6df9179"
  }
}
```

### Transaction Webhooks

**Successful debit (USD merchant)**

```json theme={null}
{
  "event": "card.transaction-event",
  "timestamp": "2026-02-19T20:59:59.793Z",
  "data": {
    "serviceCardId": "acme-corp-card-NC3dIGTEgSi89Bf",
    "serviceTransactionId": "acme-corp-card-transactions-ygmyO0zwKt",
    "amount": 10,
    "status": "approved",
    "type": "debit",
    "description": "OPEN AI"
  }
}
```

**Declined transaction**

```json theme={null}
{
  "event": "card.transaction-event",
  "timestamp": "2026-02-19T20:52:06.188Z",
  "data": {
    "serviceCardId": "acme-corp-card-NC3dIGTEgSi89Bf",
    "serviceTransactionId": "acme-corp-card-transactions-1W65PpxTZ1",
    "amount": 150,
    "fxFee": 0,
    "status": "declined",
    "type": "debit",
    "description": "OPEN AI",
    "declinedReason": "Insufficient funds"
  }
}
```

**Successful debit (non-USD merchant)**

Non-USD transactions produce two webhooks: the debit itself, followed by a separate `cross-border` FX fee webhook.

```json theme={null}
{
  "event": "card.transaction-event",
  "timestamp": "2026-02-19T20:53:16.322Z",
  "data": {
    "serviceCardId": "acme-corp-card-NC3dIGTEgSi89Bf",
    "serviceTransactionId": "acme-corp-card-transactions-ZtyJ0iPEU5",
    "amount": 15,
    "status": "approved",
    "type": "debit",
    "description": "OPEN AI"
  }
}
```

```json FX Fee (follows the debit) theme={null}
{
  "event": "card.transaction-event",
  "timestamp": "2026-02-19T20:53:16.448Z",
  "data": {
    "serviceTransactionId": "acme-corp-card-transactions-ZtyJ0iPEU5_fx",
    "serviceCardId": "acme-corp-card-NC3dIGTEgSi89Bf",
    "type": "cross-border",
    "amount": 0.45,
    "description": "FX Fee for 15 usd purchase @ OPEN AI",
    "status": "approved"
  }
}
```

<Info>
  The FX fee transaction ID is the original transaction ID with `_fx` appended. Use this to correlate the fee with its parent transaction.
</Info>

**Reversed transaction**

Sent when a previously approved transaction is reversed (e.g. merchant-initiated refund, chargeback, or authorization release). The reversal credits the original amount back to the card balance.

```json theme={null}
{
  "event": "card.transaction-event",
  "timestamp": "2026-02-19T21:15:42.610Z",
  "data": {
    "serviceCardId": "acme-corp-card-NC3dIGTEgSi89Bf",
    "serviceTransactionId": "acme-corp-card-transactions-ygmyO0zwKt_rev",
    "amount": 10,
    "fxFee": 0,
    "status": "reversed",
    "type": "credit",
    "description": "OPEN AI"
  }
}
```

| Field                  | Type   | Description                                  |
| ---------------------- | ------ | -------------------------------------------- |
| `serviceCardId`        | string | The card that received the reversal credit   |
| `serviceTransactionId` | string | Original transaction ID with `_rev` appended |
| `amount`               | number | USD amount credited back to the card         |
| `fxFee`                | number | Always `0` for reversals                     |
| `status`               | string | Always `reversed`                            |
| `type`                 | string | Always `credit` (funds returned to the card) |
| `description`          | string | Original merchant name                       |

<Info>
  The reversal transaction ID is the original transaction ID with `_rev` appended. Use this to correlate the reversal with the original debit. If the original transaction had an FX fee, only the debit is reversed — the FX fee is not refunded.
</Info>

### Crypto Deposit Webhook

Sent when a stablecoin transfer to your deposit address is confirmed and credited to your wallet balance.

```json theme={null}
{
  "event": "wallet.deposit.crypto",
  "timestamp": "2026-02-27T14:57:49.432Z",
  "data": {
    "partnerId": "service-partner-PdhFKbfvYr",
    "amount": 5,
    "txHash": "0x44d5bb06c5029ddfa64dbbb5bc10f91461acb7b2a9ebf1753f41cb32529d646b",
    "chain": "base-sepolia",
    "token": "usdc",
    "fromAddress": "0xa40aCe28a2d66f81D396c7F0ACd46Ae2e4407089",
    "toAddress": "0xFe3E1AD10Ae3ed07Dd79deb3E20E6118dEcE6904"
  }
}
```

| Field         | Type   | Description                                                                              |
| ------------- | ------ | ---------------------------------------------------------------------------------------- |
| `partnerId`   | string | Your partner service ID                                                                  |
| `amount`      | number | USD amount credited to your wallet                                                       |
| `txHash`      | string | On-chain transaction hash — use this to verify on a block explorer                       |
| `chain`       | string | Blockchain network the deposit arrived on. See [Supported networks](#supported-networks) |
| `token`       | string | Token received — `usdc`, `usdt`, or `usdc.e`, depending on the network                   |
| `fromAddress` | string | Sender's wallet address                                                                  |
| `toAddress`   | string | Your deposit address that received the funds                                             |

<Info>
  Your wallet balance is credited automatically when the deposit is confirmed on-chain. You can check your updated balance via the [Get Balance](/api-reference/wallet/get-balance) endpoint.
</Info>

<Warning>
  `chain` and `token` are no longer fixed values. Do not assume `base`/`usdc` — read both fields, and treat
  an unrecognised pair as a deposit you should not act on.
</Warning>

### Card Withdrawal Wallet Credit Webhook

Sent when a card withdrawal (`POST /partner/card/withdraw`) or full card liquidation (`DELETE /partner/card/terminate/:serviceCardId`) credits funds back to your partner wallet. Use `data.reference` to match the credit to the originating user transaction on your side.

```json Withdrawal theme={null}
{
  "event": "wallet.deposit.card-withdrawal",
  "timestamp": "2026-05-06T14:57:49.432Z",
  "data": {
    "partnerId": "service-partner-PdhFKbfvYr",
    "serviceCardId": "acme-card-xyz789jkl012mno",
    "serviceTransactionId": "acme-user-card-transactions-abc123def456ghi",
    "amount": 25,
    "fee": 0.5,
    "netAmount": 24.5,
    "currency": "USD",
    "reference": "ref-test-001"
  }
}
```

```json Liquidation theme={null}
{
  "event": "wallet.deposit.card-withdrawal",
  "timestamp": "2026-05-06T14:58:00.110Z",
  "data": {
    "partnerId": "service-partner-PdhFKbfvYr",
    "serviceCardId": "acme-card-xyz789jkl012mno",
    "serviceTransactionId": "acme-user-card-transactions-zzz999yyy888xxx",
    "amount": 12.34,
    "fee": 0.5,
    "netAmount": 11.84,
    "currency": "USD",
    "source": "liquidation",
    "reference": "ref-liq-001"
  }
}
```

| Field                  | Type                | Description                                                                                                            |
| ---------------------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `partnerId`            | string              | Your partner service ID                                                                                                |
| `serviceCardId`        | string              | The card the funds were withdrawn from                                                                                 |
| `serviceTransactionId` | string              | The card transaction ID — also returned synchronously from the withdraw call                                           |
| `amount`               | number              | Gross amount withdrawn from the card (USD)                                                                             |
| `fee`                  | number              | Withdrawal fee charged by Bitmama                                                                                      |
| `netAmount`            | number              | Amount actually credited to your partner wallet (`amount - fee`)                                                       |
| `currency`             | string              | Always `USD`                                                                                                           |
| `source`               | string \| undefined | Present and equal to `"liquidation"` only when the credit came from a card termination; absent for normal withdrawals  |
| `reference`            | string \| undefined | Echoes the `reference` you passed on the withdraw/terminate request. Required on `/withdraw`, optional on `/terminate` |

<Info>
  This event also fires for partner card liquidations when the terminated card has a positive balance. Use the `source` field to distinguish liquidations from regular withdrawals.
</Info>

### Crypto Withdrawal Webhook

Sent when a withdrawal request reaches a terminal state. The `data.status` field tells you which:

* `completed` — USDC was sent on-chain. Includes `txHash` and `explorerUrl`.
* `cancelled` — You cancelled the request via [Cancel Withdrawal](/api-reference/withdrawals/cancel-withdrawal). Wallet was refunded.
* `rejected` — An admin rejected the request. Wallet was refunded. `reason` may be present.

```json Completed theme={null}
{
  "event": "wallet.withdrawal.crypto",
  "timestamp": "2026-04-20T11:55:09.477Z",
  "data": {
    "withdrawalId": "65f0d9f4c8a3b21e9d4a6c01",
    "amount": 100,
    "fee": 0.5,
    "netAmount": 99.5,
    "toAddress": "0xa40aCe28a2d66f81D396c7F0ACd46Ae2e4407089",
    "chain": "base",
    "token": "usdc",
    "status": "completed",
    "txHash": "0x44d5bb06c5029ddfa64dbbb5bc10f91461acb7b2a9ebf1753f41cb32529d646b",
    "explorerUrl": "https://basescan.org/tx/0x44d5bb06c5029ddfa64dbbb5bc10f91461acb7b2a9ebf1753f41cb32529d646b"
  }
}
```

```json Cancelled theme={null}
{
  "event": "wallet.withdrawal.crypto",
  "timestamp": "2026-04-20T11:43:02.108Z",
  "data": {
    "withdrawalId": "65f0d9f4c8a3b21e9d4a6c01",
    "amount": 100,
    "fee": 0.5,
    "netAmount": 99.5,
    "toAddress": "0xa40aCe28a2d66f81D396c7F0ACd46Ae2e4407089",
    "chain": "base",
    "token": "usdc",
    "status": "cancelled"
  }
}
```

```json Rejected theme={null}
{
  "event": "wallet.withdrawal.crypto",
  "timestamp": "2026-04-20T11:50:14.882Z",
  "data": {
    "withdrawalId": "65f0d9f4c8a3b21e9d4a6c01",
    "amount": 100,
    "fee": 0.5,
    "netAmount": 99.5,
    "toAddress": "0xa40aCe28a2d66f81D396c7F0ACd46Ae2e4407089",
    "chain": "base",
    "token": "usdc",
    "status": "rejected",
    "reason": "Suspicious destination address"
  }
}
```

| Field          | Type   | Description                                                   |
| -------------- | ------ | ------------------------------------------------------------- |
| `withdrawalId` | string | The withdrawal record id                                      |
| `amount`       | number | Total USD amount debited from your wallet                     |
| `fee`          | number | Flat fee deducted (\$0.50)                                    |
| `netAmount`    | number | USDC actually sent on-chain (only meaningful for `completed`) |
| `toAddress`    | string | Destination address provided in the request                   |
| `chain`        | string | Always Base — `base` in production, `base-sepolia` in staging |
| `token`        | string | Always `usdc`                                                 |
| `status`       | string | One of `completed`, `cancelled`, `rejected`                   |
| `txHash`       | string | On-chain transaction hash. Present only for `completed`       |
| `explorerUrl`  | string | Basescan link. Present only for `completed`                   |
| `reason`       | string | Admin reason. May be present for `rejected`                   |

<Info>
  Pre-terminal transitions (`pending` → `approved`) do **not** fire webhooks. You only receive a webhook when the withdrawal reaches a terminal state.
</Info>

### Card Crypto Deposit Webhook

Sent when a stablecoin transfer to a crypto card's deposit address is confirmed and credited to the card's balance.

```json theme={null}
{
  "event": "card.deposit.crypto",
  "timestamp": "2026-02-27T15:12:33.841Z",
  "data": {
    "serviceCardId": "acme-card-xyz789jkl012mno",
    "partnerId": "service-partner-PdhFKbfvYr",
    "amount": 25,
    "txHash": "0x55e6cc07d6030ddfa75eccc6bd11f92472bdb8b3b0fcg2864g52dc33630e757c",
    "chain": "base-sepolia",
    "token": "usdc",
    "fromAddress": "0xa40aCe28a2d66f81D396c7F0ACd46Ae2e4407089",
    "toAddress": "0xAb4F2CE20Bf4fe18Ee90efc4E21F7838dFdF7915"
  }
}
```

| Field           | Type   | Description                                                                              |
| --------------- | ------ | ---------------------------------------------------------------------------------------- |
| `serviceCardId` | string | The card that received the deposit                                                       |
| `partnerId`     | string | Your partner service ID                                                                  |
| `amount`        | number | USD amount credited to the card                                                          |
| `txHash`        | string | On-chain transaction hash                                                                |
| `chain`         | string | Blockchain network the deposit arrived on. See [Supported networks](#supported-networks) |
| `token`         | string | Token received — `usdc`, `usdt`, or `usdc.e`, depending on the network                   |
| `fromAddress`   | string | Sender's wallet address                                                                  |
| `toAddress`     | string | Card's deposit address that received the funds                                           |

## Supported networks

Deposit addresses are EVM addresses, so the same address receives on every EVM chain. Only the
chain/token pairs below are credited — anything else that arrives has to be recovered manually, so check
this list before sending.

| Network  | Chain ID | `chain` value | Tokens                   |
| -------- | -------- | ------------- | ------------------------ |
| Base     | 8453     | `base`        | `usdc`                   |
| Polygon  | 137      | `polygon`     | `usdc`, `usdt`, `usdc.e` |
| Optimism | 10       | `optimism`    | `usdc`, `usdt`           |

Staging uses the corresponding testnets: `base-sepolia` (84532), `polygon-amoy` (80002) and
`optimism-sepolia` (11155420), which carry `usdc` only.

Every deposit-address endpoint also returns a `supportedNetworks` array with the same information, so
clients can read it at runtime rather than hardcoding this table.

<Note>
  Polygon has two USDC contracts and both are credited, but they are tracked separately because each
  reports the symbol `USDC` on-chain:

  | Token          | `token` value | Contract                                     |
  | -------------- | ------------- | -------------------------------------------- |
  | Native USDC    | `usdc`        | `0x3c499c542cef5e3811e1192ce70d8cc03d5c3359` |
  | Bridged USDC.e | `usdc.e`      | `0x2791bca1f2de4661ed88a30c99a7a9449aa84174` |

  The `token` field in the webhook tells you which one arrived, so match on it rather than assuming
  `usdc`. Bridged USDC.e exists on Polygon mainnet only — there is no equivalent on Amoy.
</Note>

<Warning>
  **Optimism bridged USDC.e is not credited.** Optimism also has two contracts reporting the symbol `USDC`,
  but only the native one is supported:

  | Token          | Contract                                     | Credited       |
  | -------------- | -------------------------------------------- | -------------- |
  | Native USDC    | `0x0b2c639c533813f4aa9d7837caf62653d097ff85` | Yes, as `usdc` |
  | Bridged USDC.e | `0x7f5c764cbc14f9669b88837ca1490cca17c31607` | **No**         |

  A USDC.e transfer on Optimism produces no deposit and no webhook — it has to be recovered manually, so
  contact support if you send one. Check the contract your wallet or exchange is sending before you use
  Optimism: some label the bridged token simply "USDC".
</Warning>

## Signature Verification

Every webhook includes an `X-Webhook-Signature` header containing an HMAC-SHA256 hex digest. **Always verify this signature** before processing the webhook.

<CodeGroup>
  ```javascript Node.js theme={null}
  const crypto = require('crypto');

  function verifyWebhook(req, webhookSecret) {
    const signature = req.headers['x-webhook-signature'];
    const payload = JSON.stringify(req.body);

    const expected = crypto
      .createHmac('sha256', webhookSecret)
      .update(payload)
      .digest('hex');

    return crypto.timingSafeEqual(
      Buffer.from(signature),
      Buffer.from(expected)
    );
  }
  ```

  ```python Python theme={null}
  import hmac
  import hashlib
  import json

  def verify_webhook(headers, body, webhook_secret):
      signature = headers.get('X-Webhook-Signature')
      payload = json.dumps(body, separators=(',', ':'))

      expected = hmac.new(
          webhook_secret.encode(),
          payload.encode(),
          hashlib.sha256
      ).hexdigest()

      return hmac.compare_digest(signature, expected)
  ```
</CodeGroup>

## Retry Policy

If your endpoint doesn't respond with a `2xx` status code, the webhook is automatically retried with exponential backoff:

| Attempt   | Delay after failure | Cumulative wait |
| --------- | ------------------- | --------------- |
| 1st retry | 1 minute            | \~1 min         |
| 2nd retry | 5 minutes           | \~6 min         |
| 3rd retry | 15 minutes          | \~21 min        |
| 4th retry | 1 hour              | \~81 min        |

After all retry attempts are exhausted, the webhook is marked as permanently failed. All webhook deliveries — including every retry attempt — are logged with status codes and response bodies for debugging.

<Warning>
  Your webhook endpoint must respond within 30 seconds. Long-running processing should be handled asynchronously after acknowledging the webhook with a `200` response.
</Warning>

<Info>
  Retries are processed automatically every 30 seconds. If your endpoint comes back online within the retry window, pending webhooks will be delivered on the next retry cycle without any manual intervention.
</Info>
