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

```http
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`](https://vatio.ai/docs/sdk/ruby#message-people-first) 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`](https://vatio.ai/docs/cli/keys) 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.

- **`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-and-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 | | |
|---|---|---|
| `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](#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](#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.

```json
{
  "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](https://vatio.ai/docs/authentication/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 |

```json
{
  "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`](https://vatio.ai/docs/cli/webhooks#templates). 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

```http
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](https://vatio.ai/docs/integrations/claude-routines) 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.

```json
{
  "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](#idempotency) and [Identity](#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`](https://vatio.ai/docs/cli/webhooks#templates), 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](https://vatio.ai/docs/channels/whatsapp#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:

   ```sh
   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.
