SDKs and tools
Java and Spring Boot SDK
Install and use com.labyrinthe-rdc:labpay (Java 17+, no dependency) and its Spring Boot 3 starter: thread-safe client, immutable models, webhook endpoint.
com.labyrinthe-rdc:labpay: Java 17+, java.net.http, no runtime dependency (internal JSON reader: no Jackson version imposed). com.labyrinthe-rdc:labpay-spring-boot-starter: Spring Boot 3 auto-configuration (labpay.* properties) and a signed notification endpoint publishing Spring events.
Version 0.1.0 (preview). Source code, examples, changelog and full README: java folder on GitHub.
Installation#
<dependency>
<groupId>com.labyrinthe-rdc</groupId>
<artifactId>labpay</artifactId>
<version>0.1.0</version>
</dependency>
<!-- ou, dans une application Spring Boot 3 -->
<dependency>
<groupId>com.labyrinthe-rdc</groupId>
<artifactId>labpay-spring-boot-starter</artifactId>
<version>0.1.0</version>
</dependency>This SDK is not published on Maven Central yet: the command above will work once it is released. Until then, install it from the SDK GitHub repository, as shown below.
From the repository: install the artifacts in your local Maven repository with the Maven Wrapper (JDK 17+), then keep the dependencies above (Gradle: add mavenLocal() to the repositories).
git clone https://github.com/Labyrinthe-Team/labyrinthe-pay-packages.git
cd labyrinthe-pay-packages/java && ./mvnw installConfiguration#
import cd.labyrinthe.labpay.LabPayClient;
import java.time.Duration;
// Depuis l'environnement : LABPAY_API_KEY (et LABPAY_BASE_URL)
LabPayClient labpay = LabPayClient.fromEnv();
// Ou explicitement
LabPayClient client = LabPayClient.builder()
.apiKey("test_xxxxxxxxxxxxxxxx")
.baseUrl("https://api.labyrinthe-rdc.com/api") // défaut ; "/V2" est ajouté
.timeout(Duration.ofSeconds(30)) // par tentative
.maxRetries(2)
.maxRetryWait(Duration.ofSeconds(10))
.autoReference(true)
.build();| Option | Default | Role |
|---|---|---|
apiKey | LABPAY_API_KEY | Live token or test_… test key. |
baseUrl | LABPAY_BASE_URL | API root; /V2 is added. |
timeout | 30 s | Per attempt. |
maxRetries / maxRetryWait | 2 / 10 s | Safe retries and longest Retry-After wait. |
autoReference | true | Generates sdk_<uuid> when reference is absent. |
httpClient | — | Your own HttpClient (it must not follow redirects). |
LabPayClient is thread-safe: create one per application and share it. Immutable models (Payment, Balance, Page<T>…), enum statuses with an UNKNOWN value. Amounts: pass a String, a BigDecimal or a long, never a double; payment.amountValue() gives an exact BigDecimal.
Spring Boot#
labpay.api-key=${LABPAY_API_KEY}
labpay.webhook.secret=${LABPAY_WEBHOOK_SECRET}
# facultatif : labpay.base-url, labpay.timeout=30s, labpay.max-retries=2, labpay.max-retry-wait=10s,
# labpay.auto-reference=true, labpay.webhook.path=/webhooks/labpay, labpay.webhook.tolerance=300s,
# labpay.webhook.enabled=trueThe starter registers a LabPayClient bean (when a key is available and you declare none) and, in a Spring MVC application with a secret, POST /webhooks/labpay. Customise the builder with a LabPayClientBuilderCustomizer bean (proxy, HttpClient…).
First collection (test mode)#
With the test key and the test number 0813456789 (success), no real money moves. The payment stays pending until the customer confirms; only deliver on completed.
import cd.labyrinthe.labpay.LabPayClient;
import cd.labyrinthe.labpay.MobileMoneyParams;
import cd.labyrinthe.labpay.Payment;
LabPayClient labpay = LabPayClient.builder().apiKey("test_xxxxxxxxxxxxxxxx").build(); // ou LabPayClient.fromEnv()
Payment payment = labpay.deposits().create(
MobileMoneyParams.builder()
.amount("1000")
.currency("CDF")
.phone("0813456789")
.reference("ORDER-42") // facultatif : sinon le SDK génère sdk_<uuid>
.callbackUrl("https://boutique.example/webhooks/labpay")
.customerMessage("Commande 42")
.metadata("orderId", "42")
.build());
System.out.println(payment.id() + " " + payment.status());Without reference, the SDK generates one (sdk_<uuid>): a resend after a network failure returns the same payment.
import cd.labyrinthe.labpay.LabPayClient;
import cd.labyrinthe.labpay.MobileMoneyParams;
import cd.labyrinthe.labpay.Payment;
LabPayClient labpay = LabPayClient.builder().apiKey("test_xxxxxxxxxxxxxxxx").build(); // ou LabPayClient.fromEnv()
// Sans reference : le SDK envoie sdk_<uuid> et peut relancer sans risque de double paiement.
Payment payment = labpay.deposits().create(
MobileMoneyParams.builder().amount("1000").currency("CDF").phone("0813456789").build());
System.out.println(payment.reference()); // sdk_…
// Désactiver : LabPayClient.builder().apiKey("test_xxxxxxxxxxxxxxxx").autoReference(false).build()Track and synchronise#
The final status arrives by notification. As a safety net, read the payment, or force a refresh from LabPay with sync (without a tight loop).
import cd.labyrinthe.labpay.LabPayClient;
import cd.labyrinthe.labpay.Payment;
LabPayClient labpay = LabPayClient.builder().apiKey("test_xxxxxxxxxxxxxxxx").build(); // ou LabPayClient.fromEnv()
Payment payment = labpay.payments().get("DEP-01JCX8Q3T6Y0V5W1Z2A3B4C5D6");
if (payment.isCompleted()) {
// livrer la commande payment.reference()
}import cd.labyrinthe.labpay.LabPayClient;
import cd.labyrinthe.labpay.Payment;
LabPayClient labpay = LabPayClient.builder().apiKey("test_xxxxxxxxxxxxxxxx").build(); // ou LabPayClient.fromEnv()
Payment payment = labpay.payments().sync("DEP-01JCX8Q3T6Y0V5W1Z2A3B4C5D6"); // relit le statut tout de suiteimport cd.labyrinthe.labpay.LabPayClient;
import cd.labyrinthe.labpay.ListPaymentsParams;
import cd.labyrinthe.labpay.Operation;
import cd.labyrinthe.labpay.Page;
import cd.labyrinthe.labpay.Payment;
import cd.labyrinthe.labpay.PaymentStatus;
LabPayClient labpay = LabPayClient.builder().apiKey("test_xxxxxxxxxxxxxxxx").build(); // ou LabPayClient.fromEnv()
ListPaymentsParams params = ListPaymentsParams.builder()
.status(PaymentStatus.COMPLETED)
.operation(Operation.DEPOSIT)
.perPage(50)
.build();
Page<Payment> page = labpay.payments().list(params);
page.data().forEach(p -> System.out.println(p.id() + " " + p.amount()));
// Toutes les pages, à la demande : labpay.payments().iterate(params) ou stream(params)
// Par votre référence : labpay.payments().findByReference("ORDER-42") (Optional)Send money#
Your balance is debited with amount + fees at initiation, and credited back if the payout fails. A queued payout (ENQUEUED) can be cancelled.
import cd.labyrinthe.labpay.LabPayClient;
import cd.labyrinthe.labpay.MobileMoneyParams;
import cd.labyrinthe.labpay.Payment;
import cd.labyrinthe.labpay.exception.InsufficientBalanceException;
LabPayClient labpay = LabPayClient.builder().apiKey("test_xxxxxxxxxxxxxxxx").build(); // ou LabPayClient.fromEnv()
try {
Payment payout = labpay.payouts().create(
MobileMoneyParams.builder()
.amount("5000")
.currency("CDF")
.phone("0813456789")
.reference("PAYOUT-17")
.callbackUrl("https://boutique.example/webhooks/labpay")
.metadata("payoutId", "17")
.build());
System.out.println(payout.id() + " " + payout.totalAmount()); // montant + frais débités
} catch (InsufficientBalanceException e) {
// rien n'a été envoyé
}import cd.labyrinthe.labpay.LabPayClient;
import cd.labyrinthe.labpay.Payment;
LabPayClient labpay = LabPayClient.builder().apiKey("test_xxxxxxxxxxxxxxxx").build(); // ou LabPayClient.fromEnv()
Payment payout = labpay.payments().get("PAY-01JCX9A1B2C3D4E5F6G7H8J9K0");
if (payout.isQueued()) { // envoi ENQUEUED : annulable
payout = labpay.payouts().cancel(payout.id());
}Refund#
All or part of a completed deposit, to the wallet that paid; only one refund in progress at a time per deposit.
import cd.labyrinthe.labpay.LabPayClient;
import cd.labyrinthe.labpay.Payment;
import cd.labyrinthe.labpay.RefundParams;
LabPayClient labpay = LabPayClient.builder().apiKey("test_xxxxxxxxxxxxxxxx").build(); // ou LabPayClient.fromEnv()
Payment refund = labpay.refunds().create(
RefundParams.builder()
.paymentId("DEP-01JCX8Q3T6Y0V5W1Z2A3B4C5D6")
.amount("300") // facultatif : par défaut, tout le reste remboursable
.reference("REFUND-42-1")
.build());
// Tout rembourser : labpay.refunds().create("DEP-01JCX8Q3T6Y0V5W1Z2A3B4C5D6")Providers, prediction and balances#
import cd.labyrinthe.labpay.LabPayClient;
import cd.labyrinthe.labpay.ProviderPrediction;
LabPayClient labpay = LabPayClient.builder().apiKey("test_xxxxxxxxxxxxxxxx").build(); // ou LabPayClient.fromEnv()
ProviderPrediction prediction = labpay.providers().predict("0813456789", "COD");
System.out.println(prediction.provider()); // VODACOM_MPESA_CODimport cd.labyrinthe.labpay.LabPayClient;
import cd.labyrinthe.labpay.Operation;
import cd.labyrinthe.labpay.Provider;
import java.util.List;
LabPayClient labpay = LabPayClient.builder().apiKey("test_xxxxxxxxxxxxxxxx").build(); // ou LabPayClient.fromEnv()
List<Provider> providers = labpay.providers().list(Operation.PAYOUT, "COD");import cd.labyrinthe.labpay.Balance;
import cd.labyrinthe.labpay.LabPayClient;
import java.math.BigDecimal;
LabPayClient labpay = LabPayClient.builder().apiKey("test_xxxxxxxxxxxxxxxx").build(); // ou LabPayClient.fromEnv()
BigDecimal cdf = labpay.account().balance("CDF").map(Balance::value).orElse(BigDecimal.ZERO);Also: open countries (GET /V2/countries), availability (GET /V2/availability), account (GET /V2/account) and rotations of the signing secret and test key (live token). Examples on Providers and prediction and Account and balances.
Error handling#
Every error extends LabPayException; API errors extend ApiException (code: e.code(), HTTP status, per-field errors, Retry-After, mode). A payment refused at initiation is not an error: it is returned with the failed status. Full list of codes: Errors.
import cd.labyrinthe.labpay.LabPayClient;
import cd.labyrinthe.labpay.MobileMoneyParams;
import cd.labyrinthe.labpay.Payment;
import cd.labyrinthe.labpay.exception.ApiException;
import cd.labyrinthe.labpay.exception.ConnectionException;
import cd.labyrinthe.labpay.exception.ServiceUnavailableException;
import cd.labyrinthe.labpay.exception.ValidationException;
LabPayClient labpay = LabPayClient.builder().apiKey("test_xxxxxxxxxxxxxxxx").build(); // ou LabPayClient.fromEnv()
try {
Payment payment = labpay.deposits().create(MobileMoneyParams.builder()
.amount("1000").currency("CDF").phone("0813456789").reference("ORDER-42").build());
if (payment.isFailed()) {
// refusé dès l'initiation (422 avec le paiement) : renvoyé, pas levé
}
} catch (ValidationException e) {
e.errors().forEach((field, messages) -> System.out.println(field + ": " + messages));
} catch (ServiceUnavailableException e) {
System.out.println(e.code() + " " + e.retryAfter()); // MAINTENANCE, DEPOSITS_DISABLED…
} catch (ConnectionException e) {
// PAS un échec : renvoyer la même référence
} catch (ApiException e) {
System.out.println(e.code()); // toujours le code, jamais le message
}| Code or case | Class |
|---|---|
UNAUTHENTICATED (401) | AuthenticationException |
TOKEN_BLOCKED, MERCHANT_INACTIVE, KYC_REQUIRED, LIVE_TOKEN_REQUIRED (403) | PermissionException |
PAYMENT_NOT_FOUND, DEPOSIT_NOT_FOUND, NOTIFICATION_NOT_FOUND (404) | NotFoundException |
NOT_A_PAYOUT, NOT_CANCELLABLE, DEPOSIT_NOT_COMPLETED, REFUND_IN_PROGRESS, DEPOSIT_ALREADY_REFUNDED (409) | ConflictException |
VALIDATION_ERROR (422) | ValidationException |
INVALID_PHONE_NUMBER | InvalidPhoneNumberException |
AMOUNT_TOO_SMALL, AMOUNT_TOO_LARGE, AMOUNT_OUT_OF_BOUNDS | AmountOutOfRangeException |
INSUFFICIENT_BALANCE | InsufficientBalanceException |
INVALID_CURRENCY, UNSUPPORTED_COUNTRY, UNSUPPORTED_PROVIDER, INVALID_AMOUNT | InvalidRequestException |
429 (rate limit) / DAILY_LIMIT_REACHED | RateLimitException / DailyLimitReachedException |
MAINTENANCE, API_DISABLED, DEPOSITS_DISABLED, WITHDRAWALS_DISABLED, PROVIDER_TEMPORARILY_UNAVAILABLE, GATEWAY_UNAVAILABLE, V2_DISABLED, TEST_MODE_DISABLED, other 5xx | ServiceUnavailableException |
| Network error or timeout | ConnectionException / RequestAbortedException |
| Invalid input, detected before any call | InvalidArgumentException |
| Notification with an invalid signature or body | SignatureVerificationException / InvalidWebhookPayloadException |
The API_VERSION_DISABLED and FEATURE_DISABLED codes (version or operation switched off from the console, 503) are newer than SDK 0.1.0: they arrive as an ApiException of the 503 (service unavailable) class, with their code intact. Do not retry in a loop: see Suspended features.
Receive notifications (webhooks)#
Webhooks.constructEvent(rawBody, header, secret) verifies the signature on the raw body (byte[]), then returns a WebhookEvent (isPaymentCompleted(), payment()). Several secrets are accepted (constructEvent(body, header, List.of(s1, s2), tolerance, clock)). With the Spring Boot starter, POST /webhooks/labpay verifies each notification and publishes it: 400 for an invalid signature or body, 500 when a listener throws (LabPay delivers it again later).
import cd.labyrinthe.labpay.webhook.WebhookEvent;
import cd.labyrinthe.labpay.webhook.Webhooks;
// N'importe quel framework : vérifier le corps BRUT (byte[]), jamais un objet ré-encodé.
WebhookEvent event = Webhooks.constructEvent(rawBody, request.getHeader(Webhooks.SIGNATURE_HEADER), secret);
if (event.isPaymentCompleted()) {
orders.deliver(event.payment().reference()); // une seule fois : X-Labyrinthe-Delivery ou payment().id()
}
// répondre 200 en moins de 10 s ; SignatureVerificationException / InvalidWebhookPayloadException → 400
// Spring Boot (starter) : POST /webhooks/labpay est fourni, écoutez l'événement
@EventListener
void onNotification(WebhookEvent event) {
if (event.isPaymentCompleted()) orders.deliver(event.payment().reference());
}- The
whsec_…secret is read onGET /V2/accountwith the live token (LABPAY_WEBHOOK_SECRETvariable). - Invalid signature or body: answer
400. Otherwise200within 10 seconds, long work afterwards. - The same notification can arrive more than once: deduplicate on
X-Labyrinthe-Deliveryor the paymentid.
History and resend#
import cd.labyrinthe.labpay.LabPayClient;
import cd.labyrinthe.labpay.ListNotificationsParams;
import cd.labyrinthe.labpay.NotificationStatus;
LabPayClient labpay = LabPayClient.builder().apiKey("test_xxxxxxxxxxxxxxxx").build(); // ou LabPayClient.fromEnv()
var page = labpay.notifications().list(
ListNotificationsParams.builder().status(NotificationStatus.ABANDONED).build());
page.data().forEach(n -> labpay.notifications().resend(n.id())); // nouvelle notification, historique conservéShared behaviour#
- `test_…` key = sandbox: no real money, separate balances and payments. Going live = switching keys.
- Automatic reference: without
reference, the SDK sendssdk_<uuid>, so that a deposit, payout or refund can be retried after a network failure or a 5xx without double payment (idempotency). Can be disabled (autoReference/auto_reference/AutoReference). - A refused payment is a payment: a
422carrying the payment (refused at initiation) is returned withstatus: failedandfailure, not thrown; a202is apendingpayment. - Errors typed by `code` (then by HTTP status), with per-field validation errors; an unknown code stays readable. Base your logic on the code, never on the message (in French).
- Safe retries (2 by default): reads and payments with a reference,
429afterRetry-After, maintenance-type refusals (503) only after aRetry-After; neverDAILY_LIMIT_REACHED. Rotations, notification resends, payout cancellations and payments without reference are never retried after a network error or a 5xx. - Local validation before any request (amount, currency, number, reference, URL…): invalid input raises an argument error with no network call.
- Verified webhooks:
constructEventchecksX-Labyrinthe-Signatureon the raw body, in constant time, with a 300 s tolerance and both secrets during a rotation. - Secure by default: the key never shows up in logs, dumps or exception messages.
See also: all SDKs and tools, V2 endpoint reference, error codes, Postman and OpenAPI.