DocumentationAPI v1 · 1.3.0

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#

EventWhendata
message.status_changedEvery 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.completedAll messages of a campaign have a final status.campaign_id, name, recipients_count, messages_by_status, credits_charged, completed_at
balance.lowThe balance falls below the account threshold (once, until it goes back above).balance, threshold
otp.verifiedCorrect code received by POST /otp/verify.otp_id, phone, status, reason, verified_at
otp.failedAttempts exhausted or expiry seen.otp_id, phone, status, reason, verified_at
test.pingClient area "Test" button.{}
Event body
{
  "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#

  1. Read the raw request body, before any JSON parsing.
  2. Compute HMAC-SHA256(secret, raw body) in hex and compare it in constant time to the X-Signature header (sha256=<hex> format).
  3. Reject a timestamp that is too old (more than 5 minutes) to prevent replay.
  4. Answer 400 when 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.
Deduplication

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.