API & intégration
Webhooks sortants
Mis à jour le 24 septembre 2026Plateforme v1 Markdown brut
Principe
Quand une organisation qui a installé votre app déclenche un événement
métier (commande passée, facture payée, devis accepté…), Capibara envoie un
POST JSON signé à l’URL que vous avez configurée. Vous n’interrogez
plus l’API en boucle : votre backend réagit en temps réel.
Configuration : portail développeur → votre app → section Backend & webhooks →
« Ajouter un webhook ». Vous choisissez l’URL (HTTPS obligatoire) et les
événements souscrits. Le secret est affiché une seule fois à la
création — stockez-le immédiatement (variable d’environnement, jamais dans
le code : le scan de review le détecterait).
5 webhooks maximum par app. Les adresses IP littérales et les hôtes
locaux/internes sont refusés.
Format de livraison
POST /votre/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: Capibara-Webhooks/1.0
x-capibara-signature: sha256=3f5a…e2c1
x-capibara-timestamp: 1756900000
x-capibara-event: invoice.paid
x-capibara-delivery: 018f3c2e-7b1a-4c5d-9e8f-2a6b4c8d0e1f
{
"id": "018f3c2e-7b1a-4c5d-9e8f-2a6b4c8d0e1f",
"event": "invoice.paid",
"occurredAt": "2026-07-02T14:32:05.120Z",
"tenantId": "cm9x…",
"data": { "invoiceId": "inv_…", "totalCents": 12050 }
}
id(=x-capibara-delivery) est stable à travers les retries d’une
même livraison : c’est votre clé d’idempotence — si vous l’avez déjà
traité, répondez 2xx sans retraiter.tenantIdidentifie l’organisation émettrice (le même que dans l’API).dataporte les champs métier de l’événement — aucun identifiant interne
de compte plateforme n’est transmis.x-capibara-timestamp(secondes epoch) date l’envoi : avec
createServerHandler({ maxAgeSec }), une livraison plus ancienne est
refusée (401stale). L’en-tête n’est pas couvert par la signature
(contrat figé) : c’est un filet contre le rejeu d’une vieille capture, pas
une preuve — l’idempotence paridreste la règle.
Richesse de l’enveloppe (par souscription)
Par défaut l’enveloppe porte l’événement tel quel (identifiants, montants).
Deux options, cochées à la création du webhook ou à tout moment sur sa ligne,
complètent data à la livraison (un réessai relit l’état à jour) :
| Option | Ajoute | Pour | Accès requis |
|---|---|---|---|
| Joindre les liens des documents | data.document = { id, number, type, status, totalTtcCents, currency, pdfUrl, payUrl? } (PDF public signé, valable 7 jours ; payUrl seulement s’il reste un solde) |
sales.document.paid |
billing-fr.documents:read |
data.charge = { id, code, label, amountCents, currency, paidAt } |
pos.charge.paid |
billing-fr.pos:read |
|
| Joindre l’identité du client | data.customer = { name, email } (figés sur le document) ou { email } (saisi au paiement d’une charge) |
les deux | l’accès du document et crm.contacts:read |
Une option n’est appliquée que si l’organisation destinataire a accordé à
l’installation l’accès indiqué (vérifié à chaque livraison) ; sinon
l’enveloppe part sans ce complément, sans erreur. Le lien pdfUrl expire
au bout de 7 jours : si vous devez conserver le document, téléchargez-le
à réception (ou relisez-le plus tard par l’API publique).
L’identité du client est une donnée personnelle : n’activez cette option que
si votre backend en a besoin, et traitez-la comme telle.
Vérifier la signature (obligatoire)
Chaque requête est signée HMAC-SHA256 sur le corps brut avec votre
secret : en-tête x-capibara-signature: sha256=<hex>. Rejetez (401) toute
requête dont la signature ne correspond pas — sinon n’importe qui pouvant
deviner votre URL peut vous injecter de faux événements.
Avec @capibara-dev/sdk :
import { verifyWebhookSignature, WEBHOOK_SIGNATURE_HEADER } from '@capibara-dev/sdk';
// ⚠️ TOUJOURS sur le corps BRUT reçu (string/octets), jamais sur un JSON
// re-sérialisé : la signature porte sur les octets exacts.
const ok = await verifyWebhookSignature(rawBody, req.headers[WEBHOOK_SIGNATURE_HEADER], process.env.CAPIBARA_WEBHOOK_SECRET!);
if (!ok) return res.status(401).end();
Sans SDK (Node.js) :
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(rawBody: string, header: string, secret: string): boolean {
const m = /^sha256=([0-9a-f]{64})$/i.exec(header ?? '');
if (!m) return false;
const expected = Buffer.from(m[1], 'hex');
const actual = createHmac('sha256', secret).update(rawBody, 'utf8').digest();
return expected.length === actual.length && timingSafeEqual(actual, expected);
}
Accusé de réception, retries, désactivation
- Répondez 2xx en moins de 10 secondes pour accuser réception. Faites le
traitement lourd en asynchrone chez vous (queue) — répondez d’abord. - Toute autre réponse (ou timeout) déclenche des retries à backoff
croissant : 1 min, 5 min, 15 min, 1 h, 3 h, 6 h, 12 h, 24 h. - Après 8 échecs consécutifs, la souscription est désactivée
automatiquement et vous êtes prévenu par e-mail. Corrigez votre endpoint
puis réactivez-la depuis la section Backend & webhooks (le compteur repart à zéro). - Le bouton Tester envoie un ping signé (
"event": "ping") immédiat,
sans pénaliser la souscription.
Événements disponibles
Un webhook ne livre jamais ce que votre app ne pourrait pas lire par l’API :
chaque événement exige que l’organisation ait accordé à l’installation
l’accès de lecture indiqué (les scopes feuilles de
votre manifeste). Sans cet accès, l’événement ne vous est pas envoyé par
cette organisation.
| Événement | Émis quand… | Accès requis |
|---|---|---|
contact.created |
Un contact est créé (CRM, formulaire du site, import…). | crm.contacts:read |
quote.created / quote.accepted |
Un devis est créé / accepté. | billing-fr.documents:read |
opportunity.won |
Une opportunité CRM est gagnée. | crm.opportunities:read |
invoice.created / invoice.sent / invoice.paid / invoice.voided |
Cycle de vie d’une facture. | billing-fr.documents:read |
order.placed / order.shipped |
Une commande boutique est passée / expédiée. | shop.orders:read |
product.created / product.updated |
Catalogue produit. | shop.products:read |
stock.low / stock.movement.recorded |
Stock. | shop.products:read |
booking.created |
Une réservation est prise (planning). | planning.bookings:read |
sales.document.paid |
Un document de vente v2 (facture, acompte) ou l’une de ses échéances est encaissé — le montant est dans l’événement ; enrichissable par les options ci-dessus. | billing-fr.documents:read |
pos.charge.paid |
Une charge TPE est payée (caisse, ou billing.charges.create de votre app). |
billing-fr.pos:read |
approval.decided |
Une demande de validation posée par votre app (ctx.approvals.request) a été tranchée par un humain — jamais les validations des modules. |
aucun (la demande vient de votre app) |
Non livrés par webhook — aucun accès d’app ne couvre encore ces
domaines, la souscription les refuse : goods.received (achats),
employee.created, leave.requested, leave.approved (RH),
document.attached (médias).
Les apps hébergées (events du manifeste) utilisent le même catalogue
fermé, ces cinq événements compris : une clé absente des deux listes
ci-dessus est refusée à la validation.
Vous ne recevez que les événements des organisations qui ont installé
votre app (installation active) et accordé l’accès requis — jamais ceux
du reste de la plateforme. Ces conditions sont revérifiées à chaque
envoi, réessais compris : une installation mise en pause ou désinstallée,
un accès retiré ou un compte développeur suspendu arrêtent aussi les
livraisons déjà en file.
Bonnes pratiques
- Idempotence : déduplicable par
id(les retries renvoient le même). - Ordre non garanti : datez vos traitements avec
occurredAt, pas avec
l’ordre d’arrivée. - Secret par webhook : régénérez-le au moindre doute (l’ancien est
invalidé immédiatement) et limitez sa diffusion à l’endpoint de réception.