In the group
¶ Someone tags Toppie
120363286291555734@g.us
Mention60162289066@s.whatsapp.net in mentioned_jids
Source: docs/toppie-webhook-tutorial.md
Step by step: Toppie receives WhatsApp group messages (and @-mentions) in real time and replies in-thread — no dashboard login, no polling.
In the group
120363286291555734@g.us
Mention60162289066@s.whatsapp.net in mentioned_jids
2xx fast, then hands off to a worker
Toppie replies
POST /api/v1/public/messages
to = the webhook's group_jid. Same token as today.
You'll do this in 5 steps
Reference IDs
Use these values throughout the tutorial. Toppie's WhatsApp number is 60162289066.
ba49003e-4ad7-4d04-a483-cb938fc90426
API basehttps://replybox-54au9.ondigitalocean.app
Toppie JID60162289066@s.whatsapp.net
Step 1 of 5
You likely already have it — it's the same rb_live_… token Toppie uses to send today. Check Toppie's existing secrets/env first.
Need a fresh one? Mint it as an owner/admin
1Log into the ReplyBox dashboard. 2Go to Settings → API (/settings/api).
3Click create, name it Toppie, and copy the rb_live_… value immediately.
It is shown only once. Copy it the moment it appears.
Keep this token in Toppie's secret store. It authorizes both sending and webhook management for this org.
Step 2 of 5
Toppie needs one public HTTPS endpoint that ReplyBox can POST to. Pick a secret string now (any long random value) — you'll register it in Step 3 and use it here to verify signatures.
The receiver does four things
1Reject stale requests (replay protection). 5-min window. 2Verify the signature:HMAC_SHA256(secret, "{ts}.{raw_body}")
3Dedup — the same message may arrive more than once (retries).
4Respond 2xx fast; do the slow work (LLM, reply) afterwards / off a queue.
import hashlib, hmac, os, time
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
SECRET = os.environ["REPLYBOX_WEBHOOK_SECRET"].encode() # the secret from Step 3
TOPPIE_JID = "60162289066@s.whatsapp.net"
seen = set() # swap for Redis/db in production — dedup across restarts
@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", "")
# 1) Reject stale requests (replay protection). 5-min window.
if not ts.isdigit() or abs(time.time() - int(ts)) > 300:
raise HTTPException(400, "stale timestamp")
# 2) Verify signature: HMAC_SHA256(secret, "{ts}.{raw_body}")
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()
# 3) Dedup — same message may arrive more than once (retries).
mid = msg["provider_message_id"]
if mid in seen:
return {"ok": True}
seen.add(mid)
# 4) Respond 2xx FAST; do the slow work (LLM, reply) afterwards / off a queue.
if TOPPIE_JID in (msg.get("mentioned_jids") or []):
# hand off to your async worker — do NOT block this response
enqueue_toppie_reply(msg)
return {"ok": True}
def enqueue_toppie_reply(msg):
... # your queue / background task
https://toppie.yourdomain.com/whatsapp-webhook
Local testing tip: use a tunnel (ngrok http 8000) to get a temporary public HTTPS URL while developing, then switch to your real host for production.
Step 3 of 5
Tell ReplyBox where to push, using the token from Step 1 and the secret from Step 2.
curl -X POST https://replybox-54au9.ondigitalocean.app/api/v1/public/webhooks \
-H "Authorization: Bearer rb_live_XXXXXXXX" \
-H "Content-Type: application/json" \
-d '{
"url": "https://toppie.yourdomain.com/whatsapp-webhook",
"events": ["message.received"],
"channel_scope": "all",
"secret": "the-same-secret-your-receiver-uses"
}'
secret?ReplyBox generates one and returns it in the response — copy it into your receiver's REPLYBOX_WEBHOOK_SECRET instead.
Response 201Includes the webhook id (save it for Step 5 management).
curl https://replybox-54au9.ondigitalocean.app/api/v1/public/webhooks \
-H "Authorization: Bearer rb_live_XXXXXXXX"
# → [{ "id": "...", "url": "...", "active": true, ... }] (secret not echoed)
Step 4 of 5
Send a real group message and watch it arrive.
60162289066) is in — ideally @-mention Toppie.
3Watch your receiver logs: within ~1–2 seconds you should see a POST with is_group: true and Toppie's JID inside mentioned_jids.
If nothing arrives, see Troubleshooting below.
Step 5 of 5
Inside your async worker (enqueue_toppie_reply), do Toppie's logic, then reply using the send API (this already works today — same token).
curl -X POST https://replybox-54au9.ondigitalocean.app/api/v1/public/messages \
-H "Authorization: Bearer rb_live_XXXXXXXX" \
-H "Content-Type: application/json" \
-d '{
"channelId": "<your channel uuid>",
"to": "120363286291555734@g.us",
"message_type": "text",
"body": { "text": "Linda'\''s order shipped this morning 📦" }
}'
group_jid from the webhook as to.
Reply to a DMPass the sender's phone or sender_jid.
Payload
{
"event": "message.received",
"channel_id": "…",
"org_id": "ba49003e-4ad7-4d04-a483-cb938fc90426",
"conversation_id": "…",
"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"
}
mentioned_jidsHow you detect a tag.
sender_namenull for group messages — resolve names from sender_jid if you need them.
group_jidPass it as to when replying in the group.
provider_message_idDedup key for retried deliveries.
Managing the webhook later
The secret is not retrievable later — if lost, delete + re-register.
/api/v1/public/webhooksSee what's registered.
DELETEthen POSTChange URL / secret: DELETE the old, POST a new one.
DELETE/api/v1/public/webhooks/{id}Turn it off / remove.
Troubleshooting
401 registering the webhookBad/expired token. Re-check the rb_live_… value; mint a fresh one in Settings → API.
404 on /public/webhooksThe webhook feature isn't deployed/migrated on the production API yet.
Receiver URL not public/HTTPS; or webhook active:false; or the message isn't on this org's channel. Re-list with GET.
401 bad signatureSecret mismatch between Step 2 and Step 3, OR you hashed the parsed JSON instead of the raw body. Sign "{ts}.{raw_bytes}".
Expected on retry — dedup on provider_message_id.
ReplyBox retries 3× (0/5/30s) then drops. Return 2xx fast; do work async.
Signature scheme
X-Replybox-Signature: sha256=<hex>, timestamp-prefixed for replay protection.
X-Replybox-Signature: sha256=<hex>
<hex> = HMAC_SHA256(secret, "{X-Replybox-Timestamp}.{raw_request_body}")
This is ReplyBox's scheme (timestamp-prefixed, for replay protection). Verify exactly as shown — not HMAC(secret, body) alone.