Accueil › Portail développeurs › Guide des webhooks
Menu du document← Accueil
Portail développeurs
  1. Démarrage rapide
  2. Authentification
  3. Référence API REST
  4. Guide des webhooks
  5. Guide des exports
  6. Guide d'intégration ERP
  7. Guide d'intégration bancaire
  8. Documentation Tauraco Collateral
  9. Documentation MCP
  10. Exemples de code
  11. Gestion des erreurs
  12. Environnements sandbox
  13. Versions et changelog
  14. FAQ
  15. Support développeurs
Développeurs

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 (émet webhook.test vers cet endpoint seul), GET …/deliveries et POST …/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épondent 404). Elle ne peut s'abonner qu'aux événements dont elle porte le scope de lecture (403 insufficient_scope sinon, à 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'un POST (même Idempotency-Key) porte secret: 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êteValeur
webhook-ididentifiant de l'événement (evt_…), identique sur les reprises et les rejeux
webhook-timestamphorodatage d'envoi, en secondes depuis l'époque Unix
webhook-signaturev1,<base64> (plusieurs signatures possibles, séparées par des espaces)
X-Tauraco-Eventtype de l'événement
X-Tauraco-Delivery-Ididentifiant de la livraison (change à chaque rejeu)
Content-Typeapplication/json
User-AgentTauraco-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.

ChampValeur
secretwhsec_dGF1cmFjby1leGVtcGxlLWRlLXZlY3RldXItMjAyNiE=
webhook-idevt_exemple_0001
webhook-timestamp1793750400 (2026-11-04 00:00:00 UTC)
webhook-signaturev1,z25573sJuRUxUk3JkjiZN3OW2KihQFtLvIGhZTHUnuM=

Corps brut (octets UTF-8, sans fin de ligne) :

{"id":"evt_exemple_0001","type":"webhook.test","data":{"message":"ok"}}
python/verifier_webhook.pyTélécharger le fichier
"""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()
typescript/verifierWebhook.tsTélécharger le fichier
/**
 * 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");
}
java/VerifierWebhook.javaTélécharger le fichier
// 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());
    }
}
csharp/VerifierWebhook.csTélécharger le fichier
// 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 2xx en 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 aussi 2xx.
  • L'ordre n'est pas garanti : appuyez-vous sur created_at et 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)

TypeDéclencheurScope de lectureDestinataires
invoice.createdfacture client crééeinvoices:readentreprise
invoice.paidfacture entièrement réglée (paiement ou avoir)invoices:readentreprise
payment.recordedencaissement enregistrépayments:readentreprise
customer.createdclient créé (sans e-mail ni téléphone)customers:readentreprise
contract.signedconvention de financement signéefinancing:readentreprise, établissement
financing.requesteddemande soumise ou destinataire ajoutéfinancing:readentreprise, établissements
financing.status_changedoffre, acceptation, refus, retrait, déclin, versement confirmé ou annuléfinancing:readentreprise, établissement concerné
financing.fundedversement confirméfinancing:readentreprise, établissement
collateral.registeredinscription d'une sûreté constatéecollateral:readétablissement
collateral.releasedmainlevée enregistréecollateral:readétablissement
export.completedexport terminéexports:readentreprise
export.failedexport en échecexports:readentreprise
webhook.testtest 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.