SDKs and tools

.NET and ASP.NET Core SDK

Install and use Labyrinthe.LabPay (.NET Standard 2.0 / .NET 8) and Labyrinthe.LabPay.AspNetCore: async client, AddLabPay() and MapLabPayWebhooks().

Labyrinthe.LabPay: netstandard2.0 and net8.0, HttpClient, System.Text.Json (no reflection: trimming / AOT friendly on net8.0), async API with CancellationToken. Labyrinthe.LabPay.AspNetCore (net8.0): services.AddLabPay(configuration) (typed IHttpClientFactory client, Microsoft.Extensions.Logging logs) and app.MapLabPayWebhooks().

Version 0.1.0 (preview). Source code, examples, changelog and full README: dotnet folder on GitHub.

Installation#

HTTP
dotnet add package Labyrinthe.LabPay
dotnet add package Labyrinthe.LabPay.AspNetCore   # intégration ASP.NET Core
Coming soon

This SDK is not published on NuGet 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: reference the projects of src/ (<ProjectReference>), or pack them locally with dotnet pack (.NET 8 SDK).

HTTP
git clone https://github.com/Labyrinthe-Team/labyrinthe-pay-packages.git
dotnet add reference ../labyrinthe-pay-packages/dotnet/src/Labyrinthe.LabPay/Labyrinthe.LabPay.csproj
dotnet add reference ../labyrinthe-pay-packages/dotnet/src/Labyrinthe.LabPay.AspNetCore/Labyrinthe.LabPay.AspNetCore.csproj

Configuration#

C#
using Labyrinthe.LabPay;

// Depuis l'environnement : LABPAY_API_KEY (et LABPAY_BASE_URL)
using var labpay = new LabPayClient();

// Ou explicitement
using var client = new LabPayClient(new LabPayClientOptions
{
    ApiKey = "test_xxxxxxxxxxxxxxxx",
    BaseUrl = "https://api.labyrinthe-rdc.com/api", // défaut ; "/V2" est ajouté
    Timeout = TimeSpan.FromSeconds(30),              // par tentative
    MaxRetries = 2,
    MaxRetryWait = TimeSpan.FromSeconds(10),
    AutoReference = true,
    Logger = LabPayLogger.Create((level, message) => Console.Error.WriteLine(message)),
});
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.
Logger—Logger; never contains the key.

LabPayClient is thread-safe: create one and reuse it (or let AddLabPay manage it). Your own HttpClient goes as second argument (it must not follow redirects). Request amounts as decimal (at most 2 decimals); in responses, strings (payment.AmountValue: exact decimal).

ASP.NET Core#

appsettings.json
{
  "LabPay": {
    "ApiKey": "test_xxxxxxxxxxxxxxxx",
    "WebhookSecret": "whsec_xxxxxxxxxxxxxxxx"
  }
}

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
using Labyrinthe.LabPay;

using var labpay = new LabPayClient(new LabPayClientOptions { ApiKey = "test_xxxxxxxxxxxxxxxx" }); // ou new LabPayClient() (LABPAY_API_KEY)

var payment = await labpay.Deposits.CreateAsync(new MobileMoneyRequest
{
    Amount = 1000m,
    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 = new Dictionary<string, object?> { ["orderId"] = "42" },
});

Console.WriteLine($"{payment.Id} {payment.Status}"); // DEP-… Pending

Without reference, the SDK generates one (sdk_<uuid>): a resend after a network failure returns the same payment.

Automatic reference
using Labyrinthe.LabPay;

using var labpay = new LabPayClient(new LabPayClientOptions { ApiKey = "test_xxxxxxxxxxxxxxxx" }); // ou new LabPayClient() (LABPAY_API_KEY)

// Sans Reference : le SDK envoie sdk_<uuid> et peut relancer sans risque de double paiement.
var payment = await labpay.Deposits.CreateAsync(new MobileMoneyRequest { Amount = 1000m, Currency = "CDF", Phone = "0813456789" });
Console.WriteLine(payment.Reference); // sdk_…

// Désactiver : new LabPayClientOptions { ApiKey = "test_xxxxxxxxxxxxxxxx", AutoReference = false }

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}
using Labyrinthe.LabPay;

using var labpay = new LabPayClient(new LabPayClientOptions { ApiKey = "test_xxxxxxxxxxxxxxxx" }); // ou new LabPayClient() (LABPAY_API_KEY)

var payment = await labpay.Payments.GetAsync("DEP-01JCX8Q3T6Y0V5W1Z2A3B4C5D6");
if (payment.IsCompleted)
{
    // livrer la commande payment.Reference
}
POST /V2/payments/{id}/sync
using Labyrinthe.LabPay;

using var labpay = new LabPayClient(new LabPayClientOptions { ApiKey = "test_xxxxxxxxxxxxxxxx" }); // ou new LabPayClient() (LABPAY_API_KEY)

var payment = await labpay.Payments.SyncAsync("DEP-01JCX8Q3T6Y0V5W1Z2A3B4C5D6"); // relit le statut tout de suite
GET /V2/payments (list, every page, by reference)
using Labyrinthe.LabPay;

using var labpay = new LabPayClient(new LabPayClientOptions { ApiKey = "test_xxxxxxxxxxxxxxxx" }); // ou new LabPayClient() (LABPAY_API_KEY)

var page = await labpay.Payments.ListAsync(new ListPaymentsRequest
{
    Status = PaymentStatus.Completed,
    Operation = Operation.Deposit,
    PerPage = 50,
});
foreach (var payment in page.Data) Console.WriteLine($"{payment.Id} {payment.Amount}");

// Toutes les pages, à la demande :
await foreach (var payment in labpay.Payments.IterateAsync(new ListPaymentsRequest { Status = PaymentStatus.Completed })) { }

// Par votre référence (null si inconnue) :
var found = await labpay.Payments.FindByReferenceAsync("ORDER-42");

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
using Labyrinthe.LabPay;

using var labpay = new LabPayClient(new LabPayClientOptions { ApiKey = "test_xxxxxxxxxxxxxxxx" }); // ou new LabPayClient() (LABPAY_API_KEY)

try
{
    var payout = await labpay.Payouts.CreateAsync(new MobileMoneyRequest
    {
        Amount = 5000m,
        Currency = "CDF",
        Phone = "0813456789",
        Reference = "PAYOUT-17",
        CallbackUrl = "https://boutique.example/webhooks/labpay",
        Metadata = new Dictionary<string, object?> { ["payoutId"] = "17" },
    });
    Console.WriteLine($"{payout.Id} {payout.TotalAmount}"); // montant + frais débités
}
catch (InsufficientBalanceException)
{
    // rien n'a été envoyé
}
POST /V2/payouts/{id}/cancel
using Labyrinthe.LabPay;

using var labpay = new LabPayClient(new LabPayClientOptions { ApiKey = "test_xxxxxxxxxxxxxxxx" }); // ou new LabPayClient() (LABPAY_API_KEY)

var payout = await labpay.Payments.GetAsync("PAY-01JCX9A1B2C3D4E5F6G7H8J9K0");
if (payout.IsQueued) // envoi ENQUEUED : annulable
{
    payout = await labpay.Payouts.CancelAsync(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
using Labyrinthe.LabPay;

using var labpay = new LabPayClient(new LabPayClientOptions { ApiKey = "test_xxxxxxxxxxxxxxxx" }); // ou new LabPayClient() (LABPAY_API_KEY)

var refund = await labpay.Refunds.CreateAsync(new RefundRequest
{
    PaymentId = "DEP-01JCX8Q3T6Y0V5W1Z2A3B4C5D6",
    Amount = 300m,                   // facultatif : par défaut, tout le reste remboursable
    Reference = "REFUND-42-1",
});

Providers, prediction and balances#

POST /V2/predict-provider
using Labyrinthe.LabPay;

using var labpay = new LabPayClient(new LabPayClientOptions { ApiKey = "test_xxxxxxxxxxxxxxxx" }); // ou new LabPayClient() (LABPAY_API_KEY)

var prediction = await labpay.Providers.PredictAsync("0813456789", "COD");
Console.WriteLine(prediction.Provider); // VODACOM_MPESA_COD
GET /V2/providers
using Labyrinthe.LabPay;

using var labpay = new LabPayClient(new LabPayClientOptions { ApiKey = "test_xxxxxxxxxxxxxxxx" }); // ou new LabPayClient() (LABPAY_API_KEY)

var providers = await labpay.Providers.ListAsync(Operation.Payout, "COD");
GET /V2/balances
using Labyrinthe.LabPay;

using var labpay = new LabPayClient(new LabPayClientOptions { ApiKey = "test_xxxxxxxxxxxxxxxx" }); // ou new LabPayClient() (LABPAY_API_KEY)

var balances = await labpay.Account.BalancesAsync();
var cdf = await labpay.Account.BalanceAsync("CDF"); // null si aucun solde dans cette devise
Console.WriteLine(cdf?.Amount);                     // "125000.00" (cdf.Value : decimal exact)

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.

C#
using Labyrinthe.LabPay;

using var labpay = new LabPayClient(new LabPayClientOptions { ApiKey = "test_xxxxxxxxxxxxxxxx" }); // ou new LabPayClient() (LABPAY_API_KEY)

try
{
    var payment = await labpay.Deposits.CreateAsync(new MobileMoneyRequest
        { Amount = 1000m, Currency = "CDF", Phone = "0813456789", Reference = "ORDER-42" });
    if (payment.IsFailed)
    {
        // refusé dès l'initiation (422 avec le paiement) : renvoyé, pas levé
        Console.WriteLine(payment.Failure?.Code);
    }
}
catch (ValidationException e) { Console.WriteLine(string.Join(", ", e.Errors.Keys)); } // champs en erreur
catch (ServiceUnavailableException e) { Console.WriteLine($"{e.Code} {e.RetryAfter}"); } // MAINTENANCE…
catch (ConnectionException) { /* PAS un échec : renvoyer la même référence */ }
catch (ApiException e) { Console.WriteLine(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
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)#

app.MapLabPayWebhooks() exposes POST /webhooks/labpay: raw body verified, then your ILabPayWebhookHandler is called. Answers: 400 (invalid signature or body), 413 (above WebhookMaxBodySize), 500 when the handler throws (LabPay delivers it again later). WebhookSecret accepts several comma-separated secrets (default: LABPAY_WEBHOOK_SECRET). Outside ASP.NET Core: Webhook.ConstructEvent(rawBodyBytes, signatureHeader, secret); Webhook.Sign(body, secret) for your tests.

Receiving endpoint (callback_url)
// appsettings.json : { "LabPay": { "ApiKey": "test_xxxxxxxxxxxxxxxx", "WebhookSecret": "whsec_xxxxxxxxxxxxxxxx" } }
builder.Services.AddLabPay(builder.Configuration);
builder.Services.AddLabPayWebhookHandler<PaymentHandler>();
var app = builder.Build();
app.MapLabPayWebhooks(); // POST /webhooks/labpay : corps brut vérifié, puis PaymentHandler

sealed class PaymentHandler : ILabPayWebhookHandler
{
    public Task HandleAsync(WebhookEvent webhookEvent, CancellationToken cancellationToken)
    {
        if (webhookEvent.IsPaymentCompleted) { /* livrer webhookEvent.Payment.Reference une seule fois */ }
        return Task.CompletedTask;
    }
}

// Ailleurs : Webhook.ConstructEvent(rawBodyBytes, signatureHeader, secret)
  • 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
using Labyrinthe.LabPay;

using var labpay = new LabPayClient(new LabPayClientOptions { ApiKey = "test_xxxxxxxxxxxxxxxx" }); // ou new LabPayClient() (LABPAY_API_KEY)

var page = await labpay.Notifications.ListAsync(new ListNotificationsRequest { Status = NotificationStatus.Abandoned });
foreach (var notification in page.Data)
{
    await labpay.Notifications.ResendAsync(notification.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.