Messaging API (AMF/1)¶
One message format for every rich messaging channel.
The Anychat Messaging API lets agents send and receive rich messages on RCS, Apple Messages for Business, SMS, web chat, and other channels — without formatting messages differently per channel, or even knowing which channel the user is on.
The wire format is AMF (spec: "amf/1"). Its core promise:
Every valid message renders on every channel — using the richest native affordance the channel offers, degrading deterministically down to plain SMS text, and never silently dropping content.
There are two ways to produce messages:
- AML (Anychat Markup Language) — plain text with lightweight
[[...]]markers, designed for LLM agents. See the AML reference. If you're building an LLM-backed agent, start here: returntext/anychat-amlfrom your webhook and you're done. - AMF JSON — the structured format documented on this page, for full programmatic control.
Set up¶
You need an Anychat account and an agent. Two secrets matter: an access token (authenticates your API calls) and a signing secret (verifies webhooks came from Anychat). Both live in Agent settings → Webhook in the console.
Warning
Keep them secret, keep them safe! Access tokens and signing secrets are sensitive credentials.
Authentication¶
The access token is per-agent: every /v4 request authenticates as one
agent and operates only on that agent's conversations. Send it as a bearer
token:
An access_token query parameter is also accepted (and takes precedence)
for clients that can't set headers. A missing or unrecognized token gets a
401. There are no scopes or roles on this surface — the token is the
agent.
The message model¶
An outbound message is an ordered list of parts, plus optional message-level chips (quick replies):
{
"spec": "amf/1",
"parts": [
{ "type": "text", "text": "Happy to set that up." },
{
"type": "ask.time",
"intentId": "booking",
"prompt": "When works best?",
"slots": [
{ "id": "tue-345", "start": { "iso": "2026-06-10T15:45:00-07:00", "tz": "America/Los_Angeles" } },
{ "id": "wed-10", "start": { "iso": "2026-06-11T10:00:00-07:00", "tz": "America/Los_Angeles" } }
]
}
]
}
Parts come in three layers:
Intents (preferred)¶
Intent parts state what you're trying to accomplish. The gateway picks the best rendering per channel and guarantees a degrade chain down to SMS text:
| Part | What it does | Rich rendering | SMS floor |
|---|---|---|---|
ask.choice |
Ask the user to pick an option | List picker (AMB) / chips (RCS) | Numbered list, "Reply 1 or 2 — or just type your answer" |
ask.time |
Offer appointment slots | Time picker (AMB) / slot chips | Numbered list |
confirm |
Yes/no confirmation | Receipt card + chips | "Reply YES to confirm" |
showcase |
Present products/options | Carousel with Select chips (single items render as a plain card, no Select) | Numbered text digest |
ask.location |
Request the user's location | Share-location chip | "Reply with your address" |
share.event |
Send a calendar event | Add-to-calendar chip | Calendar link |
share.place |
Send a location | Map chip | Maps link |
share.contact |
Send contact details | Contact text | Contact text |
A single option never renders as a picker. Native sheets and lists require something to choose between — an intent with exactly one option renders as a reply chip on chip-capable channels, and on the SMS floor becomes a sentence ("Tue 3:45 PM — reply YES to take it, or just tell me what works"), where YES accepts the lone option.
Intents are round-trip. Whether the user taps an Apple time picker, taps
an RCS chip, or texts "2" on SMS, your webhook receives the same typed
result event:
{
"type": "result",
"result": {
"intent": "ask.time",
"intentId": "booking",
"optionIds": ["wed-10"],
"labels": ["Wed, Jun 10 at 10:00 AM PDT"],
"via": "text",
"text": "2"
}
}
Free-text answers are matched deterministically, in confidence order:
exact ordinal, option id, or label; YES/NO for confirm (YES also
accepts the option of a single-option intent); then unambiguous
approximate matches — an option's label contained in the reply ("Wed 10
AM works for me"), or every word of the reply appearing in exactly one
option's label ("10 am wed"). A reply that could mean more than one
option, or doesn't clearly match any, is delivered to you as a normal
text message — Anychat never eats user input it isn't sure about.
Presentation parts¶
For explicit control: text (a CommonMark subset — bold, italic,
strikethrough, links, lists — transcoded or stripped per channel), media,
card, and carousel. Cards and carousels carry per-card chips.
{ "type": "card", "card": {
"title": "Our clinic", "description": "Open 9–5",
"mediaUrl": "https://example.com/clinic.jpg",
"chips": [ { "label": "Directions", "kind": "url", "url": "https://maps.example.com" } ]
} }
Native escape hatch¶
{ "type": "native", "channel": "appleMessages", "payload": { ... },
"fallback": [ ...parts ] } passes a channel-specific payload through
verbatim on its channel. fallback is required — a native part renders
everywhere.
Chips¶
Chips are one-tap affordances: reply (default), url, dial, calendar,
viewLocation, shareLocation. A tapped reply chip produces a result
event carrying the chip's value. On channels without tap affordances, reply
chips become a numbered text list (still answerable, still a result event)
and action chips degrade to links/text. Label limits are per-channel; Anychat
truncates rather than rejects.
Typed values¶
Dates, places, events, and money are structured —
{ "iso": "...", "tz": "America/Los_Angeles" }, { "amount": "12.00",
"currency": "USD" } — and Anychat formats them per channel and locale.
Your agent never formats a datetime for SMS.
Receiving events¶
Anychat delivers one event per webhook request:
{
"spec": "amf/1",
"instanceId": "<your agent instance>",
"userId": "<conversation user id>",
"event": {
"spec": "amf/1",
"eventId": "...",
"timestamp": "2026-06-10T15:45:12Z",
"conversation": { "userId": "...", "sessionId": "..." },
"context": {
"channel": "rcs",
"provider": "googleRbm",
"capabilities": { ... },
"window": { "state": "open" }
},
"event": { "type": "message", "message": { "text": "Hi!" } }
},
"mode": "managed"
}
Event types:
| type | meaning |
|---|---|
message |
Free-form user content: text, media, or a shared place |
result |
A resolved answer to one of your intents (or a reply-chip tap) |
action |
A non-answer tap: the user opened a url, started a dial call, saved a calendar event |
status |
Delivery lifecycle of a message you sent: queued / sent / delivered / read / failed — always referencing your messageId |
system |
Conversation lifecycle: conversationStarted, optIn, optOut, subscribed, unsubscribed, typing, windowChanged |
context.capabilities describes what the user's channel can render. Most
agents should ignore it — that's the point of the format — but it's there for
the few that want to branch, and for tooling.
Opt-out is enforced by the platform. STOP/UNSUBSCRIBE on carrier channels
becomes a system optOut event, and subsequent agent-originated sends are
refused with a typed optedOut error. Your agent does not implement carrier
compliance.
Replying¶
Reply in the webhook HTTP response, in one of three content types:
application/json — { "messages": [ ...outbound items... ] }
application/x-ndjson — stream one { "message": { ... } } per line;
the user receives each message as you emit it.
text/anychat-aml — raw AML text; Anychat compiles it. The simplest
possible rich-messaging webhook is literally:
Happy to set that up!
[[ask.time #booking | When works best?
tue-345 = 2026-06-10T15:45:00-07:00 ~ Tue 3:45 PM
wed-10 = 2026-06-11T10:00:00-07:00 ~ Wed 10:00 AM]]
You can also send asynchronously (outreach, delayed replies):
POST https://gateway.anychat.ai/v4/messages
Authorization: bearer <access token>
{ "userId": "<user id>", "messages": [ { "spec": "amf/1", "parts": [ ... ] } ] }
The response carries per-message results:
{ "results": [ { "messageId": "...", "accepted": true } ] }. Supply your own
id on a message for idempotency (Anychat dedupes for 24 hours). Rejections
carry a typed reason: optedOut, windowClosed, mediaUnreachable,
unsupportedPart, invalidMessage, unknownRecipient, ...
Signals (typing indicators, read receipts) are sent the same way:
Delivery policy¶
A message may carry a delivery block:
{
"spec": "amf/1",
"parts": [ ... ],
"delivery": {
"channels": ["rcs", "sms"],
"fallbackOn": ["sendFailed"],
"outsideWindow": "reject"
}
}
channels is the preference order; fallbackOn lists the triggers that move
delivery to the next channel. status events carry an attempt field
({ "channel": "sms", "provider": "twilio" }) so fallback is observable.
(TTL-based undelivered fallback is specified but not yet active; sendFailed
failover requires the user to be reachable on a sibling binding.)
Compile & capabilities endpoints¶
POST https://gateway.anychat.ai/v4/compile
{ "aml": "Hello!\n[[chips: Hi | Tell me more]]", "validateOnly": false }
→ { "message": { "spec": "amf/1", ... }, "warnings": [], "problems": [] }
GET https://gateway.anychat.ai/v4/capabilities?userId=<user id>
→ the CapabilityDoc for that user's channel
compile takes { "aml": string, "validateOnly"?: boolean } (aml is
required — its absence is a 400). A clean compile returns 200 with the
compiled message plus any non-fatal warnings; a compile with problems
returns 422 and no message. With validateOnly: true the message is
omitted from the response even on success.
capabilities requires the userId query parameter (400 without it) and
returns 404 for a user id the agent has never seen.
Use /v4/compile in CI to validate AML your agent might emit, or to adopt
AML from any agent framework without running Anychat's runtime.
Media¶
Media URLs must be publicly-accessible HTTPS. Anychat pulls, caches (keyed by full URL including query string), and re-hosts media on a whitelisted domain during send. Generating dynamic images? Make each URL unique. Media is retained for at least 30 days.
Webhook configuration¶
Configure your webhook URL in the console, or programmatically:
| Method | Path | Effect |
|---|---|---|
GET |
/v4/webhook |
Read the current { webhookUrl, instanceId } |
POST |
/v4/webhook |
Set the webhook (same as PATCH) |
PATCH |
/v4/webhook |
Set the webhook |
DELETE |
/v4/webhook |
Remove the webhook binding |
PATCH https://gateway.anychat.ai/v4/webhook
Authorization: bearer <my-access-token>
{ "webhookUrl": "https://www.example.com/webhook", "instanceId": "prod-7" }
webhookUrl is required on POST/PATCH (400 without it). The optional
instanceId is an opaque identifier you choose; Anychat echoes it on every
event envelope delivered to your webhook, so one endpoint can serve several
agents or deployments. All four endpoints respond with the resulting
{ webhookUrl, instanceId } (DELETE returns a confirmation message).
Webhook requests are signed: verify X-Anychat-Signature (HMAC-SHA256 of
v0:<timestamp>:<raw body> with your signing secret, hex-encoded, prefixed
v0=) and reject stale X-Anychat-Request-Timestamp values.
Errors¶
Send failures return 4xx/5xx with { "message": "...", ... } plus typed
per-message reason codes in batch results. A 2xx response means a message
was accepted for delivery; track actual delivery via status events.