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.
{
"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"
}| Header | Content |
|---|---|
X-Labyrinthe-Event | payment.completed or payment.failed. |
X-Labyrinthe-Delivery | Unique notification identifier. |
X-Labyrinthe-Mode | live or test. |
X-Labyrinthe-Signature | t=<timestamp>,v1=<signature> (see below). |
- The
callback_urlmust 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
2xxwithin 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.
- Read the raw request body, before any JSON decoding.
- Extract
tand everyv1from theX-Labyrinthe-Signatureheader. - Compute
HMAC-SHA256("<t>.<body>", secret)and compare it to eachv1in constant time. - Refuse if no signature matches, or if
tis 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 secondesFor 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
2xxanswer, the notification is retried after 1, 5, 15, 60, 240 minutes, then abandoned. - The same notification can arrive more than once: use
X-Labyrinthe-Deliveryorpayment.idto 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.
| Field | Type | Required | Description |
|---|---|---|---|
payment_id | string | No | A payment identifier (DEP-…). |
status | string | No | delivered, pending (still retrying), abandoned. |
per_page | integer | No | 1 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#
- Call the rotation and store the new secret.
- For 24 h, verify with the new secret (the header also carries the old secret's signature).
- Remove the old secret from your configuration.