# Callbacks

> Signed status posts for every terminal delivery state, how to verify them, and how to reconcile when your endpoint was down.

Pass `callback_url` on any v2 send and Nofy posts every terminal delivery
state to it: the fast path for your own records. The pull
(`GET /v2/deliveries`) is the guarantee; treat the posts as acceleration,
not the record.

## Verifying posts

Every post carries `X-ENS-Signature`: the hex HMAC-SHA256 of the raw body
under your per-client callback secret. Your operator issues that secret
once. It is never the OAuth client secret, which is stored as a password
hash and would fail every verification.

### Python
```python
import hashlib
import hmac

def valid(raw_body: bytes, signature: str, secret: str) -> bool:
    expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)
```
### Node.js
```javascript
import crypto from "node:crypto";

export function valid(rawBody, signature, secret) {
  const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
  const a = Buffer.from(expected), b = Buffer.from(signature || "");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```
### PHP
```php
<?php
function valid(string $rawBody, string $signature, string $secret): bool {
    $expected = hash_hmac('sha256', $rawBody, $secret);
    return hash_equals($expected, $signature);
}
```

!!! warning "Reject anything that does not verify"
    Compare in constant time (`hmac.compare_digest`, `timingSafeEqual`,
    `hash_equals`), never with `==`, and reject posts with a missing or
    mismatched signature before parsing the body.

Clients without an issued secret get no posts at all: an unverifiable
callback looks like assurance and provides none.

## Payload

```json
{
  "delivery_id": "dL91xTo874Y8",
  "reference": "shift-8841",
  "channel": "sms",
  "recipient": "255700000000",
  "status": "sent",
  "attempts": 1,
  "provider_message_id": "beem-req-42",
  "sent_at": "2026-09-25T10:01:00+00:00",
  "delivered_at": null,
  "read_at": null,
  "last_error": null
}
```

- `delivery_id`
Stable id for this recipient on this channel. Merge on it.
- `status`
`sent` or `failed`.
- `attempts`
How many tries it took, including the one that succeeded.
- `provider_message_id`
The provider's id, for example Beem's request id.
- `delivered_at, read_at`
Filled in for WhatsApp from Meta's status webhook. A message the handset
acknowledged stays `sent`, with these timestamps set.
- `last_error`
The provider's reason on a permanent failure.

## Reconciliation

Callbacks are best-effort with retries. If your endpoint was down, walk
`GET /v2/deliveries?updated_since=<last-seen>` page by page with `cursor`
and merge by `delivery_id`. Every attempt restamps the row, so nothing
that moved is missing from the pull.
