---
url: /topics/webhooks.md
---
# Webhooks

Quarzo sends a notification to your server when an event occurs for your Broker, for example when a payment is received or a policy is created.

## Register a callback URL

Use an access token with the `broker-admin` role to register the URL for your Broker.
The `broker-advisor` role does not permit registration.

```http
POST /v1/broker-notifications/registrations
Authorization: Bearer <access_token>
X-Quarzo-Life-Idempotency-Key: <unique-key>
Content-Type: application/json

{ "callbackUrl": "https://example.com/webhooks/quarzo" }
```

The `X-Quarzo-Life-Idempotency-Key` header is required. See [Idempotent requests](/topics/idempotent-requests).

Expected result: `201 Created` with a `Location` header and this body:

```http
Location: /v1/broker-notifications/registrations/0199b8f1-4c2e-7a10-9d3b-2f6c1e8a5b70
```

```json
{
  "id": "0199b8f1-4c2e-7a10-9d3b-2f6c1e8a5b70",
  "brokerId": "<broker-id>",
  "callbackUrl": "https://example.com/webhooks/quarzo",
  "signingSecret": "whsec_q8V4mZtL2nR7xK1cB9dF3gH6wJ0sA5eYpU4iO2lT8rE"
}
```

::: warning Keep the signing secret
Quarzo returns `signingSecret` one time only, in this response.
A replay with the same idempotency key returns the registration without `signingSecret`.
Keep the secret in a secret store, and do not write it in logs.
You need it to [verify each notification](#security).
:::

Keep the `id` of the registration. You need it to [read or revoke the registration](#list-read-or-revoke-a-registration).
A replay with the same idempotency key returns the same `id`.

| Status | Code                                         | Cause                                                                        |
| ------ | -------------------------------------------- | ---------------------------------------------------------------------------- |
| `400`  | `PARAMETER_INVALID`                          | The body is not valid, or the idempotency key is missing, empty or too long. |
| `401`  |                                              | The access token is missing, expired or not valid.                           |
| `403`  |                                              | The token does not have the permission to register a callback URL.           |
| `409`  | `CALLBACK_URL_ALREADY_REGISTERED_FOR_BROKER` | Your Broker already registered this URL.                                     |
| `409`  | `IDEMPOTENCY_KEY_CONFLICT`                   | The same key was used with a different body.                                 |

You can register more than one URL. Each registered URL receives each notification.

Use an HTTPS URL. Some notifications contain personal data of Subscribers.

## List, read or revoke a registration

Use an access token with the `broker-admin` role. The `broker-advisor` role does not permit these requests.
You can list, read and revoke only the registrations of your Broker.

### List your registrations

List your registrations to find the `id` of a registration.

```http
GET /v1/broker-notifications/registrations?page=1&perPage=20
Authorization: Bearer <access_token>
```

| Query parameter | Description                                                              |
| --------------- | ------------------------------------------------------------------------ |
| `page`          | The page number, from 1. The default value is 1.                         |
| `perPage`       | The number of registrations in a page, 100 at most. The default is 20.  |
| `status`        | `ACTIVE` or `REVOKED`. Without it, the list contains the two statuses.   |

Expected result: `200 OK` with this body. The newest registration is first:

```json
{
  "data": [
    {
      "id": "0199b8f1-4c2e-7a10-9d3b-2f6c1e8a5b70",
      "callbackUrl": "https://example.com/webhooks/quarzo",
      "status": "ACTIVE",
      "revokedAt": null
    }
  ],
  "meta": {
    "page": 1,
    "perPage": 20,
    "totalPages": 1,
    "totalCount": 1,
    "hasNext": false,
    "hasPrevious": false
  }
}
```

The response never contains a signing secret.

### Read a registration

```http
GET /v1/broker-notifications/registrations/<registration-id>
Authorization: Bearer <access_token>
```

Expected result: `200 OK` with this body:

```json
{
  "id": "0199b8f1-4c2e-7a10-9d3b-2f6c1e8a5b70",
  "callbackUrl": "https://example.com/webhooks/quarzo",
  "status": "ACTIVE",
  "revokedAt": null
}
```

| Field       | Description                                                                               |
| ----------- | ----------------------------------------------------------------------------------------- |
| `status`    | `ACTIVE`: the URL receives the notifications. `REVOKED`: the URL receives no notification. |
| `revokedAt` | The time of the revocation. It is `null` for an `ACTIVE` registration.                    |

The response never contains the signing secret.

### Revoke a registration

Revoke a registration to stop the notifications to its callback URL.
Do this immediately if the URL is wrong, or if another person possibly controls the URL or knows the signing secret.

```http
DELETE /v1/broker-notifications/registrations/<registration-id>
Authorization: Bearer <access_token>
```

Expected result: `204 No Content`. The request is idempotent: a registration that is already revoked returns `204` again.

After the revocation:

* Quarzo sends no new notification to the callback URL.
* Quarzo cancels the notifications that are not delivered, and does not try them again.
* A notification that Quarzo sends at the time of the revocation can still arrive.
* You can register the same callback URL again. The new registration has a new `id` and a new signing secret.

You cannot activate a revoked registration again.

### Errors

| Status | Code                     | Cause                                                              |
| ------ | ------------------------ | ------------------------------------------------------------------ |
| `400`  | `PARAMETER_INVALID`      | The registration identifier or a query parameter is not valid.     |
| `401`  |                          | The access token is missing, expired or not valid.                 |
| `403`  |                          | The token does not have the permission.                            |
| `404`  | `REGISTRATION_NOT_FOUND` | The registration does not exist, or belongs to a different Broker. |

In this version, you cannot change the callback URL of a registration. Revoke the registration and register the new URL.

## Notification format

Each notification is a `POST` request with two signature headers and a JSON body:

```http
POST /webhooks/quarzo HTTP/1.1
Content-Type: application/json
X-Quarzo-Timestamp: 1791274542
X-Quarzo-Signature: sha256=5d41f9c0a7b3e2d8c6f14a9b0e7d3c2a1f8e6b4d9c0a7f3e2b1d8c6a5f4e3d2c
```

```json
{
  "eventId": "0199b8f3-6c1a-7d42-9a0e-3f1b2c4d5e6f",
  "type": "VirtualIbanGenerated",
  "payload": {
    "policyId": "0199b7aa-2b3c-7d4e-8f90-1a2b3c4d5e6f",
    "virtualIban": "FR7630001007941234567890185"
  }
}
```

| Field     | Description                                                                |
| --------- | -------------------------------------------------------------------------- |
| `eventId` | The identifier of the event. It is the same in each attempt of a delivery. |
| `type`    | The event type. See the list below.                                        |
| `payload` | The data of the event. Its fields change with the event type.              |

| Header               | Description                                                                              |
| -------------------- | ---------------------------------------------------------------------------------------- |
| `X-Quarzo-Timestamp` | The Unix time of the attempt, in seconds. Each attempt has a new timestamp.              |
| `X-Quarzo-Signature` | `sha256=` and the signature of the attempt, in hexadecimal. See [Security](#security).   |

## Event types

Quarzo sends these event types:

* `DirectDebitFailed`
* `PayerCreated`
* `PayerPersonalIbanAdded`
* `PayerPersonalIbanDeleted`
* `PaymentReceived`
* `PaymentRequestCancelled`
* `PaymentRequested`
* `PolicyCreated`
* `SubscriberCreated`
* `TransferFailed`
* `VirtualIbanGenerated`

Your server receives only the events of your Broker. In this version, you cannot select event types.

## Money values

Each Money value in a payload uses the [Money](/topics/money) format: an integer string in the minor unit of the currency, and the currency code.
The payload has no `scale` field, and the amount has no `n` suffix.

This `PaymentReceived` notification is for a payment of €100.50:

```json
{
  "eventId": "0199b8f3-6c1a-7d42-9a0e-3f1b2c4d5e70",
  "type": "PaymentReceived",
  "payload": {
    "paymentRequestId": "0199b7aa-2b3c-7d4e-8f90-1a2b3c4d5e70",
    "premiumRequestId": "0199b7aa-2b3c-7d4e-8f90-1a2b3c4d5e71",
    "premiumRequestType": "SINGLE_PREMIUM_REQUEST",
    "compartmentId": "0199b7aa-2b3c-7d4e-8f90-1a2b3c4d5e72",
    "amount": { "amount": "10050", "currency": "EUR" },
    "mode": "DIRECT_DEBIT",
    "status": "DIRECT_DEBIT_SUCCESS"
  }
}
```

## Respond to a notification

Return a `2XX` status code in less than **10 seconds**. Quarzo records any other status, a timeout or a network error as a failure.

```http
HTTP/1.1 200 OK
```

If your processing takes more time, send the response first. Then do the work in the background.

::: warning Duplicate notifications
Your server can receive the same notification more than once. Use `eventId` to find duplicates, and process each `eventId` only one time.
:::

## Retries

After a failure, Quarzo tries again. The delay doubles after each failure. Quarzo makes 5 attempts at most:

| Attempt              | Approximate time after the first failure |
| -------------------- | ---------------------------------------- |
| 1st (first delivery) | —                                        |
| 2nd                  | 1 h 30 min                               |
| 3rd                  | 4 h 30 min                               |
| 4th                  | 10 h 30 min                              |
| 5th (last attempt)   | 22 h 30 min                              |

After the fifth failure, Quarzo stops. It does not send that notification again.
In this version, you cannot see the delivery history or send a notification again.

## Security

Quarzo signs each notification with the signing secret of the registration.
Verify the signature before you process a notification. A request without a valid signature does not come from Quarzo.

### The signing secret

Quarzo creates one signing secret for each registration, and returns it one time only, when you [register the callback URL](#register-a-callback-url).

The secret has this format: `whsec_` and the base64url encoding of 32 random bytes.
The HMAC key is the 32 decoded bytes, not the text of the secret. To get the key:

1. Remove the `whsec_` prefix.
2. Decode the remaining text as base64url without padding.

### The signature

Quarzo calculates the signature of each attempt as follows:

```text
signature = HMAC_SHA256(key, timestamp + "." + rawBody)
```

* `key` is the 32 decoded bytes of the signing secret.
* `timestamp` is the value of the `X-Quarzo-Timestamp` header, as text.
* `rawBody` is the exact bytes of the request body.

The `X-Quarzo-Signature` header contains `sha256=` and the signature in lowercase hexadecimal.
A retry has a new timestamp, and thus a new signature.

### Verify a notification

Do these steps for each request, before you process the notification:

1. Read the raw body of the request. Do not parse the JSON and serialize it again: the bytes can change.
2. Read the `X-Quarzo-Timestamp` and `X-Quarzo-Signature` headers. If one header is missing, refuse the request.
3. Compare the timestamp with the time of your server. Refuse a timestamp that is older than 5 minutes. Refuse a timestamp that is more than 30 seconds in the future.
4. Calculate the signature with your signing secret, the timestamp and the raw body.
5. Compare your signature with the header in constant time. If they are different, refuse the request.

To refuse a request, return a `401` status code and do not process the body.

This Node.js example does these steps:

```js
import { createHmac, timingSafeEqual } from "node:crypto";

const MAX_AGE_SECONDS = 5 * 60;
const MAX_FUTURE_SECONDS = 30;

// signingSecret: the `signingSecret` value of the registration.
// rawBody: the bytes of the request body (a Buffer), before JSON parsing.
export function isQuarzoNotification({ signingSecret, headers, rawBody }) {
  const timestamp = headers["x-quarzo-timestamp"] ?? "";
  const signature = headers["x-quarzo-signature"] ?? "";
  if (!/^\d+$/.test(timestamp)) return false;

  const age = Math.floor(Date.now() / 1000) - Number(timestamp);
  if (age > MAX_AGE_SECONDS || age < -MAX_FUTURE_SECONDS) return false;

  const key = Buffer.from(signingSecret.slice("whsec_".length), "base64url");
  const expected =
    "sha256=" +
    createHmac("sha256", key)
      .update(`${timestamp}.`)
      .update(rawBody)
      .digest("hex");

  const received = Buffer.from(signature);
  const computed = Buffer.from(expected);
  return received.length === computed.length && timingSafeEqual(received, computed);
}
```

### If you do not have the signing secret

Quarzo cannot show a signing secret again. You do not have the secret in these cases:

* you registered the callback URL before Quarzo signed notifications;
* you lost the response of the registration, or you lost the secret.

To get a signing secret, replace the registration:

1. If you do not have the `id` of the registration, [list your registrations](#list-your-registrations).
2. [Revoke the registration](#revoke-a-registration).
3. Register the callback URL again.
4. Keep the new signing secret.

A server that does not verify the signature continues to receive the notifications: the two headers do not change the body.
Until your server verifies the signature, read the current state with the Quarzo API before you change data in your system.

### Other rules

* Use an HTTPS URL. Some notifications contain personal data of Subscribers.
* Use a different signing secret for each environment. A sandbox secret does not verify production notifications.
* If a signing secret is possibly known to another person, revoke the registration and register the callback URL again.

## Best practices

* **Verify the signature first.** Refuse a request that has no valid signature or has an old timestamp.
* **Respond quickly.** Return `200` immediately, and process the notification in a background job.
* **Find duplicates with `eventId`.** Keep the `eventId` values that you processed.
* **Monitor your endpoint.** After the fifth failure, Quarzo does not send the notification again.
