Webhooks
Set a webhook URL and Sendveo relays every real inbound message to it in real time, so your systems react without polling.
Configure your webhook
Set or replace your endpoint. The response includes a shared secret, shown when the webhook is created or when you rotate it. The URL must be publicly reachable over 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"
}
}The relay payload
Each inbound message is delivered as a JSON POST to your 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"
}
}Reply to a message by sending to its chatId: POST /v1/messages with { channelId, to: chatId, text }.
Event catalogue
Sixteen event types are POSTed to your URL, all with the shared secret in the x-sendveo-secret header. Every event carries occurredAt (when it happened, not when we relayed it). The eight message events carry the whole message under message, in the same shape message.received uses, so one parser reads all eight; the three group events are about a conversation and carry chat instead; the five number events are about a line and carry number.
| Event | Fired when | Extra fields |
|---|---|---|
| message.received | A contact sends a message. | none |
| message.sent | Your number sent a message - from the phone itself, or through Sendveo. | message.origin |
| message.delivered | An outbound message reached the recipient’s device. | none |
| message.read | An outbound message was read. | none |
| message.failed | An outbound message was rejected. | reason |
| message.reaction | Someone reacted, or removed their reaction. | emoji, by |
| message.edited | The sender edited the message. | by |
| message.deleted | The message was deleted for everyone. | by |
| group.member_joined | Somebody was added to a group. | chat, member, by |
| group.member_left | Somebody left a group, or was removed. | chat, member, by |
| group.updated | A group was created, or its subject changed. | chat, subject, by |
| number.disconnected | the link to the phone stopped working | number, reason |
| number.reconnected | the link is working again | number |
| number.phone_offline | the link is intact but the handset is not answering | number |
| number.phone_online | the handset is answering again | number |
| number.history_synced | the recent history of a newly linked number has been imported | 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" }
}A reaction removal is the same message.reaction event with "emoji": null - not an eighth type, so one handler covers both halves of it.
Re-deliveries are not relayed twice: the messaging service re-delivers events as it re-delivers messages, and a repeat is de-duplicated so your webhook fires once. An event about a message Sendveo does not hold (a conversation older than the connection) is acknowledged and dropped - it is not relayed and it is not an error.
Group events
The three group events carry chat where the message events carry message, plus by (who did it). chat.members is the trimmed roster: who, their role, and whether it is your own number.
{
"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" }
}Being removed and leaving are ONE event, group.member_left, with member.removed telling them apart - the same shape decision message.reaction makes for a reaction and its removal. A group being created and a group being renamed are one event too, group.updated, because both are "this group details changed" to a handler; subject is the subject afterwards.
A message sent in a group arrives as an ordinary message.received carrying message.group (which conversation) and message.author (who said it) - there is no separate group-message event. Both fields are null on a one-to-one message, which is exactly what a handler written before groups existed keeps seeing.
An event about a conversation on a number Sendveo does not know is acknowledged and dropped, like every other unknown-account event. It is not relayed, and it is not an error.
Number events
Five events are about a number rather than a message or a conversation, so they carry number where the others carry message or chat. number is the same shape GET /v1/numbers/:id returns, status and health together, so one parser reads both.
{
"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 rides only on number.disconnected - one shape per event name, the rule the message and group events already follow. Treat number.disconnected as something to interrupt a person about and number.phone_offline as something to wait out: collapsing the two walks a client through a re-link because their phone was on charge in the next room.
A browser holding a dashboard session can read the same sixteen events live at GET /app/events. There is no API-key equivalent, deliberately: a browser EventSource cannot send an Authorization header at all, so a stream on the API would mean an API key in a query string, in a browser history and in every proxy log. For a server, the webhook is the push channel.
Payload versioning
Fields are added, never renamed. message.received keeps exactly the seven fields it had in V1 (id, channelId, chatId, from, senderName, text, timestamp) with the same names and meanings; type, status, attachments, replyTo, location, contacts and linkPreview sit beside them. A handler written against V1 keeps working, which is why a photo’s caption still arrives in text.
type appears twice and means two things: at the top of the envelope it is the event (message.read); inside message it is the message’s kind (image). The inner one is named to match the message resource, so one parser reads both.
Verifying a relay
Every relay carries your shared secret in the x-sendveo-secret header. Compare it against the value from your webhook configuration and reject anything that does not match.
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()
})What is relayed
Every event in the tables above is relayed. Your own outbound messages are never relayed back as message.received, so that event is always a real message from somebody else. Before V1.1 only message.received existed: a handler that ignores an unknown type keeps working unchanged.