## Callback: conversation.created

`POST <your callback URL>`

A conversation started - open when the contact wrote first, pending when the business did.

### 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.change` | string | created, reopened, status_changed, priority_changed, assigned, unassigned, tag_added or tag_removed. |
| `data.conversation` | object |  |
| `data.conversation.id` | integer |  |
| `data.conversation.contact` | object |  |
| `data.conversation.contact.id` | integer |  |
| `data.conversation.contact.external_id` | string | null |  |
| `data.conversation.contact.name` | string | null |  |
| `data.conversation.contact.phone` | string | null |  |
| `data.conversation.phone_number_id` | string | null | The business number it happens on. |
| `data.conversation.status` | string | open = needs the business; pending = waiting for the customer (a conversation the business started); an incoming message reopens pending, resolved and snoozed conversations; after closed, the next message starts a new conversation. |
| `data.conversation.priority` | string |  |
| `data.conversation.unread_count` | integer |  |
| `data.conversation.window_open` | boolean | Free-form replies are possible (the contact wrote in the last 24 hours). |
| `data.conversation.last_message_at` | string | null |  |
| `data.conversation.last_incoming_at` | string | null |  |
| `data.conversation.snoozed_until` | string | null |  |
| `data.conversation.assignee` | object | null | The inbox member working it. |
| `data.conversation.assignee.external_id` | string |  |
| `data.conversation.assignee.name` | string |  |
| `data.conversation.tags` | array of strings |  |
| `data.conversation.created_at` | string |  |

### Example

```json
{
  "id": "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f",
  "event": "conversation.created",
  "occurred_at": "2026-10-01T09:12:01+00:00",
  "business": {"external_id": "presec"},
  "sandbox": false,
  "data": {
    "change": "created",
    "conversation": {
      "id": 7,
      "contact": {
        "id": 41,
        "external_id": "parent-77",
        "name": "Ama Mensah",
        "phone": "233241234567"
      },
      "phone_number_id": "106540352242922",
      "status": "resolved",
      "priority": "normal",
      "unread_count": 0,
      "window_open": true,
      "last_message_at": "2026-10-01T09:12:00+00:00",
      "last_incoming_at": "2026-10-01T09:12:00+00:00",
      "snoozed_until": null,
      "assignee": null,
      "tags": ["fees"],
      "created_at": "2026-10-01T08:00:00+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'] === 'conversation.created') {
        InboxConversation::firstOrCreate(['paalchat_id' => $event['data']['conversation']['id']], ['status' => $event['data']['conversation']['status']]);
    }

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