# 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`](/docs/api/callbacks/message-received) arrives once per WhatsApp message:

```json
{
  "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](/docs/faq)). 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](/docs/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

1. **Store it.** Save `data.message` under contact `data.from`, keyed by `data.wamid` so a repeated callback does not add a duplicate. Show `data.profile_name`.
2. **Note the window.** The contact can now receive free-form text for 24 hours - from the number they wrote to (`data.phone_number_id`).
3. **Reply.** Send `type: text` to `data.from` from that `phone_number_id`, with your own `reference`.
4. **Fall back.** On `422 outside_service_window`, offer the agent a template instead.

```php
// 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();
}
```

```javascript
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);
});
```

```python
@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](/docs/callbacks) page.
