Aller au contenu

Channel Webhook

L'adapter bidirectionnel channel/webhook reçoit un JSON HTTP générique, vérifie son authenticité sur les octets bruts, le transforme en ChannelIncomingMessage, puis passe par le ChannelDispatcher. Il n'impose ni fournisseur, ni SDK, ni logique métier.

Intention

Utiliser cet adapter pour raccorder un outil interne, une automatisation ou un prototype qui sait émettre un webhook HTTP. Préférer un adapter fournisseur dédié lorsqu'un protocole impose ses propres challenges, événements ou règles d'accusé de réception.

Le webhook transporte un message. Il ne devient pas pour autant un historique de chat : consulter la distinction Channel n'est pas Chat.

Position Hexagonale

Text Only
POST JSON signé
  -> WebhookChannelAdapter
       -> contrôle taille, media type, HMAC et event ID
       -> normalisation provider-neutral
       -> ChannelDispatcher
            -> ChannelEventStore
            -> ChannelIdentityResolver
            -> ChannelMessageHandler
            -> réponse inline ou WebhookCallbackSender
  -> réponse HTTP explicite

FastAPI reste dans l'adapter. Le handler applicatif reçoit uniquement les modèles communs channel, et domain/ ne dépend ni de FastAPI ni de httpx.

Quickstart

Installer L'adapter

Bash
1
2
3
4
uvx --from arclith-cli arclith-cli add-adapter \
  --capability channel \
  --adapter webhook \
  --yes

La commande :

  • crée config/adapters/bidirectional/webhook.yaml ;
  • ajoute l'extra channel à la dépendance Arclith du projet ;
  • crée dans config/secrets.yaml le mapping adapters.channel.webhook.secret: ARCLITH_WEBHOOK_SECRET ;
  • ne place aucune valeur de secret dans un fichier versionné.

Le YAML généré utilise le mode synchrone :

YAML
enabled: true
path: /channels/webhook
secret: null
signature_header: X-Arclith-Signature
timestamp_header: X-Arclith-Timestamp
signature_tolerance_seconds: 300
idempotency_header: X-Arclith-Event-Id
event_ttl_seconds: 86400
max_payload_bytes: 1048576
metadata_allowlist: []
response_mode: sync
callback_url: null
callback_allowed_host: null
callback_timeout_seconds: 5.0

Pour un endpoint exposé, fournir un secret aléatoire d'au moins 32 octets via le resolver secrets. secret: null désactive volontairement HMAC et convient uniquement à un test local ou à une autre frontière déjà authentifiée.

Brancher Le Router

Ce POC utilise les implémentations mémoire afin de rester exécutable. En production, remplacer le resolver et surtout le store d'événements par des adapters partagés entre replicas.

Python
from fastapi import FastAPI

from arclith import (
    ChannelHandlerResult,
    ChannelIdentity,
    ChannelMessageHandler,
    ChannelOutgoingMessage,
    MemoryChannel,
    MemoryChannelIdentityResolver,
    ResolvedChannelIdentity,
    WebhookChannelSettings,
    build_webhook_router,
)


class EchoHandler(ChannelMessageHandler):
    async def handle(self, message, identity):
        return ChannelHandlerResult(
            responses=(
                ChannelOutgoingMessage(
                    channel="webhook",
                    conversation_id=message.conversation_id,
                    thread_id=message.thread_id,
                    text=f"Reçu pour {identity.user_id}: {message.text}",
                ),
            )
        )


settings = WebhookChannelSettings(
    secret="replace-with-a-secret-of-at-least-32-bytes",
    metadata_allowlist=("trace_id",),
)
resolver = MemoryChannelIdentityResolver()
resolver.register(
    ChannelIdentity(provider="webhook", external_user_id="external-42"),
    ResolvedChannelIdentity(user_id="user-42", tenant_id="tenant-demo"),
)
event_store = MemoryChannel()

app = FastAPI()
app.include_router(
    build_webhook_router(settings, EchoHandler(), resolver, event_store)
)

Dans une application Arclith, utiliser normalement les settings déjà chargés :

Python
1
2
3
4
5
settings = arclith.config.adapters.channel.webhook
if settings is not None and settings.enabled:
    fastapi_app.include_router(
        build_webhook_router(settings, handler, resolver, event_store)
    )

Envoyer Un Événement Signé

La signature canonique est :

Text Only
sha256=HMAC-SHA256(secret, "<timestamp>.<corps-brut>")

Le corps signé doit être exactement celui envoyé, octet pour octet. Cet exemple évite de mettre le secret dans la ligne de commande :

Bash
export ARCLITH_WEBHOOK_SECRET='replace-with-a-random-secret-of-at-least-32-bytes'
WEBHOOK_BODY='{"sender_id":"external-42","conversation_id":"conversation-1","text":"bonjour","metadata":{"trace_id":"demo-1","ignored":"drop-me"}}'
WEBHOOK_TIMESTAMP=$(date +%s)
export WEBHOOK_BODY WEBHOOK_TIMESTAMP

WEBHOOK_SIGNATURE=$(python - <<'PY'
import hashlib
import hmac
import os

secret = os.environ["ARCLITH_WEBHOOK_SECRET"].encode()
timestamp = os.environ["WEBHOOK_TIMESTAMP"]
body = os.environ["WEBHOOK_BODY"].encode()
digest = hmac.new(secret, timestamp.encode() + b"." + body, hashlib.sha256)
print("sha256=" + digest.hexdigest())
PY
)

curl -fsS http://127.0.0.1:8000/channels/webhook \
  -H 'Content-Type: application/json' \
  -H "X-Arclith-Event-Id: event-demo-1" \
  -H "X-Arclith-Timestamp: $WEBHOOK_TIMESTAMP" \
  -H "X-Arclith-Signature: $WEBHOOK_SIGNATURE" \
  --data-binary "$WEBHOOK_BODY" | python -m json.tool

Une réponse synchrone réussie contient les messages provider-neutral à rendre au client :

JSON
{
  "status": "completed",
  "responses": [
    {
      "message_id": "0199...",
      "channel": "webhook",
      "conversation_id": "conversation-1",
      "thread_id": null,
      "recipient_id": null,
      "text": "Reçu pour user-42: bonjour",
      "attachments": [],
      "reply_to": null,
      "metadata": {}
    }
  ],
  "receipts": [
    {
      "message_id": "0199...",
      "provider_message_id": null,
      "status": "accepted",
      "timestamp": "2026-09-04T12:00:00Z",
      "metadata": {}
    }
  ]
}

Formation

Le quickstart Channel explique d'abord les quatre ports avec le fake mémoire. Cette page ajoute ensuite le geste HTTP, HMAC et les contraintes de production.

Payload Entrant

Le JSON est strict : tout champ inconnu est rejeté.

Champ Requis Description
sender_id oui identifiant externe, résolu ensuite par ChannelIdentityResolver
conversation_id oui conversation externe stable
text conditionnel texte non vide ; requis si aucune pièce jointe
sender_display_name non libellé externe non fiable
external_tenant_id non tenant affirmé par l'émetteur, à mapper explicitement
external_workspace_id non workspace affirmé par l'émetteur
thread_id non thread externe, sans persistance implicite
attachments non métadonnées ChannelAttachment, jamais des octets embarqués
metadata non JSON fini ; seules les clés de metadata_allowlist sont transmises

L'identifiant d'événement n'est pas accepté dans le JSON. Il vient uniquement du header idempotency_header, afin de conserver une source stable et configurée côté serveur.

Modes De Réponse

Mode Résultat handler valide HTTP Livraison
sync completed 200 les réponses sont retournées dans le corps HTTP
accepted accepted 202 le handler confirme avoir persisté ou publié durablement la suite
callback completed 200 les réponses sont POSTées immédiatement vers l'URL serveur
callback accepted 202 le handler a pris le travail durablement et livrera plus tard via WebhookCallbackSender

Un résultat accepted ne déclenche aucune tâche FastAPI locale. Le handler doit avoir obtenu une garantie durable avant de le retourner. Une combinaison incompatible, par exemple accepted en mode sync, retourne 500 response_mode_error.

Pour configurer un callback depuis la CLI :

Bash
1
2
3
4
5
6
7
arclith-cli add-adapter \
  --capability channel \
  --adapter webhook \
  --adapter-param response_mode=callback \
  --adapter-param callback_url=https://hooks.example.test/arclith \
  --adapter-param callback_allowed_host=hooks.example.test \
  --yes

Arclith.channel_sender("webhook") construit un WebhookCallbackSender uniquement lorsque ce mode est complètement configuré. Le même sender peut être injecté dans un worker durable. Sans client injecté, sa construction vérifie immédiatement la présence de httpx : une installation sans extra channel échoue donc au montage du router ou du sender, avant la première requête.

Idempotence Et Échecs Sortants

Le store réserve atomiquement ("webhook", event_id) avant la résolution d'identité et le handler. Un doublon retourne 200 avec status: duplicate sans réexécuter l'application.

Une erreur de résolution ou de handler libère le claim. Après la réussite du handler, le claim est conservé même si le callback sortant échoue : rejouer le handler pourrait dupliquer ses effets métier. Un callback critique doit donc être écrit dans un outbox ou une file durable par le handler, puis livré et retenté indépendamment. Un retry identique de l'émetteur ne rejouera pas le callback perdu.

Le fake MemoryChannel fournit un claim atomique dans un seul processus, mais perd son état au redémarrage et ne coordonne pas plusieurs replicas.

Statuts HTTP

Le router déclare explicitement ses réponses dans OpenAPI :

HTTP Code JSON Cause
200 traitement terminé ou doublon
202 prise en charge durable confirmée par le handler
400 missing_event_id header d'événement absent ou vide
401 invalid_signature signature absente, invalide ou périmée
403 identity_not_resolved, channel_unauthorized identité non mappée ou refusée
413 payload_too_large corps au-delà de max_payload_bytes
415 unsupported_media_type media type non JSON
422 invalid_payload, unsupported_event schéma ou événement non supporté
429 callback_rate_limited callback limité ; Retry-After numérique est propagé
500 response_mode_error contrat handler/mode incohérent
502 callback_rejected callback HTTP non-2xx et non-429/5xx
503 callback_unavailable timeout, transport ou erreur 5xx du callback

Les réponses d'erreur utilisent des détails stables. Elles ne reprennent ni corps fournisseur, ni URL, ni signature, ni secret.

Sécurité

  • La taille est bornée pendant la lecture du stream, puis contrôlée à nouveau avant parsing.
  • Si HMAC est activé, le corps brut est authentifié avant le parsing JSON.
  • Le timestamp signé doit rester dans signature_tolerance_seconds et la comparaison utilise hmac.compare_digest.
  • L'URL de callback ne vient jamais du payload. Elle doit être HTTPS, sans credentials, query ni fragment, et son hostname doit correspondre exactement à callback_allowed_host.
  • Les IP littérales privées, loopback ou non globales et localhost sont refusées. Les redirects HTTP ne sont jamais suivies.
  • La validation applicative ne remplace pas une politique egress : en production, contrôler également DNS, réseau sortant et résolution privée pour réduire les risques de rebinding ou de compromission de configuration.
  • Les pièces jointes ne sont pas téléchargées par l'adapter.
  • sender_id, tenant et workspace restent des affirmations externes jusqu'au mapping explicite du resolver.

Production

Avant exposition publique :

  1. activer HMAC avec un secret distinct par intégration et le faire tourner via la capability secrets ;
  2. utiliser un ChannelEventStore atomique partagé et dimensionner son TTL ;
  3. définir une metadata_allowlist minimale ;
  4. injecter un resolver d'identité tenant-aware ;
  5. préférer accepted avec file/outbox durable pour les traitements longs ;
  6. borner timeout, retries et egress du callback ;
  7. journaliser uniquement event ID, statut, latence et corrélation technique.

Validation

Bash
1
2
3
4
5
6
7
8
9
uv run pytest -q \
  tests/units/adapters/bidirectional/webhook \
  tests/units/infrastructure/test_channel_settings.py \
  tests/units/infrastructure/test_channel_factory.py

cd cli
uv run --frozen pytest -q \
  tests/test_capabilities.py \
  tests/test_add_adapter.py

Vérifier ensuite make quality, make precommit, les deux lockfiles et make docs.

Troubleshooting

Symptôme Diagnostic Correction
401 invalid_signature timestamp hors fenêtre ou corps modifié après signature signer les octets transmis avec le même timestamp
400 missing_event_id header configuré absent envoyer un ID stable, identique sur les retries
403 identity_not_resolved identité externe inconnue créer le mapping sans utiliser directement sender_id comme user métier
duplicate le couple provider/event ID est déjà claim ne pas générer un nouvel ID pour le même événement ; inspecter le store
500 response_mode_error le handler n'honore pas le mode aligner completed/accepted et la garantie durable
502 ou 503 callback endpoint rejeté, indisponible ou expiré consulter la télémétrie technique et utiliser une reprise outbound durable

Projet

Dans un service consommateur, garder le handler dans application/, le resolver et le store dans les ports/adapters appropriés, et inclure le router depuis le bootstrap FastAPI. Ne placer aucune logique métier, URL de callback issue de l'utilisateur ou appel HTTP dans domain/.