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>
Coming soon

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).

HTTP
git clone https://github.com/Labyrinthe-Team/labyrinthe-pay-packages.git
cd labyrinthe-pay-packages/java && ./mvnw install

Configuration#

Java
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();
OptionDefaultRole
apiKeyLABPAY_API_KEYLive token or test_… test key.
baseUrlLABPAY_BASE_URLAPI root; /V2 is added.
timeout30 sPer attempt.
maxRetries / maxRetryWait2 / 10 sSafe retries and longest Retry-After wait.
autoReferencetrueGenerates 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#

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=true

The 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.

POST /V2/deposits
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.

Automatic reference
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).

GET /V2/payments/{id}
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()
}
POST /V2/payments/{id}/sync
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 suite
GET /V2/payments (list, every page, by reference)
import 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.

POST /V2/payouts
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é
}
POST /V2/payouts/{id}/cancel
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.

POST /V2/refunds
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#

POST /V2/predict-provider
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_COD
GET /V2/providers
import 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");
GET /V2/balances
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.

Java
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 caseClass
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_NUMBERInvalidPhoneNumberException
AMOUNT_TOO_SMALL, AMOUNT_TOO_LARGE, AMOUNT_OUT_OF_BOUNDSAmountOutOfRangeException
INSUFFICIENT_BALANCEInsufficientBalanceException
INVALID_CURRENCY, UNSUPPORTED_COUNTRY, UNSUPPORTED_PROVIDER, INVALID_AMOUNTInvalidRequestException
429 (rate limit) / DAILY_LIMIT_REACHEDRateLimitException / DailyLimitReachedException
MAINTENANCE, API_DISABLED, DEPOSITS_DISABLED, WITHDRAWALS_DISABLED, PROVIDER_TEMPORARILY_UNAVAILABLE, GATEWAY_UNAVAILABLE, V2_DISABLED, TEST_MODE_DISABLED, other 5xxServiceUnavailableException
Network error or timeoutConnectionException / RequestAbortedException
Invalid input, detected before any callInvalidArgumentException
Notification with an invalid signature or bodySignatureVerificationException / 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).

Receiving endpoint (callback_url)
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 on GET /V2/account with the live token (LABPAY_WEBHOOK_SECRET variable).
  • Invalid signature or body: answer 400. Otherwise 200 within 10 seconds, long work afterwards.
  • The same notification can arrive more than once: deduplicate on X-Labyrinthe-Delivery or the payment id.

History and resend#

GET /V2/notifications, POST /V2/notifications/{id}/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 sends sdk_<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 422 carrying the payment (refused at initiation) is returned with status: failed and failure, not thrown; a 202 is a pending payment.
  • 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, 429 after Retry-After, maintenance-type refusals (503) only after a Retry-After; never DAILY_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: constructEvent checks X-Labyrinthe-Signature on 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.