Common workflows
Receiving messages
Build an inbox from message.received callbacks.
In a nutshell
Every WhatsApp message a customer sends arrives once as a message.received callback with Meta's message object. PaalChat does not keep the text, so store it. It also opens the 24-hour window for replies.
The callback
message.received arrives once per WhatsApp message:
{
"id": "7f0c2d9e-5a41-4f7b-9b0e-3c1d2e4f5a6b",
"event": "message.received",
"occurred_at": "2026-09-30T18:54:28+00:00",
"business": {"external_id": "presec"},
"data": {
"message_id": 57,
"wamid": "wamid.HBgMMjMzMjQxMjM0NTY3FQIAEhgg",
"from": "233241234567",
"profile_name": "Ama Mensah",
"phone_number_id": "106540352242922",
"type": "text",
"timestamp": "1759240000",
"message": {
"from": "233241234567", "id": "wamid.HBgMMjMzMjQxMjM0NTY3FQIAEhgg", "timestamp": "1759240000",
"type": "text", "text": {"body": "Good morning. Is there school on Friday?"}
}
}
}
Important
PaalChat keeps the conversation history, encrypted, for 365 days by default (details). Store what you need for longer yourself.
Message types
data.message is Meta's message object, unchanged:
type |
Where the content is |
|---|---|
text |
message.text.body |
button |
message.button.text, message.button.payload (quick reply on a template) |
interactive |
message.interactive.button_reply or list_reply (id, title) |
image, document, audio, video, sticker |
message.<type>.id (Meta's ID), mime_type, caption; PaalChat's copy is media_id - see Media |
location |
message.location.latitude, longitude, name, address |
reaction |
message.reaction.message_id, emoji |
contacts |
message.contacts[] |
unsupported |
nothing usable |
message.context.id is present when the customer replied to a specific message -
it is that message's wamid.
Build an inbox
- Store it. Save
data.messageunder contactdata.from, keyed bydata.wamidso a repeated callback does not add a duplicate. Showdata.profile_name. - Note the window. The contact can now receive free-form text for 24 hours - from the number they wrote to (
data.phone_number_id). - Reply. Send
type: texttodata.fromfrom thatphone_number_id, with your ownreference. - Fall back. On
422 outside_service_window, offer the agent a template instead.
// routes/api.php: Route::post('/paalchat/callback', PaalChatCallbackController::class);
public function __invoke(Request $request)
{
abort_unless(paalchatSignatureValid(
$request->header('X-PaalChat-Signature', ''), $request->getContent(), config('services.paalchat.callback_secret'),
), 401);
$event = $request->json()->all();
if ($event['event'] === 'message.received') {
InboxMessage::firstOrCreate(['wamid' => $event['data']['wamid']], [
'contact' => $event['data']['from'],
'name' => $event['data']['profile_name'],
'type' => $event['data']['type'],
'body' => $event['data']['message']['text']['body'] ?? null,
'payload' => $event['data']['message'],
]);
}
return response()->noContent();
}
app.post('/paalchat/callback', express.raw({ type: 'application/json' }), async (req, res) => {
if (!paalchatSignatureValid(req.get('X-PaalChat-Signature'), req.body.toString(), process.env.PAALCHAT_CALLBACK_SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body);
if (event.event === 'message.received') {
await inbox.upsert({ wamid: event.data.wamid, contact: event.data.from, body: event.data.message.text?.body });
}
res.sendStatus(204);
});
@app.post("/paalchat/callback")
def paalchat_callback():
if not paalchat_signature_valid(request.headers.get("X-PaalChat-Signature", ""), request.get_data(), CALLBACK_SECRET):
abort(401)
event = request.get_json()
if event["event"] == "message.received":
inbox.upsert(wamid=event["data"]["wamid"], contact=event["data"]["from"],
body=event["data"]["message"].get("text", {}).get("body"))
return "", 204
The signature helpers are on the Callbacks page.