## Callback: message.status

`POST <your callback URL>`

An outgoing message moved to `sent`, `delivered`, `read`, `failed` or `unknown`. Carries
your `reference`. Statuses only move forward - ignore updates that would move one back.
`unknown` means Meta's answer to the send was lost (timeout or server error): it may or
may not have reached the customer, and it is never sent again automatically. If Meta
did send it, a later `sent`/`delivered`/`read` arrives for the same message.
`pricing` is what Meta reports it charges for the message; Meta bills the business's
WhatsApp Business Account directly, never PaalChat.

### 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.channel` | string |  |
| `data.reference` | string | null |  |
| `data.wamid` | string | null | The provider's message id (Meta's wamid; Arkesel's, Hubtel's or Resend's id for SMS and email). |
| `data.contact_id` | integer | null |  |
| `data.conversation_id` | integer | null |  |
| `data.to` | string |  |
| `data.phone_number_id` | string | null |  |
| `data.status` | string |  |
| `data.previous_status` | string | Outgoing moves forward only (queued < sent < delivered < read); failed is final and never follows read. cancelled: a scheduled message withdrawn before it went. unknown follows queued when Meta's answer to the send was lost - it may still move to sent, delivered, read or failed if Meta reports the message. |
| `data.error` | object | null |  |
| `data.error.code` | string | Meta's error code (e.g. 131026) or a platform code (connection_unavailable, no_message_id, circuit_open when Meta was failing for longer than the send's retries; for unknown: http_5xx or no_response). |
| `data.error.message` | string |  |
| `data.pricing` | object | null | Meta's pricing for the message, from its status webhooks (null until Meta reports it, and for incoming messages). Meta bills the business's WhatsApp Business Account directly; PaalChat never charges for Meta messages. From 2026-10-01 replies inside the 24-hour window are billable too. |
| `data.pricing.billable` | boolean | null | Whether Meta charges for this message. |
| `data.pricing.category` | string | null | Meta's pricing category. |
| `data.pricing.type` | string | null | regular (charged), free_customer_service or free_entry_point. |
| `data.pricing.model` | string | null | Meta's pricing model: PMP (per message). |
| `data.sent_at` | string | null |  |
| `data.delivered_at` | string | null |  |
| `data.read_at` | string | null |  |
| `data.failed_at` | string | null |  |

### Example

```json
{
  "id": "3b8e9f10-2c4d-4e6f-8a1b-2c3d4e5f6a7b",
  "event": "message.status",
  "occurred_at": "2026-09-30T18:54:33+00:00",
  "business": {"external_id": "presec"},
  "sandbox": false,
  "data": {
    "message_id": 2,
    "reference": "skuul-msg-8812",
    "wamid": "wamid.HBgMMjMzMjQxMjM0NTY3FQIAERgSQ0Q",
    "to": "233241234567",
    "phone_number_id": "106540352242922",
    "status": "delivered",
    "previous_status": "sent",
    "error": null,
    "pricing": {"billable": true, "category": "utility", "type": "regular", "model": "PMP"},
    "sent_at": "2026-09-30T18:54:31+00:00",
    "delivered_at": "2026-09-30T18:54:33+00:00",
    "read_at": null,
    "failed_at": null
  }
}
```

### 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.status') {
        Notification::where('id', $event['data']['reference'])->first()?->advanceTo($event['data']['status'], $event['data']['error']);
    }

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