## Callback: media.updated

`POST <your callback URL>`

An incoming attachment is ready - PaalChat downloaded it from Meta and stored it
(`status: stored`, with a 15-minute signed `url`) - or it could not be fetched
(`status: failed`, with `error`). Follows the message's `message.received`, which carries
its `media_id`. Only for businesses with media enabled.

### 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.media` | object |  |
| `data.media.id` | integer |  |
| `data.media.direction` | string | incoming: a customer's attachment; outgoing: your upload. |
| `data.media.status` | string | pending: being fetched from Meta; deleted: removed after the retention period. |
| `data.media.mime_type` | string | null |  |
| `data.media.filename` | string | null |  |
| `data.media.size` | integer | null | Bytes. |
| `data.media.sha256` | string | null | Hex SHA-256 of the file. |
| `data.media.url` | string | null | Signed link to the bytes (stored only). No token needed; expires at url_expires_at. |
| `data.media.url_expires_at` | string | null |  |
| `data.media.error` | string | null | Why the file could not be fetched. |
| `data.media.created_at` | string |  |
| `data.media.stored_at` | string | null |  |
| `data.message_id` | integer | null | The incoming message the file came with. |
| `data.conversation_id` | integer | null |  |

### Example

```json
{
  "id": "0b1c2d3e-4f5a-4b6c-8d7e-9f0a1b2c3d4e",
  "event": "media.updated",
  "occurred_at": "2026-10-02T09:00:01+00:00",
  "business": {"external_id": "presec"},
  "sandbox": false,
  "data": {
    "message_id": 58,
    "conversation_id": 7,
    "media": {
      "id": 12,
      "direction": "incoming",
      "status": "stored",
      "mime_type": "image/jpeg",
      "filename": null,
      "size": 48213,
      "sha256": "8f434346648f6b96df89dda901c5176b10a6d83961dd3c1ac88b59b2dc327aa4",
      "url": "https://whatsapp.paaltech.org/media/12?expires=1759242000&signature=3c1d...",
      "url_expires_at": "2026-10-02T09:15:00+00:00",
      "error": null,
      "created_at": "2026-10-02T08:59:58+00:00",
      "stored_at": "2026-10-02T09:00:01+00:00"
    }
  }
}
```

### 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'] === 'media.updated') {
        if ($event['data']['media']['status'] === 'stored') {
            Attachment::fetchFromPaalChat($event['data']['message_id'], $event['data']['media']['url']);
        }
    }

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