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_ 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_ |
| 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.
{
"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
webhookscope - 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_) 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_ with:
callback_url: your public HTTPS URL, for examplehttps://example.com/webhooks/qontotypes: the subscription types you need, such asv1/transactionsandv1/transferssecret(optional): your own secret
Subscribe only to the types you use. The schema is in Create a webhook subscription.
Test a delivery
Start a local receiver and forward requests with the Hookdeck CLI:
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 gives a test case (secret test-secret, body {"test":"data"}, t=1704110400) that's ideal for a unit test.
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.
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 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
2xxbefore 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
idin 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.eventby 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 describes, and record the envelope id with each change, following the idempotency guide.
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 takes the one-second deadline off your service and keeps every Qonto event verified and replayable.
Get started with Hookdeck for free and handle Qonto webhooks reliably in minutes.