Build and operate
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:
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.
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
2xxquickly. Do slow work afterwards, in a queue. - Make processing idempotent: the same event may be delivered more than once.
The request
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
{
"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.
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]);
}
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]));
}
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
2xxwithin 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 |
message.status |
An outgoing message was sent, delivered, read or failed, or its outcome became unknown | message.status |
template.status |
Meta approved, rejected or paused a template | template.status |
whatsapp.connection |
A WhatsApp account connected, disconnected, broke or its token is expiring | 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.