Guide des webhooks
Les webhooks v2 de Tauraco envoient à votre endpoint HTTPS les faits métier de l'entreprise : facture créée ou réglée, paiement, demande de financement, export terminé… Ils suivent la spécification Standard Webhooks : signature HMAC-SHA256, identifiant stable, horodatage.
État : développé et testé (lot 1D) ; module webhooks v2 ouvert en production depuis le 2026-11-05.
Déclarer un endpoint
- Dans l'application : Données et intégrations › Webhooks (administrateur de l'entreprise), ou Webhooks de l'établissement pour un établissement financier.
- Par l'API publique, avec le scope
webhooks:manage:GET /webhooks/event-types(types auxquels l'application peut s'abonner),POST /webhooks,GET/PATCH/DELETE /webhooks/{endpoint_id},POST …/rotate-secret,…/enable,…/disable,…/test(émetwebhook.testvers cet endpoint seul),GET …/deliveriesetPOST …/deliveries/{delivery_id}/replay. Une application ne voit et ne gère que les endpoints qu'elle a créés (ceux de l'application Tauraco ou d'une autre application répondent404). Elle ne peut s'abonner qu'aux événements dont elle porte le scope de lecture (403 insufficient_scopesinon, à la création comme à la modification) ; c'est revérifié à chaque envoi, et une livraison dont l'application a perdu le scope est annulée. - Le secret
whsec_…n'est affiché qu'une fois, à la création ou à la rotation ; la réponse rejouée d'unPOST(mêmeIdempotency-Key) portesecret: null. Les livraisons lues par l'API ne publient jamais leur charge utile.
Règles sur l'adresse : HTTPS obligatoire, ports 443 ou 8443, aucun identifiant dans l'URL ; le nom est résolu et toutes ses adresses doivent être publiques (aucune adresse privée, de bouclage, de lien local, de métadonnées cloud, ni nom interne). Les redirections ne sont jamais suivies. L'adresse est revérifiée à chaque envoi.
Enveloppe
{"id":"evt_3f2a9c0d6b1e4a7f8c5d2e1b0a9f8e7d","type":"invoice.paid","created_at":"2026-11-03T09:14:05Z",
"api_version":"v2","tenant_id":"acme-ci","data":{"object":"invoice","id":"6c1e…","number":"FAC-2026-0042",
"status":"PAID","previous_status":"PARTIALLY_PAID","currency":"XOF","total":"1180000","amount_paid":"1180000",
"settled_by":"payment","paid_at":"2026-11-03T09:14:04Z",
"links":{"self":"https://api.tauraco.io/public/v1/invoices/6c1e…"}}}
Les charges utiles sont minces : identifiants, statuts, horodatages et lien de relecture. Aucun secret, aucune coordonnée bancaire ; aucun montant ni taux pour le financement et Collateral. Pour le détail, relisez la ressource par data.links.self avec votre jeton.
En-têtes d'une livraison
| En-tête | Valeur |
|---|---|
webhook-id | identifiant de l'événement (evt_…), identique sur les reprises et les rejeux |
webhook-timestamp | horodatage d'envoi, en secondes depuis l'époque Unix |
webhook-signature | v1,<base64> (plusieurs signatures possibles, séparées par des espaces) |
X-Tauraco-Event | type de l'événement |
X-Tauraco-Delivery-Id | identifiant de la livraison (change à chaque rejeu) |
Content-Type | application/json |
User-Agent | Tauraco-Webhook/2.0 |
Vérifier la signature
Le contenu signé est {webhook-id}.{webhook-timestamp}. suivi du corps brut reçu (octets, jamais un JSON re-sérialisé). La clé HMAC est le secret décodé de base64, sans le préfixe whsec_. La signature attendue vaut v1, suivi du base64 de HMAC-SHA256(clé, contenu). Comparez en temps constant et refusez un horodatage écarté de plus de 5 minutes (protection contre le rejeu).
Pseudo-code :
cle = base64_decode(secret sans "whsec_")
contenu = webhook_id + "." + webhook_timestamp + "." + corps_brut
attendue = "v1," + base64(hmac_sha256(cle, contenu))
valide = |maintenant - webhook_timestamp| <= 300
et une des signatures de webhook-signature == attendue (comparaison en temps constant)
Refusez un secret vide ou illisible (base64 invalide, ou whsec_ seul) : une clé HMAC vide permettrait à n'importe qui de signer. Une signature candidate illisible est ignorée, et l'on essaie la suivante.
Vecteur de référence
Un secret de démonstration (jamais émis par Tauraco) et sa signature, calculée par le code de signature du hub. Votre vérification doit l'accepter à l'horodatage indiqué, et le refuser si un seul octet du corps change ou si l'horloge est décalée de plus de 300 secondes. Le même vecteur figure en commentaire de chaque exemple ci-dessous.
| Champ | Valeur |
|---|---|
| secret | whsec_dGF1cmFjby1leGVtcGxlLWRlLXZlY3RldXItMjAyNiE= |
webhook-id | evt_exemple_0001 |
webhook-timestamp | 1793750400 (2026-11-04 00:00:00 UTC) |
webhook-signature | v1,z25573sJuRUxUk3JkjiZN3OW2KihQFtLvIGhZTHUnuM= |
Corps brut (octets UTF-8, sans fin de ligne) :
{"id":"evt_exemple_0001","type":"webhook.test","data":{"message":"ok"}}
"""Vérifier la signature d'un webhook Tauraco (Standard Webhooks) et le recevoir.
Verify a Tauraco webhook signature (Standard Webhooks) and receive it.
Bibliothèque standard seule (Python 3.9+). Le contenu signé est
``"{webhook-id}.{webhook-timestamp}." + corps brut`` ; la signature vaut
``"v1," + base64(HMAC-SHA256(clé, contenu))``, la clé étant le secret ``whsec_…``
décodé de base64 (préfixe retiré). L'en-tête ``webhook-signature`` peut porter
plusieurs signatures séparées par des espaces. Un secret vide ou illisible est refusé :
une clé HMAC vide permettrait à n'importe qui de signer.
Vecteur de référence (identique dans la page « Guide des webhooks ») :
secret whsec_dGF1cmFjby1leGVtcGxlLWRlLXZlY3RldXItMjAyNiE=
webhook-id evt_exemple_0001
webhook-timestamp 1793750400
corps {"id":"evt_exemple_0001","type":"webhook.test","data":{"message":"ok"}}
signature v1,z25573sJuRUxUk3JkjiZN3OW2KihQFtLvIGhZTHUnuM=
Lancer le récepteur de démonstration (port 8787) :
TAURACO_WEBHOOK_SECRET='whsec_…' python3 verifier_webhook.py
"""
from __future__ import annotations
import base64
import binascii
import hashlib
import hmac
import json
import os
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
from typing import Mapping, Optional
TOLERANCE_SECONDS = 300
def secret_key(secret: str) -> Optional[bytes]:
"""Clé HMAC du secret ``whsec_…`` (base64, préfixe retiré) ; ``None`` si vide ou illisible."""
if not isinstance(secret, str):
return None
try:
key = base64.b64decode(secret.removeprefix("whsec_").encode("ascii"), validate=True)
except (binascii.Error, UnicodeEncodeError, ValueError):
return None
return key or None # clé vide : refusée
def verify_signature(secret: str, headers: Mapping[str, str], body: bytes, *,
tolerance: int = TOLERANCE_SECONDS, now: Optional[int] = None) -> bool:
"""Vrai si une signature ``v1`` de ``webhook-signature`` correspond et que l'horodatage est récent.
``body`` doit être le corps BRUT reçu (octets), jamais un JSON re-sérialisé.
"""
lowered = {str(name).lower(): str(value) for name, value in headers.items()}
msg_id = lowered.get("webhook-id")
raw_timestamp = lowered.get("webhook-timestamp")
raw_signatures = lowered.get("webhook-signature")
if not msg_id or not raw_timestamp or not raw_signatures:
return False
try:
timestamp = int(raw_timestamp.strip())
except ValueError:
return False
current = int(time.time()) if now is None else now
if abs(current - timestamp) > tolerance:
return False # rejeu d'un ancien message ou horloge déréglée
key = secret_key(secret)
if key is None:
return False
signed = f"{msg_id}.{timestamp}.".encode() + body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode("ascii")
for candidate in raw_signatures.split():
version, _, value = candidate.partition(",")
if version == "v1" and hmac.compare_digest(value, expected):
return True
return False
class AlreadySeen:
"""Dédoublonnage par ``webhook-id`` : la livraison est « au moins une fois ».
En production, conserver les identifiants dans une base (contrainte d'unicité),
pas en mémoire : un rejeu ou une reprise renvoie le même ``webhook-id``.
"""
def __init__(self) -> None:
self._ids: set[str] = set()
def first_time(self, msg_id: str) -> bool:
if msg_id in self._ids:
return False
self._ids.add(msg_id)
return True
def handle_event(event: dict) -> None:
"""Traitement métier, idempotent (à remplacer)."""
print(f"{event.get('type')} {event.get('id')} tenant={event.get('tenant_id')}")
def make_handler(secret: str, seen: AlreadySeen) -> type[BaseHTTPRequestHandler]:
class Receiver(BaseHTTPRequestHandler):
def do_POST(self) -> None: # noqa: N802 (nom imposé par http.server)
length = int(self.headers.get("Content-Length") or 0)
body = self.rfile.read(length)
headers = {name: value for name, value in self.headers.items()}
if not verify_signature(secret, headers, body):
self.send_response(401)
self.end_headers()
return
# Répondre vite (2xx) ; traiter ensuite. Un doublon reçoit aussi 2xx.
if seen.first_time(headers.get("webhook-id") or headers.get("Webhook-Id", "")):
handle_event(json.loads(body))
self.send_response(204)
self.end_headers()
def log_message(self, *_args) -> None: # pas de corps ni d'en-tête dans les journaux
return
return Receiver
if __name__ == "__main__":
webhook_secret = os.environ["TAURACO_WEBHOOK_SECRET"] # aucune valeur par défaut
if secret_key(webhook_secret) is None:
raise SystemExit("TAURACO_WEBHOOK_SECRET vide ou illisible (whsec_ + base64 attendu)")
HTTPServer(("127.0.0.1", 8787), make_handler(webhook_secret, AlreadySeen())).serve_forever()
/**
* Vérifier la signature d'un webhook Tauraco (Standard Webhooks) — Node.js 18+.
* Verify a Tauraco webhook signature (Standard Webhooks) — Node.js 18+.
*
* Contenu signé : `${webhook-id}.${webhook-timestamp}.` + corps BRUT (octets).
* Signature : "v1," + base64(HMAC-SHA256(base64decode(secret sans "whsec_"), contenu)).
* Un secret vide ou qui n'est pas du base64 strict est refusé (Buffer.from(…, "base64")
* accepte n'importe quoi et rendrait une clé vide : n'importe qui pourrait signer).
*
* Vecteur de référence (page « Guide des webhooks ») :
* secret whsec_dGF1cmFjby1leGVtcGxlLWRlLXZlY3RldXItMjAyNiE=
* webhook-id evt_exemple_0001
* webhook-timestamp 1793750400
* corps {"id":"evt_exemple_0001","type":"webhook.test","data":{"message":"ok"}}
* signature v1,z25573sJuRUxUk3JkjiZN3OW2KihQFtLvIGhZTHUnuM=
*/
import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";
const TOLERANCE_SECONDS = 300;
const STRICT_BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
/** Clé HMAC du secret `whsec_…`, ou `null` si vide ou illisible. */
export function secretKey(secret: string): Buffer | null {
const raw = secret.replace(/^whsec_/, "");
if (raw.length === 0 || !STRICT_BASE64.test(raw)) return null;
const key = Buffer.from(raw, "base64");
return key.length > 0 ? key : null;
}
export function verifySignature(
secret: string,
headers: Record<string, string | string[] | undefined>,
body: Buffer,
now: number = Math.floor(Date.now() / 1000),
): boolean {
const header = (name: string): string | undefined => {
const value = headers[name] ?? headers[name.toLowerCase()];
return Array.isArray(value) ? value[0] : value;
};
const id = header("webhook-id");
const timestamp = Number(header("webhook-timestamp"));
const signatures = header("webhook-signature");
if (!id || !signatures || !Number.isInteger(timestamp)) return false;
if (Math.abs(now - timestamp) > TOLERANCE_SECONDS) return false;
const key = secretKey(secret);
if (key === null) return false;
const expected = Buffer.from(
createHmac("sha256", key)
.update(Buffer.concat([Buffer.from(`${id}.${timestamp}.`), body]))
.digest("base64"),
);
return signatures.split(" ").some((candidate) => {
const [version, value] = candidate.split(",", 2);
if (version !== "v1" || !value) return false; // autre version ou candidate illisible : suivante
const received = Buffer.from(value); // comparaison du texte base64, en temps constant
return received.length === expected.length && timingSafeEqual(received, expected);
});
}
// Récepteur de démonstration : TAURACO_WEBHOOK_SECRET=whsec_… node verifierWebhook.js
const seen = new Set<string>(); // en production : table avec contrainte d'unicité sur webhook-id
if (process.env.TAURACO_WEBHOOK_SECRET !== undefined) {
const secret = process.env.TAURACO_WEBHOOK_SECRET;
if (secretKey(secret) === null) throw new Error("TAURACO_WEBHOOK_SECRET vide ou illisible");
createServer((req, res) => {
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => chunks.push(chunk));
req.on("end", () => {
const body = Buffer.concat(chunks);
if (!verifySignature(secret, req.headers, body)) {
res.writeHead(401).end();
return;
}
res.writeHead(204).end(); // répondre vite ; un doublon reçoit aussi 2xx
const id = String(req.headers["webhook-id"]);
if (seen.has(id)) return;
seen.add(id);
const event = JSON.parse(body.toString("utf8"));
console.log(event.type, event.id);
});
}).listen(8787, "127.0.0.1");
}
// Vérifier la signature d'un webhook Tauraco (Standard Webhooks) — Java 17+, sans dépendance.
// Verify a Tauraco webhook signature (Standard Webhooks) — Java 17+, no dependency.
// Vecteur de référence (page « Guide des webhooks ») :
// secret whsec_dGF1cmFjby1leGVtcGxlLWRlLXZlY3RldXItMjAyNiE=
// webhook-id evt_exemple_0001
// webhook-timestamp 1793750400
// corps {"id":"evt_exemple_0001","type":"webhook.test","data":{"message":"ok"}}
// signature v1,z25573sJuRUxUk3JkjiZN3OW2KihQFtLvIGhZTHUnuM=
// Un secret vide ou illisible est refusé ; une signature candidate illisible est ignorée
// (on essaie la suivante : l'en-tête peut en porter plusieurs).
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.Base64;
import java.util.Map;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public final class VerifierWebhook {
private static final long TOLERANCE_SECONDS = 300;
private VerifierWebhook() {}
/** {@code headers} : noms en minuscules ; {@code body} : corps BRUT reçu. */
public static boolean verifySignature(String secret, Map<String, String> headers, byte[] body, long now) {
String id = headers.get("webhook-id");
String rawTimestamp = headers.get("webhook-timestamp");
String signatures = headers.get("webhook-signature");
if (id == null || rawTimestamp == null || signatures == null) return false;
long timestamp;
try {
timestamp = Long.parseLong(rawTimestamp.trim());
} catch (NumberFormatException e) {
return false;
}
if (Math.abs(now - timestamp) > TOLERANCE_SECONDS) return false;
byte[] key;
try {
key = Base64.getDecoder().decode(secret.startsWith("whsec_") ? secret.substring(6) : secret);
} catch (IllegalArgumentException e) {
return false; // secret illisible
}
if (key.length == 0) return false; // clé vide : n'importe qui pourrait signer
byte[] expected;
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(key, "HmacSHA256"));
mac.update((id + "." + timestamp + ".").getBytes(StandardCharsets.UTF_8));
expected = mac.doFinal(body);
} catch (java.security.GeneralSecurityException e) {
return false;
}
for (String candidate : signatures.split(" ")) {
String[] parts = candidate.split(",", 2);
if (parts.length != 2 || !parts[0].equals("v1")) continue;
byte[] received;
try {
received = Base64.getDecoder().decode(parts[1]);
} catch (IllegalArgumentException e) {
continue; // candidate illisible : essayer la suivante
}
if (MessageDigest.isEqual(received, expected)) return true;
}
return false;
}
public static boolean verifySignature(String secret, Map<String, String> headers, byte[] body) {
return verifySignature(secret, headers, body, Instant.now().getEpochSecond());
}
}
// Vérifier la signature d'un webhook Tauraco (Standard Webhooks) — .NET 6+, sans dépendance.
// Verify a Tauraco webhook signature (Standard Webhooks) — .NET 6+, no dependency.
// Vecteur de référence (page « Guide des webhooks ») :
// secret whsec_dGF1cmFjby1leGVtcGxlLWRlLXZlY3RldXItMjAyNiE=
// webhook-id evt_exemple_0001
// webhook-timestamp 1793750400
// corps {"id":"evt_exemple_0001","type":"webhook.test","data":{"message":"ok"}}
// signature v1,z25573sJuRUxUk3JkjiZN3OW2KihQFtLvIGhZTHUnuM=
// Un secret vide ou illisible est refusé ; une signature candidate illisible est ignorée
// (on essaie la suivante : l'en-tête peut en porter plusieurs).
using System;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Text;
public static class VerifierWebhook
{
private const long ToleranceSeconds = 300;
/// <summary><paramref name="headers"/> : noms en minuscules ; <paramref name="body"/> : corps BRUT.</summary>
public static bool VerifySignature(string secret, IReadOnlyDictionary<string, string> headers, byte[] body, long? now = null)
{
if (!headers.TryGetValue("webhook-id", out var id) ||
!headers.TryGetValue("webhook-timestamp", out var rawTimestamp) ||
!headers.TryGetValue("webhook-signature", out var signatures) ||
!long.TryParse(rawTimestamp.Trim(), out var timestamp))
{
return false;
}
var current = now ?? DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (Math.Abs(current - timestamp) > ToleranceSeconds) return false;
byte[] key;
try
{
key = Convert.FromBase64String(secret.StartsWith("whsec_") ? secret.Substring(6) : secret);
}
catch (FormatException)
{
return false; // secret illisible
}
if (key.Length == 0) return false; // clé vide : n'importe qui pourrait signer
var prefix = Encoding.UTF8.GetBytes($"{id}.{timestamp}.");
var signed = new byte[prefix.Length + body.Length];
Buffer.BlockCopy(prefix, 0, signed, 0, prefix.Length);
Buffer.BlockCopy(body, 0, signed, prefix.Length, body.Length);
using var hmac = new HMACSHA256(key);
var expected = hmac.ComputeHash(signed);
foreach (var candidate in signatures.Split(' ', StringSplitOptions.RemoveEmptyEntries))
{
var parts = candidate.Split(',', 2);
if (parts.Length != 2 || parts[0] != "v1") continue;
byte[] received;
try
{
received = Convert.FromBase64String(parts[1]);
}
catch (FormatException)
{
continue; // candidate illisible : essayer la suivante
}
if (CryptographicOperations.FixedTimeEquals(received, expected)) return true;
}
return false;
}
}
Les bibliothèques officielles Standard Webhooks (standardwebhooks pour Python, JavaScript, Java, C#, Go, Ruby, PHP…) vérifient le même format.
Répondre et traiter
- Répondez
2xxen moins de 10 secondes (échéance totale de la livraison), puis traitez de façon asynchrone. - La livraison est au moins une fois : dédoublonnez sur
webhook-id(contrainte d'unicité en base). Un doublon reçoit aussi2xx. - L'ordre n'est pas garanti : appuyez-vous sur
created_atet sur la relecture de la ressource.
Reprises, file morte, désactivation
Sans 2xx, la livraison est reprise après 1 min, 5 min, 30 min, 2 h, 8 h puis 24 h ; au septième échec elle passe à l'état DEAD (file morte), consultable et rejouable depuis la console. Un endpoint en échec continu depuis plus de 7 jours est désactivé automatiquement (aucune notification n'est envoyée en v1 : surveillez l'état dans la console). Le rejeu crée une nouvelle livraison avec le même webhook-id, sans nouveau fait métier. Les livraisons sont conservées 90 jours ; la console n'en montre qu'un aperçu de 512 caractères.
Catalogue des événements (v1)
| Type | Déclencheur | Scope de lecture | Destinataires |
|---|---|---|---|
invoice.created | facture client créée | invoices:read | entreprise |
invoice.paid | facture entièrement réglée (paiement ou avoir) | invoices:read | entreprise |
payment.recorded | encaissement enregistré | payments:read | entreprise |
customer.created | client créé (sans e-mail ni téléphone) | customers:read | entreprise |
contract.signed | convention de financement signée | financing:read | entreprise, établissement |
financing.requested | demande soumise ou destinataire ajouté | financing:read | entreprise, établissements |
financing.status_changed | offre, acceptation, refus, retrait, déclin, versement confirmé ou annulé | financing:read | entreprise, établissement concerné |
financing.funded | versement confirmé | financing:read | entreprise, établissement |
collateral.registered | inscription d'une sûreté constatée | collateral:read | établissement |
collateral.released | mainlevée enregistrée | collateral:read | établissement |
export.completed | export terminé | exports:read | entreprise |
export.failed | export en échec | exports:read | entreprise |
webhook.test | test lancé depuis la console | — | l'endpoint seul |
Les liens links.self des événements de financement pointent vers /public/v1/financing/requests/{id} pour l'entreprise et vers /public/v1/bank/requests/{id} pour un établissement. Non publiés en v1 : invoice.updated, tender.published, guarantee.status_changed.
Webhooks hérités (v1)
Les endpoints créés avant la v2 (/v3/webhooks) gardent leur en-tête X-Tauraco-Signature: sha256=<hex> et ne reçoivent pas les événements v2. Pour un nouvel usage, déclarez un endpoint v2.