RelayPlus Developers OpenAPI Sign in

Developer documentation

RelayPlus API

Send approved WhatsApp templates, keep contacts in step, and hear back when a customer replies — from your own systems.

API version v1.1 · Last updated 5 September 2026 · OpenAPI document

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:

text
https://go.relayplus.app/api/public/v1
  • 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:

http
Authorization: Bearer rly_live_…
ScopeGrants
messages:writePOST /messages/template, POST /messages/text
messages:readGET /messages/{id}
templates:readGET /templates
contacts:writePOST /contacts, POST /contacts/{id}/tags, DELETE /contacts/{id}/tags/{tag}
contacts:readGET /contacts, GET /contacts/{id}
conversations:readGET /conversations, GET /conversations/{id}
conversations:writePOST /conversations/{id}/assign, POST /conversations/{id}/status, POST /conversations/{id}/notes
  • A missing or unknown key is 401 with code unauthorized or invalid_token.
  • A key without the scope an endpoint needs is 403 with code scope_required, and fields.scope names 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:

bash
curl -X POST https://go.relayplus.app/api/public/v1/contacts \
-H "Authorization: Bearer rly_live_…" \
-H "Content-Type: application/json" \
-d '{
"phone": "+966501234567",
"name": "Alaa",
"language": "ar",
"marketingConsent": "granted",
"consentEvidence": "Checkout opt-in, order 4711",
"attributes": { "customer_id": "C-88" },
"tags": ["vip"]
}'

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:

bash
curl -X POST https://go.relayplus.app/api/public/v1/messages/template \
-H "Authorization: Bearer rly_live_…" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-4711-shipped" \
-d '{
"to": "+966501234567",
"template": { "name": "order_shipped", "language": "ar" },
"parameters": {
"header": "https://cdn.shop.example/orders/4711.jpg",
"body": ["Alaa", "4711", "Thursday"],
"buttons": [{ "index": 0, "value": "track/4711" }]
},
"channelId": "ch_main"
}'
  • 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

bash
curl https://go.relayplus.app/api/public/v1/messages/msg_… \
-H "Authorization: Bearer rly_live_…"

The same three calls in Node

Or in Node, with fetch:

javascript
const BASE = "https://go.relayplus.app/api/public/v1";
const headers = {
Authorization: `Bearer ${process.env.RELAYPLUS_API_KEY}`,
"Content-Type": "application/json",
};
const contact = await fetch(`${BASE}/contacts`, {
method: "POST", headers,
body: JSON.stringify({ phone: "+966501234567", name: "Alaa", language: "ar" }),
}).then((r) => r.json());
const message = await fetch(`${BASE}/messages/template`, {
method: "POST",
headers: { ...headers, "Idempotency-Key": "order-4711-shipped" },
body: JSON.stringify({
to: "+966501234567",
template: { name: "order_shipped", language: "ar" },
parameters: { body: ["Alaa", "4711", "Thursday"] },
channelId: "ch_main",
}),
}).then((r) => r.json());
const status = await fetch(`${BASE}/messages/${message.id}`, { headers })
.then((r) => r.json());
console.log(status.status); // queued → sent → delivered → read

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:

json
{
"items": [{
"id": "tpl_…", "name": "order_shipped", "language": "ar",
"category": "utility", "status": "approved",
"header": { "mode": "image", "parameter": "media_url" },
"body": { "text": "…{{1}}…{{2}}…{{3}}…", "parameterCount": 3 },
"footer": null,
"buttons": [
{ "index": 0, "type": "url", "text": "Track", "parameter": "url_suffix" },
{ "index": 1, "type": "quick_reply", "text": "Thanks", "parameter": null }
]
}],
"nextCursor": null, "total": 1
}
PartWhat you sendRule
header.parameter = "text"parameters.header: a stringOnly when the header text carries {{1}}
header.parameter = "media_url"parameters.header: a public https URLMeta fetches the image, video or document from it
header.parameter = nullnothingSending a header value is template_parameter_invalid
body.parameterCount = nparameters.body: exactly n non-empty strings, in orderOtherwise template_variable_missing
buttons[i].parameter = "url_suffix"{ "index": i, "value": "…" } — appended to the button’s URLRequired
buttons[i].parameter = "coupon_code"{ "index": i, "value": "SAVE10" }Required
buttons[i].parameter = nullnothingA value here is template_parameter_invalid
  • Marketing templates are refused with marketing_opted_out for 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

MethodPathScopeSuccess
POST/messages/templatemessages:write202 message
POST/messages/textmessages:write202 message
GET/messages/{id}messages:read200 message
POST/contactscontacts:write201 created · 200 updated
GET/contactscontacts:read200 { items, nextCursor, total }
GET/contacts/{id}contacts:read200 contact
POST/contacts/{id}/tagscontacts:write200 contact
DELETE/contacts/{id}/tags/{tag}contacts:write200 contact
GET/conversationsconversations:read200 { items, nextCursor, total }
GET/conversations/{id}conversations:read200 conversation
POST/conversations/{id}/assignconversations:write200 conversation
POST/conversations/{id}/statusconversations:write200 conversation
POST/conversations/{id}/notesconversations:write201 note
GET/templatestemplates:read200 { 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.

json
{
"id": "msg_…", "conversationId": "cv_…", "contactId": "ct_…",
"to": "+966501234567", "direction": "outbound", "status": "queued",
"contentType": "template",
"template": { "name": "order_shipped", "language": "ar" },
"externalId": "wamid.…", "failureCode": null, "failureMessage": null,
"cost": { "amount": 5, "currency": "USD" },
"createdAt": "2026-09-03T10:00:00Z", "updatedAt": null
}
FieldTypeNotes
tostring, requiredE.164 with country code: +9665… — anything else is invalid_phone
template.namestring, requiredAs listed by GET /templates
template.languagestring, requiredAn approved variant: en_US, ar, …
parameters.headerstringText or a public https URL, per the template
parameters.bodystring[]One value per {{n}}, in order
parameters.buttons[{ index, value }]Only for buttons that take a value
channelIdstringRequired when the workspace has more than one WhatsApp number
contact.namestringUsed 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.

bash
curl -X POST https://go.relayplus.app/api/public/v1/messages/text \
-H "Authorization: Bearer rly_live_…" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-4711-eta" \
-d '{
"conversationId": "cv_…",
"body": "Your package is running a day late — sorry! It will arrive Friday."
}'
FieldTypeNotes
conversationIdstringReply into this thread; required unless to is given
tostringE.164 phone; required unless conversationId is given
bodystring, requiredPlain text, up to 4096 characters
channelIdstringOptional. 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 422 window_closed — send a template instead.
  • Neither conversationId nor to is 422 conversation_required.
  • A contact that has never had a conversation is 422 no_conversation.
  • An unknown contact or conversation id is 404 not_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.

json
{
"id": "ct_…", "phone": "+966501234567", "name": "Alaa", "language": "ar",
"marketingConsent": "granted", "lifecycleStage": "new_lead", "source": "api",
"tags": ["vip"], "attributes": { "customer_id": "C-88" },
"lastSeenAt": null, "createdAt": "2026-09-03T09:58:00Z", "updatedAt": null
}
FieldTypeNotes
phonestring, requiredE.164
namestringOverwrites 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
consentEvidencestringWhat 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
tagsstring[]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.

QueryNotes
phoneExact match, E.164 — a missing + is added for you
tagExact match, case-insensitive
searchMatches name, email or the digits of phone
limit1–200, default 50
cursorThe 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.

bash
curl -X POST https://go.relayplus.app/api/public/v1/contacts/ct_…/tags \
-H "Authorization: Bearer rly_live_…" \
-H "Content-Type: application/json" \
-d '{ "tags": ["vip", "escalated"] }'
  • 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.

json
{
"id": "cv_…", "contactId": "ct_…", "phone": "+966501234567", "channelType": "whatsapp",
"status": "open", "assigneeId": "usr_…",
"windowExpiresAt": "2026-09-05T10:00:00Z", "isWindowOpen": true,
"lastMessageAt": "2026-09-04T10:00:00Z", "unreadCount": 2,
"tags": ["vip"],
"createdAt": "2026-08-20T09:00:00Z", "updatedAt": "2026-09-04T10:00:00Z"
}
QueryNotes
contactIdOnly that contact’s conversations
statusopen, pending, snoozed, solved or expired — expired is an open thread whose 24-hour window has closed
assigneeIdA member id, or unassigned for threads nobody holds
limit1–200, default 50
cursorThe nextCursor from the previous page

POST /conversations/{id}/assign · status · notes

Three actions on one conversation, each scope conversations:write.

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

QueryNotes
languageFilter by Meta language code
categorymarketing, utility or authentication
limit1–200, default 50
cursorThe 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.

json
{
"code": "template_parameter_invalid",
"message": "This template’s image header needs a public https URL.",
"fields": { "header": "This template’s image header needs a public https URL." }
}
StatusCodeMeaning
401unauthorizedNo API key on the request
401invalid_tokenUnknown, revoked or expired key
403scope_requiredThe key lacks the scope in fields.scope
403workspace_suspendedThe workspace is read-only — no writes right now
404not_foundNo such id in this workspace
404template_not_foundNo template with that name
409idempotency_conflictIdempotency-Key reused with a different body
422validation_failedA field is missing or malformed
422invalid_phoneto / phone is not E.164
422template_not_approvedThe language variant is not approved
422template_variable_missingWrong number of body values, or a blank one
422template_parameter_invalidHeader or button value wrong for this template
422channel_requiredMore than one WhatsApp number: pass channelId
422no_channelNo connected WhatsApp number
422marketing_opted_outMarketing template to a contact who opted out
422window_closedThe 24-hour window is closed — send a template instead
422conversation_requiredGive conversationId or to
422no_conversationThat contact has never had a conversation
422invalid_statusUse open, pending, snoozed or solved (expired is read-only)
422<Meta code>Meta refused the send; failureMessage carries its words
429rate_limitedOver 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:

http
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 42
X-RateLimit-Reset: 1756893660
  • Over the limit is 429 with code rate_limited, fields.dimension = "token", and a Retry-After header in seconds.
  • X-RateLimit-Reset is a Unix timestamp — the top of the next minute.
  • Use a queue and honour Retry-After rather 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.

http
Idempotency-Key: order-4711-shipped

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.

EventWhen
message.receivedA customer sent a message (data carries channelType)
message.sentMeta accepted an outbound message
message.deliveredMeta reported delivery
message.readThe customer read it
message.failedMeta refused it — failureCode says why
contact.createdA contact was created, by any path
conversation.assignedA conversation was assigned to an agent
conversation.status_changedOpen, pending, snoozed, solved, expired
broadcast.finishedA broadcast completed or stopped
wallet.debitedThe workspace wallet was charged

Envelope and payload

json
{
"id": "evt_msg_…_delivered_20260903100004123",
"type": "message.delivered",
"occurredAt": "2026-09-03T10:00:04Z",
"workspaceId": "ws_…",
"data": {
"messageId": "msg_…", "conversationId": "cv_…", "contactId": "ct_…",
"direction": "outbound", "status": "delivered",
"template": { "name": "order_shipped", "language": "ar" },
"externalId": "wamid.…", "failureCode": null, "failureMessage": null,
"occurredAt": "2026-09-03T10:00:04Z"
}
}
  • 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-Delivery header — that is the finer key to deduplicate on.
  • message.received data 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.

javascript
// Node — an Express handler with the raw body available
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyRelay(req, secret) {
const ts = req.header("X-Relay-Timestamp");
const given = req.header("X-Relay-Signature") ?? "";
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const expected = createHmac("sha256", secret)
.update(`${ts}.${req.rawBody}`).digest("hex");
return given.length === expected.length
&& timingSafeEqual(Buffer.from(given), Buffer.from(expected));
}

The same check in Python

python
import hmac, hashlib, time
def verify_relay(headers, raw_body: bytes, secret: str) -> bool:
ts = headers.get("X-Relay-Timestamp", "")
given = headers.get("X-Relay-Signature", "")
if abs(time.time() - int(ts or 0)) > 300:
return False
expected = hmac.new(secret.encode(), f"{ts}.".encode() + raw_body,
hashlib.sha256).hexdigest()
return hmac.compare_digest(given, expected)

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 responseWhat we do
2xxDelivered. No further attempts.
5xx, 408, 425, 429, a timeout (10 s) or a connection failureRetried on the schedule until the attempt budget runs out; Retry-After is honoured up to one hour.
Any other 4xxRejected — the delivery is dead-lettered at once and not retried.

Retry schedule

Whatever the attempt budget, the spacing between attempts is fixed:

After attemptNext attempt in
130 s
22 min
310 min
430 min
52 h
66 h
715 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

HeaderMeaning
X-Relay-DeliveryThe delivery id — identical on every attempt of one delivery; deduplicate on it.
X-Relay-Attempt1 for the first attempt, then 2, 3 …
X-Relay-EventThe event type — for example message.delivered.
X-Relay-TimestampPer attempt — the signature changes each time, the body does not.
X-Relay-SignatureHMAC-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.

bash
curl -X POST https://go.relayplus.app/api/hooks/v1/ihk_… \
-H "Content-Type: application/json" \
-d '{ "phone": "+96550012288", "orderId": "A-77", "status": "shipped" }'
ResponseMeaning
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_payloadThe body is not a JSON object
404 not_foundUnknown, regenerated or removed URL — or the workspace is suspended
409 not_published / trigger_mismatchThe flow has no published version, or no longer starts on an inbound webhook
413 payload_too_largeOver 64 KB
422 contact_required / contact_not_found / no_conversationNo contactId or phone; no such contact; the contact has never had a conversation to send into
429 rate_limitedMore 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.

json
{
"id": "…", "type": "flow.webhook", "occurredAt": "2026-09-04T10:00:00Z", "workspaceId": "ws_…",
"data": {
"flow": { "id": "fl_…", "name": "Order lookup", "version": 3 },
"node": { "id": "n_4", "title": "Notify CRM" },
"contact": { "id": "ct_…", "name": "Dana Aziz", "phoneE164": "+96550012288", "language": "ar", "tags": ["vip"] },
"conversation": { "id": "cv_…", "channelType": "whatsapp", "status": "open" },
"variables": { "payload.orderId": "A-77", "r.status": "shipped" }
}
}
  • 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.

LimitValue
Timeout10 seconds
RedirectsNot followed — a 3xx is a failure
Response keptFirst 256 KB read; {{r}} holds the first 4 000 characters
JSON fields flattenedUp to 200 keys; objects by key, arrays by index
Unknown {{name}}Replaced with nothing — check the URL in the activity log step
Calls per walkAt 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):

text
https://go.relayplus.app/mcp
ToolSame asScope
send_templatePOST /messages/templatemessages:write
get_messageGET /messages/{id}messages:read
list_templatesGET /templatestemplates:read
upsert_contactPOST /contactscontacts:write
get_contactGET /contacts/{id}contacts:read
send_textPOST /messages/textmessages:write
list_contactsGET /contactscontacts:read
add_contact_tagsPOST /contacts/{id}/tagscontacts:write
remove_contact_tagDELETE /contacts/{id}/tags/{tag}contacts:write
list_conversationsGET /conversationsconversations:read
get_conversationGET /conversations/{id}conversations:read
assign_conversationPOST /conversations/{id}/assignconversations:write
set_conversation_statusPOST /conversations/{id}/statusconversations:write
add_conversation_notePOST /conversations/{id}/notesconversations: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 from list_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:

json
{
"mcpServers": {
"relayplus": {
"url": "https://go.relayplus.app/mcp",
"headers": { "Authorization": "Bearer rly_live_…" }
}
}
}
  • Use a key with only the scopes the agent needs — an agent that only reads status needs messages:read and 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

DateChange
2026-09-03v1: 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-04Automation 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-04v1.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-05Webhook 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.