Webhooks
Définissez une URL de webhook et Sendveo y relaie chaque message entrant réel en temps réel, pour que vos systèmes réagissent sans interrogation continue.
Configurer votre webhook
Définissez ou remplacez votre point de terminaison. La réponse inclut un secret partagé, affiché à la création du webhook ou lorsque vous le renouvelez. L'URL doit être joignable publiquement via HTTPS.
curl -X PUT https://api.sendveo.com/v1/webhook \
-H "X-API-KEY: sv_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{ "url": "https://hooks.yourapp.com/sendveo/inbound" }'{
"webhook": {
"url": "https://hooks.yourapp.com/sendveo/inbound",
"active": true,
"secret": "whsec_2f8b41c9a7e04d6fb0c3e1a95d72"
}
}La charge utile du relais
Chaque message entrant est livré sous forme de POST JSON vers votre URL :
{
"type": "message.received",
"message": {
"id": "msg_7Qk2",
"channelId": "chn_sales01",
"chatId": "chat_aa11",
"from": "+212670111222",
"senderName": "Khadija Alaoui",
"text": "Hi, is the order still available?",
"timestamp": "2026-09-09T14:02:11Z"
}
}Répondez à un message en l'envoyant vers son chatId : POST /v1/messages avec { channelId, to: chatId, text }.
Catalogue des événements
Seize types d'événements sont envoyés en POST à votre URL, tous avec le secret partagé dans l'en-tête x-sendveo-secret. Chaque événement porte occurredAt (le moment où il s'est produit, pas celui où nous l'avons relayé). Les huit événements de message transportent le message entier sous message, dans la forme même qu'utilise message.received, si bien qu'un seul parseur les lit tous les huit ; les trois événements de groupe portent sur une conversation et transportent chat à la place ; les cinq événements de numéro portent sur une ligne et transportent number.
| Événement | Déclenché quand | Champs supplémentaires |
|---|---|---|
| message.received | Un contact envoie un message. | aucun |
| message.sent | Votre numéro a envoyé un message, depuis le téléphone lui-même ou via Sendveo. | message.origin |
| message.delivered | Un message sortant est arrivé sur l'appareil du destinataire. | aucun |
| message.read | Un message sortant a été lu. | aucun |
| message.failed | Un message sortant a été rejeté. | reason |
| message.reaction | Quelqu'un a réagi, ou a retiré sa réaction. | emoji, by |
| message.edited | L'expéditeur a modifié le message. | by |
| message.deleted | Le message a été supprimé pour tout le monde. | by |
| group.member_joined | Quelqu'un a été ajouté à un groupe. | chat, member, by |
| group.member_left | Quelqu'un a quitté un groupe, ou en a été retiré. | chat, member, by |
| group.updated | Un groupe a été créé, ou son sujet a changé. | chat, subject, by |
| number.disconnected | le lien avec le téléphone a cessé de fonctionner | number, reason |
| number.reconnected | le lien fonctionne à nouveau | number |
| number.phone_offline | le lien est intact mais le téléphone ne répond pas | number |
| number.phone_online | le téléphone répond à nouveau | number |
| number.history_synced | l'historique récent d'un numéro qui vient d'être relié a été importé | number, counts |
{
"type": "message.read",
"occurredAt": "2026-09-16T11:01:00Z",
"by": "+212670111222",
"message": {
"id": "msg_8Rm5",
"channelId": "chn_sales01",
"chatId": "chat_aa11",
"from": null,
"senderName": null,
"text": "Votre commande est prete.",
"timestamp": "2026-09-16T10:59:00Z",
"type": "text",
"status": "READ",
"attachments": [],
"replyTo": null,
"location": null,
"contacts": null,
"linkPreview": null
}
}{
"type": "message.reaction",
"occurredAt": "2026-09-16T11:03:00Z",
"emoji": "❤️",
"by": "+212670111222",
"message": { "id": "msg_8Rm5", "chatId": "chat_aa11", "type": "text", "status": "READ" }
}Le retrait d'une réaction est le même événement message.reaction avec "emoji": null, et non un huitième type : un seul gestionnaire couvre les deux moitiés.
Les redistributions ne sont pas relayées deux fois : le service de messagerie redistribue les événements comme il redistribue les messages, et un doublon est dédupliqué, si bien que votre webhook se déclenche une seule fois. Un événement portant sur un message que Sendveo ne détient pas (une conversation antérieure à la connexion) est acquitté puis abandonné : il n'est pas relayé, et ce n'est pas une erreur.
Événements de groupe
Les trois événements de groupe portent chat là où les événements de message portent message, plus by (qui a agi). chat.members est la liste réduite des participants : qui, leur rôle, et s'il s'agit de votre propre numéro.
{
"type": "group.member_joined",
"occurredAt": "2026-09-16T11:40:00Z",
"by": "+212661234567",
"member": { "phone": "+212612345678", "name": "Hind Zerouali", "removed": false },
"chat": {
"id": "cht_m8k3x7q2a1b4",
"chatId": "120363001122334455@g.us",
"channelId": "chn_sales01",
"kind": "group",
"name": "Atlas team - deliveries",
"members": [
{ "phone": "+212661234567", "name": null, "role": "super_admin", "self": true },
{ "phone": "+212612345678", "name": "Hind Zerouali", "role": "member", "self": false }
]
}
}{
"type": "group.updated",
"occurredAt": "2026-09-16T11:45:00Z",
"by": "+212661234567",
"subject": "Atlas team - deliveries 2026",
"chat": { "id": "cht_m8k3x7q2a1b4", "chatId": "120363001122334455@g.us", "kind": "group" }
}Être retiré et partir de soi-même forment UN SEUL événement, group.member_left, que member.removed permet de distinguer : exactement le choix de forme que fait message.reaction pour une réaction et son retrait. La création d'un groupe et son renommage sont eux aussi un seul événement, group.updated, parce que pour un gestionnaire les deux disent « les détails de ce groupe ont changé » ; subject est le sujet après le changement.
Un message envoyé dans un groupe arrive comme un message.received ordinaire portant message.group (quelle conversation) et message.author (qui a parlé) : il n'existe pas d'événement distinct pour les messages de groupe. Les deux champs valent null sur un message en tête-à-tête, ce qui est exactement ce que continue de voir un gestionnaire écrit avant les groupes.
Un événement portant sur une conversation d'un numéro que Sendveo ne connaît pas est acquitté puis abandonné, comme tout autre événement de compte inconnu. Il n'est pas relayé, et ce n'est pas une erreur.
Événements de numéro
Cinq événements portent sur un numéro plutôt que sur un message ou une conversation : ils transportent donc number là où les autres transportent message ou chat. number a exactement la forme que renvoie GET /v1/numbers/:id, statut et santé réunis, si bien qu'un seul parseur lit les deux.
{
"type": "number.disconnected",
"occurredAt": "2026-09-16T10:05:00Z",
"reason": "SESSION_EXPIRED",
"number": {
"id": "chn_sales01",
"displayName": "Sales line",
"declaredNumber": "+212661234567",
"pairedNumber": "+212661234567",
"status": "CREDENTIALS",
"health": {
"phoneOnline": false,
"lastSeenAt": "2026-09-16T09:58:00Z",
"lastEventAt": "2026-09-16T10:05:00Z"
}
}
}reason n'accompagne que number.disconnected : une forme par nom d'événement, la règle que suivent déjà les événements de message et de groupe. Traitez number.disconnected comme une raison de déranger quelqu'un, et number.phone_offline comme une chose qu'on laisse passer : confondre les deux fait refaire une liaison complète à un client parce que son téléphone était en charge dans la pièce d'à côté.
Un navigateur qui tient une session de tableau de bord peut lire en direct les mêmes seize événements sur GET /app/events. Il n'existe volontairement pas d'équivalent par clé API : un EventSource de navigateur ne peut pas envoyer d'en-tête Authorization, donc un flux sur l'API signifierait une clé API dans une chaîne de requête, dans un historique de navigateur et dans chaque journal de proxy. Pour un serveur, le webhook est le canal de push.
Versionnage de la charge utile
Des champs sont ajoutés, jamais renommés. message.received conserve exactement les sept champs qu'il avait en V1 (id, channelId, chatId, from, senderName, text, timestamp), avec les mêmes noms et les mêmes sens ; type, status, attachments, replyTo, location, contacts et linkPreview viennent s'y ajouter. Un gestionnaire écrit pour la V1 continue de fonctionner, et c'est pourquoi la légende d'une photo arrive toujours dans text.
type apparaît deux fois et désigne deux choses : en tête de l'enveloppe, c'est l'événement (message.read) ; à l'intérieur de message, c'est le genre du message (image). Le second est nommé comme dans la ressource message, de sorte qu'un seul analyseur lit les deux.
Vérifier un relais
Chaque relais transmet votre secret partagé dans l'en-tête x-sendveo-secret. Comparez-le à la valeur de votre configuration de webhook et rejetez tout ce qui ne correspond pas.
app.post("/sendveo/inbound", (req, res) => {
const secret = req.header("x-sendveo-secret")
if (secret !== process.env.SENDVEO_WEBHOOK_SECRET) {
return res.status(401).end()
}
const { message } = req.body
handleInbound(message)
res.status(200).end()
})Ce qui est relayé
Tous les événements des tableaux ci-dessus sont relayés. Vos propres messages sortants ne vous sont jamais renvoyés comme message.received : cet événement est donc toujours un vrai message venu de quelqu'un d'autre. Avant la V1.1, seul message.received existait ; un gestionnaire qui ignore un type inconnu continue de fonctionner sans changement.