# Webhooks

xMoney sends webhooks for asynchronous card issuing events. Your backend should verify signatures, deduplicate events, acknowledge quickly, and process state changes asynchronously.

## Delivery

xMoney sends an HTTP `POST` to your configured webhook URL.

Delivery uses the webhook URL and signing key configured for your partner.

Headers:

| Header | Description |
|  --- | --- |
| `Content-Type` | `application/json` |
| `X-Money-Signature` | Compact JWS/JWT signature over the full payload, signed with your webhook signing key using `HS512`. |
| `X-Money-Delivery-Id` | Unique delivery identifier for this HTTP attempt. |


The current dispatcher uses a 30 second HTTP timeout and up to 3 delivery attempts. Backoff starts at 5 seconds and doubles between attempts.

## Payload

Every webhook body uses the same envelope:

```json
{
  "eventType": "cardStatusChanged",
  "eventId": "evt_01JQX0FAY4JPEHG5EPEJ0R3XEQ",
  "customerId": "partner-customer-123",
  "data": {
    "cardId": "25bba666-6d89-4452-8462-c193982690b1",
    "newStatus": "active"
  }
}
```

| Field | Description |
|  --- | --- |
| `eventType` | Event name. See [Supported events](#supported-events). |
| `eventId` | Unique event identifier. Deduplicate on this value. |
| `customerId` | Partner-scoped customer identifier. |
| `data` | Event-specific payload. |


## Supported events

### `kycStatusChanged`

Sent when the customer's KYC status changes.

```json
{
  "eventType": "kycStatusChanged",
  "eventId": "evt_01JQX0FAY4JPEHG5EPEJ0R3XEQ",
  "customerId": "partner-customer-123",
  "data": {
    "customerId": "partner-customer-123",
    "status": "approved"
  }
}
```

`data.status` is one of:

- `not_started`
- `in_progress`
- `pending_review`
- `approved`
- `rejected`


When the customer is rejected, `data.rejectReason` can be included.

### `cardStatusChanged`

Sent when a card changes status.

```json
{
  "eventType": "cardStatusChanged",
  "eventId": "evt_01JQX0FAY4JPEHG5EPEJ0R3XEQ",
  "customerId": "partner-customer-123",
  "data": {
    "cardId": "25bba666-6d89-4452-8462-c193982690b1",
    "newStatus": "active"
  }
}
```

`data.newStatus` is mapped to the whitelabel card status model. Possible values include:

- `pending`
- `readyToBeActivated`
- `active`
- `suspended`
- `frozen`
- `closed`


### `transaction3dsRequested`

Sent when a card transaction requires 3D Secure.

```json
{
  "eventType": "transaction3dsRequested",
  "eventId": "evt_01JQX0FAY4JPEHG5EPEJ0R3XEQ",
  "customerId": "partner-customer-123",
  "data": {
    "transactionId": "2f5c62c0-7e71-47ec-8f6b-80d7e9df3ca2",
    "cardId": "25bba666-6d89-4452-8462-c193982690b1",
    "amount": 49.99,
    "currency": "EUR",
    "merchant": "Example merchant",
    "requestedAt": "2026-04-21T10:00:00.000Z",
    "expiresAt": "2026-04-21T10:10:00.000Z"
  }
}
```

After this event, list pending challenges with `GET /v1/transactions/3d-secure` and submit the customer decision with `PATCH /v1/transactions/3d-secure/{transactionAuthId}`.

### `transaction3dsCompleted`

Sent when a 3D Secure challenge reaches a terminal status.

```json
{
  "eventType": "transaction3dsCompleted",
  "eventId": "evt_01JQX0FAY4JPEHG5EPEJ0R3XEQ",
  "customerId": "partner-customer-123",
  "data": {
    "transactionId": "2f5c62c0-7e71-47ec-8f6b-80d7e9df3ca2",
    "status": "authorized"
  }
}
```

`data.status` is mapped to the whitelabel transaction-auth status model. Possible values include:

- `initiated`
- `pending`
- `authorized`
- `declined`
- `cancelled`
- `timeout`
- `failed`


### `transactionCreated`

Sent when xMoney creates a transaction record.

```json
{
  "eventType": "transactionCreated",
  "eventId": "evt_01JQX0FAY4JPEHG5EPEJ0R3XEQ",
  "customerId": "partner-customer-123",
  "data": {
    "transactionId": "bbfbb8f8-11a9-4c24-b331-640d21da87d4"
  }
}
```

Use `GET /v1/transactions/{transactionId}` to fetch full details.

### `transactionSettled`

Sent when a transaction is confirmed or settled.

```json
{
  "eventType": "transactionSettled",
  "eventId": "evt_01JQX0FAY4JPEHG5EPEJ0R3XEQ",
  "customerId": "partner-customer-123",
  "data": {
    "transactionId": "bbfbb8f8-11a9-4c24-b331-640d21da87d4"
  }
}
```

### `topupCreated`

Sent when a fiat or crypto topup order is created.

```json
{
  "eventType": "topupCreated",
  "eventId": "evt_01JQX0FAY4JPEHG5EPEJ0R3XEQ",
  "customerId": "partner-customer-123",
  "data": {
    "topupId": "topup_01JQX0FAY4JPEHG5EPEJ0R3XEQ"
  }
}
```

Use `GET /v1/topups/{id}` to fetch current topup details when needed.

### `topupStatusChanged`

Sent when a topup status changes or the topup confirmation state changes.

```json
{
  "eventType": "topupStatusChanged",
  "eventId": "evt_01JQX0FAY4JPEHG5EPEJ0R3XEQ",
  "customerId": "partner-customer-123",
  "data": {
    "topupId": "topup_01JQX0FAY4JPEHG5EPEJ0R3XEQ"
  }
}
```

## Signature verification

`X-Money-Signature` is a compact JWS/JWT created from the full JSON payload with `HS512` and your webhook signing key. The signature does not include an automatic timestamp claim.

The signed JWT payload is the same object sent in the HTTP request body:

```json
{
  "eventType": "cardStatusChanged",
  "eventId": "evt_01JQX0FAY4JPEHG5EPEJ0R3XEQ",
  "customerId": "partner-customer-123",
  "data": {
    "cardId": "25bba666-6d89-4452-8462-c193982690b1",
    "newStatus": "active"
  }
}
```

To verify a webhook:

1. Read the raw JSON body.
2. Verify `X-Money-Signature` with your webhook signing key and the `HS512` algorithm.
3. Confirm the decoded payload matches the received body.
4. Process the event only after signature verification succeeds.


Reject invalid signatures with `400` or `401`.

### Node.js example

```javascript
import express from 'express';
import jwt from 'jsonwebtoken';
import crypto from 'crypto';

const app = express();

// Keep the raw body so you can compare exactly what was received.
app.post('/xmoney/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.header('X-Money-Signature');
  const deliveryId = req.header('X-Money-Delivery-Id');
  const webhookSigningKey = process.env.XMONEY_WEBHOOK_SIGNING_KEY;

  if (!signature || !webhookSigningKey) {
    return res.sendStatus(401);
  }

  let body;
  let verifiedPayload;

  try {
    body = JSON.parse(req.body.toString('utf8'));
    verifiedPayload = jwt.verify(signature, webhookSigningKey, {
      algorithms: ['HS512'],
    });
  } catch (error) {
    return res.sendStatus(401);
  }

  const received = JSON.stringify(body);
  const verified = JSON.stringify(verifiedPayload);
  const receivedBuffer = Buffer.from(received);
  const verifiedBuffer = Buffer.from(verified);

  if (
    receivedBuffer.length !== verifiedBuffer.length ||
    !crypto.timingSafeEqual(receivedBuffer, verifiedBuffer)
  ) {
    return res.sendStatus(401);
  }

  // Deduplicate by eventId. Store deliveryId for troubleshooting.
  console.log('Accepted xMoney webhook', {
    eventId: body.eventId,
    eventType: body.eventType,
    deliveryId,
  });

  return res.sendStatus(204);
});
```

### PHP example

```php
<?php

use Firebase\JWT\JWT;
use Firebase\JWT\Key;

$signature = $_SERVER['HTTP_X_MONEY_SIGNATURE'] ?? null;
$deliveryId = $_SERVER['HTTP_X_MONEY_DELIVERY_ID'] ?? null;
$webhookSigningKey = getenv('XMONEY_WEBHOOK_SIGNING_KEY');
$rawBody = file_get_contents('php://input');

if (!$signature || !$webhookSigningKey || !$rawBody) {
    http_response_code(401);
    exit;
}

try {
    $body = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);
    $decoded = (array) JWT::decode($signature, new Key($webhookSigningKey, 'HS512'));
    $decoded = json_decode(json_encode($decoded), true, 512, JSON_THROW_ON_ERROR);
} catch (Throwable $error) {
    http_response_code(401);
    exit;
}

if ($body !== $decoded) {
    http_response_code(401);
    exit;
}

// Deduplicate by $body['eventId']. Store $deliveryId for troubleshooting.
http_response_code(204);
```

### Verification checklist

- Use the partner's `webhook_signing_key`, not the partner API key.
- Restrict accepted algorithms to `HS512`.
- Compare the verified JWT payload with the JSON body you received.
- Deduplicate on `eventId`; retries use a new `X-Money-Delivery-Id` but the same event payload.
- Do not validate `iat` or timestamp claims. xMoney signs with no automatic timestamp claim.
- Do not use the HMAC payload-sorting algorithm from xMoney Crypto API webhooks. Card Issuing webhooks use compact JWS/JWT signatures.


## Retries and ordering

xMoney records each delivery attempt with a new `X-Money-Delivery-Id`. If a request fails, xMoney retries delivery up to 3 total attempts with exponential backoff.

Do not rely on webhook ordering across event types. Your integration should be idempotent and should fetch the latest resource state from the API when an event arrives out of order or after a delay.

## Best practices

- Verify `X-Money-Signature` before processing.
- Deduplicate by `eventId`.
- Store `X-Money-Delivery-Id` for delivery troubleshooting.
- Return a `2xx` response only after accepting the event.
- Treat webhooks as the source of truth for asynchronous status changes.
- Ignore unknown fields in `data` so your integration remains forward-compatible.