On this page

#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:

MethodPathBody or result
GET/api/developer/v1/:slug/server_keysActive 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/:prefixRevoke 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 textYou sent only brief
in the last 24 hourstext, exactly as written (mode: "text")The agent writes it from the brief (mode: "agent")
more than 24 hours ago, or neverYour 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.

  • text is 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.
  • brief is 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: params fill the body's {{1}}, {{2}}, … in order, and the count must match. GET /templates lists 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
torequiredThe contact's number in international format
texttext or briefYour own message, sent as is inside the window. Up to 4,096 characters
brieftext or briefWhat the agent should write about, and context for later turns. Up to 2,000 characters
templateoptional{ "name", "language", "params": [] }, used only outside the window
identityoptionalA JWT your workspace signed for this person; see Identity
external_refoptionalYour 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 or GET /messages/:id.
  • 200 with Idempotent-Replayed: true: an Idempotency-Key that 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": "…"
}
  • status is the message's own state: queued, sent or failed.
  • content is what reached the phone: your text, the agent's words or the rendered template. chat_message_id is that message in the conversation.
  • delivery is Meta's word on the first message: sent, delivered, read or failed, with Meta's error code.
  • A failed message carries error.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 in error.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:

EventWhen
message.sentIt went out to WhatsApp
message.failedIt could not go out; data.message.error says why
message.delivered, message.readMeta says it reached the phone, or was read
message.delivery_failedMeta refused it after sending, most often because the window closed
message.repliedThe 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.

MethodPath (developer API)
GET/webhook_endpointsEndpoints, with their last success and failures
POST/webhook_endpoints{ "environment": "live", "url": "https://…", "events": [] } (empty = all); returns secret once
PATCH/webhook_endpoints/:idurl, events, label, or { "enabled": true }
DELETE/webhook_endpoints/:idRemove it
POST/webhook_endpoints/:id/testQueue a ping
GET/webhook_endpoints/:id/deliveriesThe 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

MethodPath
GET/messages/:idOne message
GET/messages?external_ref=&status=&limit=&before=Newest first, up to 50; page with before = the next_before of the last page
GET/templatesThe 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" }.

Statuscode
401invalid_keyMissing, wrong or revoked key
403workspace_forbiddenThe key belongs to another workspace
404not_foundNo such chat in this key's environment
409opted_outThe contact asked not to be written to
409human_takeover, handoff_pendingA person is handling the conversation
409whatsapp_not_connected, whatsapp_pausedNo number, or Live is off on it
409use_live_keyA preview key, on a workspace with its own number connected
409idempotency_conflict, identity_conflictSee Idempotency and Identity
422outside_windowWindow closed and no template; carries last_inbound_at
422template_not_found, template_not_approved, template_params, template_requiredThe template cannot be sent as given
422invalid_identity, identity_not_configuredThe token does not verify, or the deployment has no auth: key
422not_a_test_phoneA preview key, to a number that is not a verified test phone
422content_required, text_too_long, brief_too_long, invalid_recipient
429rate_limitedMore 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

  1. Add your phone on Test → WhatsApp, as in Test with the shared preview.

  2. Create a preview key: vatio keys create --env preview --label try.

  3. Write anything from your phone to the preview number. That opens the 24-hour window, so brief and text go out without a template.

  4. 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.

Last updated:

Latest CLI version: 0.79.0 · Changelog · The whole contract as Markdown: /docs/docs.md · or vatio docs