## Callback: message.received

`POST <your callback URL>`

A customer sent the business a message. Sent once per WhatsApp message (`wamid`).
`message` is Meta's message object, unchanged. PaalChat keeps it, encrypted, in the
conversation history (365 days by default - see the FAQ). Also opens the
24-hour window for free-form replies to `from`.

### Headers

| Name | Description |
|---|---|
| `X-PaalChat-Event` |  |
| `X-PaalChat-Delivery` | Unique per event (equals body `id`). De-duplicate on it. |
| `X-PaalChat-Signature` | `t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>`. Reject if invalid or older than 300 seconds. |

### Body

| Field | Type | Description |
|---|---|---|
| `id` | string | Equals X-PaalChat-Delivery. |
| `sandbox` | boolean | true for sandbox businesses (test keys) - nothing reached WhatsApp. |
| `event` | any |  |
| `occurred_at` | string |  |
| `business` | object |  |
| `business.external_id` | string |  |
| `data` | object |  |
| `data.message_id` | integer |  |
| `data.wamid` | string |  |
| `data.contact_id` | integer | null |  |
| `data.conversation_id` | integer | null |  |
| `data.from` | string |  |
| `data.profile_name` | string | null |  |
| `data.phone_number_id` | string | null |  |
| `data.type` | string |  |
| `data.media_id` | integer | null | PaalChat's copy of the attachment (pending until media.updated); null without media, or when media is not enabled. |
| `data.timestamp` | string | null | Unix seconds as a string (Meta's). |
| `data.message` | object | Meta's message object, unchanged (text.body, button, interactive, image.id, context.id...). |

### Example

```json
{
  "id": "7f0c2d9e-5a41-4f7b-9b0e-3c1d2e4f5a6b",
  "event": "message.received",
  "occurred_at": "2026-09-30T18:54:28+00:00",
  "business": {"external_id": "presec"},
  "sandbox": false,
  "data": {
    "message_id": 57,
    "wamid": "wamid.HBgMMjMzMjQxMjM0NTY3FQIAEhgg",
    "from": "233241234567",
    "profile_name": "Ama Mensah",
    "phone_number_id": "106540352242922",
    "type": "text",
    "media_id": null,
    "timestamp": "1759240000",
    "message": {
      "from": "233241234567",
      "id": "wamid.HBgMMjMzMjQxMjM0NTY3FQIAEhgg",
      "timestamp": "1759240000",
      "type": "text",
      "text": {"body": "Good morning. Is there school on Friday?"}
    }
  }
}
```

### Verify and handle (PHP)

```php
// routes/api.php: Route::post('/paalchat/callback', PaalChatCallbackController::class);
public function __invoke(Request $request)
{
    $header = (string) $request->header('X-PaalChat-Signature');
    $raw = $request->getContent();

    if (! preg_match('/^t=(\d+),v1=([a-f0-9]{64})$/', $header, $m)
        || abs(time() - (int) $m[1]) > 300
        || ! hash_equals(hash_hmac('sha256', $m[1].'.'.$raw, config('services.paalchat.callback_secret')), $m[2])) {
        abort(401);
    }

    // Process each delivery once.
    if (! Cache::add('paalchat:'.$request->header('X-PaalChat-Delivery'), true, now()->addDays(2))) {
        return response()->noContent();
    }

    $event = json_decode($raw, true);

    if ($event['event'] === 'message.received') {
        InboxMessage::firstOrCreate(['wamid' => $event['data']['wamid']], [
            'contact' => $event['data']['from'],
            'body' => $event['data']['message']['text']['body'] ?? null,
        ]);
    }

    return response()->noContent();
}
```
