One-time setup
¶ Register Toppie's receiver
https://toppie.yourdomain.com/whatsapp-webhook
Eventmessage.received
Scopeall channels, or selected channel IDs
whsec_... is returned only when the webhook is created.
Toppie integration spec
Everything Toppie's team needs to receive WhatsApp messages from ReplyBox in near-real-time, without dashboard login or polling. ReplyBox pushes each inbound message to an HTTPS URL Toppie hosts, signed with a webhook secret.
One-time setup
https://toppie.yourdomain.com/whatsapp-webhook
Eventmessage.received
Scopeall channels, or selected channel IDs
whsec_... is returned only when the webhook is created.
2xx promptly.
Delivery contract
provider_message_id.
End-to-end path
A WhatsApp DM or group message reaches the ReplyBox-connected channel.
Baileys hands it to ReplyBox, which stores the inbound message and resolves sender IDs.
ReplyBox posts a message.received JSON payload to Toppie's receiver.
The receiver checks the HMAC over the raw body, rejects stale timestamps, and dedupes.
If Toppie's JID is mentioned, Toppie sends the reply through the existing public messages API.
Status
The ReplyBox side ships in migration 0067_org_webhooks plus the /api/v1/public/webhooks endpoints. Toppie's required work is the HTTPS receiver described on this page.
message.received for inbound messages today.
1. Register
Use the same per-organization bearer token Toppie already uses to send messages.
Create subscription
Request fields
urlrequiredToppie's public HTTPS receiver, for example https://toppie.yourdomain.com/whatsapp-webhook.
eventsrequired hereSubscribe to message.received.
channel_scopeoptionalall by default, or specific to limit delivery.
channel_idsconditionalRequired when channel_scope is specific; each value is a channel UUID.
secretoptionalSupply your own signing secret. If omitted, ReplyBox generates one and returns it once.
curl -X POST https://replybox-54au9.ondigitalocean.app/api/v1/public/webhooks \
-H "Authorization: Bearer <YOUR_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"url": "https://toppie.yourdomain.com/whatsapp-webhook",
"events": ["message.received"],
"channel_scope": "all"
}'
{
"id": "...uuid...",
"url": "https://toppie.yourdomain.com/whatsapp-webhook",
"events": ["message.received"],
"channel_scope": "all",
"channel_ids": null,
"active": true,
"created_at": "2026-06-30T...Z",
"secret": "whsec_xxxxxxxx"
}
Store secret immediately. It is returned only at creation. Listing webhooks does not echo it.
/api/v1/public/webhooksList Toppie's webhooks. Secrets are not returned.
DELETE/api/v1/public/webhooks/{id}Remove a webhook.
2. Payload
Requests use Content-Type: application/json. This example is a group @-mention.
{
"event": "message.received",
"channel_id": "f1cab8ae-...",
"org_id": "ba49003e-4ad7-4d04-a483-cb938fc90426",
"conversation_id": "...uuid...",
"group_jid": "120363286291555734@g.us",
"is_group": true,
"provider_message_id": "WA-stable-id",
"direction": "inbound",
"sender_jid": "60123456789@s.whatsapp.net",
"sender_name": null,
"kind": "text",
"body": "@Toppie what's the status of Linda's order?",
"mentioned_jids": ["60162289066@s.whatsapp.net"],
"reply_to_message_id": null,
"quoted_text": null,
"timestamp": "2026-06-30T10:00:00+00:00"
}
is_group / group_jidgroup_jid is the WhatsApp @g.us JID, or null for a DM.
provider_message_idStable WhatsApp ID. Use it to dedupe deliveries.
sender_jidThe individual who sent the message, with LID-to-phone resolution applied.
sender_namePush name for DMs only. For groups it is null; resolve display names from sender_jid on Toppie's side if needed.
mentioned_jidsThis is how Toppie detects a tag. Check for 60162289066@s.whatsapp.net. Empty list means no mentions.
reply_to_message_id / quoted_textSet when the message quotes another message; both are null otherwise.
kindtext, image, video, audio, document, sticker, or location. Media bytes are not included; body carries text or caption only.
timestampISO-8601 timestamp for when ReplyBox recorded the message.
3. Verify
The signature is HMAC_SHA256(secret, "{timestamp}.{raw_request_body}"), encoded as hex. Compute it over the raw body bytes; do not re-serialize parsed JSON because key order and whitespace must match what ReplyBox signed.
Headers
Reject stale timestamps, compare signatures in constant time, then parse the JSON body.
import hashlib, hmac, time
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
SECRET = b"whsec_xxxxxxxx"
@app.post("/whatsapp-webhook")
async def receive(request: Request):
raw = await request.body()
ts = request.headers.get("X-Replybox-Timestamp", "")
sig = request.headers.get("X-Replybox-Signature", "")
if not ts.isdigit() or abs(time.time() - int(ts)) > 300:
raise HTTPException(400, "stale timestamp")
expected = "sha256=" + hmac.new(
SECRET,
f"{ts}.".encode() + raw,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, sig):
raise HTTPException(401, "bad signature")
msg = await request.json()
# dedupe on msg["provider_message_id"], check mentioned_jids, enqueue work
return {"ok": True}
const crypto = require("crypto");
function verify(req) {
const ts = req.header("X-Replybox-Timestamp") || "";
const sig = req.header("X-Replybox-Signature") || "";
const expected = "sha256=" + crypto
.createHmac("sha256", SECRET)
.update(`${ts}.${req.body}`)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(sig),
);
}
Express must expose the raw body, for example with express.raw({type:'*/*'}), before JSON parsing mutates it.
4. Delivery semantics
ReplyBox treats any 2xx as delivered. Do heavy work such as LLM calls or replies after responding, or off a queue.
Use provider_message_id, or the X-Replybox-Event-Id header, as the idempotency key.
ReplyBox retries up to three times on non-2xx, timeout, or connection error, with backoff of 0 s, 5 s, then 30 s.
A slow first response can still complete after a retry lands, so dedupe must be durable.
Each retry is re-signed with a fresh X-Replybox-Timestamp and matching signature.
Only direction: "inbound" is sent today, so Toppie will not receive its own outbound sends.
5. Replying
When mentioned_jids contains Toppie's number, reply through the same send API that already works. For group replies, pass the webhook's group_jid as to.
curl -X POST https://replybox-54au9.ondigitalocean.app/api/v1/public/messages \
-H "Authorization: Bearer <YOUR_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"channelId": "<channel uuid>",
"to": "<group_jid or phone>",
"message_type": "text",
"body": { "text": "Linda'\''s order shipped this morning." }
}'
Open items
The current contract is enough for real-time inbound handling and mention-triggered replies. These two items need ReplyBox confirmation if Toppie needs them.
sender_name for group messages is null by design. Persisting per-message group push names would require a ReplyBox-side change.
message.sent outbound delivery confirmation is reserved but not wired yet. Ask if Toppie needs delivery receipts.