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

# Webhooks

> Receive real-time event notifications via webhooks

Webhooks let you receive HTTP callbacks when events occur in your partner account - such as card transactions, KYC status changes, or balance updates.

## Configure webhooks

### Get current config

<CodeGroup>
  ```bash cURL theme={null}
  curl https://api.contro.me/v1/partner/webhooks/config \
    -H "x-contro-api-key: $CONTRO_API_KEY"
  ```

  ```typescript SDK (Node) theme={null}
  const config = await contro.webhooks.getConfig();
  ```

  ```python SDK (Python) theme={null}
  config = contro.webhooks.get_config()
  ```
</CodeGroup>

Response:

```json theme={null}
{
  "webhookUrl": "https://your-app.com/webhooks/contro",
  "webhookSecret": "whsec_...",
  "subscribedEvents": ["card.transaction", "cardholder.kyc.updated"]
}
```

### Update config

<CodeGroup>
  ```bash cURL theme={null}
  curl -X PATCH https://api.contro.me/v1/partner/webhooks/config \
    -H "x-contro-api-key: $CONTRO_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "webhookUrl": "https://your-app.com/webhooks/contro",
      "webhookSecret": "whsec_your_secret_min_16_chars",
      "subscribedEvents": ["card.transaction", "cardholder.kyc.updated", "balance.low"]
    }'
  ```

  ```typescript SDK (Node) theme={null}
  await contro.webhooks.updateConfig({
    webhookUrl: "https://your-app.com/webhooks/contro",
    webhookSecret: "whsec_your_secret_min_16_chars",
    subscribedEvents: ["card.transaction", "cardholder.kyc.updated", "balance.low"],
  });
  ```

  ```python SDK (Python) theme={null}
  contro.webhooks.update_config(
      webhook_url="https://your-app.com/webhooks/contro",
      webhook_secret="whsec_your_secret_min_16_chars",
      subscribed_events=["card.transaction", "cardholder.kyc.updated", "balance.low"],
  )
  ```
</CodeGroup>

| Field | Type | Description |
| - | - | - |
| `webhookUrl` | string | HTTPS URL to receive events. Must use `https://`. Example: `"https://your-app.com/webhooks/contro"` |
| `webhookSecret` | string | Secret for HMAC-SHA256 signature verification. Min 16 characters. Example: `"whsec_your_secret_min_16_chars"` |
| `subscribedEvents` | string\[] | Event types to subscribe to. Example: `["card.transaction", "cardholder.kyc.updated"]` |

## Event types

| Event | Description |
| - | - |
| [`card.transaction`](/partner/webhooks/card-transaction) | Card transaction lifecycle event (authorized, settled, declined, reversed) |
| [`card.issued`](/partner/webhooks/card-issued) | A new card was issued |
| [`card.status.changed`](/partner/webhooks/card-status-changed) | A card was activated, frozen, unfrozen, or cancelled |
| [`card.3ds_otp`](/partner/webhooks/card-3ds-otp) | 3DS one-time passcode for partner-issued cards — deliver to cardholder within 60s |
| [`cardholder.kyc.updated`](/partner/webhooks/cardholder-kyc-updated) | KYC status changed (approved, rejected) |
| [`cardholder.approved`](/partner/webhooks/cardholder-approved) | Cardholder passed KYC and can be issued cards |
| [`cardholder.created`](/partner/webhooks/cardholder-created) | A new cardholder was created |
| [`balance.low`](/partner/webhooks/balance-low) | Balance fell below threshold (daily check) |
| [`balance.alert`](/partner/webhooks/balance-alert) | Balance crossed the alert threshold (fires immediately, once per crossing) |
| [`balance.top_up`](/partner/webhooks/balance-top-up) | Balance was topped up |
| [`kyc_session.completed`](/partner/webhooks/kyc-session-completed) | A KYC session was approved |
| [`kyc_session.failed`](/partner/webhooks/kyc-session-failed) | A KYC session was rejected |

## Verifying webhook signatures

Every webhook request includes an HMAC-SHA256 signature in the `X-Contro-Signature` header. The signature format is `t={timestamp},v1={hmac}`, where the HMAC is computed over `{timestamp}.{body}`.

<CodeGroup>
  ```javascript Node.js theme={null}
  import crypto from "node:crypto";

  function verifyWebhookSignature(body, signatureHeader, secret) {
    const parts = Object.fromEntries(
      signatureHeader.split(",").map((p) => p.split("=", 2))
    );
    const timestamp = parts.t;
    const receivedHmac = parts.v1;

    const expected = crypto
      .createHmac("sha256", secret)
      .update(`${timestamp}.${body}`)
      .digest("hex");

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

  // In your webhook handler:
  app.post("/webhooks/contro", (req, res) => {
    const body = JSON.stringify(req.body);
    const signature = req.headers["x-contro-signature"];

    if (!verifyWebhookSignature(body, signature, WEBHOOK_SECRET)) {
      return res.status(401).send("Invalid signature");
    }

    // Process the event - event type is in the X-Contro-Event header
    const eventType = req.headers["x-contro-event"];
    console.log("Event type:", eventType);
    res.status(200).send("OK");
  });
  ```

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

  def verify_webhook_signature(body: bytes, signature_header: str, secret: str) -> bool:
      parts = dict(p.split("=", 1) for p in signature_header.split(","))
      timestamp = parts["t"]
      received_hmac = parts["v1"]

      expected = hmac.new(
          secret.encode(),
          f"{timestamp}.{body.decode()}".encode(),
          hashlib.sha256
      ).hexdigest()
      return hmac.compare_digest(received_hmac, expected)
  ```
</CodeGroup>

<Warning>
  Always verify webhook signatures before processing events. Unverified webhooks could be spoofed by attackers.
</Warning>

## Retry policy

Failed deliveries (non-2xx responses or timeouts) are retried with exponential backoff:

| Attempt | Delay |
| - | - |
| 1st retry | 1 minute |
| 2nd retry | 5 minutes |
| 3rd retry | 30 minutes |
| 4th retry | 2 hours |
| 5th retry | 24 hours |

After 5 failed retries, the event is marked as `failed`. You can manually retry failed events.

## List webhook events

View the delivery history for your webhooks:

<CodeGroup>
  ```bash cURL theme={null}
  curl "https://api.contro.me/v1/partner/webhooks/events?limit=20&status=failed" \
    -H "x-contro-api-key: $CONTRO_API_KEY"
  ```

  ```typescript SDK (Node) theme={null}
  const events = await contro.webhooks.listEvents({
    limit: 20,
    status: "failed",
  });
  ```

  ```python SDK (Python) theme={null}
  events = contro.webhooks.list_events(limit=20, status="failed")
  ```
</CodeGroup>

### Query parameters

| Parameter | Type | Description |
| - | - | - |
| `page` | integer | Page number (default `1`). |
| `limit` | integer | Items per page (1–100, default `20`). Example: `50` |
| `status` | string | Filter by delivery status. One of `pending`, `delivered`, `failed`. |
| `eventType` | string | Filter by event type. Example: `"card.transaction"`. |
| `from` | string | ISO 8601 start timestamp (inclusive) on `createdAt`. |
| `to` | string | ISO 8601 end timestamp (inclusive) on `createdAt`. |

### Event fields

| Field | Type | Description |
| - | - | - |
| `id` | string | Event ID. Example: `"evt_abc123"` |
| `eventType` | string | Event type. One of `card.transaction`, `card.issued`, `card.status.changed`, `cardholder.kyc.updated`, `cardholder.created`, `balance.low`, `balance.top_up`, `kyc_session.completed`, `kyc_session.failed` |
| `status` | string | Delivery status. One of `pending`, `delivered`, `failed` |
| `attemptCount` | number | Number of delivery attempts made. Example: `3` |
| `lastAttemptAt` | string \| null | ISO 8601 timestamp of last delivery attempt, or `null`. Example: `"2026-03-20T14:30:00Z"` |
| `nextRetryAt` | string \| null | ISO 8601 timestamp of the next scheduled retry, or `null` if no retry is pending. Example: `"2026-03-20T15:00:00Z"` |
| `lastResponseStatus` | number \| null | HTTP status code from the last delivery attempt, or `null`. Example: `500` |
| `createdAt` | string | ISO 8601 event creation timestamp. Example: `"2026-03-20T14:30:00Z"` |

## Get a webhook event

Fetch a single delivery event including the full payload and the last response body. Both fields are redacted server-side to mask secrets, tokens, and other sensitive values.

<CodeGroup>
  ```bash cURL theme={null}
  curl https://api.contro.me/v1/partner/webhooks/events/{eventId} \
    -H "x-contro-api-key: $CONTRO_API_KEY"
  ```

  ```typescript SDK (Node) theme={null}
  const event = await contro.webhooks.getEvent("evt_abc123");
  ```

  ```python SDK (Python) theme={null}
  event = contro.webhooks.get_event("evt_abc123")
  ```
</CodeGroup>

### Additional response fields

In addition to the list fields above, the detail response includes:

| Field | Type | Description |
| - | - | - |
| `payload` | string | Redacted JSON body that was (or will be) sent to your endpoint. |
| `lastResponseBody` | string \| null | Redacted response body from the destination, or `null`. |
| `webhookUrl` | string \| null | Destination URL the event was delivered to, or `null` if not configured. |

## Retry a failed event

Manually retry delivery of a failed event:

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://api.contro.me/v1/partner/webhooks/events/{eventId}/retry \
    -H "x-contro-api-key: $CONTRO_API_KEY"
  ```

  ```typescript SDK (Node) theme={null}
  await contro.webhooks.retryEvent("evt_abc123");
  ```

  ```python SDK (Python) theme={null}
  contro.webhooks.retry_event("evt_abc123")
  ```
</CodeGroup>
