#Messages
Your backend tells Vatio who to write to and what to say, and the conversation opens on WhatsApp: an order that shipped, an appointment tomorrow, a quote that is ready. When the person answers, the agent already knows what was said and why.
POST /api/server/v1/:slug/messages
Authorization: Bearer vsk_…
Content-Type: application/json
Idempotency-Key: order-1042-shipped
{
"to": "+56912345678",
"brief": "Order #1042 shipped today with Starken, tracking 99812. Offer to send the tracking link.",
"template": { "name": "order_shipped", "language": "es", "params": ["Ana", "#1042"] },
"external_ref": "order-1042"
}
In Ruby, the vatio-sdk gem's
require "vatio/server" wraps all of this: typed errors, an ActiveJob that
retries only what is safe to retry, a webhook receiver, and a test fake.
This is the one endpoint family outside /api/developer/v1. Your server calls
it, not the CLI, and it takes a server key, not a developer token.
#Server keys
A server key (vsk_…) belongs to the workspace and one environment. It does
not expire when someone signs the CLI in again, and a preview key cannot
message a live customer. Create one with vatio keys create or
over the developer API:
| Method | Path | Body or result |
|---|---|---|
| GET | /api/developer/v1/:slug/server_keys | Active keys: prefix, environment, label, last_used_at |
| POST | /api/developer/v1/:slug/server_keys | { "environment": "live", "label": "backend" }; returns the full key once |
| DELETE | /api/developer/v1/:slug/server_keys/:prefix | Revoke a key |
Keep it on your server. It can message your customers.
#What goes out
WhatsApp delivers free-form text only within 24 hours of the person's last message. After that, it only delivers a template that Meta approved. Vatio checks the window, and your request decides what is sent:
| The contact wrote… | You sent text | You sent only brief |
|---|---|---|
| in the last 24 hours | text, exactly as written (mode: "text") | The agent writes it from the brief (mode: "agent") |
| more than 24 hours ago, or never | Your template, as is (mode: "template") | Your template, as is (mode: "template") |
With the window closed and no template, the request is refused with
422 outside_window. To always reach someone, send a template as well;
Vatio only uses it when it has to.
textis for words that must not change, such as an amount or a legal notice. It goes out as one message, with no model in between.briefis your words to the agent, not to the customer. Write it the way you would brief a colleague, with the facts it should use; the agent uses no facts beyond it and its tools. Say who the news is from and the agent passes it on as theirs: "Tell Ana that Claude finished: …" goes out as "Claude tells me that…".template:paramsfill the body's{{1}},{{2}}, … in order, and the count must match.GET /templateslists each template's count.
You can send both text and brief. The text goes out, and the brief stays
with the conversation as context. In every mode the agent is shown the brief
on each later turn, so "yes, send it" gets an answer that knows what "it" is.
#Body
| Field | ||
|---|---|---|
to | required | The contact's number in international format |
text | text or brief | Your own message, sent as is inside the window. Up to 4,096 characters |
brief | text or brief | What the agent should write about, and context for later turns. Up to 2,000 characters |
template | optional | { "name", "language", "params": [] }, used only outside the window |
identity | optional | A JWT your workspace signed for this person; see Identity |
external_ref | optional | Your own id. Filter by it with GET /messages?external_ref= |
#Responses
A message is recorded by the request and sent by Vatio right after. The request never waits on Meta or on the model, so it answers in milliseconds:
202: accepted,status: "queued". Follow it with webhooks orGET /messages/:id.200withIdempotent-Replayed: true: anIdempotency-Keythat was already used. You get the original message as it stands now; nothing is sent again.
{
"id": 81,
"status": "sent",
"mode": "template",
"channel": "whatsapp",
"environment": "live",
"to": "+56912345678",
"chat_id": 5521,
"external_ref": "order-1042",
"idempotency_key": "order-1042-shipped",
"brief": "Order #1042 shipped today…",
"text": null,
"template": { "name": "order_shipped", "language": "es", "params": ["Ana", "#1042"] },
"content": "Hi Ana, your order #1042 is on its way…",
"chat_message_id": 90412,
"delivery": { "status": "read", "sent_at": "…", "delivered_at": "…", "read_at": "…", "error_code": null, "error_message": null },
"error": null,
"sent_at": "…",
"replied_at": "…",
"created_at": "…"
}
statusis the message's own state:queued,sentorfailed.contentis what reached the phone: your text, the agent's words or the rendered template.chat_message_idis that message in the conversation.deliveryis Meta's word on the first message:sent,delivered,readorfailed, with Meta's error code.- A
failedmessage carrieserror.code:human_takeover,handoff_pending: someone took the chat first.outside_window: the window closed while it waited.chat_busy: the chat was busy for ten minutes.no_reply,generation_failed: the agent wrote nothing.whatsapp_error: Meta refused the send; Meta's message is inerror.message.
A failed message stays silent: Vatio never sends the person an apology for a message they did not ask for. Nothing is retried against Meta, either. A send that timed out may have arrived, and a second one would be a second message on the person's phone.
#Idempotency
Send Idempotency-Key (or idempotency_key in the body) and retry as much as
you like: the same key, in the same environment, always returns the same
message. A key sent with a different body is refused with
409 idempotency_conflict. Derive it from your own event, such as
order-1042-shipped, so a retry from a job queue cannot message anyone twice.
An identity signed again for the same person, with a new exp, is still
the same request.
#Identity
On WhatsApp, the person is anonymous unless you say otherwise. Send
identity, a JWT your workspace signed with the same key as the widget's
(sub, aud = the workspace slug, exp), and the conversation is signed in:
when the person answers, private tools run for that
sub. Vatio checks the signature; it never vouches for anyone. Give it an
exp that covers how long they may take to answer, such as 48 hours.
A conversation already signed in as someone else is not handed over:
409 identity_conflict.
#Webhooks
Vatio POSTs what happened to your server, so you never poll:
| Event | When |
|---|---|
message.sent | It went out to WhatsApp |
message.failed | It could not go out; data.message.error says why |
message.delivered, message.read | Meta says it reached the phone, or was read |
message.delivery_failed | Meta refused it after sending, most often because the window closed |
message.replied | The person answered, once per message; data.reply has the text |
{
"id": "evt_3141",
"type": "message.replied",
"created_at": "…",
"workspace": "acme",
"environment": "live",
"data": {
"message": { "id": 81, "status": "sent", "external_ref": "order-1042", "...": "…" },
"reply": { "chat_message_id": 90433, "content": "Great, send me the link", "at": "…" }
}
}
Every delivery carries Vatio-Event, Vatio-Delivery (the event id) and
Vatio-Signature: t=1696262400,v1=…. To verify it, compute the hex
HMAC-SHA256 of "#{t}.#{raw body}" with the endpoint's secret, compare it in
constant time, and reject a t older than five minutes. Answer 2xx within
ten seconds. Anything else, including a redirect, is retried with backoff for
about a day. After 50 failures in a row the endpoint is switched off until you
enable it again. Deliveries are at least once, so dedupe on id.
| Method | Path (developer API) | |
|---|---|---|
| GET | /webhook_endpoints | Endpoints, with their last success and failures |
| POST | /webhook_endpoints | { "environment": "live", "url": "https://…", "events": [] } (empty = all); returns secret once |
| PATCH | /webhook_endpoints/:id | url, events, label, or { "enabled": true } |
| DELETE | /webhook_endpoints/:id | Remove it |
| POST | /webhook_endpoints/:id/test | Queue a ping |
| GET | /webhook_endpoints/:id/deliveries | The last 50 deliveries and how each went |
The URL must be https on a public host. Each endpoint belongs to one
environment and hears only that environment's events.
#Lists and templates
| Method | Path | |
|---|---|---|
| GET | /messages/:id | One message |
| GET | /messages?external_ref=&status=&limit=&before= | Newest first, up to 50; page with before = the next_before of the last page |
| GET | /templates | The templates this key's number can send: name, language, status, body, parameter_count, and an example object to send as template |
Create templates with vatio templates create. Until
you connect your own number it puts them on Vatio's test number, where a
preview key can send them, and moves them to your number when you connect
it. The inbox's Outreach → Templates shows the same list, with each
one's body, status and the reason Meta gave when it refused one. Once you
connect your number you can also create them there or in Meta. Meta approves
each one on each number.
Vatio reads its own copy of them. If
you name one on your own number that it does not know yet, the request is
refused with template_not_found, and Vatio fetches the list from Meta in the
background: retry in a minute.
#Read a conversation
GET /api/server/v1/:slug/chats/:id/messages?before=&limit=
Authorization: Bearer vsk_…
One conversation of the key's environment, as both sides saw it: what a
Claude Code routine reads first, from the
messages_url it was started with. The newest messages come last, up to 100
(limit, at most 200); pass before = oldest_id to get the page before, and
stop when has_more is false.
{
"chat": { "id": 349, "channel": "whatsapp", "environment": "live",
"contact": { "name": "Ivan", "phone_number": "+56912345678" } },
"messages": [
{ "id": 9875, "role": "user", "content": "¿Pueden cambiar el banner?",
"forwarded": "forwarded", "occurred_at": "2026-10-11T16:02:11Z", "attachments": [] },
{ "id": 9876, "role": "user", "content": "Implementa lo que me pide Sofía",
"forwarded": null, "occurred_at": "2026-10-11T16:02:40Z", "attachments": [] }
],
"oldest_id": 9801,
"has_more": true
}
role is user for the person on WhatsApp and assistant for everything
sent to them: the agent, a person from the inbox (human_author_name) or your
backend. forwarded is forwarded or frequently_forwarded when the message
came from another chat. A voice note's content is its transcript; images and
files are in attachments, as signed URLs, with the text Vatio read in a PDF
or a video as description. Tool calls and discarded drafts are not part of
it. Every read is recorded in the workspace's Activity, and a key reads at
most 120 times a minute.
#Refusals
Errors carry { "error": { "code", "message" }, "request_id" }.
| Status | code | |
|---|---|---|
| 401 | invalid_key | Missing, wrong or revoked key |
| 403 | workspace_forbidden | The key belongs to another workspace |
| 404 | not_found | No such chat in this key's environment |
| 409 | opted_out | The contact asked not to be written to |
| 409 | human_takeover, handoff_pending | A person is handling the conversation |
| 409 | whatsapp_not_connected, whatsapp_paused | No number, or Live is off on it |
| 409 | use_live_key | A preview key, on a workspace with its own number connected |
| 409 | idempotency_conflict, identity_conflict | See Idempotency and Identity |
| 422 | outside_window | Window closed and no template; carries last_inbound_at |
| 422 | template_not_found, template_not_approved, template_params, template_required | The template cannot be sent as given |
| 422 | invalid_identity, identity_not_configured | The token does not verify, or the deployment has no auth: key |
| 422 | not_a_test_phone | A preview key, to a number that is not a verified test phone |
| 422 | content_required, text_too_long, brief_too_long, invalid_recipient | |
| 429 | rate_limited | More than 60 messages, or 120 conversation reads, a minute on one key |
#Preview
A preview key writes from the shared preview number, and only to a test phone
verified for that workspace and environment on Test → WhatsApp. Outside
the 24-hour window it sends the templates you created with
vatio templates create, by the same name you send
them by from your own number.
The test number is for a workspace with no number of its own. Once you
connect yours, your templates move to it and a preview key is refused with
409 use_live_key: your backend sends from your number, with a live key.
#Try it from your phone
-
Add your phone on Test → WhatsApp, as in Test with the shared preview.
-
Create a preview key:
vatio keys create --env preview --label try. -
Write anything from your phone to the preview number. That opens the 24-hour window, so
briefandtextgo out without a template. -
Send a message:
curl https://vatio.ai/api/server/v1/acme/messages \ -H "Authorization: Bearer $VATIO_SERVER_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: try-1" \ -d '{"to": "+56912345678", "brief": "Order #1042 shipped today, tracking 99812. Offer to send the tracking link."}'
It answers 202 with the message's id, and the agent's message reaches your
phone seconds later. GET /messages/:id shows what went out, and the inbox's
Outreach page lists it. Answer it from your phone: the agent knows what it
wrote and why.