Source:
docs/public-api.md
The ReplyBox API lets you send WhatsApp messages, read your contacts and tags,
and subscribe to real-time events — programmatically, from your own systems
(a CRM, an automation platform such as n8n/Zapier/Make, or a custom backend).
There are two ways to integrate:
| Direction | Use it to… | |
|---|---|---|
| REST API | You → ReplyBox | Send messages, look up contacts and tags on demand. |
| Webhooks | ReplyBox → You | Receive an HTTP callback the moment something happens (a message arrives, a message is sent, a contact is tagged). |
Use the REST API to push actions and pull data; use webhooks to receive events without polling. Most integrations use both — webhooks for real-time updates, and the REST API for backfill and reconciliation.
All API requests are made to:
https://api.replybox.utopiagroup.com.my/api/v1
Note: If your workspace uses a custom API domain, replace the host above
with the one provided to you. Every path in this document is relative to
/api/v1.
All requests and responses use JSON (Content-Type: application/json) and
must be made over HTTPS.
The REST API authenticates with a per-organization API token, sent as a
bearer token in the Authorization header:
Authorization: Bearer rb_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Every token is scoped to a single organization (workspace). ReplyBox resolves
your organization from the token — you never pass an organization ID to the
public API.
crm-sync), and copy it.The token is shown only once. Store it in a secrets manager or environment
variable. If you lose it, revoke it and generate a new one. Generating a token
requires theapi:managepermission.
A token looks like rb_live_ followed by a random string. Treat it like a
password: anyone holding it can send messages and read data for your workspace.
Delete a token from the same API Token screen. Revocation takes effect
immediately; any request using a revoked token returns 401 Unauthorized.
3f2a…).+,60123456789). You may also pass a full60123456789@s.whatsapp.net).Errors use standard HTTP status codes and a JSON body:
{ "detail": "channel not found" }
| Status | Meaning |
|---|---|
400 Bad Request |
Malformed request or invalid field (e.g. bad phone number). |
401 Unauthorized |
Missing, invalid, or revoked API token. |
404 Not Found |
The resource doesn't exist in your organization. |
409 Conflict |
The channel isn't connected. |
413 Payload Too Large |
Media exceeds the size limit (100 MB). |
422 Unprocessable Entity |
Validation failed (e.g. unsupported message_type). |
502 Bad Gateway |
A downstream dependency (media fetch, storage) failed. |
List endpoints that can return many rows use limit and offset query
parameters and return a total count for the current filter. See
List contacts.
Send a WhatsApp message (text or media) to any phone number. If the recipient
isn't already a contact, ReplyBox creates the contact and conversation
automatically.
POST /public/messages
| Field | Type | Required | Description |
|---|---|---|---|
channelId |
string (UUID) | Yes | The connected WhatsApp channel to send from. |
to |
string | Yes | Recipient phone number (digits only) or JID. |
message_type |
string | No | One of text (default), image, video, document, audio, voice. |
body |
object | Yes | Message content — see below. |
body.text |
string | For text |
The message text. Max 4096 characters. |
body.media_url |
string | For media | Publicly reachable URL ReplyBox fetches the media from. Max 100 MB. |
body.caption |
string | No | Caption for media messages. Max 4096 characters. |
The channel must belong to your organization and be connected; otherwise the
request returns 409 Conflict.
curl -X POST https://api.replybox.utopiagroup.com.my/api/v1/public/messages \
-H "Authorization: Bearer rb_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"channelId": "b1c2d3e4-0000-0000-0000-000000000000",
"to": "60123456789",
"message_type": "text",
"body": { "text": "Hi! Thanks for reaching out." }
}'
curl -X POST https://api.replybox.utopiagroup.com.my/api/v1/public/messages \
-H "Authorization: Bearer rb_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"channelId": "b1c2d3e4-0000-0000-0000-000000000000",
"to": "60123456789",
"message_type": "image",
"body": {
"media_url": "https://example.com/promo.jpg",
"caption": "This week only 🎉"
}
}'
200 OK{
"ok": true,
"queued_id": "9a8b7c6d-0000-0000-0000-000000000000",
"status": "pending"
}
The message is queued for delivery. queued_id is the ReplyBox message ID.
To learn when it's actually sent to WhatsApp — and when it's delivered, read, or
fails — subscribe to the message-update webhook.
This is a separate publishing workflow. A ReplyBox channel is the connected
phone number that sends. A WhatsApp Channel is the one-way publishing
destination whose JID ends in @newsletter. It is not a contact, group, or
ReplyBox inbox conversation.
An API token is as sensitive as a password. Anyone who has it can use the API
permissions granted to that token, including reading workspace data and sending
messages. Keep it in an environment variable or secrets store. Never paste it
into source code, a prompt transcript, a ticket, a screenshot, a log, or a file
that Git can commit. Add local secret files such as .env and .env.local to
.gitignore before giving a coding agent access to the project.
Only a connected ReplyBox channel with a Baileys connector leg can publish to a
WhatsApp Channel. ReplyBox does not list every WhatsApp Channel on the account,
show the account's admin role, accept an invitation, or follow a Channel. You
must start with the intended WhatsApp Channel invite link or known
@newsletter JID.
POST /public/channel-targets/resolve
{
"channelId": "123e4567-e89b-42d3-a456-426614174000",
"target": "https://whatsapp.com/channel/EXAMPLE123"
}
ReplyBox returns the WhatsApp Channel name and safe JID. This lookup does not
save, follow, or post to the destination.
{
"channelId": "123e4567-e89b-42d3-a456-426614174000",
"sendingChannelName": "Sales WhatsApp",
"newsletterJid": "120363000000000001@newsletter",
"displayName": "Company Updates"
}
Show the human all of these before continuing:
channelId;newsletterJid;After the human confirms the displayed values, call:
POST /public/channel-targets
{
"channelId": "123e4567-e89b-42d3-a456-426614174000",
"target": "https://whatsapp.com/channel/EXAMPLE123",
"confirm": true
}
ReplyBox resolves the target again and saves the provider result. It does not
trust a caller-supplied Channel name or JID. Use GET /public/channel-targets
to list the saved destinations available to the token's workspace.
Before every post, show the human the ReplyBox sending channel, WhatsApp Channel
name, and complete final text. Call the endpoint only after the human explicitly
approves that exact post.
POST /public/channel-targets/{targetId}/posts
Idempotency-Key: your-unique-post-id
{
"text": "The quarterly update is now available: https://example.com/update",
"confirm": true
}
Idempotency-Key is required. The response starts at queued. Reuse the same
key only when retrying the exact same target and text. Reusing it with different
text returns 409 Conflict instead of risking a second or changed post.
Check the outcome with:
GET /public/channel-targets/posts/{postId}
The result reports queued, sent, or failed, plus a safe error detail when
available. A sent result means WhatsApp accepted the post and returned a
provider message ID; it does not promise subscriber delivery or read analytics.
Connect this project to ReplyBox so I can publish text to one known WhatsApp Channel.
Before changing code:
1. Ask me for the ReplyBox API base URL, the ReplyBox sending channel ID, and the intended WhatsApp Channel invite link or @newsletter JID.
2. Tell me to store the API token in a local environment variable. Never print it, log it, put it in source code, or commit it. Check that .env and .env.local are in .gitignore.
3. Resolve the destination through POST /public/channel-targets/resolve. Show me the ReplyBox sending channel ID, resolved WhatsApp Channel name, and newsletter JID. Wait for my confirmation.
4. Only after I confirm, save it through POST /public/channel-targets with confirm=true.
5. Before every post, show me the sending channel, destination name, and complete final text. Wait for my explicit approval of that exact post.
6. Publish through POST /public/channel-targets/{targetId}/posts with confirm=true and a unique Idempotency-Key. Then check GET /public/channel-targets/posts/{postId} and report the final status.
Do not discover other Channels, accept or follow an invite, send media, or make a live post without my explicit confirmation.
Return your organization's contacts (newest first), each with its full set of
tags. Supports filtering by tag and searching by name or number.
GET /public/contacts
| Parameter | Type | Default | Description |
|---|---|---|---|
limit |
integer | 50 |
Page size, 1–200. |
offset |
integer | 0 |
Number of rows to skip. |
tag |
string or string[] | — | Return only contacts carrying this tag. Accepts a tag ID or a tag name (case-insensitive). Repeat tag or pass comma-separated values for multi-tag filters. |
tag_match |
string | all |
With multiple tag values, all requires every tag; any returns contacts carrying at least one. |
q |
string | — | Case-insensitive substring search over name, phone, and JID. |
curl "https://api.replybox.utopiagroup.com.my/api/v1/public/contacts?tag=VIP&limit=50" \
-H "Authorization: Bearer rb_live_xxx"
Multiple tags, requiring all:
curl "https://api.replybox.utopiagroup.com.my/api/v1/public/contacts?tag=VIP&tag=Lead&tag_match=all" \
-H "Authorization: Bearer rb_live_xxx"
Multiple tags, matching any:
curl "https://api.replybox.utopiagroup.com.my/api/v1/public/contacts?tag=VIP,Lead&tag_match=any" \
-H "Authorization: Bearer rb_live_xxx"
200 OK{
"contacts": [
{
"id": "c0ffee00-0000-0000-0000-000000000000",
"wa_jid": "60123456789@s.whatsapp.net",
"phone": "+60123456789",
"display_name": "Aisha Rahman",
"push_name": "Aisha",
"avatar_url": "/api/v1/.../avatar?v=1717000000",
"tags": [
{ "id": "7a…", "name": "VIP", "color_hex": "#2563eb" },
{ "id": "9b…", "name": "Lead", "color_hex": "#16a34a" }
],
"created_at": "2026-06-01T09:15:00+00:00"
}
],
"total": 1,
"limit": 50,
"offset": 0
}
total is the count for the current filter, so you can paginate with
offset until offset + len(contacts) >= total.
Fetch a single contact by phone number (or JID), with its full tag set.
GET /public/contacts/{phone}
| Parameter | Type | Description |
|---|---|---|
phone |
string | Phone number (digits only) or WhatsApp JID. |
curl "https://api.replybox.utopiagroup.com.my/api/v1/public/contacts/60123456789" \
-H "Authorization: Bearer rb_live_xxx"
200 OK{
"id": "c0ffee00-0000-0000-0000-000000000000",
"wa_jid": "60123456789@s.whatsapp.net",
"phone": "+60123456789",
"display_name": "Aisha Rahman",
"push_name": "Aisha",
"avatar_url": null,
"tags": [
{ "id": "7a…", "name": "VIP", "color_hex": "#2563eb" }
],
"created_at": "2026-06-01T09:15:00+00:00"
}
Returns 404 Not Found if no contact with that number exists in your
organization.
Return your organization's full tag catalog.
GET /public/tags
curl "https://api.replybox.utopiagroup.com.my/api/v1/public/tags" \
-H "Authorization: Bearer rb_live_xxx"
200 OK[
{ "id": "7a…", "name": "VIP", "color_hex": "#2563eb" },
{ "id": "9b…", "name": "Lead", "color_hex": "#16a34a" }
]
A webhook is an HTTPS endpoint you host. When a subscribed event occurs,
ReplyBox sends it an HTTP POST with a JSON body, signed so you can verify it
came from ReplyBox.
Webhooks are managed with the same bearer token as the REST API.
POST /public/webhooks
| Field | Type | Required | Description |
|---|---|---|---|
url |
string | Yes | Your HTTPS endpoint. Must start with https://. |
events |
string[] | No | Events to subscribe to. Defaults to ["message.received"]. See Events. |
channel_scope |
string | No | all (default) — every channel; or specific — only channel_ids. |
channel_ids |
string[] | If specific |
Channel IDs this subscription applies to. |
secret |
string | No | Your own signing secret. If omitted, ReplyBox generates one and returns it once. |
channel_scopeapplies to message events (message-insert,message-update
and their legacy equivalents). Tag events (contact-tag-update/
contact.tagged) are organization-wide and always fire regardless of scope.
curl -X POST https://api.replybox.utopiagroup.com.my/api/v1/public/webhooks \
-H "Authorization: Bearer rb_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.example.com/replybox",
"events": ["message-insert", "message-update", "contact-tag-update"],
"channel_scope": "all"
}'
201 Created{
"id": "d1e2f3a4-0000-0000-0000-000000000000",
"url": "https://hooks.example.com/replybox",
"events": ["message-insert", "message-update", "contact-tag-update"],
"channel_scope": "all",
"channel_ids": null,
"active": true,
"created_at": "2026-07-07T10:00:00+00:00",
"secret": "whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}
Save the
secret. It's returned only at creation and is required to
verify signatures. Listing webhooks never echoes it.
GET /public/webhooks
Returns your subscriptions (without secrets).
[
{
"id": "d1e2f3a4-0000-0000-0000-000000000000",
"url": "https://hooks.example.com/replybox",
"events": ["message.received"],
"channel_scope": "all",
"channel_ids": null,
"active": true,
"created_at": "2026-07-07T10:00:00+00:00"
}
]
PATCH /public/webhooks/{webhook_id}
Partially updates an existing webhook. Secrets are not rotated here; create a
new webhook if you need a new signing secret.
| Field | Type | Description |
|---|---|---|
url |
string | New HTTPS endpoint URL. |
events |
string[] | New subscribed event list. |
channel_scope |
string | all or specific. |
channel_ids |
string[] | Required when the effective channel_scope is specific. |
active |
boolean | Enable or disable delivery without deleting the subscription. |
curl -X PATCH https://api.replybox.utopiagroup.com.my/api/v1/public/webhooks/d1e2f3a4-0000-0000-0000-000000000000 \
-H "Authorization: Bearer rb_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.example.com/replybox-v2",
"events": ["message-insert", "message-update"],
"active": true
}'
200 OK{
"id": "d1e2f3a4-0000-0000-0000-000000000000",
"url": "https://hooks.example.com/replybox-v2",
"events": ["message-insert", "message-update"],
"channel_scope": "all",
"channel_ids": null,
"active": true,
"created_at": "2026-07-07T10:00:00+00:00"
}
DELETE /public/webhooks/{webhook_id}
Returns 204 No Content on success, or 404 Not Found if the webhook isn't in
your organization.
Every delivery is a POST with these headers:
| Header | Description |
|---|---|
X-Replybox-Event |
The event name, e.g. message-insert. |
X-Replybox-Event-Id |
A stable ID for the event — use it to deduplicate. |
X-Replybox-Timestamp |
Unix seconds when the request was signed. |
X-Replybox-Signature |
sha256=<hex> HMAC signature — see Verifying signatures. |
Respond with any 2xx status to acknowledge. Non-2xx responses are retried
(see Delivery, retries & idempotency).
ReplyBox offers two event formats. Use the v2 events below for new
integrations — they use a uniform envelope and cover the full message
lifecycle. The legacy events are still delivered for existing
consumers.
Every v2 event has the same top-level shape. The event field tells you what
happened; the details are always under data:
{
"event": "message-insert",
"timestamp": "2026-07-07T10:05:00+00:00",
"data": { "...": "event-specific fields" }
}
| Event | Fires when… |
|---|---|
message-insert |
An inbound WhatsApp message arrives (groups included). |
message-update |
An outbound message changes status: sent, delivered, read, or failed. |
contact-tag-update |
A contact's tags change (added or removed). |
message-insert{
"event": "message-insert",
"timestamp": "2026-07-07T10:05:00+00:00",
"data": {
"id": "3EB0…",
"channel_id": "b1c2d3e4-…",
"conversation_id": "11112222-…",
"direction": "inbound",
"type": "text",
"text": "Is this available?",
"is_group": false,
"group_jid": null,
"contact": {
"wa_jid": "60123456789@s.whatsapp.net",
"phone": "+60123456789",
"name": "Aisha"
},
"sender_jid": "60123456789@s.whatsapp.net",
"mentioned_jids": [],
"reply_to_message_id": null,
"quoted_text": null,
"created_at": "2026-07-07T10:05:00+00:00"
}
}
message-updateFires on every status transition of an outbound message, so you can track
the full delivery lifecycle. data.status is one of sent, delivered,
read, or failed. Use data.id (the WhatsApp message ID) to correlate the
transitions of a single message.
{
"event": "message-update",
"timestamp": "2026-07-07T10:06:03+00:00",
"data": {
"id": "3EB0…",
"channel_id": "b1c2d3e4-…",
"conversation_id": "11112222-…",
"direction": "outbound",
"type": "text",
"text": "Hi! Thanks for reaching out.",
"status": "delivered",
"contact": {
"wa_jid": "60123456789@s.whatsapp.net",
"phone": "+60123456789",
"name": "Aisha Rahman"
},
"created_at": "2026-07-07T10:06:00+00:00"
}
}
contact-tag-updateFires whenever a contact's tags change. data.tags is the contact's complete
current tag set, so you can treat each event as the authoritative state and
mirror removals as well as additions.
{
"event": "contact-tag-update",
"timestamp": "2026-07-07T10:07:00+00:00",
"data": {
"contact": {
"id": "c0ffee00-…",
"wa_jid": "60123456789@s.whatsapp.net",
"phone": "+60123456789",
"name": "Aisha Rahman"
},
"tags": [
{ "id": "7a…", "name": "VIP", "color_hex": "#2563eb" },
{ "id": "9b…", "name": "Lead", "color_hex": "#16a34a" }
],
"tag_ids": ["7a…", "9b…"]
}
}
When the last tag is removed,
tagsandtag_idsare empty arrays.
These flat events (no data envelope) predate the v2 catalog and remain
supported for backward compatibility. New integrations should prefer the
v2 events above. Subscribe to a legacy event by name and you'll
receive the legacy shape; the two coexist on the same webhook.
| Legacy event | v2 equivalent |
|---|---|
message.received |
message-insert |
message.sent (fires once, on sent only) |
message-update (fires on every status) |
contact.tagged |
contact-tag-update |
message.received{
"event": "message.received",
"channel_id": "b1c2d3e4-…",
"org_id": "a0a0a0a0-…",
"conversation_id": "11112222-…",
"group_jid": null,
"is_group": false,
"provider_message_id": "3EB0…",
"direction": "inbound",
"sender_jid": "60123456789@s.whatsapp.net",
"sender_name": "Aisha",
"kind": "text",
"body": "Is this available?",
"mentioned_jids": [],
"reply_to_message_id": null,
"quoted_text": null,
"timestamp": "2026-07-07T10:05:00+00:00"
}
message.sent & contact.tagged{
"event": "message.sent",
"channel_id": "b1c2d3e4-…",
"org_id": "a0a0a0a0-…",
"conversation_id": "11112222-…",
"provider_message_id": "3EB0…",
"direction": "outbound",
"recipient_jid": "60123456789@s.whatsapp.net",
"recipient_phone": "+60123456789",
"recipient_name": "Aisha Rahman",
"kind": "text",
"body": "Hi! Thanks for reaching out.",
"status": "sent",
"timestamp": "2026-07-07T10:06:00+00:00"
}
{
"event": "contact.tagged",
"org_id": "a0a0a0a0-…",
"contact_id": "c0ffee00-…",
"wa_jid": "60123456789@s.whatsapp.net",
"phone": "+60123456789",
"display_name": "Aisha Rahman",
"tags": [
{ "id": "7a…", "name": "VIP", "color_hex": "#2563eb" }
],
"tag_ids": ["7a…"],
"timestamp": "2026-07-07T10:07:00+00:00"
}
Every request is signed with the webhook's secret using HMAC-SHA256. Always
verify the signature before trusting a payload.
The signed string is the request timestamp and the raw request body joined with
a dot:
signed_string = "{X-Replybox-Timestamp}.{raw_request_body}"
signature = hex( HMAC_SHA256(secret, signed_string) )
Compare that against the X-Replybox-Signature header (drop the sha256=
prefix) using a constant-time comparison.
Verify against the raw body bytes, before any JSON re-serialization —
re-encoding can change whitespace and break the signature.
const crypto = require("crypto");
// Use the raw body: app.use(express.raw({ type: "application/json" }))
function verifyReplyBox(req, secret) {
const ts = req.header("X-Replybox-Timestamp");
const received = (req.header("X-Replybox-Signature") || "").replace("sha256=", "");
const expected = crypto
.createHmac("sha256", secret)
.update(`${ts}.${req.body}`) // req.body is the raw Buffer/string
.digest("hex");
return crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
}
app.post("/replybox", express.raw({ type: "application/json" }), (req, res) => {
if (!verifyReplyBox(req, process.env.REPLYBOX_WEBHOOK_SECRET)) {
return res.status(401).send("bad signature");
}
const event = JSON.parse(req.body);
// ... handle event ...
res.sendStatus(200);
});
import hmac, hashlib
from flask import request, abort
def verify_replybox(secret: str) -> bytes:
ts = request.headers.get("X-Replybox-Timestamp", "")
received = request.headers.get("X-Replybox-Signature", "").removeprefix("sha256=")
raw = request.get_data() # raw bytes, before JSON parsing
expected = hmac.new(
secret.encode(), f"{ts}.".encode() + raw, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(received, expected):
abort(401)
return raw
2xx (or times out after 5 s) isX-Replybox-Event-Id. Because aX-Replybox-Event-Id.timestamp field (and, for messages, the current tagX-Replybox-TimestampGet a token. Dashboard → Settings → Developers → API Token → Generate.
Find your channel ID. Dashboard → Channels (or list via the app). You'll
pass it as channelId when sending.
Send a test message:
curl -X POST https://api.replybox.utopiagroup.com.my/api/v1/public/messages \
-H "Authorization: Bearer $REPLYBOX_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "channelId": "<CHANNEL_ID>", "to": "60123456789",
"message_type": "text", "body": { "text": "Hello from the API!" } }'
Receive events. Stand up an HTTPS endpoint, then register it:
curl -X POST https://api.replybox.utopiagroup.com.my/api/v1/public/webhooks \
-H "Authorization: Bearer $REPLYBOX_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "url": "https://hooks.example.com/replybox",
"events": ["message-insert", "message-update", "contact-tag-update"] }'
Verify signatures on every incoming webhook using the returned secret
(see Verifying signatures).
Questions or issues? Contact support@utopiagroup.com.my (or your ReplyBox
account manager). Include your organization name and, for webhook problems, the
X-Replybox-Event-Id of an affected delivery.
ReplyBox is operated by UTOPIA ACCELERATOR SDN BHD.