Accueil › Portail développeurs › Exemples de code
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

Exemples de code

Exemples complets, sans dépendance tierce sauf mention, téléchargeables un par un. Ils visent les adresses de production sous api.tauraco.io, en ligne depuis le 2026-11-05 ; remplacez-les par celles de la sandbox quand elle existera. Aucun exemple ne contient d'identifiant réel : les secrets se passent par variables d'environnement.

cURL

Obtenir un jeton client_credentials :

curl/obtenir_jeton.shTélécharger le fichier
#!/usr/bin/env sh
# Obtenir un jeton d'accès client_credentials (10 minutes).
# Get a client_credentials access token (10 minutes).
# Variables : TAURACO_CLIENT_ID (tc_live_…), TAURACO_CLIENT_SECRET (tcs_…), TAURACO_TENANT (facultatif).
# Le secret passe par l'entrée standard (--config -), jamais sur la ligne de commande :
# les arguments d'un processus sont lisibles par les autres comptes de la machine (ps).
set -eu
: "${TAURACO_CLIENT_ID:?identifiant du client requis}" "${TAURACO_CLIENT_SECRET:?secret du client requis}"
printf 'user = "%s:%s"\n' "${TAURACO_CLIENT_ID}" "${TAURACO_CLIENT_SECRET}" |
curl -sS --config - -X POST "https://api.tauraco.io/oauth/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "grant_type=client_credentials" \
  --data-urlencode "scope=invoices:read customers:read" \
  --data-urlencode "resource=https://api.tauraco.io/public/v1" \
  ${TAURACO_TENANT:+--data-urlencode "tenant=${TAURACO_TENANT}"}
# Réponse : {"access_token":"…","token_type":"Bearer","expires_in":600,"scope":"customers:read invoices:read"}

Lire les factures payées, page par page :

curl/lister_factures.shTélécharger le fichier
#!/usr/bin/env sh
# Lire toutes les factures payées, page par page (curseur opaque). Nécessite jq.
# Read every paid invoice, page by page (opaque cursor). Requires jq.
set -eu
: "${TAURACO_TOKEN:?jeton requis (voir obtenir_jeton.sh)}"
BASE="https://api.tauraco.io/public/v1"
CURSOR=""
while : ; do
  PAGE=$(curl -sS -G "${BASE}/invoices" \
    -H "Authorization: Bearer ${TAURACO_TOKEN}" -H "Accept: application/json" \
    --data-urlencode "status=PAID" --data-urlencode "limit=100" \
    ${CURSOR:+--data-urlencode "cursor=${CURSOR}"})
  echo "${PAGE}" | jq -r '.data[] | [.number, .customer_name, .total, .currency] | @tsv'
  [ "$(echo "${PAGE}" | jq -r '.has_more')" = "true" ] || break
  CURSOR=$(echo "${PAGE}" | jq -r '.next_cursor')
done

Créer un client avec une clé d'idempotence :

curl/creer_client.shTélécharger le fichier
#!/usr/bin/env sh
# Créer un client : tout POST exige un en-tête Idempotency-Key (≤ 255 caractères).
# Create a customer: every POST requires an Idempotency-Key header (≤ 255 characters).
# Rejouer la même requête avec la même clé renvoie la réponse d'origine (Idempotent-Replayed: true) ;
# la même clé avec un corps différent est refusée (422 idempotency_key_reused).
set -eu
: "${TAURACO_TOKEN:?jeton requis (scope customers:write)}"
KEY="${IDEMPOTENCY_KEY:-$(uuidgen 2>/dev/null || cat /proc/sys/kernel/random/uuid)}"
curl -sS -X POST "https://api.tauraco.io/public/v1/customers" \
  -H "Authorization: Bearer ${TAURACO_TOKEN}" \
  -H "Content-Type: application/json" -H "Accept: application/json" \
  -H "Idempotency-Key: ${KEY}" \
  -d '{"name":"Société Exemple SARL","country":"CI","currency":"XOF"}'

Découvrir le serveur MCP (401, métadonnées, deux ères) :

curl/mcp_decouverte.shTélécharger le fichier
#!/usr/bin/env sh
# Découverte MCP : sans jeton, le serveur répond 401 avec l'adresse des métadonnées (RFC 9728).
# MCP discovery: without a token the server answers 401 with the metadata URL (RFC 9728).
set -eu
MCP="https://api.tauraco.io/mcp"
# 1) Ère moderne (2026-07-28) : sans état, en-têtes MCP-Protocol-Version et Mcp-Method.
curl -si -X POST "${MCP}" -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -H "MCP-Protocol-Version: 2026-07-28" -H "Mcp-Method: server/discover" \
  -d '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}' \
  | sed -n '1p;/^[Ww][Ww][Ww]-[Aa]uthenticate/p'
# 2) Métadonnées de la ressource protégée, puis du serveur d'autorisation.
curl -sS "https://api.tauraco.io/.well-known/oauth-protected-resource/mcp"
curl -sS "https://api.tauraco.io/.well-known/oauth-authorization-server"
# 3) Ère historique (2025-11-25, celle de Claude.ai) : poignée de main initialize, avec jeton.
if [ -n "${TAURACO_MCP_TOKEN:-}" ]; then
  curl -sS -X POST "${MCP}" -H "Authorization: Bearer ${TAURACO_MCP_TOKEN}" \
    -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \
    -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}}}'
fi

Python (3.9 et plus, bibliothèque standard)

Jeton et lecture paginée, avec reprise sur 429 :

python/client_credentials.pyTélécharger le fichier
"""Obtenir un jeton ``client_credentials`` et lire les factures, page par page.

Get a ``client_credentials`` token and read invoices page by page.

Bibliothèque standard seule (Python 3.9+). Variables d'environnement :
``TAURACO_CLIENT_ID`` (``tc_live_…``), ``TAURACO_CLIENT_SECRET`` (``tcs_…``, affiché une
seule fois à la création de l'application), et facultativement ``TAURACO_TENANT``
(obligatoire si plusieurs entreprises ont autorisé l'application).

Les adresses par défaut sont les surfaces prévues sous ``api.tauraco.io`` ; elles ne
répondent qu'une fois l'API publique ouverte (voir la page « Versions et changelog »).
"""
from __future__ import annotations

import base64
import json
import os
import time
import urllib.error
import urllib.parse
import urllib.request
from typing import Callable, Dict, Iterator, Optional, Tuple

API_BASE = os.environ.get("TAURACO_API_BASE", "https://api.tauraco.io/public/v1")
TOKEN_URL = os.environ.get("TAURACO_TOKEN_URL", "https://api.tauraco.io/oauth/token")

#: Transport : (méthode, url, en-têtes, corps) -> (statut, en-têtes, corps). Remplaçable en test.
Transport = Callable[[str, str, Dict[str, str], Optional[bytes]], Tuple[int, Dict[str, str], bytes]]


class TauracoError(RuntimeError):
    """Erreur de l'API : corps ``application/problem+json`` (RFC 9457) ou erreur OAuth."""

    def __init__(self, status: int, body: dict):
        self.status = status
        self.code = body.get("code") or body.get("error") or "unknown"
        self.request_id = body.get("request_id")
        super().__init__(f"{status} {self.code}: {body.get('detail') or body.get('error_description') or ''}")


class _NoRedirect(urllib.request.HTTPRedirectHandler):
    """Aucune redirection suivie : urllib renverrait sinon l'en-tête ``Authorization`` (jeton)
    à l'hôte de destination. L'API Tauraco ne redirige jamais ; un 3xx est rendu tel quel."""

    def redirect_request(self, req, fp, code, msg, headers, newurl):
        return None


_OPENER = urllib.request.build_opener(_NoRedirect)


def http_transport(method: str, url: str, headers: Dict[str, str],
                   body: Optional[bytes]) -> Tuple[int, Dict[str, str], bytes]:
    request = urllib.request.Request(url, data=body, headers=headers, method=method)
    try:
        with _OPENER.open(request, timeout=30) as response:  # noqa: S310 (URL maîtrisée)
            return response.status, dict(response.headers.items()), response.read()
    except urllib.error.HTTPError as err:
        return err.code, dict(err.headers.items()), err.read()


def get_token(client_id: str, client_secret: str, scopes: str, *, tenant: Optional[str] = None,
              resource: str = API_BASE, transport: Transport = http_transport) -> dict:
    """``POST /oauth/token`` (``grant_type=client_credentials``), authentification HTTP Basic."""
    form = {"grant_type": "client_credentials", "scope": scopes, "resource": resource}
    if tenant:
        form["tenant"] = tenant
    basic = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode("ascii")
    status, _headers, raw = transport("POST", TOKEN_URL, {
        "Authorization": f"Basic {basic}",
        "Content-Type": "application/x-www-form-urlencoded",
        "Accept": "application/json",
    }, urllib.parse.urlencode(form).encode())
    payload = json.loads(raw or b"{}")
    if status != 200:
        raise TauracoError(status, payload)
    return payload  # {"access_token", "token_type": "Bearer", "expires_in": 600, "scope"}


def iterate(path: str, token: str, params: Optional[Dict[str, str]] = None, *,
            transport: Transport = http_transport, max_retries: int = 3) -> Iterator[dict]:
    """Toutes les lignes d'une liste paginée par curseur (``data``, ``next_cursor``, ``has_more``)."""
    query = dict(params or {})
    retries = 0
    while True:
        url = f"{API_BASE}{path}?{urllib.parse.urlencode(query)}" if query else f"{API_BASE}{path}"
        status, headers, raw = transport("GET", url, {"Authorization": f"Bearer {token}",
                                                      "Accept": "application/json"}, None)
        if status == 429 and retries < max_retries:
            retries += 1
            time.sleep(int(headers.get("Retry-After") or headers.get("retry-after") or 1))
            continue
        payload = json.loads(raw or b"{}")
        if status != 200:
            raise TauracoError(status, payload)
        retries = 0
        yield from payload.get("data", [])
        if not payload.get("has_more") or not payload.get("next_cursor"):
            return
        query["cursor"] = payload["next_cursor"]


def main() -> None:
    token = get_token(os.environ["TAURACO_CLIENT_ID"], os.environ["TAURACO_CLIENT_SECRET"],
                      "invoices:read", tenant=os.environ.get("TAURACO_TENANT"))["access_token"]
    for invoice in iterate("/invoices", token, {"status": "PAID", "limit": "100"}):
        # Montants : chaînes décimales en unités majeures ("1180000" XOF), jamais des flottants.
        print(invoice["number"], invoice.get("customer_name"), invoice["total"], invoice["currency"])


if __name__ == "__main__":
    main()

Vérifier la signature d'un webhook et le recevoir :

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()

Synchroniser un logiciel comptable (journal des ventes CSV, incrémental) :

python/synchro_comptable.pyTélécharger le fichier
"""Synchronisation incrémentale des factures vers un logiciel comptable (journal des ventes CSV).

Incremental invoice sync to accounting software (sales journal as CSV).

Principe : on relit les factures modifiées depuis le dernier passage (filtre
``updated_after``), avec un recouvrement de 5 minutes, et on dédoublonne par
identifiant de facture : une facture déjà écrite n'est pas réécrite. La table de
correspondance des comptes est celle de VOTRE plan comptable : Tauraco ne reclasse
aucun compte. Les montants restent des chaînes décimales (``Decimal``), jamais des
flottants.

Usage : TAURACO_CLIENT_ID=… TAURACO_CLIENT_SECRET=… python3 synchro_comptable.py
"""
from __future__ import annotations

import csv
import json
import os
from datetime import datetime, timedelta, timezone
from decimal import Decimal
from pathlib import Path
from typing import Iterable, Iterator, List

from client_credentials import get_token, iterate

STATE_FILE = Path(os.environ.get("SYNC_STATE", "etat_synchro.json"))
OUTPUT_FILE = Path(os.environ.get("SYNC_OUTPUT", "journal_ventes.csv"))
OVERLAP = timedelta(minutes=5)

#: Correspondance déclarée par l'entreprise (exemple SYSCOHADA : 411 clients, 701 ventes).
ACCOUNT_CUSTOMERS = os.environ.get("ACCOUNT_CUSTOMERS", "411100")
ACCOUNT_SALES = os.environ.get("ACCOUNT_SALES", "701100")
ACCOUNT_VAT = os.environ.get("ACCOUNT_VAT", "443100")
SYNCED_STATUSES = {"SENT", "PARTIALLY_PAID", "PAID", "OVERDUE"}


def journal_lines(invoice: dict) -> List[List[str]]:
    """Trois lignes équilibrées d'une facture : débit client, crédit ventes HT, crédit TVA."""
    total = Decimal(invoice["total"])
    subtotal = Decimal(invoice.get("subtotal") or invoice["total"])
    tax = Decimal(invoice.get("tax_total") or "0")
    if subtotal + tax != total:
        raise ValueError(f"facture {invoice['number']} : HT + taxes ≠ TTC, à vérifier")
    label = f"{invoice['number']} {invoice.get('customer_name') or ''}".strip()
    rows = [[invoice["issue_date"], "VT", invoice["number"], ACCOUNT_CUSTOMERS, label, str(total), "0"],
            [invoice["issue_date"], "VT", invoice["number"], ACCOUNT_SALES, label, "0", str(subtotal)]]
    if tax:
        rows.append([invoice["issue_date"], "VT", invoice["number"], ACCOUNT_VAT, label, "0", str(tax)])
    return rows


def new_invoices(invoices: Iterable[dict], already_synced: set) -> Iterator[dict]:
    for invoice in invoices:
        if invoice["status"] in SYNCED_STATUSES and invoice["id"] not in already_synced:
            already_synced.add(invoice["id"])
            yield invoice


def main() -> None:
    state = json.loads(STATE_FILE.read_text()) if STATE_FILE.exists() else {"since": None, "synced": []}
    started = datetime.now(timezone.utc)
    params = {"limit": "200"}
    if state["since"]:
        since = datetime.fromisoformat(state["since"].replace("Z", "+00:00")) - OVERLAP
        params["updated_after"] = since.isoformat().replace("+00:00", "Z")
    token = get_token(os.environ["TAURACO_CLIENT_ID"], os.environ["TAURACO_CLIENT_SECRET"],
                      "invoices:read", tenant=os.environ.get("TAURACO_TENANT"))["access_token"]
    synced = set(state["synced"])
    with OUTPUT_FILE.open("a", newline="", encoding="utf-8") as handle:
        writer = csv.writer(handle, delimiter=";")
        for invoice in new_invoices(iterate("/invoices", token, params), synced):
            writer.writerows(journal_lines(invoice))
    state = {"since": started.isoformat().replace("+00:00", "Z"), "synced": sorted(synced)}
    STATE_FILE.write_text(json.dumps(state))


if __name__ == "__main__":
    main()

Recevoir un webhook bancaire et relire le dossier :

python/recepteur_webhook_banque.pyTélécharger le fichier
"""Recevoir un webhook bancaire (événements ``financing.*``) et relire le dossier.

Receive a bank webhook (``financing.*`` events) and fetch the request.

L'événement est mince (identifiants, statut, horodatages ; aucun montant, aucun taux) :
on vérifie la signature, on dédoublonne par ``webhook-id``, puis on relit le dossier
par ``data.links.self`` (``…/public/v1/bank/requests/{id}``) avec un jeton du client
``bank`` (scopes ``bank:*``). La relecture applique les mêmes contrôles que le portail
banque : dossier adressé à l'institution, destinataire n'ayant pas décliné.

Le jeton n'est envoyé qu'à l'API Tauraco : un lien qui ne commence pas exactement par
``TAURACO_API_BASE + "/bank/requests/"`` est ignoré (un événement forgé ne doit pas
faire partir le jeton vers un autre hôte). Le secret du webhook est obligatoire.

Usage : TAURACO_WEBHOOK_SECRET=… TAURACO_CLIENT_ID=… TAURACO_CLIENT_SECRET=… \\
        python3 recepteur_webhook_banque.py
"""
from __future__ import annotations

import json
import os
import re
from http.server import BaseHTTPRequestHandler, HTTPServer
from typing import Optional

from client_credentials import API_BASE, TauracoError, get_token, http_transport
from verifier_webhook import AlreadySeen, secret_key, verify_signature

FINANCING_EVENTS = {"financing.requested", "financing.status_changed", "financing.funded", "contract.signed"}
#: Seuls liens suivis : l'API Tauraco configurée, ressource ``/bank/requests/{id}``.
REQUEST_PREFIX = API_BASE.rstrip("/") + "/bank/requests/"
_REQUEST_ID = re.compile(r"[A-Za-z0-9_-]{1,128}")


def request_link(event: dict) -> Optional[str]:
    """Lien de relecture du dossier, ou ``None`` (événement hors financement, lien absent)."""
    if event.get("type") not in FINANCING_EVENTS:
        return None
    links = (event.get("data") or {}).get("links") or {}
    link = links.get("self")
    if not isinstance(link, str) or not link.startswith(REQUEST_PREFIX):
        return None  # autre hôte, autre chemin : jamais de jeton envoyé
    return link if _REQUEST_ID.fullmatch(link[len(REQUEST_PREFIX):]) else None


def fetch_request(link: str) -> dict:
    if request_link({"type": "financing.requested", "data": {"links": {"self": link}}}) != link:
        raise ValueError("lien de relecture hors de l'API Tauraco : refusé")
    token = get_token(os.environ["TAURACO_CLIENT_ID"], os.environ["TAURACO_CLIENT_SECRET"],
                      "bank:requests:read")["access_token"]
    status, _headers, raw = http_transport("GET", link, {"Authorization": f"Bearer {token}",
                                                          "Accept": "application/json"}, None)
    payload = json.loads(raw or b"{}")
    if status != 200:
        raise TauracoError(status, payload)
    return payload


def make_bank_handler(secret: str) -> type[BaseHTTPRequestHandler]:
    """Récepteur lié à ``secret`` ; ``ValueError`` si le secret est vide ou illisible."""
    if secret_key(secret) is None:
        raise ValueError("secret de webhook vide ou illisible (whsec_ + base64 attendu)")
    seen = AlreadySeen()

    class BankReceiver(BaseHTTPRequestHandler):
        def do_POST(self) -> None:  # noqa: N802
            body = self.rfile.read(int(self.headers.get("Content-Length") or 0))
            headers = dict(self.headers.items())
            if not verify_signature(secret, headers, body):
                self.send_response(401)
                self.end_headers()
                return
            self.send_response(204)
            self.end_headers()
            event = json.loads(body)
            if not seen.first_time(event["id"]):
                return  # reprise ou rejeu : déjà traité
            link = request_link(event)
            if link:
                dossier = fetch_request(link)
                print(event["type"], event["data"].get("request_id"), dossier.get("status"))

        def log_message(self, *_args) -> None:
            return

    return BankReceiver


if __name__ == "__main__":
    handler = make_bank_handler(os.environ["TAURACO_WEBHOOK_SECRET"])  # aucune valeur par défaut
    HTTPServer(("127.0.0.1", 8788), handler).serve_forever()

TypeScript (Node.js 18 et plus)

typescript/clientCredentials.tsTélécharger le fichier
/**
 * Jeton client_credentials puis lecture paginée des factures — Node.js 18+ (fetch natif).
 * client_credentials token, then paginated invoice reads — Node.js 18+ (native fetch).
 */
const API_BASE = process.env.TAURACO_API_BASE ?? "https://api.tauraco.io/public/v1";
const TOKEN_URL = process.env.TAURACO_TOKEN_URL ?? "https://api.tauraco.io/oauth/token";

interface Page<T> { data: T[]; next_cursor: string | null; has_more: boolean }
interface Invoice { id: string; number: string; customer_name: string | null; total: string; currency: string; status: string }

export async function getToken(clientId: string, clientSecret: string, scope: string, tenant?: string): Promise<string> {
  const form = new URLSearchParams({ grant_type: "client_credentials", scope, resource: API_BASE });
  if (tenant) form.set("tenant", tenant);
  const response = await fetch(TOKEN_URL, {
    method: "POST",
    headers: {
      Authorization: "Basic " + Buffer.from(`${clientId}:${clientSecret}`).toString("base64"),
      "Content-Type": "application/x-www-form-urlencoded",
    },
    body: form,
  });
  const payload = await response.json();
  if (!response.ok) throw new Error(`${response.status} ${payload.error ?? payload.code}`);
  return payload.access_token as string;
}

export async function* iterate<T>(path: string, token: string, params: Record<string, string> = {}): AsyncGenerator<T, void, undefined> {
  const query = new URLSearchParams(params);
  for (;;) {
    const response = await fetch(`${API_BASE}${path}?${query}`, {
      headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
    });
    if (response.status === 429) {
      await new Promise((resolve) => setTimeout(resolve, 1000 * Number(response.headers.get("Retry-After") ?? 1)));
      continue;
    }
    const page = (await response.json()) as Page<T> & { code?: string; request_id?: string };
    if (!response.ok) throw new Error(`${response.status} ${page.code} (request_id ${page.request_id})`);
    yield* page.data;
    if (!page.has_more || !page.next_cursor) return;
    query.set("cursor", page.next_cursor);
  }
}

async function main(): Promise<void> {
  const token = await getToken(process.env.TAURACO_CLIENT_ID!, process.env.TAURACO_CLIENT_SECRET!, "invoices:read", process.env.TAURACO_TENANT);
  for await (const invoice of iterate<Invoice>("/invoices", token, { status: "PAID", limit: "100" })) {
    // Montants : chaînes décimales (unités majeures), à ne pas convertir en number.
    console.log(invoice.number, invoice.customer_name, invoice.total, invoice.currency);
  }
}

if (process.env.TAURACO_CLIENT_ID) {
  main().catch((error) => { console.error(error); process.exit(1); });
}
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 (17 et plus)

java/ClientCredentials.javaTélécharger le fichier
// Jeton client_credentials puis lecture d'une page de factures — Java 17+ (java.net.http).
// client_credentials token, then one page of invoices — Java 17+ (java.net.http).
// Pour un vrai projet, analyser le JSON avec Jackson ou Gson ; ici on affiche la réponse brute.
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public final class ClientCredentials {
    static final String API_BASE = "https://api.tauraco.io/public/v1";
    static final String TOKEN_URL = "https://api.tauraco.io/oauth/token";
    private static final Pattern ACCESS_TOKEN = Pattern.compile("\"access_token\"\\s*:\\s*\"([^\"]+)\"");

    private ClientCredentials() {}

    static String form(String... pairs) {
        StringBuilder out = new StringBuilder();
        for (int i = 0; i < pairs.length; i += 2) {
            if (out.length() > 0) out.append('&');
            out.append(URLEncoder.encode(pairs[i], StandardCharsets.UTF_8)).append('=')
               .append(URLEncoder.encode(pairs[i + 1], StandardCharsets.UTF_8));
        }
        return out.toString();
    }

    static String token(HttpClient http, String clientId, String clientSecret, String scope) throws Exception {
        String basic = Base64.getEncoder().encodeToString((clientId + ":" + clientSecret).getBytes(StandardCharsets.UTF_8));
        HttpRequest request = HttpRequest.newBuilder(URI.create(TOKEN_URL))
            .header("Authorization", "Basic " + basic)
            .header("Content-Type", "application/x-www-form-urlencoded")
            .POST(HttpRequest.BodyPublishers.ofString(form(
                "grant_type", "client_credentials", "scope", scope, "resource", API_BASE)))
            .build();
        HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
        Matcher matcher = ACCESS_TOKEN.matcher(response.body());
        if (response.statusCode() != 200 || !matcher.find()) {
            throw new IllegalStateException("jeton refusé : HTTP " + response.statusCode());
        }
        return matcher.group(1);
    }

    public static void main(String[] args) throws Exception {
        HttpClient http = HttpClient.newHttpClient();
        String token = token(http, System.getenv("TAURACO_CLIENT_ID"), System.getenv("TAURACO_CLIENT_SECRET"), "invoices:read");
        HttpRequest list = HttpRequest.newBuilder(URI.create(API_BASE + "/invoices?status=PAID&limit=100"))
            .header("Authorization", "Bearer " + token)
            .header("Accept", "application/json")
            .GET().build();
        HttpResponse<String> page = http.send(list, HttpResponse.BodyHandlers.ofString());
        // Corps : {"data":[{"number":…,"customer_name":…,"total":"1180000","currency":"XOF"}],"next_cursor":…,"has_more":…}
        System.out.println(page.statusCode() + " " + page.body());
    }
}
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());
    }
}

C# (.NET 6 et plus)

csharp/ClientCredentials.csTélécharger le fichier
// Jeton client_credentials puis lecture paginée des factures — .NET 6+ (HttpClient, System.Text.Json).
// client_credentials token, then paginated invoice reads — .NET 6+ (HttpClient, System.Text.Json).
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;

public static class ClientCredentials
{
    private const string ApiBase = "https://api.tauraco.io/public/v1";
    private const string TokenUrl = "https://api.tauraco.io/oauth/token";

    public static async Task<string> GetTokenAsync(HttpClient http, string clientId, string clientSecret, string scope)
    {
        using var request = new HttpRequestMessage(HttpMethod.Post, TokenUrl)
        {
            Content = new FormUrlEncodedContent(new Dictionary<string, string>
            {
                ["grant_type"] = "client_credentials",
                ["scope"] = scope,
                ["resource"] = ApiBase,
            }),
        };
        request.Headers.Authorization = new AuthenticationHeaderValue(
            "Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes($"{clientId}:{clientSecret}")));
        using var response = await http.SendAsync(request);
        using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
        if (!response.IsSuccessStatusCode)
        {
            throw new InvalidOperationException($"jeton refusé : {(int)response.StatusCode} {payload.RootElement.GetProperty("error")}");
        }
        return payload.RootElement.GetProperty("access_token").GetString()!;
    }

    public static async Task Main()
    {
        using var http = new HttpClient();
        var token = await GetTokenAsync(http, Environment.GetEnvironmentVariable("TAURACO_CLIENT_ID")!,
            Environment.GetEnvironmentVariable("TAURACO_CLIENT_SECRET")!, "invoices:read");
        http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
        string? cursor = null;
        do
        {
            var url = $"{ApiBase}/invoices?status=PAID&limit=100" + (cursor is null ? "" : $"&cursor={Uri.EscapeDataString(cursor)}");
            using var page = JsonDocument.Parse(await http.GetStringAsync(url));
            foreach (var invoice in page.RootElement.GetProperty("data").EnumerateArray())
            {
                // Montants : chaînes décimales en unités majeures, à lire en decimal, jamais en double.
                Console.WriteLine($"{invoice.GetProperty("number")} {invoice.GetProperty("customer_name")} {invoice.GetProperty("total")} {invoice.GetProperty("currency")}");
            }
            cursor = page.RootElement.GetProperty("has_more").GetBoolean()
                ? page.RootElement.GetProperty("next_cursor").GetString()
                : null;
        } while (cursor is not null);
    }
}
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;
    }
}

Collection Postman