Aller au contenu principal
Version: 2.2

Le webhook de commande

Votre endpoint reçoit un POST en JSON à chaque règlement. Répondez 2xx dès que vous avez pris la commande en charge.

En-têtes

Content-Type: application/json
User-Agent: Qlower-Partner-Notifier/1.0
X-API-KEY: <votre clé api>
X-Qlower-Signature: t=1767612138,v1=8d3f1c9a...

Les deux derniers ne sont présents que si une clé, respectivement un secret, ont été convenus — voir Configuration.

Payload

{
"event_type": "order.created",
"event_id": "evt_1U10P7BRvWe0K5pq93AePnrD",
"order_id": 3051,
"timestamp": "2026-08-05T08:42:18+00:00",

"customer": {
"first_name": "Marie",
"last_name": "Martin",
"email": "marie.martin@example.com",
"phone": "+33687654321"
},

"order": {
"total_amount": 315.0,
"currency": "EUR",
"payment_date": "2026-08-05T08:42:18+00:00",
"is_subscription": true,
"products": [
{
"product_id": "prod_UysoTyUjG5IsMB",
"product_name": "BIC réel (LMNP ou LMP), SCI (IR ou IS)",
"quantity": 1,
"unit_price": 315.0,
"amount": 315.0
}
],
"coverage": [
{
"year": 2026,
"property": {
"external_id": "00322f69-c2a2-41d3-9848-0d74c838a6c4",
"name": "Appartement Bordeaux"
}
}
]
},

"invoice": {
"pdf_url": "https://qlower-documents.s3.eu-west-3.amazonaws.com/...",
"pdf_filename": "facture_20260805.pdf",
"number": "082A05A2-0530"
}
}

Racine

ChampTypeDescription
event_typestringVoir ci-dessous
event_idstringIdentifiant de l'événement Stripe. Clé de déduplication : identique d'une tentative à l'autre
order_idintegerNotre identifiant de commande
timestampstringISO 8601 avec offset UTC explicite (+00:00, jamais le suffixe Z)

event_type

ValeurSignification
order.createdPremier règlement : achat unique, ou première échéance d'un abonnement
order.renewedÉchéance de renouvellement d'un abonnement existant
pingTest manuel pendant l'intégration. Ne contient que event_type et timestamp — répondez 2xx sans rien traiter

Un order.renewed porte le même abonnement qu'un order.created antérieur, mais un nouvel exercice dans coverage : il prolonge un dossier, il n'en ouvre pas un nouveau.

Modifications d'un abonnement

Ce qui se passeCe que vous recevez
Le client ajoute un bien à son abonnementorder.created dont coverage ne contient que les biens ajoutés
Échéance annuelleorder.renewed avec le nouvel exercice
Changement de quantité refacturé par Striperien — l'ajustement est déjà couvert par l'événement d'ajout
Résiliation, remboursement, changement de formulerien, à ce jour
Les fins d'abonnement ne sont pas notifiées

Nous n'émettons aujourd'hui que des événements de commande. Une résiliation ou un remboursement ne produit aucun webhook : ne vous appuyez pas sur ce canal pour détecter la fin d'un engagement.

customer

ChampTypeNullable
first_namestringOui — vide si le client ne l'a pas renseigné au paiement
last_namestringOui
emailstringNon
phonestringOui

order

ChampTypeDescription
total_amountfloatMontant total payé, TVA incluse
currencystringCode ISO 4217, ex. "EUR"
payment_datestringDate du paiement (ISO 8601)
is_subscriptionbooleantrue si le règlement provient d'un abonnement
productsarrayProduits achetés
coveragearrayBiens et exercices couverts

products[]

ChampTypeToujours présent
product_idstringOui
product_namestringOui
quantityintegerOui
unit_pricefloatOui
amountfloatNon
sub_itemsarrayNon

Sur un prix par tranches, la ligne parente porte le total et sub_items[] détaille chaque tranche (description, quantity, unit_price, amount) :

{
"product_id": "prod_P6P4ct2l0xBRSB",
"product_name": "Abonnement fiscal autonome",
"quantity": 2,
"unit_price": 199.5,
"amount": 399.0,
"sub_items": [
{ "description": "Abonnement fiscal autonome 2026 pour 1 propriété", "quantity": 1, "unit_price": 269.0, "amount": 269.0 },
{ "description": "1 propriété additionnelle", "quantity": 1, "unit_price": 130.0, "amount": 130.0 }
]
}

coverage[]

Ce que le règlement couvre concrètement : une entrée par couple (bien, exercice fiscal). C'est cette section qui vous dit pour quel bien et pour quelle année le client a payé.

ChampTypeDescription
yearintegerExercice fiscal couvert
property.external_idstringIdentifiant du bien, votre clé de rapprochement
property.namestringLibellé du bien, tel que saisi par le client

Le bloc s'arrête là : un bien créé depuis la page de paiement n'a que son nom, et pour un bien qui vient de vous, l'external_id vous donne accès au reste chez vous.

Le préfixe qlw_ signale un bien que vous ne connaissez pas

Pour un bien que vous nous avez transmis via les Loaders, nous vous rendons votre identifiant, inchangé.

Mais un client peut créer un bien lui-même depuis la page de paiement : il n'existe pas chez vous, donc nous lui en attribuons un, toujours préfixé qlw_ — par exemple qlw_9f2c1b84-3d7e-4a21-9c05-6b8f2ea71d43. Un external_id commençant par qlw_ est donc un bien à créer de votre côté.

Cet identifiant est stable et conservé chez nous : si vous nous rechargez ce bien plus tard via les Loaders en réutilisant ce même external_id, nous le rapprocherons du bien existant au lieu de créer un doublon.

coverage peut être vide

La section vaut [] quand le règlement ne provisionne aucun bien chez nous : achat de service sans exercice rattaché, ou acheteur sans compte sur notre plateforme. C'est un cas normal, pas une erreur.

invoice

ChampTypeDescription
pdf_urlstringURL signée de notre facture PDF, valide 7 jours
pdf_filenamestringNom de fichier suggéré
numberstringNuméro de facture unique

Le bloc est toujours renseigné et pointe toujours sur notre facture : nous ne vous envoyons la notification qu'une fois celle-ci produite.

Implémentation

Deux points portent la fiabilité de cet endpoint :

  • Le corps brut est nécessaire à la vérification de signature — récupérez-le avant tout parsing.
  • L'enregistrement de l'event_id et les effets métier sont dans une seule transaction. Poser le marqueur de déduplication avant d'avoir terminé le travail vous exposerait au pire cas : notre nouvelle tentative répondrait « déjà traité » alors que la moitié des biens n'a pas été activée. La contrainte unique sur event_id porte la déduplication, sans lecture préalable, donc deux tentatives simultanées ne peuvent pas aboutir toutes les deux.
const HANDLED_EVENTS = new Set(['order.created', 'order.renewed']);
const QLOWER_PREFIX = 'qlw_';
const UNIQUE_VIOLATION = '23505';

app.post('/api/qlower/orders', express.raw({ type: 'application/json' }), async (req, res) => {
if (!isRequestAuthentic(req.headers, req.body)) return res.sendStatus(401);

const event = JSON.parse(req.body);
if (event.event_type === 'ping') return res.sendStatus(200);
if (!HANDLED_EVENTS.has(event.event_type)) return res.sendStatus(400);

try {
await db.transaction(async tx => {
await tx.orders.insert({
qlower_event_id: event.event_id,
qlower_order_id: event.order_id,
customer_email: event.customer.email,
amount: event.order.total_amount,
invoice_url: event.invoice.pdf_url,
});

for (const { year, property } of event.order.coverage) {
if (property.external_id.startsWith(QLOWER_PREFIX)) await tx.properties.create(property);
await tx.declarations.activate(property.external_id, year);
}
});
res.sendStatus(200);
} catch (error) {
if (error.code === UNIQUE_VIOLATION) return res.sendStatus(200);
logger.error('Webhook Qlower en échec', { event_id: event.event_id, error });
res.sendStatus(500);
}
});

Codes de réponse attendus et politique de reprise : voir Échecs et reprises.