Guides
Webhooks
Events sent to your server, HMAC-SHA256 signature verification, retries and deduplication.
Declare a URL in the client area (Developers > Webhooks) and choose the events. Each event is sent as a JSON POST, signed with the endpoint secret. The Test button sends a test.ping.
Events#
| Event | When | data |
|---|---|---|
message.status_changed | Every status change of an account message (API, client area, campaign). | message_id, recipient, status, provider_message_id, campaign_id, reference, error_code, error_message |
campaign.completed | All messages of a campaign have a final status. | campaign_id, name, recipients_count, messages_by_status, credits_charged, completed_at |
balance.low | The balance falls below the account threshold (once, until it goes back above). | balance, threshold |
otp.verified | Correct code received by POST /otp/verify. | otp_id, phone, status, reason, verified_at |
otp.failed | Attempts exhausted or expiry seen. | otp_id, phone, status, reason, verified_at |
test.ping | Client area "Test" button. | {} |
{
"id": "1f0c7e52-8d3a-4b6e-9c2f-5a7d1e3b9f40",
"event": "message.status_changed",
"data": {
"message_id": "9d5c0a3e-6f0b-4c2e-9a51-3b7f2d8e4c10",
"recipient": "243812345678",
"status": "delivered",
"provider_message_id": "DD-88231",
"campaign_id": null,
"reference": "CMD-1042",
"error_code": null,
"error_message": null
},
"timestamp": 1793952000
}Verify the signature#
- Read the raw request body, before any JSON parsing.
- Compute
HMAC-SHA256(secret, raw body)in hex and compare it in constant time to theX-Signatureheader (sha256=<hex>format). - Reject a
timestampthat is too old (more than 5 minutes) to prevent replay. - Answer
400when verification fails.
Without a package#
Manual verification
<?php
$secret = getenv('LABYRINTHE_WEBHOOK_SECRET');
$rawBody = file_get_contents('php://input'); // raw body, before json_decode
$signature = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
$expected = 'sha256='.hash_hmac('sha256', $rawBody, $secret);
if (! hash_equals($expected, $signature)) { // constant-time comparison
http_response_code(400);
exit;
}
$event = json_decode($rawBody, true);
if (abs(time() - $event['timestamp']) > 300) { // reject replays older than 5 min
http_response_code(400);
exit;
}
// Deduplicate on $event['id'], then answer 2xx within 10 s (queue slow work)
http_response_code(200);With the packages#
Every SDK checks the signature and timestamp, then returns a typed event: Webhook::constructEvent() (PHP), Route::labyrintheWebhooks() (Laravel), Webhooks.constructEvent() and Express / Next.js adapters (Node.js), construct_event() and Flask / FastAPI / Django helpers (Python), MapLabyrintheWebhooks() (ASP.NET Core), Webhooks.constructEvent() and Spring Boot controller (Java).
With the packages
<?php
use Labyrinthe\Connect\Exception\InvalidWebhookPayloadException;
use Labyrinthe\Connect\Exception\SignatureVerificationException;
use Labyrinthe\Connect\Webhook\Event\BalanceLow;
use Labyrinthe\Connect\Webhook\Event\MessageStatusChanged;
use Labyrinthe\Connect\Webhook\Webhook;
try {
$event = Webhook::constructEvent(
file_get_contents('php://input'),
$_SERVER['HTTP_X_SIGNATURE'] ?? '',
getenv('LABYRINTHE_WEBHOOK_SECRET'),
); // checks the signature and the timestamp (5 min)
} catch (SignatureVerificationException|InvalidWebhookPayloadException) {
http_response_code(400);
exit;
}
// Deduplicate on $event->id()
match (true) {
$event instanceof MessageStatusChanged => updateOrder($event->reference, $event->status->value),
$event instanceof BalanceLow => alertTeam($event->balance, $event->threshold),
default => null, // unknown events: answer 2xx
};Delivery and retries#
- Any 2xx response within 10 s counts as acknowledgement. Queue slow work.
- Otherwise, up to 5 attempts: after 30 s, 2 min, 5 min and 15 min.
- Arrival order is not guaranteed: rely on status and dates, not order.
The same event may arrive several times: deduplicate on `id`, identical across attempts. The Laravel and WordPress packages do it for you.
Headers#
X-Signature is authoritative. The X-Webhook-Id (= id), X-Webhook-Event (= event) and X-Webhook-Timestamp (= timestamp) headers are informative: only the signed body counts.
New events#
New events may appear without notice (see Versions): ignore an unknown event by answering 2xx.