API V2 (recommended)

Notifications (webhooks)

Receive a payment's final status on your callback_url, verify the X-Labyrinthe-Signature signature, handle redeliveries and resend a notification.

When a notification is sent#

If the payment has a callback_url, LabPay sends a JSON POST to it as soon as the payment reaches its final status (completed or failed). The body contains the payment, in the same format as API responses.

Notification body
{
  "event": "payment.completed",
  "payment": {
    "id": "DEP-01JCX8Q3T6Y0V5W1Z2A3B4C5D6",
    "reference": "ORDER-42",
    "operation": "deposit",
    "livemode": false,
    "status": "completed",
    "gateway_status": "COMPLETED",
    "amount": "1000.00",
    "fee": "35.00",
    "total_amount": "1035.00",
    "currency": "CDF",
    "country": "COD",
    "phone": "0813456789",
    "provider": "VODACOM_MPESA_COD",
    "provider_transaction_id": null,
    "failure": null,
    "metadata": {
      "orderId": "42"
    },
    "created_at": "2026-11-11T10:00:00+00:00",
    "completed_at": "2026-11-11T10:00:42+00:00",
    "refunded_amount": "0.00"
  },
  "sent_at": "2026-11-11T10:00:43+00:00"
}
HeaderContent
X-Labyrinthe-Eventpayment.completed or payment.failed.
X-Labyrinthe-DeliveryUnique notification identifier.
X-Labyrinthe-Modelive or test.
X-Labyrinthe-Signaturet=<timestamp>,v1=<signature> (see below).
  • The callback_url must be a public HTTPS address: private or local addresses are refused (VALIDATION_ERROR). To test locally, use a tunnel or a webhook receiving service.
  • Answer with a 2xx within 10 seconds; do long processing after answering.

Verify the signature#

v1 is the HMAC-SHA256 (hex) of the string "<timestamp>.<raw body>", keyed with your signing secret (whsec_…). This secret is distinct from the API token: it is read on GET /V2/account (webhook_secret field), with the live token only.

  1. Read the raw request body, before any JSON decoding.
  2. Extract t and every v1 from the X-Labyrinthe-Signature header.
  3. Compute HMAC-SHA256("<t>.<body>", secret) and compare it to each v1 in constant time.
  4. Refuse if no signature matches, or if t is too old (the timestamp is signed: a notification replayed later can be detected).

The SDKs do all of this in one call: constructEvent (Webhook::constructEvent, Webhooks.constructEvent, webhooks.construct_event, Webhook.ConstructEvent) verifies the signature in constant time with a 300 s tolerance, accepts both secrets during a rotation and returns the typed event; Laravel, Spring Boot and ASP.NET Core even provide the receiving route. The "HTTP" tabs show the verification by hand.

Merchant-side verification

<?php
use Labyrinthe\LabPay\Exception\InvalidWebhookPayloadException;
use Labyrinthe\LabPay\Exception\SignatureVerificationException;
use Labyrinthe\LabPay\Webhook\Webhook;

try {
    $event = Webhook::constructEvent(
        file_get_contents('php://input'),               // corps BRUT
        $_SERVER['HTTP_X_LABYRINTHE_SIGNATURE'] ?? '',
        getenv('LABPAY_WEBHOOK_SECRET'),                // whsec_xxxxxxxxxxxxxxxx
    );
} catch (SignatureVerificationException|InvalidWebhookPayloadException) {
    http_response_code(400);
    exit;
}

if ($event->isPaymentCompleted()) {
    // livrer $event->payment->reference, une seule fois (X-Labyrinthe-Delivery ou payment->id)
}
http_response_code(200);                                 // en moins de 10 secondes

For 24 h after a secret rotation, the header contains two v1 values (new and old secret): accept the notification if either matches.

Redeliveries and duplicates#

  • Without a 2xx answer, the notification is retried after 1, 5, 15, 60, 240 minutes, then abandoned.
  • The same notification can arrive more than once: use X-Labyrinthe-Delivery or payment.id to process it only once.
  • Arrival order is not guaranteed: when in doubt, read the payment (GET /V2/payments/{id}).

History and resend#

GET /V2/notifications#

Notifications sent for the token's payments (mode of the key used), newest first.

FieldTypeRequiredDescription
payment_idstringNoA payment identifier (DEP-…).
statusstringNodelivered, pending (still retrying), abandoned.
per_pageintegerNo1 to 100, 20 by default.

Each data item gives id, event, payment_id, url, status, attempts, last_status_code, last_error, next_attempt_at, delivered_at and created_at; meta gives current_page, last_page, per_page and total.

Abandoned notifications (and resend with the SDKs)

curl "https://api.labyrinthe-rdc.com/api/V2/notifications?status=abandoned" \
  -H "Authorization: Bearer test_xxxxxxxxxxxxxxxx" \
  -H "Accept: application/json"

POST /V2/notifications/{id}/resend#

Resends a notification: a new notification (same event, same URL, up-to-date payment) is created and delivered; the history of previous attempts is kept. 202 response with the new notification.

Resend a notification

curl -X POST "https://api.labyrinthe-rdc.com/api/V2/notifications/1842/resend" \
  -H "Authorization: Bearer test_xxxxxxxxxxxxxxxx" \
  -H "Accept: application/json"

Rotate the signing secret#

POST /V2/account/webhook-secret/rotate#

Generates a new signing secret, returned in data.webhook_secret; the old one stays accepted for 24 h (data.webhook_secret_previous_expires_at). Live token only: with the test key, 403 LIVE_TOKEN_REQUIRED.

Rotation (live token)

curl -X POST "https://api.labyrinthe-rdc.com/api/V2/account/webhook-secret/rotate" \
  -H "Authorization: Bearer test_xxxxxxxxxxxxxxxx" \
  -H "Accept: application/json"

Rotation without downtime#

  1. Call the rotation and store the new secret.
  2. For 24 h, verify with the new secret (the header also carries the old secret's signature).
  3. Remove the old secret from your configuration.