# Webhooks and callbacks

Receive events safely - verify signatures, acknowledge fast, handle retries.

> **In a nutshell:** PaalChat POSTs signed JSON events to your callback URL. Verify the X-PaalChat-Signature on the raw body, answer 2xx within 10 seconds, de-duplicate on X-PaalChat-Delivery, and do slow work in a queue.

## Set up

A PaalChat operator registers your callback URL and generates the signing secret:

```bash
php artisan products:callback multi-skuul --url=https://skuuls.paaltech.org/api/paalchat/callback
```

The secret (`pcs_...`) is printed once - keep it in your secret store. Rotate it with `--rotate-secret`.

### Several endpoints

A product can have more than one webhook endpoint - for example one service
for incoming messages and another for delivery receipts. Each endpoint has its
**own secret**, and can be limited to some events (`message.received`,
`message.status`, `template.status`, `whatsapp.connection`), some businesses
(by `external_id`) or some channels. Each matching endpoint gets its own
delivery, with its own `X-PaalChat-Delivery` id and its own retries.

```bash
php artisan products:webhook multi-skuul add --url=https://receipts.example.com/hook --event=message.status
php artisan products:webhook multi-skuul            # list
```

Ask a PaalChat operator to add, filter, pause or remove endpoints; self-service
comes with the developer portal.

## Design a reliable endpoint

- Use HTTPS, accept only `POST`.
- Verify the signature **before** parsing JSON.
- Record the event, then answer `2xx` quickly. Do slow work afterwards, in a queue.
- Make processing idempotent: the same event may be delivered more than once.

## The request

```http
POST /api/paalchat/callback
Content-Type: application/json
X-PaalChat-Event: message.status
X-PaalChat-Delivery: 7f0c2d9e-5a41-4f7b-9b0e-3c1d2e4f5a6b
X-PaalChat-Signature: t=1759258468,v1=5d41402abc4b2a76b9719d911017c592e0d5ad0d4e6a1b4c9e3f2a1b0c9d8e7f
```

```json
{
  "id": "7f0c2d9e-5a41-4f7b-9b0e-3c1d2e4f5a6b",
  "event": "message.status",
  "occurred_at": "2026-09-30T18:54:28+00:00",
  "business": {"external_id": "presec"},
  "data": {}
}
```

`id` equals `X-PaalChat-Delivery`.

## Verify the signature

`v1` is the hex HMAC-SHA256 of `"<t>.<raw body>"` with your secret. Reject the
request if it does not match (constant-time compare) or if `t` is more than 300
seconds from now.

```php
function paalchatSignatureValid(string $header, string $rawBody, string $secret): bool
{
    if (! preg_match('/^t=(\d+),v1=([a-f0-9]{64})$/', $header, $m)) return false;
    if (abs(time() - (int) $m[1]) > 300) return false;
    return hash_equals(hash_hmac('sha256', $m[1].'.'.$rawBody, $secret), $m[2]);
}
```

```javascript
const crypto = require('crypto');
function paalchatSignatureValid(header, rawBody, secret) {
  const m = /^t=(\d+),v1=([a-f0-9]{64})$/.exec(header || '');
  if (!m || Math.abs(Date.now() / 1000 - Number(m[1])) > 300) return false;
  const expected = crypto.createHmac('sha256', secret).update(`${m[1]}.${rawBody}`).digest('hex');
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(m[2]));
}
```

```python
import hmac, hashlib, re, time
def paalchat_signature_valid(header: str, raw_body: bytes, secret: str) -> bool:
    m = re.fullmatch(r"t=(\d+),v1=([a-f0-9]{64})", header or "")
    if not m or abs(time.time() - int(m[1])) > 300:
        return False
    expected = hmac.new(secret.encode(), m[1].encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, m[2])
```

> [!WARNING]
> Verify against the exact bytes received. Re-encoding parsed JSON changes spacing
> and breaks the signature.

## Retries

- Any `2xx` within 10 seconds acknowledges the delivery.
- Anything else, a timeout or a redirect is a failure. PaalChat retries after 1 min,
  5 min, 30 min, 2 h, 6 h and 12 h (7 attempts, about 21 hours), then marks the
  delivery failed; an operator can resend it.
- Redirects are not followed - register the final URL.

## Events

| Event | When | Reference |
|---|---|---|
| `message.received` | A customer sent the business a message | [message.received](/docs/api/callbacks/message-received) |
| `message.status` | An outgoing message was sent, delivered, read or failed, or its outcome became unknown | [message.status](/docs/api/callbacks/message-status) |
| `template.status` | Meta approved, rejected or paused a template | [template.status](/docs/api/callbacks/template-status) |
| `whatsapp.connection` | A WhatsApp account connected, disconnected, broke or its token is expiring | [whatsapp.connection](/docs/api/callbacks/whatsapp-connection) |

## Verify the final state

Callbacks can arrive out of order. Apply message statuses only forward, and for
anything important (a connection change, a failed payment receipt), read the current
state from the API before acting.
