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

# Webhooks

> Receive real-time notifications when events happen in your account

Webhooks let you receive **instant HTTP notifications** when events happen — a KYC is approved, a card transaction occurs, a wallet deposit is received, etc. Instead of polling our API, we push the data to your server.

<Warning>
  You **must** configure at least one active webhook endpoint before you can create customers. This ensures you can receive important notifications about your customers' activity.
</Warning>

## How webhooks work

```
1. You register a webhook URL          → POST /v1/webhooks
2. We ping your URL to validate it     → Must respond 2xx
3. Events happen (KYC approved, etc.)  → We POST to your URL
4. You verify the signature            → HMAC-SHA256
5. You process the event               → Return 2xx to acknowledge
```

## Step 1: Set up your server

Before creating a webhook, your server must be ready to receive POST requests. Here's a minimal example:

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

  app.post('/webhooks/kemycard', express.raw({ type: 'application/json' }), (req, res) => {
    const payload = req.body.toString();
    const signature = req.headers['x-kemycard-signature'];
    const timestamp = req.headers['x-kemycard-timestamp'];
    const idempotencyKey = req.headers['x-kemycard-idempotency-key'];

    // 1. Verify signature
    const secret = process.env.KEMYCARD_WEBHOOK_SECRET;
    const signedPayload = `${timestamp}.${payload}`;
    const expectedSig = crypto.createHmac('sha256', secret).update(signedPayload).digest('hex');

    const parts = signature.split(',');
    const v1 = parts.find(p => p.startsWith('v1='))?.slice(3);

    if (v1 !== expectedSig) {
      return res.status(401).json({ error: 'Invalid signature' });
    }

    // 2. Reject old timestamps (> 5 minutes)
    const age = Math.floor(Date.now() / 1000) - parseInt(timestamp);
    if (age > 300) {
      return res.status(401).json({ error: 'Timestamp too old' });
    }

    // 3. Check idempotency (prevent duplicate processing)
    // Store idempotencyKey in your database and skip if already processed

    // 4. Process the event
    const event = JSON.parse(payload);
    console.log(`Received ${event.type}:`, event.data);

    switch (event.type) {
      case 'kyc.approved':
        // Update customer status in your system
        break;
      case 'card.transaction':
        // Log the transaction
        break;
      // ... handle other events
    }

    // 5. Return 2xx to acknowledge
    res.status(200).json({ received: true });
  });

  app.listen(3000);
  ```

  ```python Python (Flask) theme={null}
  import hmac
  import hashlib
  import time
  import json
  from flask import Flask, request, jsonify

  app = Flask(__name__)

  @app.route('/webhooks/kemycard', methods=['POST'])
  def webhook():
      payload = request.get_data(as_text=True)
      signature = request.headers.get('X-KemyCard-Signature', '')
      timestamp = request.headers.get('X-KemyCard-Timestamp', '')
      idempotency_key = request.headers.get('X-KemyCard-Idempotency-Key', '')

      # 1. Verify signature
      secret = os.environ['KEMYCARD_WEBHOOK_SECRET']
      signed_payload = f"{timestamp}.{payload}"
      expected_sig = hmac.new(
          secret.encode(), signed_payload.encode(), hashlib.sha256
      ).hexdigest()

      parts = dict(p.split('=', 1) for p in signature.split(','))
      if not hmac.compare_digest(parts.get('v1', ''), expected_sig):
          return jsonify(error='Invalid signature'), 401

      # 2. Reject old timestamps
      if int(time.time()) - int(timestamp) > 300:
          return jsonify(error='Timestamp too old'), 401

      # 3. Process the event
      event = json.loads(payload)
      print(f"Received {event['type']}: {event['data']}")

      return jsonify(received=True), 200
  ```

  ```php PHP theme={null}
  <?php
  $payload = file_get_contents('php://input');
  $signature = $_SERVER['HTTP_X_KEMYCARD_SIGNATURE'] ?? '';
  $timestamp = $_SERVER['HTTP_X_KEMYCARD_TIMESTAMP'] ?? '';
  $idempotencyKey = $_SERVER['HTTP_X_KEMYCARD_IDEMPOTENCY_KEY'] ?? '';

  // 1. Verify signature
  $secret = getenv('KEMYCARD_WEBHOOK_SECRET');
  $signedPayload = $timestamp . '.' . $payload;
  $expectedSig = hash_hmac('sha256', $signedPayload, $secret);

  preg_match('/v1=([a-f0-9]+)/', $signature, $matches);
  $v1 = $matches[1] ?? '';

  if (!hash_equals($expectedSig, $v1)) {
      http_response_code(401);
      echo json_encode(['error' => 'Invalid signature']);
      exit;
  }

  // 2. Reject old timestamps (> 5 minutes)
  if (time() - intval($timestamp) > 300) {
      http_response_code(401);
      echo json_encode(['error' => 'Timestamp too old']);
      exit;
  }

  // 3. Process the event
  $event = json_decode($payload, true);
  error_log("Received {$event['type']}: " . json_encode($event['data']));

  // 4. Return 200
  http_response_code(200);
  echo json_encode(['received' => true]);
  ```
</CodeGroup>

<Info>
  Your endpoint **must** return a `2xx` status code to acknowledge receipt. Any other status code will be treated as a failure and trigger retries.
</Info>

## Step 2: Register your webhook

Once your server is ready, register your endpoint:

```bash theme={null}
curl -X POST https://api.kemycard.com/v1/webhooks \
  -H "Authorization: Bearer sk_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-app.com/webhooks/kemycard",
    "events": ["kyc.approved", "kyc.rejected", "card.transaction"],
    "description": "Production webhook"
  }'
```

<Steps>
  <Step title="We validate your URL">
    Must start with `https://`. HTTP URLs are rejected.
  </Step>

  <Step title="We send a ping">
    A test POST is sent immediately to your URL. It must respond with a `2xx` status code.
  </Step>

  <Step title="Endpoint is created">
    If the ping succeeds, the endpoint is created and the response includes your **secret** — this is the **only time** the secret is shown. Store it securely.
  </Step>
</Steps>

Response:

```json theme={null}
{
  "success": true,
  "data": {
    "code": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "url": "https://your-app.com/webhooks/kemycard",
    "secret": "whsec_a1b2c3d4e5f6...",
    "events": ["kyc.approved", "kyc.rejected", "card.transaction"],
    "is_active": true,
    "description": "Production webhook",
    "created_at": "2026-07-02T10:00:00+00:00"
  }
}
```

<Warning>
  **Save the `secret` immediately!** It is only shown once, at creation time. You need it to verify webhook signatures. If you lose it, use the `/rotate-secret` endpoint to generate a new one (the old one becomes invalid immediately).
</Warning>

## Step 3: You're ready

That's it. From now on, every time an event you subscribed to occurs, we'll POST it to your URL with the full payload and signature headers.

## Available events (30)

Subscribe to specific events, or use `"*"` to receive everything.

| Category | Events |
| - | - |
| **KYC** | `kyc.approved`, `kyc.rejected`, `kyc.expired`, `kyc.resubmission_requested`, `kyc.pending_review`, `kyc.identity_mismatch`, `kyc.identity_synchronized` |
| **POA** | `poa.approved`, `poa.rejected` |
| **Cards** | `card.activated`, `card.creation_failed`, `card.topup.completed`, `card.topup.failed`, `card.transaction`, `card.3ds_request`, `card.frozen`, `card.unfrozen`, `card.terminated` |
| **Wallets** | `wallet.deposit_received`, `wallet.withdrawal_completed`, `wallet.withdrawal_failed` |
| **Virtual Accounts** | `virtual_account.deposit_received`, `virtual_account.transfer_completed`, `virtual_account.transfer_failed` |
| **Gift Cards** | `gift_card.delivered`, `gift_card.failed`, `gift_card.refunded` |
| **Balance** | `balance.deposit_confirmed`, `balance.debited`, `balance.low_balance` |

## Webhook payload format

Every webhook delivery is wrapped in this envelope:

```json theme={null}
{
  "id": "evt_a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "type": "kyc.approved",
  "created_at": "2026-07-02T14:30:00+00:00",
  "data": { ... },
  "mode": "live"
}
```

| Field | Description |
| - | - |
| `id` | Unique event ID (format: `evt_<uuid>`) |
| `type` | Event type (e.g. `kyc.approved`) |
| `created_at` | ISO 8601 timestamp |
| `data` | Event-specific payload (see below) |
| `mode` | `live` or `test` |

***

## Event payloads

Below is the detailed `data` payload for each event, along with when it is triggered.

### KYC events

<AccordionGroup>
  <Accordion title="kyc.approved">
    **Triggered when:** The customer's identity verification is approved. The customer reaches the `verified` level. The verification is billed at this moment (see `balance.debited`, `reason: kyc_verification`).

    ```json theme={null}
    {
      "customer_code": "019abc12-3456-7890-abcd-ef0123456789",
      "external_reference": "cust-789",
      "kyc_status": "approved",
      "verification_level": "verified",
      "approved_at": "2026-07-04T14:30:00+00:00",
      "verified_identity": {
        "first_name": "John",
        "last_name": "Doe",
        "date_of_birth": "1990-05-15"
      },
      "identity_mismatch": false,
      "status": "active"
    }
    ```

    `verified_identity` is the identity read on the document. `identity_mismatch` is `true` when it differs from the identity you declared for this customer (name or date of birth). In that case `status` is `restricted` and a `kyc.identity_mismatch` event follows.
  </Accordion>

  <Accordion title="kyc.identity_mismatch">
    **Triggered when:** A KYC is approved but the identity read on the document differs from the one you declared. The customer is `restricted` by KemyCard and cannot be used until you call `POST /kyc/{customerCode}/synchronize`. The activate endpoint does not lift this restriction.

    ```json theme={null}
    {
      "customer_code": "019abc12-3456-7890-abcd-ef0123456789",
      "external_reference": "cust-789",
      "declared_identity": {
        "first_name": "Jon",
        "last_name": "Doe",
        "date_of_birth": "1990-05-15"
      },
      "verified_identity": {
        "first_name": "John",
        "last_name": "Doe",
        "date_of_birth": "1990-05-15"
      },
      "status": "restricted",
      "restriction_reason": "identity_mismatch",
      "required_action": "synchronize_identity"
    }
    ```
  </Accordion>

  <Accordion title="kyc.identity_synchronized">
    **Triggered when:** You called `POST /kyc/{customerCode}/synchronize`. The declared identity now matches the verified identity and the restriction is lifted.

    ```json theme={null}
    {
      "customer_code": "019abc12-3456-7890-abcd-ef0123456789",
      "external_reference": "cust-789",
      "first_name": "John",
      "last_name": "Doe",
      "date_of_birth": "1990-05-15",
      "status": "active"
    }
    ```
  </Accordion>

  <Accordion title="kyc.rejected">
    **Triggered when:** The verification is declined (unreadable document, blurry photo, fraud detected, etc.) or abandoned by the customer. Request a new link with `POST /kyc/{customerCode}/link` to let the customer retry.

    ```json theme={null}
    {
      "customer_code": "019abc12-3456-7890-abcd-ef0123456789",
      "external_reference": "cust-789",
      "kyc_status": "rejected",
      "verification_level": "basic",
      "reason": "Document is not readable"
    }
    ```
  </Accordion>

  <Accordion title="kyc.expired">
    **Triggered when:** The verification session expired before the customer completed it. Request a new link to restart.

    ```json theme={null}
    {
      "customer_code": "019abc12-3456-7890-abcd-ef0123456789",
      "external_reference": "cust-789",
      "kyc_status": "rejected",
      "verification_level": "basic",
      "reason": "Verification failed"
    }
    ```
  </Accordion>

  <Accordion title="kyc.resubmission_requested">
    **Triggered when:** The submitted documents are insufficient and the customer must submit again. Request a new link and redirect the customer.

    ```json theme={null}
    {
      "customer_code": "019abc12-3456-7890-abcd-ef0123456789",
      "external_reference": "cust-789",
      "kyc_status": "resubmission_requested",
      "verification_level": "basic",
      "reason": "Additional documents required"
    }
    ```
  </Accordion>

  <Accordion title="kyc.pending_review">
    **Triggered when:** The verification has been submitted and requires a manual review. No action needed: a final `kyc.approved` or `kyc.rejected` event follows.

    ```json theme={null}
    {
      "customer_code": "019abc12-3456-7890-abcd-ef0123456789",
      "external_reference": "cust-789",
      "kyc_status": "pending",
      "verification_level": "basic"
    }
    ```
  </Accordion>
</AccordionGroup>

### POA events

<AccordionGroup>
  <Accordion title="poa.approved">
    **Triggered when:** The customer's proof of address is approved. The customer reaches the `full` level. The verification is billed at this moment (see `balance.debited`, `reason: poa_verification`).

    ```json theme={null}
    {
      "customer_code": "019abc12-3456-7890-abcd-ef0123456789",
      "external_reference": "cust-789",
      "poa_status": "approved",
      "verification_level": "full",
      "approved_at": "2026-07-04T14:30:00+00:00"
    }
    ```
  </Accordion>

  <Accordion title="poa.rejected">
    **Triggered when:** The proof of address is rejected (name or address does not match, document too old, unreadable document, etc.). The customer stays `verified`. Submit a new document with `POST /poa/{customerCode}/document` to retry. If the document is genuine but does not show the customer's full name, open a support ticket from your secure KemyCard account with the `customer_code` (priority channel); to follow up, email [hi@kemycard.com](mailto:hi@kemycard.com) with your ticket number.

    ```json theme={null}
    {
      "customer_code": "019abc12-3456-7890-abcd-ef0123456789",
      "external_reference": "cust-789",
      "poa_status": "rejected",
      "verification_level": "verified",
      "reason": "Address on the document does not match the expected address"
    }
    ```
  </Accordion>
</AccordionGroup>

### Card events

Every card event carries the same base fields, plus the fields specific to the event:

```json theme={null}
{
  "card_code": "019abc12-3456-7890-abcd-ef0123456789",
  "customer_code": "019def34-5678-9012-abcd-ef3456789012",
  "external_reference": "cust-789",
  "product_code": "019aaa11-2222-7333-8444-555566667777",
  "brand": "VISA",
  "status": "active",
  "last_four": "4242",
  "balance": 100.00,
  "currency": "USD"
}
```

<AccordionGroup>
  <Accordion title="card.activated">
    **Triggered when:** A card requested with `POST /cards` has been issued and is ready to use. Call `GET /cards/{code}/sensitive` to get its number, CVV and expiry.

    ```json theme={null}
    {
      "card_code": "019abc12-3456-7890-abcd-ef0123456789",
      "customer_code": "019def34-5678-9012-abcd-ef3456789012",
      "external_reference": "cust-789",
      "product_code": "019aaa11-2222-7333-8444-555566667777",
      "brand": "VISA",
      "status": "active",
      "last_four": "4242",
      "balance": 100.00,
      "currency": "USD",
      "name_on_card": "John Doe",
      "expiry": "12/29"
    }
    ```
  </Accordion>

  <Accordion title="card.creation_failed">
    **Triggered when:** A card requested with `POST /cards` could not be issued. The card is `failed` and everything that was debited (initial load and fees) is refunded to the same `cards_visa` or `cards_mastercard` balance.

    ```json theme={null}
    {
      "card_code": "019abc12-3456-7890-abcd-ef0123456789",
      "customer_code": "019def34-5678-9012-abcd-ef3456789012",
      "external_reference": "cust-789",
      "product_code": "019aaa11-2222-7333-8444-555566667777",
      "brand": "VISA",
      "status": "failed",
      "last_four": null,
      "balance": 100.00,
      "currency": "USD",
      "reason": "The card could not be issued.",
      "refunded_amount": 135.00
    }
    ```
  </Accordion>

  <Accordion title="card.topup.completed">
    **Triggered when:** A top-up requested with `POST /cards/{code}/topup` has been applied. `balance` is the new card balance.

    ```json theme={null}
    {
      "card_code": "019abc12-3456-7890-abcd-ef0123456789",
      "customer_code": "019def34-5678-9012-abcd-ef3456789012",
      "external_reference": "cust-789",
      "product_code": "019aaa11-2222-7333-8444-555566667777",
      "brand": "VISA",
      "status": "active",
      "last_four": "4242",
      "balance": 150.00,
      "currency": "USD",
      "amount": 50.00,
      "fee": 5.00
    }
    ```
  </Accordion>

  <Accordion title="card.topup.failed">
    **Triggered when:** A top-up could not be applied, for example because the card was terminated in the meantime. The amount and the fee are refunded to the same `cards_visa` or `cards_mastercard` balance.

    ```json theme={null}
    {
      "card_code": "019abc12-3456-7890-abcd-ef0123456789",
      "customer_code": "019def34-5678-9012-abcd-ef3456789012",
      "external_reference": "cust-789",
      "product_code": "019aaa11-2222-7333-8444-555566667777",
      "brand": "VISA",
      "status": "cancelled",
      "last_four": "4242",
      "balance": 0.00,
      "currency": "USD",
      "amount": 50.00,
      "reason": "The top-up could not be applied to the card.",
      "refunded_amount": 55.00
    }
    ```
  </Accordion>

  <Accordion title="card.transaction">
    **Triggered when:** An operation occurs on the card: payment, declined payment, merchant refund or fee. The `transaction` object has the same format as `GET /cards/{code}/transactions`; `balance` is the card balance after the operation.

    ```json theme={null}
    {
      "card_code": "019abc12-3456-7890-abcd-ef0123456789",
      "customer_code": "019def34-5678-9012-abcd-ef3456789012",
      "external_reference": "cust-789",
      "product_code": "019aaa11-2222-7333-8444-555566667777",
      "brand": "VISA",
      "status": "active",
      "last_four": "4242",
      "balance": 70.01,
      "currency": "USD",
      "transaction": {
        "id": "ctx_5f2a9c1e7b3d4a6089c1e2f3",
        "type": "settle",
        "direction": "debit",
        "status": "completed",
        "amount": 29.99,
        "currency": "USD",
        "merchant_name": "Netflix",
        "merchant_city": null,
        "merchant_country": null,
        "description": "Settlement — Netflix — $29.99",
        "failure_reason": null,
        "balance_after": 70.01,
        "date": "2026-07-04T14:30:00+00:00"
      }
    }
    ```

    `transaction.type` values: `settle` (payment), `auth`, `decline`, `refund`, `fee`, `auth_fee`, `fx_fee`.
  </Accordion>

  <Accordion title="card.3ds_request">
    **Triggered when:** A merchant asks for a 3-D Secure confirmation. The event carries the one-time code your customer must enter on the merchant page: **relay it to your customer immediately**, it is only valid for a few minutes. KemyCard does not send it to your customer.

    ```json theme={null}
    {
      "card_code": "019abc12-3456-7890-abcd-ef0123456789",
      "customer_code": "019def34-5678-9012-abcd-ef3456789012",
      "external_reference": "cust-789",
      "product_code": "019aaa11-2222-7333-8444-555566667777",
      "brand": "VISA",
      "status": "active",
      "last_four": "4242",
      "balance": 100.00,
      "currency": "USD",
      "otp": "123456",
      "merchant_name": "Netflix",
      "amount": "29.99",
      "transaction_currency": "USD"
    }
    ```
  </Accordion>

  <Accordion title="card.frozen">
    **Triggered when:** You froze the card with `POST /cards/{code}/freeze`. Payments are declined until it is unfrozen. The payload is the base card payload, with `status: frozen`.
  </Accordion>

  <Accordion title="card.unfrozen">
    **Triggered when:** You unfroze the card with `POST /cards/{code}/unfreeze`. The payload is the base card payload, with `status: active`.
  </Accordion>

  <Accordion title="card.terminated">
    **Triggered when:** The card has been terminated and can no longer be used. Its remaining balance is returned to your `cards_visa` or `cards_mastercard` balance, and any top-up still in progress is refunded (see `card.topup.failed`).

    ```json theme={null}
    {
      "card_code": "019abc12-3456-7890-abcd-ef0123456789",
      "customer_code": "019def34-5678-9012-abcd-ef3456789012",
      "external_reference": "cust-789",
      "product_code": "019aaa11-2222-7333-8444-555566667777",
      "brand": "VISA",
      "status": "cancelled",
      "last_four": "4242",
      "balance": 0.00,
      "currency": "USD",
      "refunded_amount": 42.10
    }
    ```
  </Accordion>
</AccordionGroup>

### Wallet events

<AccordionGroup>
  <Accordion title="wallet.deposit_received">
    **Triggered when:** A crypto deposit has been received and confirmed on a customer's wallet.

    ```json theme={null}
    {
      "wallet_id": "019abc12-3456-7890-abcd-ef0123456789",
      "customer_code": "019def34-5678-9012-abcd-ef3456789012",
      "amount": 150.00,
      "currency": "USDC",
      "network": "solana",
      "tx_hash": "5xYz...abc123",
      "from_address": "9WzD...def456",
      "new_balance": 350.00,
      "confirmed_at": "2026-07-04T14:30:00+00:00"
    }
    ```
  </Accordion>

  <Accordion title="wallet.withdrawal_completed">
    **Triggered when:** A crypto withdrawal initiated by the partner has been confirmed on-chain.

    ```json theme={null}
    {
      "wallet_id": "019abc12-3456-7890-abcd-ef0123456789",
      "customer_code": "019def34-5678-9012-abcd-ef3456789012",
      "amount": 100.00,
      "currency": "USDC",
      "network": "solana",
      "tx_hash": "7aBC...xyz789",
      "to_address": "3eFg...hij012",
      "new_balance": 250.00,
      "completed_at": "2026-07-04T14:30:00+00:00"
    }
    ```
  </Accordion>

  <Accordion title="wallet.withdrawal_failed">
    **Triggered when:** A crypto withdrawal has failed (insufficient on-chain balance, provider error, etc.).

    ```json theme={null}
    {
      "wallet_id": "019abc12-3456-7890-abcd-ef0123456789",
      "customer_code": "019def34-5678-9012-abcd-ef3456789012",
      "amount": 100.00,
      "currency": "USDC",
      "network": "solana",
      "reason": "Insufficient on-chain balance",
      "failed_at": "2026-07-04T14:30:00+00:00"
    }
    ```
  </Accordion>
</AccordionGroup>

### Virtual account events

<AccordionGroup>
  <Accordion title="virtual_account.deposit_received">
    **Triggered when:** An incoming bank transfer has been received on a customer's virtual account.

    ```json theme={null}
    {
      "account_id": "019abc12-3456-7890-abcd-ef0123456789",
      "customer_code": "019def34-5678-9012-abcd-ef3456789012",
      "amount": 500.00,
      "currency": "EUR",
      "sender_name": "ACME Corp",
      "sender_iban": "FR76...1234",
      "reference": "INV-2026-001",
      "new_balance": 1500.00,
      "received_at": "2026-07-04T14:30:00+00:00"
    }
    ```
  </Accordion>

  <Accordion title="virtual_account.transfer_completed">
    **Triggered when:** An outgoing transfer initiated via the API has been executed successfully.

    ```json theme={null}
    {
      "account_id": "019abc12-3456-7890-abcd-ef0123456789",
      "customer_code": "019def34-5678-9012-abcd-ef3456789012",
      "amount": 200.00,
      "currency": "EUR",
      "recipient_name": "John Doe",
      "recipient_iban": "DE89...5678",
      "reference": "PAY-2026-042",
      "new_balance": 1300.00,
      "completed_at": "2026-07-04T14:30:00+00:00"
    }
    ```
  </Accordion>

  <Accordion title="virtual_account.transfer_failed">
    **Triggered when:** An outgoing transfer has failed (invalid recipient account, bank rejection, etc.).

    ```json theme={null}
    {
      "account_id": "019abc12-3456-7890-abcd-ef0123456789",
      "customer_code": "019def34-5678-9012-abcd-ef3456789012",
      "amount": 200.00,
      "currency": "EUR",
      "reason": "Recipient account closed",
      "failed_at": "2026-07-04T14:30:00+00:00"
    }
    ```
  </Accordion>
</AccordionGroup>

### Gift card events

<AccordionGroup>
  <Accordion title="gift_card.delivered">
    **Triggered when:** A gift card order has been fulfilled successfully. The gift card code is available.

    ```json theme={null}
    {
      "order_id": "019abc12-3456-7890-abcd-ef0123456789",
      "customer_code": "019def34-5678-9012-abcd-ef3456789012",
      "product_name": "Netflix Gift Card $25",
      "brand": "Netflix",
      "amount": 25.00,
      "currency": "USD",
      "delivered_at": "2026-07-04T14:30:00+00:00"
    }
    ```
  </Accordion>

  <Accordion title="gift_card.failed">
    **Triggered when:** A gift card order has failed (product unavailable, provider error, etc.).

    ```json theme={null}
    {
      "order_id": "019abc12-3456-7890-abcd-ef0123456789",
      "customer_code": "019def34-5678-9012-abcd-ef3456789012",
      "product_name": "Amazon Gift Card $50",
      "brand": "Amazon",
      "amount": 50.00,
      "currency": "USD",
      "reason": "Product temporarily unavailable",
      "failed_at": "2026-07-04T14:30:00+00:00"
    }
    ```
  </Accordion>

  <Accordion title="gift_card.refunded">
    **Triggered when:** A failed gift card order has been refunded. The amount has been credited back to your ops balance.

    ```json theme={null}
    {
      "order_id": "019abc12-3456-7890-abcd-ef0123456789",
      "customer_code": "019def34-5678-9012-abcd-ef3456789012",
      "amount": 50.00,
      "currency": "USD",
      "balance_type": "gift_cards",
      "new_balance": 450.00,
      "refunded_at": "2026-07-04T14:30:00+00:00"
    }
    ```
  </Accordion>
</AccordionGroup>

### Balance events

<AccordionGroup>
  <Accordion title="balance.deposit_confirmed">
    **Triggered when:** One of your ops balances is credited, either by a USDC (Solana) deposit confirmed on its deposit address, or by a KemyCard API gift card redeemed from your dashboard.

    USDC deposit:

    ```json theme={null}
    {
      "amount": 485.50,
      "fee": 14.50,
      "gross_amount": 500.00,
      "tx_hash": "5xYz...abc123",
      "balance_type": "cards_visa",
      "new_balance": 1485.50,
      "currency": "USD",
      "from_address": "9WzD...def456",
      "chain": "solana"
    }
    ```

    Gift card redemption (same top-up fees as a USDC deposit, no on-chain fields):

    ```json theme={null}
    {
      "amount": 95.50,
      "fee": 4.50,
      "gross_amount": 100.00,
      "source": "gift_card",
      "balance_type": "cards_visa",
      "new_balance": 1581.00,
      "currency": "USD"
    }
    ```

    The `source` field is only present for gift card redemptions. A gift card is issued for one specific ops balance and always credits that balance. `gross_amount` is the gift card value, `fee` the top-up fee of that balance and `amount` the net amount credited.
  </Accordion>

  <Accordion title="balance.debited">
    **Triggered when:** An automatic debit occurs on your ops balance — card creation, card top-up, subscription payment, gift card purchase, etc. Sent for every paid operation.

    ```json theme={null}
    {
      "amount": 5.00,
      "reason": "card_creation",
      "balance_type": "cards_visa",
      "balance_before": 1485.00,
      "balance_after": 1480.00,
      "currency": "USD",
      "brand": "VISA",
      "customer_code": "019abc12-3456-7890-abcd-ef0123456789"
    }
    ```

    **Possible `reason` values:**

    | Reason | Description |
    | - | - |
    | `card_creation` | Virtual or physical card created |
    | `card_topup` | Card top-up / reload |
    | `subscription` | API subscription payment or renewal |
    | `gift_card_purchase` | Gift card order |
    | `kyc_verification` | Customer KYC verification |
    | `wallet_creation` | Crypto wallet creation |
    | `virtual_account_creation` | Virtual bank account creation |

    Additional fields (e.g. `brand`, `customer_code`, `product`, `billing_cycle`) vary depending on the `reason`.
  </Accordion>

  <Accordion title="balance.low_balance">
    **Triggered when:** Your ops balance drops below the threshold you configured (`lowBalanceThreshold`, default \$100). This is triggered in two ways:

    * **Real-time** — immediately after a debit that pushes the balance below the threshold
    * **Scheduled** — every 4 hours, a CRON job checks all active partners

    An alert email is also sent to your registered email address.

    ```json theme={null}
    {
      "threshold": 100.00,
      "balances": [
        {
          "type": "cards_visa",
          "balance": 45.20,
          "currency": "USD"
        },
        {
          "type": "cards_mastercard",
          "balance": 12.50,
          "currency": "USD"
        },
        {
          "type": "gift_cards",
          "balance": 8.00,
          "currency": "USD"
        },
        {
          "type": "others",
          "balance": 35.00,
          "currency": "USD"
        }
      ]
    }
    ```

    **Balance types:**

    | Type | Covers |
    | - | - |
    | `cards_visa` | Visa card operations |
    | `cards_mastercard` | Mastercard card operations |
    | `gift_cards` | Gift card purchases |
    | `others` | Wallets, virtual accounts, KYC, subscriptions |
  </Accordion>
</AccordionGroup>

## Signature verification

Every delivery includes these headers:

| Header | Example | Description |
| - | - | - |
| `X-KemyCard-Signature` | `t=1719849000,v1=abc123...` | Timestamp + HMAC-SHA256 signature |
| `X-KemyCard-Event` | `kyc.approved` | Event type |
| `X-KemyCard-Delivery-Id` | `uuid` | Unique delivery ID |
| `X-KemyCard-Timestamp` | `1719849000` | Unix timestamp |
| `X-KemyCard-Idempotency-Key` | `wh_del_uuid` | Idempotency key for deduplication |

**How to verify:**

<Steps>
  <Step title="Extract the timestamp and signature">
    Parse `X-KemyCard-Signature`: split by `,`, extract `t=` and `v1=` values.
  </Step>

  <Step title="Compute expected signature">
    Concatenate `{timestamp}.{raw_body}` and compute `HMAC-SHA256` using your webhook secret.
  </Step>

  <Step title="Compare signatures">
    Use constant-time comparison (e.g. `crypto.timingSafeEqual` in Node.js, `hash_equals` in PHP).
  </Step>

  <Step title="Reject old timestamps">
    If the timestamp is more than 5 minutes old, reject the request to prevent replay attacks.
  </Step>
</Steps>

## Retry policy

If your endpoint doesn't respond with a `2xx` status code, we retry with exponential backoff:

| Attempt | Delay after failure |
| - | - |
| 1 | Immediate |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 8 hours |
| 7 (final) | 24 hours |

After 7 failed attempts, the delivery is marked as `failed` and no further retries are made.

<Warning>
  If your endpoint accumulates **100 consecutive failures**, it will be **automatically deactivated**. You can reactivate it via `PATCH /webhooks/{code}` with `{"is_active": true}`.
</Warning>

## Managing endpoints

### Update an endpoint

```bash theme={null}
curl -X PATCH https://api.kemycard.com/v1/webhooks/{code} \
  -H "Authorization: Bearer sk_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://new-url.com/webhooks",
    "events": ["*"],
    "description": "Updated webhook"
  }'
```

<Info>
  If you change the URL, we send a new ping to validate it. The update is rejected if the new URL doesn't respond with `2xx`.
</Info>

### Disable / re-enable an endpoint

```bash theme={null}
# Disable
curl -X PATCH .../webhooks/{code} -d '{"is_active": false}'

# Re-enable (resets failure counter)
curl -X PATCH .../webhooks/{code} -d '{"is_active": true}'
```

<Tip>
  Endpoints can never be deleted — only disabled. This preserves the delivery history.
</Tip>

### Rotate the secret

If your secret is compromised, generate a new one immediately:

```bash theme={null}
curl -X POST https://api.kemycard.com/v1/webhooks/{code}/rotate-secret \
  -H "Authorization: Bearer sk_live_YOUR_KEY"
```

The old secret is **invalidated instantly**. Update your server code with the new secret before any pending deliveries arrive.

### Test your endpoint

Send a manual ping to verify your endpoint is working:

```bash theme={null}
curl -X POST https://api.kemycard.com/v1/webhooks/{code}/ping \
  -H "Authorization: Bearer sk_live_YOUR_KEY"
```

### View delivery history

Check the delivery status for a specific endpoint:

```bash theme={null}
curl https://api.kemycard.com/v1/webhooks/{code}/deliveries?page=1&limit=20 \
  -H "Authorization: Bearer sk_live_YOUR_KEY"
```

Each delivery shows: event type, status (`delivered`/`failed`/`pending`), HTTP response code, attempt number, duration, and idempotency key.

## Best practices

<AccordionGroup>
  <Accordion title="Always verify signatures">
    Never trust a webhook payload without verifying the HMAC signature. This prevents attackers from sending fake events to your endpoint.
  </Accordion>

  <Accordion title="Use idempotency keys">
    Store the `X-KemyCard-Idempotency-Key` header value and check it before processing. Due to retries, you may receive the same event multiple times. Idempotency keys let you safely deduplicate.
  </Accordion>

  <Accordion title="Return 200 quickly">
    Process webhook events asynchronously. Return a `200` response immediately, then handle the event in a background job. If your endpoint takes too long (>10 seconds), the request will time out and trigger a retry.
  </Accordion>

  <Accordion title="Subscribe only to events you need">
    Don't use `"*"` unless you truly need every event. Subscribing to specific events reduces noise and unnecessary load on your server.
  </Accordion>

  <Accordion title="Monitor your delivery history">
    Check your webhook dashboard regularly. If you see failures, investigate and fix your endpoint before it reaches the 100-failure auto-deactivation threshold.
  </Accordion>

  <Accordion title="Handle secret rotation gracefully">
    When rotating secrets, your server should temporarily accept both the old and new secret during the transition window.
  </Accordion>
</AccordionGroup>


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