aethexDocs

Webhooks

Soyez notifié quand les appels se terminent, quand les enregistrements sont prêts et quand les transcriptions sont complètes.

Aperçu

Les webhooks permettent à Aethex de pousser des événements vers votre serveur en temps réel. Enregistrez une URL sur votre agent et Aethex publiera un payload JSON signé à chaque fois qu'un événement se déclenche. Aucun polling nécessaire.

Types d'événements

Chaque webhook est une requête HTTP POST avec un corps JSON plat. Le type d'événement se trouve dans l'en-tête `X-Aethex-Event` — pas dans le corps — et chaque payload inclut event_id ; les événements d'appel, d'enregistrement, de base de connaissances, de transcription et de lot TTS incluent également timestamp (secondes Unix). Les événements de compte (usage.threshold.crossed) utilisent fired_at (ISO 8601) au lieu de timestamp.Événements d'appel — envoyés au webhook_url configuré sur l'agent :- `call.ended` — déclenché à la fin d'un appel (tous les transports, y compris WebRTC). Champs : call_id, conversation_id, agent_id, status (l'une des valeurs completed, failed, no-answer, busy ou canceled), direction, from_number, to_number, duration_seconds, cost_cents, started_at, ended_at, transcript, transcript_text, batch_id et batch_recipient_index (tous deux null sauf si l'appel provient d'un lot), metadata (vos propres métadonnées, toujours un objet), et les indicateurs de délestage metadata_truncated et transcript_truncated (toujours des booléens) — positionnés quand le rapport était trop volumineux pour être envoyé entier, auquel cas récupérez le reste via GET /calls/{id}.
- `recording.ready` — déclenché une fois que l'enregistrement de l'appel a fini d'être téléversé et est téléchargeable. Les enregistrements sont téléversés de façon asynchrone et cet événement est livré indépendamment de call.ended ; il suit généralement call.ended mais l'ordre n'est pas garanti, il peut donc arriver avant. Corrélez les deux via call_id et ordonnez les événements selon leur timestamp. Champs : call_id, conversation_id, audio_url (une URL présignée à durée limitée), size_bytes, recording_region.Événements de base de connaissances — envoyés au webhook_url configuré sur l'agent, signés exactement comme call.ended :- `knowledge.document.created` — déclenché après l'ajout d'un document à la base de connaissances de l'agent via POST /agents/{id}/knowledge-base ou /knowledge-base/by-upload. Champs : agent_id, document_id, filename, status (completed ou failed), content_type, size_bytes. Le texte du document n'est pas dans le payload ; lisez-le via les endpoints de la base de connaissances.
- `knowledge.document.deleted` — déclenché après la suppression d'un document avec DELETE /agents/{id}/knowledge-base/{doc_id}. Champs : agent_id, document_id, filename.Les pages ajoutées par l'exploration d'un lien de référence ne déclenchent pas ces événements. La livraison est au mieux-effort : un endpoint en échec est réessayé pendant quelques minutes puis abandonné ; réconciliez avec GET /agents/{id}/knowledge-base si vous ne devez manquer aucun document.Événements de tâche — envoyés au webhook_url que vous passez dans la requête qui démarre la tâche :- `transcription.completed` — déclenché lorsqu'une transcription asynchrone (POST /transcribe/async) se termine. Champs : id, status, text, language, duration_seconds.
- `tts.batch.completed` — déclenché lorsqu'une tâche TTS par lot termine son traitement (status completed, même si certains éléments ont échoué — voir les compteurs completed/failed). Champs : batch_id, status, total, completed, failed.
- `tts.batch.failed` — déclenché lorsqu'un lot est rejeté avant toute synthèse (par ex. blocage de facturation, ou voix demandée devenue indisponible). Mêmes champs ; status vaut failed avec completed à 0.Événements de compte — envoyés au event_callback_url du déclencheur d'usage :- `usage.threshold.crossed` — déclenché lorsqu'un seuil de déclencheur d'usage est franchi. Champs : event_type, trigger_id, resource_type, threshold_type, threshold_value, observed_value, period, period_started_at, period_ends_at, fired_at.Événements WhatsApp — envoyés au webhook_url de l'agent associé au numéro WhatsApp, signés exactement comme call.ended. Contrairement aux événements ci-dessus, ceux-ci répètent aussi le nom de l'événement dans le corps, dans event, aux côtés de phone_number_id :- `whatsapp.message.received` — émis lorsqu'un client écrit alors qu'une personne a la main sur la conversation : l'agent n'a donc pas répondu et quelqu'un doit le faire. Champs : event, phone_number_id, thread_id, customer, customer_name, text, message_id, assigned_to, reason.
- `whatsapp.handoff.requested` — émis lorsqu'un client demande à parler à quelqu'un et que la conversation passe en mode humain. Mêmes champs ; reason indique pourquoi.
- `whatsapp.reply.undelivered` — émis lorsque Meta refuse d'emblée une réponse de l'agent, ou ne l'a toujours pas acceptée au dernier réessai : le client ne l'a donc jamais reçue. Champs : event, event_id, phone_number_id, thread_id, customer, reply, in_reply_to (le message du client auquel elle répondait), refusal, reason, carrier_detail, folded, folded_since. refusal vaut recipient_cannot_receive, opted_out, sender_cannot_send, credential_rejected, throttled, carrier_error ou unknown ; reason dit en une phrase ce que cela signifie ; carrier_detail est l'erreur de Meta telle quelle. folded est le nombre de refus du même numéro pour le même refusal passés sous silence entre l'événement précédent et celui-ci, 0 sur un événement ordinaire. Quand un numéro est refusé à chaque réponse (sender_cannot_send, credential_rejected, unknown), ou l'est encore au dernier réessai (throttled, carrier_error), chaque heure d'horloge vous apporte normalement deux événements pour ce numéro et cette cause, au lieu d'un par message entrant : le premier refus de l'heure immédiatement, puis en fin d'heure le dernier, avec le compte de ceux d'entre les deux dans folded et le début de cette heure dans folded_since (null sur un événement ordinaire). Les deux peuvent se croiser au changement d'heure, c'est à cela que sert folded_since. Les refus qui concernent un client précis (recipient_cannot_receive, opted_out) ne sont jamais regroupés. Un échec que Meta ne signale que plus tard arrive plutôt en whatsapp.message.status à failed.
- `whatsapp.message.status` — un accusé de livraison pour un message que vous avez envoyé : sent, delivered, read ou failed. Champs : event, phone_number_id, message_id, status, recipient, timestamp, conversation, pricing, errors. pricing correspond à ce que WhatsApp a facturé pour la conversation ; errors n'est renseigné qu'en cas d'échec.
- `whatsapp.campaign.paused` — émis lorsque le worker arrête une campagne de lui-même, aujourd'hui parce que le compte n'a plus de crédit. Envoyé au webhook_url donné à la création de la campagne, pas à un agent : une campagne n'en nomme aucun. Champs : event, event_id, campaign_id, name, reason (une classe bornée, aujourd'hui billing_hard_block), error (la phrase que la campagne affiche désormais). Une pause que vous avez demandée vous-même n'est pas signalée. Redémarrez la campagne une fois la cause corrigée ; les destinataires en attente sont toujours là.Événements SMS — envoyés au webhook_url de l'agent lié au numéro, signés exactement comme call.ended, avec le nom de l'événement dans le corps, dans event, aux côtés du numéro de la ligne, phone_number :- `sms.handoff.requested` — émis lorsqu'une conversation SMS passe en mode humain : le client a demandé à parler à quelqu'un, ou l'agent a répondu si souvent en peu de temps que l'autre bout est manifestement automatique. Champs : event, event_id, phone_number, thread_id, customer, text, message_id, assigned_to, reason.
- `sms.reply.undelivered` — émis lorsque l'opérateur refuse une réponse de l'agent. Mêmes champs et même sens que whatsapp.reply.undelivered, avec phone_number à la place de phone_number_id. Un opérateur qui limite le débit ou tombe brièvement en panne n'a rien refusé : la réponse est réessayée, et l'événement n'est envoyé que si le dernier réessai est encore refusé. refusal vaut alors throttled ou carrier_error, ou credential_rejected si l'opérateur rejetait toujours les identifiants du compte.
- `sms.campaign.paused` — émis lorsque le worker arrête une campagne de lui-même. Envoyé au webhook_url donné à la création de la campagne, pas à un agent. Champs : event, event_id, campaign_id, name, reason, error. reason vaut number_inactive, number_not_receiving, sender_unresolvable, account_refused (l'opérateur a refusé le compte expéditeur), identical_refusals (cinq d'affilée), repeated_error ou billing_hard_block ; error est la phrase que la campagne affiche désormais. Une pause que vous avez demandée vous-même n'est pas signalée. Corrigez la cause et redémarrez la campagne ; les destinataires en attente sont toujours là.Ces événements sont au mieux-effort : la boîte de réception a déjà enregistré le fait, donc un tenant sans webhook configuré n'est pas une erreur, et un endpoint qui échoue durablement est réessayé puis abandonné.

ORDREdeux événements, un appell'appelant raccrochecall.endedémis ici, à chaque foisrecording.readyémis n'importe où ici — c'est le téléversement qui décideLes deux portent le même call_id. Triez par timestamp ; ne présumez jamais lequel arrive en premier.

Signature et vérification

Chaque requête webhook contient un en-tête X-Aethex-Signature au format t='<'unix_timestamp'>',v1='<'hmac_hex'>'. Le HMAC est SHA-256(secret, "'<'t'>'.'<'raw_body'>'"). Vérifiez-le de votre côté avant de traiter le payload. L'exemple ci-dessous présente un vérificateur Node.js.

Vérificateur de signature webhook (Node.js)
javascript
import crypto from "node:crypto"

function verifyAethexWebhook(rawBody, signatureHeader, secret) {
  // Header looks like: "t=1700000000,v1=abc123..."
  const parts = Object.fromEntries(
    signatureHeader.split(",").map((p) => p.split("="))
  )
  const t = parseInt(parts.t, 10)
  const sig = parts.v1

  if (!t || !sig) return false

  // Reject anything older than 5 minutes to defend against replay.
  if (Math.abs(Math.floor(Date.now() / 1000) - t) > 300) return false

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${t}.${rawBody}`)
    .digest("hex")

  return crypto.timingSafeEqual(
    Buffer.from(sig, "hex"),
    Buffer.from(expected, "hex")
  )
}

Livraison et nouvelles tentatives

Aethex effectue d'abord jusqu'à 3 tentatives courtes, avec un délai d'attente de 10 secondes par tentative et un backoff exponentiel aléatoire. Une livraison call.ended en échec reste ensuite durable dans Postgres et est retentée sur une période d'environ 3 jours (jusqu'à 20 cycles, avec un délai plafonné à 6 heures). L'enregistrement d'appel passe par pending, delivering et failed, puis se termine en delivered, unsafe (la destination a échoué à la politique de sécurité réseau), skipped (le point de terminaison a été supprimé) ou exhausted. Les livraisons de transcription et de lot TTS conservent leur comportement existant de tentatives courtes.La livraison est au moins une fois. Chaque rejeu d'un même événement call.ended porte le même event_id, y compris après la récupération d'une panne de worker ou de Redis. Répondez avec 200 le plus rapidement possible (avant tout traitement lourd), traitez le payload de manière asynchrone et renvoyez 2xx même pour les doublons. Stockez event_id et rendez votre gestionnaire idempotent.

RELANCESchaque trait est une tentativeDELIVEREDun 2xx, à toute tentativele premier 2xx l'emporte3 tentatives rapidesdélai de 10 s · attente aléatoirepuis 20 cycles durables1 minute, doublée à chaque fois, plafonnée à 6 heures≈ 3 joursOÙ CELA S'ARRÊTEexhaustedles 20 cycles épuisésunsafeéchec du contrôle réseauskippedendpoint suppriméfailedéchec, relance à venir

Liste de contrôle de sécurité

Avant la mise en production :1. Vérifiez la signature sur chaque requête — rejetez tout ce qui ne correspond pas.
2. Vérifiez l'horodatage (champ t) — rejetez les événements de plus de 5 minutes pour vous défendre contre les attaques par rejeu.
3. Répondez rapidement — renvoyez 200 avant tout traitement pour éviter les nouvelles tentatives parasites.
4. Dédupliquez — stockez event_id et ignorez les événements déjà traités.

Rotation du secret de signature

Faites pivoter le secret de signature webhook à tout moment via POST /usage/webhook-secret/rotate. Le nouveau secret prend effet immédiatement ; mettez à jour votre logique de vérification avant de faire pivoter en production pour éviter une interruption.