# Webhooks sortants

> Recevez les événements métier (commande, facture, contact…) des organisations qui utilisent votre app : configuration, signature HMAC, retries, idempotence.
> Catégorie : API & intégration · mis à jour le 2026-09-24 · plateforme v1

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

```http
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
```

```json
{
  "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.
- `tenantId` identifie l'organisation émettrice (le même que dans l'API).
- `data` porte 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 (401 `stale`). 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 par `id` reste 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](/dev/docs/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](/dev/docs/sdk) :

```ts
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) :

```ts
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](/dev/docs/permissions) 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.
