Gareth Wilson Gareth Wilson

Guide to Qonto Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationPOST /v2/webhook_subscriptions
Scopeswebhook plus each type's own scope
Subscriptiontypes such as v1/transactions, v1/cards
Signaturehex HMAC-SHA256
Signature headerx-qonto-signature (t=<unix>,v1=<hex>)
Signed content<t>.<raw body>
KeySubscription secret, 32-128 characters, used whole
BodyJSON envelope with id and data
Event routingBody field type, then data.event
Timeout1 second
RetriesExponential backoff for a few days

Events and payloads

typedata.event examples (from the docs' sample payloads)Common use cases
v1/transactionscreatedReconcile bank transactions, update bookkeeping
v1/cardscreatedTrack card issuance and status
v1/transferscreatedTrack SEPA transfer status
v1/accountscreatedBank account changes
v1/membershipsupdatedTeam member changes
v1/organizationssuspendedOrganisation status
v1/payment-linkscreatedPayment link lifecycle
v1/payment-links-connectionsconnection_enabledPayment link connections
v1/terminal-paymentsauthorizedTerminal payments
v1/sepa-direct-debit-collectionscompletedDirect debit collections
v1/recurring-transfers, v1/beneficiaries, v1/clients, v1/products, v1/client-invoices, v1/consent-revocationssee the docsOther 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 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.

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 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 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.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.