Developer documentation
RelayPlus API
Send approved WhatsApp templates, keep contacts in step, and hear back when a customer replies — from your own systems.
Introduction
The RelayPlus API is a versioned REST API. Every request goes to the base URL below, carries an API key, and gets JSON back. Version 1 covers the order-notification loop: describe the templates Meta approved for your workspace, create or update a contact, send a template to a phone number or reply inside the 24-hour window, list and tag contacts, read its delivery status, act on conversations — assign, change status, leave a note — and receive webhooks when it is delivered, read, or answered.
Base URL:
- All timestamps are UTC, ISO-8601.
- Money is { amount, currency } with amount an integer in minor units (cents, fils). Never a float.
- Identifiers are opaque strings. Do not parse them.
- Requests and responses are application/json.
Authentication
Create an API key in the RelayPlus admin under Settings → API & webhooks. Give it a name and only the scopes it needs. The key is shown exactly once; we store a hash, so if you lose it, create another and revoke the old one.
Send it on every request as a bearer token:
| Scope | Grants |
|---|---|
messages:write | POST /messages/template, POST /messages/text |
messages:read | GET /messages/{id} |
templates:read | GET /templates |
contacts:write | POST /contacts, POST /contacts/{id}/tags, DELETE /contacts/{id}/tags/{tag} |
contacts:read | GET /contacts, GET /contacts/{id} |
conversations:read | GET /conversations, GET /conversations/{id} |
conversations:write | POST /conversations/{id}/assign, POST /conversations/{id}/status, POST /conversations/{id}/notes |
- A missing or unknown key is
401with code unauthorized orinvalid_token. - A key without the scope an endpoint needs is
403with codescope_required, andfields.scopenames the scope. - Keys act only within their own workspace. Another workspace’s ids are 404, never 403.
- Revoking a key takes effect on its next request.
Quickstart: notify a customer that an order shipped
Three calls. First, create or update the contact so consent and attributes travel with the number. Second, send the template. Third, read the status — or, better, subscribe to webhooks and let us tell you.
1. Upsert the contact:
2. Send the template
Find the template first with GET /templates — it tells you the header mode, how many body values, and which buttons want a value. Then send:
- Response 202: { "id": "msg_…", "status": "queued", … }. Queued means Meta accepted it; sent, delivered and read arrive later.
- A request with wrong parameters is refused before anything is created — no contact, no conversation, no webhook.
- The contact and the conversation are created if they do not exist.
- channelId is required only when the workspace has more than one WhatsApp number.
3. Read the status
The same three calls in Node
Or in Node, with fetch:
Templates and parameters
A WhatsApp template has up to four parts: a header (text or one media item), a body with positional {{1}}, {{2}} placeholders, a footer, and up to ten buttons. Meta approves each language variant separately, and only approved variants can be sent.
GET /templates describes every approved variant so you never guess:
| Part | What you send | Rule |
|---|---|---|
header.parameter = "text" | parameters.header: a string | Only when the header text carries {{1}} |
header.parameter = "media_url" | parameters.header: a public https URL | Meta fetches the image, video or document from it |
header.parameter = null | nothing | Sending a header value is template_parameter_invalid |
body.parameterCount = n | parameters.body: exactly n non-empty strings, in order | Otherwise template_variable_missing |
buttons[i].parameter = "url_suffix" | { "index": i, "value": "…" } — appended to the button’s URL | Required |
buttons[i].parameter = "coupon_code" | { "index": i, "value": "SAVE10" } | Required |
| buttons[i].parameter = null | nothing | A value here is template_parameter_invalid |
- Marketing templates are refused with
marketing_opted_outfor a contact whose marketing consent is revoked. Utility and authentication templates are not affected by marketing consent. - Language is Meta’s code exactly as listed — en_US, ar — and must match an approved variant.
Endpoint reference
| Method | Path | Scope | Success |
|---|---|---|---|
| POST | /messages/template | messages:write | 202 message |
| POST | /messages/text | messages:write | 202 message |
| GET | /messages/{id} | messages:read | 200 message |
| POST | /contacts | contacts:write | 201 created · 200 updated |
| GET | /contacts | contacts:read | 200 { items, nextCursor, total } |
| GET | /contacts/{id} | contacts:read | 200 contact |
| POST | /contacts/{id}/tags | contacts:write | 200 contact |
| DELETE | /contacts/{id}/tags/{tag} | contacts:write | 200 contact |
| GET | /conversations | conversations:read | 200 { items, nextCursor, total } |
| GET | /conversations/{id} | conversations:read | 200 conversation |
| POST | /conversations/{id}/assign | conversations:write | 200 conversation |
| POST | /conversations/{id}/status | conversations:write | 200 conversation |
| POST | /conversations/{id}/notes | conversations:write | 201 note |
| GET | /templates | templates:read | 200 { items, nextCursor, total } |
- The complete, generated OpenAPI 3 document — every schema and every response — is at https://go.relayplus.app/swagger/public/swagger.json. It is built from the same source as the API, so it cannot drift from what the server does.
POST /messages/template
Sends an approved template to a phone number, creating the contact and conversation if needed. Scope messages:write. Honours Idempotency-Key.
| Field | Type | Notes |
|---|---|---|
| to | string, required | E.164 with country code: +9665… — anything else is invalid_phone |
template.name | string, required | As listed by GET /templates |
template.language | string, required | An approved variant: en_US, ar, … |
parameters.header | string | Text or a public https URL, per the template |
parameters.body | string[] | One value per {{n}}, in order |
parameters.buttons | [{ index, value }] | Only for buttons that take a value |
| channelId | string | Required when the workspace has more than one WhatsApp number |
contact.name | string | Used only if the contact is created |
POST /messages/text
Sends a free-form text reply — no template, no approval — but only inside the 24-hour customer service window. Give conversationId to reply into a thread you already have, or "to" with a phone number when you have no thread id; one of the two is required. It is sent as the API key rather than any agent: it claims nobody and leaves the conversation’s unread count untouched. Scope messages:write. Honours Idempotency-Key.
| Field | Type | Notes |
|---|---|---|
| conversationId | string | Reply into this thread; required unless to is given |
| to | string | E.164 phone; required unless conversationId is given |
| body | string, required | Plain text, up to 4096 characters |
| channelId | string | Optional. With "to", limits the reply to that channel’s thread; omitted, the most recently active thread with the contact is used. Ignored when conversationId is given. |
- Response 202: { "id": "msg_…", "conversationId": "cv_…", "contentType": "text", "status": "queued", … }.
- Outside the window, this is
422window_closed— send a template instead. - Neither conversationId nor to is
422conversation_required. - A contact that has never had a conversation is
422no_conversation. - An unknown contact or conversation id is
404not_found.
GET /messages/{id}
The same shape as the send response, with the current status: queued, sent, delivered, read or failed. sent means Meta said so — never merely that we accepted it. On failed, failureCode is Meta’s numeric code and failureMessage its sentence. Scope messages:read.
POST /contacts
Creates a contact by phone number, or updates the one that already has it. 201 when created, 200 when updated. Absent fields are left unchanged. Scope contacts:write. Honours Idempotency-Key.
| Field | Type | Notes |
|---|---|---|
| phone | string, required | E.164 |
| name | string | Overwrites when present; empty falls back to the number |
| language | "ar" | "en" | "" | Empty string unsets |
| marketingConsent | "granted" | "revoked" | Written to the consent ledger with source API; unknown is not a value you can set |
| consentEvidence | string | What backs the claim — an order number, a form. Defaults to a note naming your key |
| attributes | { key: value } | Merged by key; never removes. Up to 50 per contact; key ≤ 64 characters, value ≤ 1024 |
| tags | string[] | Added; never removes — an order system must not wipe a tag an agent set. Up to 50 per contact; each ≤ 64 characters |
GET /contacts
Lists contacts in this workspace, ordered by id, wrapped in the same { items, nextCursor, total } envelope as every other list. Scope contacts:read.
| Query | Notes |
|---|---|
| phone | Exact match, E.164 — a missing + is added for you |
| tag | Exact match, case-insensitive |
| search | Matches name, email or the digits of phone |
| limit | 1–200, default 50 |
| cursor | The nextCursor from the previous page |
GET /contacts/{id}
The same shape. Scope contacts:read. This is deliberately not the CRM view — no spend, notes or history — because a key is not a bulk-export tool.
POST /contacts/{id}/tags · DELETE /contacts/{id}/tags/{tag}
Add or remove tags on one contact directly, alongside the tags field on POST /contacts. Scope contacts:write.
POST takes { "tags": [...] } and adds them to the contact’s existing tags — a case-insensitive union, up to 50 tags per contact, each up to 64 characters. Both calls return 200 with the contact. Removing a tag the contact does not have is still 200, unchanged. A tag containing / cannot be removed through the URL in v1.1.
- A workspace rule on Tag added fires once for each tag actually added — not for one already present.
GET /conversations · GET /conversations/{id}
Lists or reads conversations in this workspace. Scope conversations:read.
| Query | Notes |
|---|---|
| contactId | Only that contact’s conversations |
| status | open, pending, snoozed, solved or expired — expired is an open thread whose 24-hour window has closed |
| assigneeId | A member id, or unassigned for threads nobody holds |
| limit | 1–200, default 50 |
| cursor | The nextCursor from the previous page |
POST /conversations/{id}/assign · status · notes
Three actions on one conversation, each scope conversations:write.
| Action | Body | Notes |
|---|---|---|
| assign | { "assigneeId": "usr_…" } | Empty or null unassigns. The member must belong to this workspace, else 404 not_found with fields.assigneeId. The new assignee is notified. 200 conversation. |
| status | { "status": "…" } | open, pending, snoozed or solved — expired is read-only and is 422 invalid_status. Setting the status it already has is 200, unchanged. |
| notes | { "body": "…" } | Up to 4096 characters. Internal — never sent to the customer. 201 with id, conversationId, body, authorName, mentionedUserIds, createdAt. |
- A note is authored "API · <key name>" so agents can tell it apart from their own. @mentions in a note’s body resolve and notify exactly as an agent’s note would.
GET /templates
Approved variants, one item per name and language, with the parameter description shown above. Scope templates:read.
| Query | Notes |
|---|---|
| language | Filter by Meta language code |
| category | marketing, utility or authentication |
| limit | 1–200, default 50 |
| cursor | The nextCursor from the previous page; null when there is no more |
Errors
Every error has one shape. Branch on code; message is for humans and may change; fields names the offending part when there is one.
| Status | Code | Meaning |
|---|---|---|
| 401 | unauthorized | No API key on the request |
| 401 | invalid_token | Unknown, revoked or expired key |
| 403 | scope_required | The key lacks the scope in fields.scope |
| 403 | workspace_suspended | The workspace is read-only — no writes right now |
| 404 | not_found | No such id in this workspace |
| 404 | template_not_found | No template with that name |
| 409 | idempotency_conflict | Idempotency-Key reused with a different body |
| 422 | validation_failed | A field is missing or malformed |
| 422 | invalid_phone | to / phone is not E.164 |
| 422 | template_not_approved | The language variant is not approved |
| 422 | template_variable_missing | Wrong number of body values, or a blank one |
| 422 | template_parameter_invalid | Header or button value wrong for this template |
| 422 | channel_required | More than one WhatsApp number: pass channelId |
| 422 | no_channel | No connected WhatsApp number |
| 422 | marketing_opted_out | Marketing template to a contact who opted out |
| 422 | window_closed | The 24-hour window is closed — send a template instead |
| 422 | conversation_required | Give conversationId or to |
| 422 | no_conversation | That contact has never had a conversation |
| 422 | invalid_status | Use open, pending, snoozed or solved (expired is read-only) |
| 422 | <Meta code> | Meta refused the send; failureMessage carries its words |
| 429 | rate_limited | Over the per-key limit; see Retry-After |
Rate limits
Each key has a fixed window of 60 requests per minute. Every response carries the numbers to back off from:
- Over the limit is
429with coderate_limited,fields.dimension= "token", and aRetry-Afterheader in seconds. X-RateLimit-Resetis a Unix timestamp — the top of the next minute.- Use a queue and honour
Retry-Afterrather than retrying in a tight loop. Higher limits are available on request.
Idempotency
Network calls fail after the server has acted. To retry a POST safely, send an Idempotency-Key header — any string up to 255 characters that is unique per action, such as your order id plus the event.
The same key with the same body within 24 hours returns the original response with Idempotent-Replayed: true and sends nothing again. The same key with a different body is 409 idempotency_conflict. A refused request (4xx) is not stored, so you may retry it with the same key after fixing it.
Webhooks
Subscribe an https endpoint under Settings → API & webhooks and choose events. Each delivery is one POST with a JSON envelope, signed so you can verify it came from us.
A delivery that does not get a 2xx is retried on a fixed schedule — see Delivery, retries and replay below. Answer 2xx within 10 seconds and do the work afterwards; deduplicate on the X-Relay-Delivery header.
| Event | When |
|---|---|
message.received | A customer sent a message (data carries channelType) |
message.sent | Meta accepted an outbound message |
message.delivered | Meta reported delivery |
message.read | The customer read it |
message.failed | Meta refused it — failureCode says why |
contact.created | A contact was created, by any path |
conversation.assigned | A conversation was assigned to an agent |
conversation.status_changed | Open, pending, snoozed, solved, expired |
broadcast.finished | A broadcast completed or stopped |
wallet.debited | The workspace wallet was charged |
Envelope and payload
- All payload keys are camelCase, for every event type.
- id is stable per event — use it to ignore a duplicate you have already processed. Status events carry the event time in the id, so a send that is retried and fails again is a new event, not a duplicate. Retries of one delivery carry the same
X-Relay-Deliveryheader — that is the finer key to deduplicate on. message.receiveddata also carries channelType (whatsapp, instagram, messenger); on Instagram and Messenger, from is the platform-scoped id, not a phone number.- By default, message events carry ids and status only. Turn on “Include message content” on the subscription to receive the customer’s text and phone number on
message.received(as body and from) and the text on outbound events. Off is the default because a misconfigured URL must not leak customer data. - Correlate a reply to your own records through contactId — the id you received when you upserted the contact.
Verifying the signature
Every delivery carries X-Relay-Event, X-Relay-Timestamp (Unix seconds) and X-Relay-Signature, which is the lowercase hex HMAC-SHA256, keyed with your signing secret, of the string "<timestamp>.<raw body>". Verify against the raw bytes, before parsing, and reject anything older than a few minutes.
The same check in Python
Delivery, retries and replay
Delivery is at least once. A delivery is recorded before the first attempt is made, and is retried until it gets a 2xx, is rejected, or runs out of attempts.
A recovered endpoint drains up to five queued deliveries per 15-second pass, so a short outage clears quickly.
The platform sets how many attempts every endpoint gets, the same number for every workspace — default three: the first attempt plus two retries, about two and a half minutes apart in total. The ceiling is eight attempts, about 24 hours end to end.
Ordering is not guaranteed once anything has been retried — a later event can arrive before an earlier one that needed a retry. Order on occurredAt, not on arrival.
| Your response | What we do |
|---|---|
| 2xx | Delivered. No further attempts. |
| 5xx, 408, 425, 429, a timeout (10 s) or a connection failure | Retried on the schedule until the attempt budget runs out; Retry-After is honoured up to one hour. |
| Any other 4xx | Rejected — the delivery is dead-lettered at once and not retried. |
Retry schedule
Whatever the attempt budget, the spacing between attempts is fixed:
| After attempt | Next attempt in |
|---|---|
| 1 | 30 s |
| 2 | 2 min |
| 3 | 10 min |
| 4 | 30 min |
| 5 | 2 h |
| 6 | 6 h |
| 7 | 15 h |
When an endpoint keeps failing
An endpoint with ten consecutive failed attempts spanning at least 24 hours — the platform default — is paused: the workspace’s admins and developers are notified, and nothing more is sent until someone re-enables it under Settings → API & webhooks. Re-enabling sends nothing by itself.
Every attempt is logged for 30 days with the exact payload and the response we got back. From the same screen you can replay one delivery, or every failed delivery since a chosen moment, up to 1000 per run. A replay keeps its original delivery id.
Headers on every attempt
| Header | Meaning |
|---|---|
X-Relay-Delivery | The delivery id — identical on every attempt of one delivery; deduplicate on it. |
X-Relay-Attempt | 1 for the first attempt, then 2, 3 … |
X-Relay-Event | The event type — for example message.delivered. |
X-Relay-Timestamp | Per attempt — the signature changes each time, the body does not. |
X-Relay-Signature | HMAC-SHA256 of the timestamp and the raw body — see Verifying the signature above. |
What a good receiver does
A few habits keep a receiver in good standing:
- Answer 2xx in under 10 seconds, then do the work.
- Make handling idempotent on
X-Relay-Delivery. - Return 4xx only for a request you will never accept (bad signature, unknown event) — a 5xx means try again later.
- Keep the endpoint up: a paused endpoint receives nothing until it is re-enabled.
Automation hooks: call in, call out
Two doors into the automation engine that need no code on our side. An inbound URL starts one rule or one flow when your system calls it — a lead form, an order system, a Zap, a Make scenario, an n8n workflow. A flow’s HTTP request node, a flow’s Webhook node and a rule’s Call-a-webhook action call out from inside a conversation.
Outbound calls are made once, time out after 10 seconds, follow no redirects and are never retried — unlike webhook subscriptions, which are (see Delivery, retries and replay). A flow step needs its answer now. Targets on private, loopback or link-local addresses are refused before any request is made. Header values containing line breaks are dropped.
Inbound URL
Open a rule or a flow whose trigger is Inbound webhook received and choose Generate URL. The URL is shown once — copy it then. It is the credential: anyone holding it can start that one automation against contacts your workspace already has. Regenerate or Remove it under the same card; the old URL stops working on the next delivery.
POST a JSON object of at most 64 KB. Name the contact with contactId, or with phone in E.164 (+ and digits) — a missing + is added for you. The contact must already exist — an inbound URL never creates one; create it first with POST /contacts under an API key. A flow receives every field of the body as variables: {{payload.orderId}}, {{payload.items.0.sku}}. A rule runs its conditions and actions for that contact and receives no fields.
| Response | Meaning |
|---|---|
| 200 { "status": "started", "target": "flow", "runId": "…" } | The flow’s published version started for the contact |
| 200 { "status": "accepted", "target": "rule" } | The rule ran; its outcome is in the activity log |
| 200 { "status": "skipped", "target": "flow", "detail": "…" } | The contact is already part-way through a flow; nothing started |
400 invalid_payload | The body is not a JSON object |
404 not_found | Unknown, regenerated or removed URL — or the workspace is suspended |
409 not_published / trigger_mismatch | The flow has no published version, or no longer starts on an inbound webhook |
413 payload_too_large | Over 64 KB |
422 contact_required / contact_not_found / no_conversation | No contactId or phone; no such contact; the contact has never had a conversation to send into |
429 rate_limited | More than sixty deliveries in a minute to one URL — Retry-After says when |
- Every delivery to a valid URL counts on the automation’s card, even one that is then rejected, so you can see your system is reaching us.
- A rejected delivery is also a row in Automation → Activity, under the automation’s name.
What a Webhook node or a Call-a-webhook action sends
One POST, application/json, in the same envelope as webhook subscriptions. type is rule.webhook or flow.webhook. The phone is included — you configured this URL on this automation for this purpose. When you set a signing secret on the node or the action, the request carries X-Relay-Timestamp and X-Relay-Signature exactly as subscriptions do, so the verifier you already wrote serves both; X-Relay-Event carries the type.
- A rule’s envelope carries "rule": { id, name } and "trigger" in place of flow, node and variables.
- A Webhook node with a body of its own sends that body verbatim instead of the envelope. An HTTP request node always sends exactly what is typed and is never signed — put your credential in its headers.
The HTTP request node
Method, URL, headers (one per line, Name: value) and body, each with {{name}} references replaced from the run’s variables. Save the response as r and later messages can say {{r}} for the body, {{r.httpStatus}} for the status code, and {{r.field}} or {{r.items.0.sku}} for any field of a JSON body. A response that is not 2xx, times out or cannot be reached leaves by the node’s error exit with the same variables set; a node with no error exit connected stops the run and the activity log names it.
| Limit | Value |
|---|---|
| Timeout | 10 seconds |
| Redirects | Not followed — a 3xx is a failure |
| Response kept | First 256 KB read; {{r}} holds the first 4 000 characters |
| JSON fields flattened | Up to 200 keys; objects by key, arrays by index |
| Unknown {{name}} | Replaced with nothing — check the URL in the activity log step |
| Calls per walk | At most 5 between customer turns — the run stops with "too many calls in one run" |
MCP: use RelayPlus from an AI agent
RelayPlus is also an MCP server. Point any MCP client at the endpoint below with your API key as a bearer header and it gets the same fourteen operations as tools, under the same scopes, rate limit and idempotency rules as the REST API. Claude Code, Cursor, Claude Desktop and custom agents all support this today.
Endpoint (streamable HTTP):
| Tool | Same as | Scope |
|---|---|---|
send_template | POST /messages/template | messages:write |
get_message | GET /messages/{id} | messages:read |
list_templates | GET /templates | templates:read |
upsert_contact | POST /contacts | contacts:write |
get_contact | GET /contacts/{id} | contacts:read |
send_text | POST /messages/text | messages:write |
list_contacts | GET /contacts | contacts:read |
add_contact_tags | POST /contacts/{id}/tags | contacts:write |
remove_contact_tag | DELETE /contacts/{id}/tags/{tag} | contacts:write |
list_conversations | GET /conversations | conversations:read |
get_conversation | GET /conversations/{id} | conversations:read |
assign_conversation | POST /conversations/{id}/assign | conversations:write |
set_conversation_status | POST /conversations/{id}/status | conversations:write |
add_conversation_note | POST /conversations/{id}/notes | conversations:write |
- Tool arguments are the REST fields in
snake_case(to,template_name, language, header, body, buttons,channel_id,contact_name,idempotency_key,conversation_id,assignee_id, status, tags, tag, search, cursor, limit). Each tool describes its arguments, so the model can fill them fromlist_templates. - A tool error carries the same { code, message, fields } object as the REST API, as the error text.
- Actions taken through MCP are audited as an AI agent acting through your named key, so you can tell them apart from your own scripts.
- Hosted connectors that require an OAuth login (rather than a header you control) are not supported yet; the endpoint is designed so that can be added without changing the tools.
Client configuration
Claude Code (.mcp.json in your project), Claude Desktop (claude_desktop_config.json) and Cursor (.cursor/mcp.json) all take the same shape:
- Use a key with only the scopes the agent needs — an agent that only reads status needs
messages:readand nothing else. - Revoke the key under Settings → API & webhooks to cut the agent off at once.
Versioning and deprecation
- The version is in the path:
/api/public/v1. Behaviour is stable within a version; fields may be added, never removed or renamed. - A breaking change is a new version. The previous version stays supported for at least 12 months.
- Deprecation is announced at least 6 months ahead, with Deprecation and Sunset headers on affected responses and a note in the changelog below.
Changelog
| Date | Change |
|---|---|
| 2026-09-03 | v1: template send by phone with header, body and button values; message status; approved-template list; contact upsert and read; Idempotency-Key; per-key rate limits; five message.* webhook events; Include message content per subscription. |
| 2026-09-04 | Automation hooks: per-automation inbound URLs (POST /api/hooks/v1/{token}); flow HTTP request and Webhook nodes and the Call-a-webhook rule action execute — one attempt, 10 s, signed when a secret is set. |
| 2026-09-04 | v1.1: free-form text reply; contacts list + tags; conversations list/read/assign/status/notes; scopes conversations:read/write; nine new MCP tools (fourteen in all). |
| 2026-09-05 | Webhook deliveries are retried on a fixed schedule for a platform-set number of attempts (default three); every attempt is logged for 30 days; failed deliveries can be replayed; persistently failing endpoints are paused and their admins notified. New headers X-Relay-Delivery and X-Relay-Attempt. |
- Questions or a limit increase: [email protected].