Home › Developer portal › Webhooks guide
Document menu← Home
Developer portal
  1. Quick start
  2. Authentication
  3. REST API reference
  4. Webhooks guide
  5. Exports guide
  6. ERP integration guide
  7. Bank integration guide
  8. Tauraco Collateral documentation
  9. MCP documentation
  10. Code samples
  11. Error handling
  12. Sandbox environments
  13. Versions and changelog
  14. FAQ
  15. Developer support
Developers

Webhooks guide

Tauraco v2 webhooks send the company's business facts to your HTTPS endpoint: invoice created or paid, payment, financing request, export completed… They follow the Standard Webhooks specification: HMAC-SHA256 signature, stable identifier, timestamp.

Status: developed and tested (lot 1D); webhooks v2 module open in production since 2026-11-05.

Declare an endpoint

  • In the application: Data & integrations › Webhooks (company administrator), or Institution webhooks for a financial institution.
  • Through the public API, with the webhooks:manage scope: GET /webhooks/event-types (types the application may subscribe to), POST /webhooks, GET / PATCH / DELETE /webhooks/{endpoint_id}, POST …/rotate-secret, …/enable, …/disable, …/test (sends webhook.test to that endpoint only), GET …/deliveries and POST …/deliveries/{delivery_id}/replay. An application only sees and manages the endpoints it created (those of the Tauraco application or of another application answer 404). It can only subscribe to events whose read scope it holds (403 insufficient_scope otherwise, on creation as on update); this is checked again on every send, and a delivery whose application lost the scope is cancelled.
  • The whsec_… secret is shown only once, at creation or rotation; the replayed response of a POST (same Idempotency-Key) carries secret: null. Deliveries read through the API never publish their payload.

Address rules: HTTPS required, ports 443 or 8443, no credentials in the URL; the name is resolved and all its addresses must be public (no private, loopback, link-local or cloud metadata address, no internal name). Redirects are never followed. The address is checked again on every send.

Envelope

{"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…"}}}

Payloads are thin: identifiers, statuses, timestamps and a link to read the resource again. No secret, no bank details; no amount and no rate for financing and Collateral. For details, read the resource through data.links.self with your token.

Delivery headers

HeaderValue
webhook-idevent identifier (evt_…), the same across retries and replays
webhook-timestampsend time, in seconds since the Unix epoch
webhook-signaturev1,<base64> (several signatures possible, separated by spaces)
X-Tauraco-Eventevent type
X-Tauraco-Delivery-Iddelivery identifier (changes on every replay)
Content-Typeapplication/json
User-AgentTauraco-Webhook/2.0

Verify the signature

The signed content is {webhook-id}.{webhook-timestamp}. followed by the raw body received (bytes, never re-serialised JSON). The HMAC key is the secret decoded from base64, without the whsec_ prefix. The expected signature is v1, followed by the base64 of HMAC-SHA256(key, content). Compare in constant time and reject a timestamp more than 5 minutes away (replay protection).

Pseudo-code:

key      = base64_decode(secret without "whsec_")
content  = webhook_id + "." + webhook_timestamp + "." + raw_body
expected = "v1," + base64(hmac_sha256(key, content))
valid    = |now - webhook_timestamp| <= 300
           and one of the webhook-signature signatures == expected (constant-time comparison)

Reject an empty or unreadable secret (invalid base64, or whsec_ alone): an empty HMAC key would let anyone sign. An unreadable candidate signature is skipped and the next one is tried.

Reference vector

A demonstration secret (never issued by Tauraco) and its signature, computed by the hub's signing code. Your verification must accept it at the given timestamp, and reject it if a single byte of the body changes or if the clock is more than 300 seconds off. The same vector appears in a comment of each example below.

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

Raw body (UTF-8 bytes, no trailing newline):

{"id":"evt_exemple_0001","type":"webhook.test","data":{"message":"ok"}}
python/verifier_webhook.pyDownload the file
"""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.tsDownload the file
/**
 * 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.javaDownload the file
// 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.csDownload the file
// 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;
    }
}

The official Standard Webhooks libraries (standardwebhooks for Python, JavaScript, Java, C#, Go, Ruby, PHP…) verify the same format.

Answer and process

  • Answer 2xx within 10 seconds (total deadline of the delivery), then process asynchronously.
  • Delivery is at least once: deduplicate on webhook-id (a uniqueness constraint in your database). A duplicate also gets 2xx.
  • Ordering is not guaranteed: rely on created_at and on reading the resource again.

Retries, dead letters, disabling

Without a 2xx, the delivery is retried after 1 min, 5 min, 30 min, 2 h, 8 h then 24 h; on the seventh failure it moves to the DEAD state (dead letters), visible and replayable from the console. An endpoint failing continuously for more than 7 days is disabled automatically (no notification is sent in v1: watch the status in the console). A replay creates a new delivery with the same webhook-id, without a new business fact. Deliveries are kept for 90 days; the console only shows a 512-character preview.

Event catalogue (v1)

TypeTriggerRead scopeRecipients
invoice.createdcustomer invoice createdinvoices:readcompany
invoice.paidinvoice fully settled (payment or credit note)invoices:readcompany
payment.recordedpayment recordedpayments:readcompany
customer.createdcustomer created (no e-mail, no phone)customers:readcompany
contract.signedfinancing agreement signedfinancing:readcompany, institution
financing.requestedrequest submitted or recipient addedfinancing:readcompany, institutions
financing.status_changedoffer, acceptance, rejection, withdrawal, decline, disbursement confirmed or reversedfinancing:readcompany, institution concerned
financing.fundeddisbursement confirmedfinancing:readcompany, institution
collateral.registeredregistration of a security interest recordedcollateral:readinstitution
collateral.releasedrelease recordedcollateral:readinstitution
export.completedexport completedexports:readcompany
export.failedexport failedexports:readcompany
webhook.testtest sent from the console—that endpoint only

The links.self of financing events point to /public/v1/financing/requests/{id} for the company and to /public/v1/bank/requests/{id} for an institution. Not published in v1: invoice.updated, tender.published, guarantee.status_changed.

Legacy webhooks (v1)

Endpoints created before v2 (/v3/webhooks) keep their X-Tauraco-Signature: sha256=<hex> header and do not receive v2 events. For a new use, declare a v2 endpoint.