# Guide to Qonto Webhooks: Features and Best Practices

Qonto is a business bank for European SMEs and freelancers, with current accounts, cards, transfers, invoicing and payment links. Its Business API lets accounting tools, ERPs and finance platforms read that data, and its webhooks push changes as they happen: a card payment settles, a transfer completes, a client pays an invoice. For bookkeeping integrations, that means categorising a transaction within seconds instead of waiting for the next sync.

This guide covers Qonto's subscription types and event values, the very short response deadline, the secret format that looks like Standard Webhooks but isn't, and verifying `X-Qonto-Signature`.

## What are Qonto webhooks?

Qonto webhooks are managed through the API. You call `POST /v2/webhook_subscriptions` with a `callback_url`, a list of `types` such as `v1/transactions` and `v1/cards`, and optionally your own `secret`. The OAuth token needs the `webhook` scope as well as the scope for each type you subscribe to.

For every new event of those types, Qonto sends a JSON `POST`. The envelope names the subscription, the organisation and membership, the `type` and a `data` object that mirrors the resource, with `data.event` saying what happened (`created`, `updated`, `settled` and so on). Events from before the subscription aren't sent.

Each request is signed. `X-Qonto-Signature` contains a timestamp and a hex HMAC-SHA256 over the timestamp and raw body, in the same `t=...,v1=...` layout Stripe uses.

## Qonto webhook features

| Feature | Details |
| --- | --- |
| Configuration | `POST /v2/webhook_subscriptions` |
| Scopes | `webhook` plus each type's own scope |
| Subscription | `types` such as `v1/transactions`, `v1/cards` |
| Signature | hex HMAC-SHA256 |
| Signature header | `x-qonto-signature` (`t=<unix>,v1=<hex>`) |
| Signed content | `<t>.<raw body>` |
| Key | Subscription secret, 32-128 characters, used whole |
| Body | JSON envelope with `id` and `data` |
| Event routing | Body field `type`, then `data.event` |
| Timeout | 1 second |
| Retries | Exponential backoff for a few days |

## Events and payloads

| `type` | `data.event` examples (from the docs' sample payloads) | Common use cases |
| --- | --- | --- |
| `v1/transactions` | `created` | Reconcile bank transactions, update bookkeeping |
| `v1/cards` | `created` | Track card issuance and status |
| `v1/transfers` | `created` | Track SEPA transfer status |
| `v1/accounts` | `created` | Bank account changes |
| `v1/memberships` | `updated` | Team member changes |
| `v1/organizations` | `suspended` | Organisation status |
| `v1/payment-links` | `created` | Payment link lifecycle |
| `v1/payment-links-connections` | `connection_enabled` | Payment link connections |
| `v1/terminal-payments` | `authorized` | Terminal payments |
| `v1/sepa-direct-debit-collections` | `completed` | Direct debit collections |
| `v1/recurring-transfers`, `v1/beneficiaries`, `v1/clients`, `v1/products`, `v1/client-invoices`, `v1/consent-revocations` | see the docs | Other resources |

Qonto's "Supported events" lists for transactions, cards, memberships, organisations and payment links use prefixed names (`transactions.created`, `cards.locked`), while the examples on the same pages show the bare value (`"event": "created"`). Every example uses the bare form, but Qonto doesn't reconcile the two, so match both and log anything unexpected.

The two SEPA Direct Debit types have their own documentation pages but are missing from the subscription API's type enum, so check that subscribing to them works before relying on them.

### Event Payload Structure

Every delivery has `id`, `subscription_id`, `organization_id`, `membership_id`, `type`, `created_at` and `data`. The contents of `data` depend on the type. The transfer and recurring-transfer pages state that deliveries aren't deduplicated, so keep the envelope `id`.

### Published payload example

This `v1/accounts` delivery comes from the [webhook samples library](https://github.com/hookdeck/webhook-samples/blob/main/providers/qonto/v1/v1.accounts.json).

```json
{
  "id": "123e4567-e89b-12d3-a456-426614174000",
  "subscription_id": "123e4567-e89b-12d3-a456-426614174000",
  "organization_id": "123e4567-e89b-12d3-a456-426614174000",
  "membership_id": "123e4567-e89b-12d3-a456-426614174000",
  "type": "v1/accounts",
  "created_at": "2025-01-24T10:55:00Z",
  "data": {
    "event": "created",
    "id": "123e4567-e89b-12d3-a456-426614174000",
    "slug": "example-account",
    "iban": "DE89370400440532013000",
    "currency": "EUR",
    "authorized_balance": {
      "amount": "1000.00",
      "currency": "EUR"
    },
    "name": "Example Account",
    "status": "active",
    "updated_at": "2025-01-24T10:55:00Z"
  }
}

```

## Setting up Qonto webhooks

### Prerequisites

* A Qonto account with Business API access and an OAuth client with the `webhook` scope
* A publicly reachable HTTPS endpoint that accepts `POST`

### How Do I Get the Signing Secret?

Each subscription has its own secret of 32 to 128 characters. Pass one as `secret` in the create request, or let Qonto generate one. It's returned **only once**, in the create response, so store it straight away as `WEBHOOK_SECRET`.

Paste the whole secret, including the whsec_ prefix. Qonto uses it as a plain HMAC key.

Qonto's schema example (`whsec_VuvcYxzMbryt1UBytq58jtGBxCgbQdzX/c0vj5ZLjUA=`) looks like a Standard Webhooks secret, but Qonto's test data and steps use the whole string as the key. Nothing is stripped or base64-decoded. Supplying your own secret avoids any confusion.

### How Do I Register My Endpoint?

Call `POST /v2/webhook_subscriptions` with:

* `callback_url`: your public HTTPS URL, for example `https://example.com/webhooks/qonto`
* `types`: the subscription types you need, such as `v1/transactions` and `v1/transfers`
* `secret` (optional): your own secret

Subscribe only to the types you use. The schema is in [Create a webhook subscription](https://docs.qonto.com/api-reference/business-api/webhooks/create-a-webhook-subscription).

### Test a delivery

Start a local receiver and forward requests with the [Hookdeck CLI](/docs/cli):

```bash
npx hookdeck-cli listen 3000 qonto --path /webhooks/qonto

```

Subscribe the CLI's URL to `v1/transactions` in Qonto's sandbox, then create a test transaction. With only one second to answer, a local debugger will often cause timeouts; Hookdeck answers Qonto for you while you step through code.

## Securing Qonto webhooks

Split `X-Qonto-Signature` into `t` and `v1`, build the message as the `t` text, a dot and the raw body, and compute HMAC-SHA256 with the whole secret as a UTF-8 key. Compare the hex result with `v1` in constant time. Qonto's [setup page](https://docs.qonto.com/api-reference/business-api/webhooks/setup#callback-url) gives a test case (secret `test-secret`, body `{"test":"data"}`, `t=1704110400`) that's ideal for a unit test.

```javascript
const crypto = require('node:crypto');

function verifySignature(rawBody, headers, secret) {
  if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
  const signature = headers['x-qonto-signature'];
  if (typeof signature !== 'string') return false;
  const key = Buffer.from(secret, 'utf8');
  const parts = signature.split(',').map((part) => part.trim());
  const times = parts.filter((part) => part.startsWith('t=')).map((part) => part.slice(2));
  if (times.length !== 1 || !/^[0-9]+$/.test(times[0])) return false;
  const message = Buffer.concat([Buffer.from(`${times[0]}.`, 'utf8'), rawBody]);
  const candidates = parts.filter((part) => part.startsWith('v1=')).map((part) => part.slice(3));
  const expected = crypto.createHmac('sha256', key).update(message).digest();
  return candidates.some((candidate) => {
    if (!/^[0-9a-fA-F]{64}$/.test(candidate)) return false;
    const received = Buffer.from(candidate, 'hex');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

```

The handler stores the event and responds at once, leaving the actual bookkeeping to a worker, because Qonto allows only one second.

```javascript
const express = require('express');

function createQontoApp({ storeOnce, notifyWorker }) {
  const app = express();
  app.post('/webhooks/qonto', express.raw({ type: '*/*' }), async (req, res) => {
    const secret = process.env.WEBHOOK_SECRET;
    if (!secret) return res.sendStatus(500);
    if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);
    let parsed;
    try {
      parsed = JSON.parse(req.body.toString('utf8'));
    } catch {
      return res.sendStatus(400);
    }
    if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return res.sendStatus(400);
    const { id, type, data } = parsed;
    if (!id || typeof type !== 'string' || !data || typeof data !== 'object') {
      return res.sendStatus(400);
    }
    try {
      const action = String(data.event).split('.').pop();
      const isNew = await storeOnce(id, { type, action, data });
      if (isNew) notifyWorker(type, id);
    } catch {
      return res.sendStatus(503);
    }
    return res.sendStatus(200);
  });
  return app;
}

```

Splitting on the last dot turns both `transactions.created` and `created` into `created`, which copes with the naming conflict above.

The timestamp is signed, but Qonto documents no tolerance, and its retries run for days. A direct receiver that rejects old `t` values would refuse legitimate retries, so rely on the envelope `id` for repeats.

Hookdeck's Qonto source type reads `t` and `v1`, signs `<t>.<raw body>` with the whole secret, and compares hex. It answers within Qonto's one-second window with `200`, and records any mismatch as failed verification without forwarding it. Hookdeck checks no timestamp age.

> **Make Qonto webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) meets Qonto's one-second deadline for you, verifies every banking event, and gives your bookkeeping code as long as it needs.

## Qonto webhook limitations and pain points

### A one-second deadline

**The Problem:** Deliveries fail and are retried even though your service eventually processes them.

**Why It Happens:** Qonto counts anything slower than one second as a failure. Cold starts, database locks or a single external call can exceed that.

**Workarounds:**

* Persist the raw body and return `2xx` before doing anything else.
* Keep the endpoint warm and close to your database.

**How Hookdeck Can Help:** Hookdeck replies to Qonto immediately and delivers to your service with a timeout you choose.

### Retries for days, without deduplication

**The Problem:** The same transaction is booked twice.

**Why It Happens:** Failed deliveries are retried with exponential backoff for a few days, and Qonto notes that transfer events aren't deduplicated.

**Workarounds:**

* Record the envelope `id` in the same transaction as the bookkeeping change.

**How Hookdeck Can Help:** Hookdeck deduplication on the `id` field drops repeats before they reach your service.

### Inconsistent event names

**The Problem:** Filters keyed on the documented names never match.

**Why It Happens:** Some type pages list `transactions.created`-style names, while every example sends `created`.

**Workarounds:**

* Normalise `data.event` by taking the part after the last dot.

**How Hookdeck Can Help:** Hookdeck stores every real delivery, so you can see which form your account receives before writing filters.

### A secret that's easy to mishandle

**The Problem:** Verification fails because the secret was decoded or its prefix stripped.

**Why It Happens:** Generated secrets look like Standard Webhooks `whsec_` values, which other providers decode, but Qonto uses the whole string.

**Workarounds:**

* Supply your own secret when creating the subscription.
* Test your verifier with Qonto's published test case.

**How Hookdeck Can Help:** Hookdeck's Qonto source uses the whole string as the key, matching Qonto's behaviour.

## Best practices

### Store the secret immediately

It's returned only in the create response. If it's lost, create a new subscription.

### Ask only for the scopes and types you need

Each type needs its own OAuth scope. Narrow subscriptions keep both permissions and traffic down.

### Backfill through the API

Webhooks only cover events after the subscription was created. Load history through the Business API's list endpoints.

### Queue everything

With a one-second limit, every handler should store and acknowledge first, as our [asynchronous processing guide](/webhooks/guides/why-implement-asynchronous-processing-webhooks) describes, and record the envelope `id` with each change, following the [idempotency guide](/webhooks/guides/implement-webhook-idempotency).

## Conclusion

Qonto's webhooks cover the full range of business banking events with a solid, test-vector-backed signature. The one-second deadline is the dominant constraint, followed by days of retries without deduplication, the inconsistent `data.event` names and a secret that must be used whole.

[Hookdeck Event Gateway](https://hookdeck.com) takes the one-second deadline off your service and keeps every Qonto event verified and replayable.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Qonto webhooks reliably in minutes.