Subscription
¶ Register once
all channels
secret returned only at creation
Integration guide
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. Use webhooks to receive events without polling; use the REST API to push actions and pull data.
Subscription
all channels
secret returned only at creation
2xx
Retries
X-Replybox-Event-Id on every retry
Non-2xx or a 5 s timeout triggers the next attempt.
Delivery lifecycle
Subscribe your HTTPS endpoint with POST /public/webhooks. Save the secret.
A message arrives, an outbound message changes status, or a contact's tags change.
ReplyBox sends a JSON body with the event, ID, timestamp, and signature headers.
Recompute the HMAC-SHA256 over the raw body and compare in constant time.
Return any 2xx. Dedupe on X-Replybox-Event-Id.
Access
Webhooks are managed with the same per-organization API token as the REST API. Every path is relative to /api/v1, and all requests use JSON over HTTPS. Token handling, error codes, and the REST endpoints are covered in the Public API guide.
https://api.replybox.utopiagroup.com.my/api/v1
HeaderAuthorization: Bearer rb_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Get a tokenDashboard → Settings → Developers → API Token → Generate. Requires api:manage; shown only once.
Manage subscriptions
Create
/public/webhooksSubscribe an HTTPS endpoint to one or more events.
Request body
urlstring · requiredYour HTTPS endpoint. Must start with https://.
eventsstring[] · optionalEvents to subscribe to. Defaults to ["message.received"]. See Events.
channel_scopestring · optionalall (default) — every channel; or specific — only channel_ids.
channel_idsstring[] · if specificChannel IDs this subscription applies to.
secretstring · optionalYour own signing secret. If omitted, ReplyBox generates one and returns it once.
channel_scope applies 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"
}'
{
"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.
Read
/public/webhooksReturns 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"
}
]
Change
/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.
Request body
urlstringNew HTTPS endpoint URL.
eventsstring[]New subscribed event list.
channel_scopestringall or specific.
channel_idsstring[]Required when the effective channel_scope is specific.
activebooleanEnable 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
}'
{
"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"
}
Remove
/public/webhooks/{webhook_id}Removes the subscription.
204 No Contenton success
404 Not Foundif the webhook isn't in your organization
Events
Every delivery is a POST with four headers. Respond with any 2xx status to acknowledge; non-2xx responses are retried (see Delivery, retries & idempotency).
message-insert.
X-Replybox-Event-IdA stable ID for the event — use it to deduplicate.
X-Replybox-TimestampUnix seconds when the request was signed.
X-Replybox-Signaturesha256=<hex> HMAC signature — see Verifying signatures.
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.
The envelope
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" }
}
Inbound
message-insertAn inbound WhatsApp message arrives (groups included).
{
"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"
}
}
Outbound status
message-updateFires on every status transition of an outbound message, so you can track the full delivery lifecycle. Use data.id (the WhatsApp message ID) to correlate the transitions of a single message.
data.status is one of sent, delivered, read, or failed.
{
"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"
}
}
Organization-wide
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, tags and tag_ids are empty arrays.
Legacy events
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.
message.receivedmessage-insert
message.sent (fires once, on sent only)message-update (fires on every status)
contact.taggedcontact-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"
}
Verifying signatures
Every request is signed with the webhook's secret using HMAC-SHA256. The signed string is the request timestamp and the raw request body joined with a dot. Compare the result against the X-Replybox-Signature header (drop the sha256= prefix) using a constant-time comparison.
signed_string = "{X-Replybox-Timestamp}.{raw_request_body}"
signature = hex( HMAC_SHA256(secret, signed_string) )
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
Delivery, retries & idempotency
A delivery that doesn't return 2xx (or times out after 5 s) is retried up to 3 times total, with backoff (immediately, then ~5 s, then ~30 s). After that it's dropped.
Retries reuse the same X-Replybox-Event-Id. Because a timeout can occur after your server processed the event, treat handlers as idempotent — dedupe on X-Replybox-Event-Id.
Events are dispatched independently and are not guaranteed to arrive in order. Use the timestamp field (and, for messages, the current tag set / status) rather than assuming arrival order.
Only public HTTPS destinations are accepted; requests to private, loopback, or internal addresses are rejected.
Optionally reject deliveries whose X-Replybox-Timestamp is more than a few minutes old to mitigate replay.
Quickstart
Dashboard → Settings → Developers → API Token → Generate.
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"] }'
On every incoming webhook, using the returned secret (see Verifying signatures).
Support
Contact support@utopiagroup.com.my (or your ReplyBox account manager).
X-Replybox-Event-Id of an affected delivery
ReplyBox is operated by UTOPIA ACCELERATOR SDN BHD