Aller au contenu

C# / .NET

Le package NuGet Wajub, ses services et l'interface asynchrone qu'il expose.

Le package Wajub constitue la partie serveur d'une intégration Wajub. Il conserve votre clé secrète, crée des paiements, lit leur statut réel et vérifie les signatures des webhooks. Les types de référence nullables sont activés. Chaque appel est awaitable et accepte un CancellationToken.

Version
1.1.1
Runtime
.NET 8+, no package dependency

Couvre

  • Payments
  • Billing
  • Transfers
  • Sync
  • Shield
  • Tax

Installer

dotnet add package Wajub

L'assembly cible net8.0 et utilise System.Text.Json et HttpClient du framework. Aucune autre dépendance n'est nécessaire.

Créer le client

WajubClient.Create est le point d'entrée. Il utilise l'environnement comme valeur de repli. Une configuration vide suffit donc généralement.

Deux façons de créer le client
using Wajub;

// Reads WAJUB_API_KEY and WAJUB_WEBHOOK_SECRET when the property is null.
var wajub = WajubClient.Create(new WajubConfig());

// Or spell it out.
var wajub = WajubClient.Create(new WajubConfig
{
    ApiKey = Environment.GetEnvironmentVariable("WAJUB_API_KEY"),
    WebhookSecret = Environment.GetEnvironmentVariable("WAJUB_WEBHOOK_SECRET"),
});
PropriétéFonction
ApiKeyVotre clé sk. ou sk_test.. Utilise WAJUB_API_KEY comme valeur de repli
WebhookSecretSecret whsec_. Utilise WAJUB_WEBHOOK_SECRET comme valeur de repli
IdempotencyKeyPrefixPréfixe de la clé générée. "wajub" par défaut
HttpClientVotre propre HttpClient, issu de IHttpClientFactory ou d'un handler de test

Une clé vide génère ArgumentException: wajub: api key is required lors de la construction. Une mauvaise configuration échoue donc au démarrage.

Enregistrement dans ASP.NET Core
builder.Services.AddHttpClient("wajub");

builder.Services.AddSingleton(sp =>
    WajubClient.Create(new WajubConfig
    {
        ApiKey = builder.Configuration["Wajub:ApiKey"],
        WebhookSecret = builder.Configuration["Wajub:WebhookSecret"],
        HttpClient = sp.GetRequiredService<IHttpClientFactory>().CreateClient("wajub"),
    }));

Premier appel

Chaque méthode est asynchrone et se termine par Async.

Créer un paiement
var payment = await wajub.Payments.CreateAsync(new Dictionary<string, object?>
{
    ["amount"] = 25000,
    ["currency"] = "XAF",
    ["email"] = "amina@example.com",
    ["description"] = "Order 4172",
    ["reference"] = $"order-{orderId}",
    ["callback"] = "https://shop.example.com/complete",
}, cancellationToken: ct);

return Results.Redirect(payment.AuthorizationUrl);

Les paramètres sont transmis dans un Dictionary<string, object?> avec les noms de champs exacts de l'API. Le résultat est un record, donc immuable et comparé par valeur.

Toutes les valeurs au-delà des six propriétés décodées
var reference = payment.Raw.GetValueOrDefault("reference")?.ToString();
var createdAt = payment.Raw.GetValueOrDefault("created_at")?.ToString();

Tous les services du client

ServiceMéthodes
client.GlobalPingAsync, ChannelsAsync, CountriesAsync, CurrenciesAsync
client.PaymentsCreateAsync, InitializeAsync, RetrieveAsync, ListAsync, CancelAsync, ProcessAsync, ProcessSplitAsync, ListRefundsAsync
client.CustomersCreateAsync, RetrieveAsync, UpdateAsync, DeleteAsync, ListAsync, BlockAsync, UnblockAsync, ActivateAsync, DeactivateAsync, ListTaxIdsAsync, CreateTaxIdAsync, DeleteTaxIdAsync
client.RefundsCreateAsync, RetrieveAsync, ListAsync
client.TransfersCreateAsync, RetrieveAsync, ListAsync
client.BeneficiariesCreateAsync, RetrieveAsync, UpdateAsync, DeleteAsync, ListAsync
client.LinksCreateAsync, RetrieveAsync, UpdateAsync, DeleteAsync, ListAsync
client.InvoicesCreateAsync, RetrieveAsync, UpdateAsync, DeleteAsync, ListAsync, SendAsync, MarkPaidAsync, CancelAsync
client.AccountsCreateAsync, RetrieveAsync, UpdateAsync, DeleteAsync, ListAsync, RegenerateTokenAsync
client.WebhookEndpointsCreateAsync, RetrieveAsync, UpdateAsync, DeleteAsync, ListAsync, RotateSecretAsync
client.BalanceRetrieveAsync
client.EventsListAsync, RetrieveAsync, ResendAsync
client.DisputesListAsync, RetrieveAsync, SubmitEvidenceAsync, AcceptAsync, CloseAsync, SendMessageAsync
client.IdentityResolveAsync, ValidateAsync
client.TaxGetSettingsAsync, UpdateSettingsAsync, RatesAsync, CalculateAsync, ReportsAsync, ListCodesAsync, RetrieveCodeAsync, ListRegistrationsAsync, CreateRegistrationAsync, RetrieveRegistrationAsync, UpdateRegistrationAsync, DeleteRegistrationAsync, JurisdictionsAsync, ThresholdsAsync, ThresholdAlertsAsync
client.ShieldGetSettingsAsync, UpdateSettingsAsync, StatsAsync, ListBlocklistAsync, AddToBlocklistAsync, RemoveFromBlocklistAsync
client.ListenConfigAsync, AuthAsync
client.WebhooksConstructEvent
  • webhooks / ConstructEvent runs locally, no HTTP call. All other services call the merchant REST API.
  • links, invoices, tax and shield are live only. A sandbox key gets 403 This feature is only available in live mode. on every one of their methods.
  • refunds and transfers are create, retrieve and list only. The shared CRUD base also exposes update and delete on them, but the API serves no such route.

Parcourir une liste paginée

Une page ou toutes les pages
var page = await wajub.Payments.ListAsync(new Dictionary<string, object?>
{
    ["status"] = "success",
    ["per_page"] = 50,
}, ct);

foreach (var payment in page.Data)
{
    logger.LogInformation("{Id} {Amount}", payment["id"], payment["amount"]);
}

if (page.HasMore)
{
    page = await page.GetNextPageAsync(ct);
}

// Or collect every page at once.
var all = await page.AutoPagingIterAsync(ct);

Les lignes sont des Dictionary<string, object?>, pas des records typés. AutoPagingIterAsync renvoie une liste plutôt qu'un IAsyncEnumerable. Il conserve donc toutes les lignes en mémoire avant de répondre.

Idempotence et nouvelles tentatives

Chaque POST et PUT contient une valeur Idempotency-Key générée, qui sécurise les nouvelles tentatives automatiques.

Un numéro de commande constitue la meilleure clé
var payment = await wajub.Payments.CreateAsync(
    parameters,
    new RequestOptions { IdempotencyKey = $"order-{orderId}" },
    ct);
ÉlémentValeur
Statuts réessayés429, 500, 502, 503, 504 et tout échec réseau
Tentatives3 au total, un appel initial et deux nouvelles tentatives
Attente progressiveExponentielle avec part d'aléa, en respectant Retry-After sur 429
Délai d'expiration30 secondes sur le HttpClient créé par le SDK

Le nombre de tentatives est fixe. Fournissez votre propre HttpClient pour modifier le délai d'expiration.

N'ajoutez pas Polly par-dessus

Le SDK réessaie déjà les échecs temporaires. Ajouter une règle Polly au HttpClient injecté multiplie les deux mécanismes. Une réponse 503 peut alors provoquer neuf tentatives au lieu de trois. Choisissez une seule couche, celle du SDK connaît déjà les clés d'idempotence.

Agir pour un compte connecté

Un appel pour un vendeur
await wajub.Payments.CreateAsync(
    parameters,
    new RequestOptions { Sync = seller.WajubAccountId },
    ct);

Sync devient l'en-tête X-Sync. La configuration figure dans Sync.

Webhooks

ConstructEvent vérifie la signature et renvoie l'événement analysé. Il reçoit le corps sous forme d'octets exactement comme il a été livré. C'est la seule méthode synchrone du client.

Un endpoint API minimal
app.MapPost("/webhooks/wajub", async (HttpRequest request, WajubClient wajub) =>
{
    using var buffer = new MemoryStream();
    await request.Body.CopyToAsync(buffer);

    Dictionary<string, object?> evt;
    try
    {
        evt = wajub.Webhooks.ConstructEvent(
            buffer.ToArray(),
            request.Headers["X-Wajub-Signature"]!,
            request.Headers["X-Wajub-Timestamp"]!);
    }
    catch (WebhookSignatureVerificationError)
    {
        return Results.BadRequest();
    }

    if (evt["event"] as string == "payment.succeeded")
    {
        await queue.EnqueueAsync(evt);
    }

    return Results.Ok();
});

ConstructEvent accepte un ReadOnlySpan<byte>. L'appel ne peut donc pas figurer dans une expression async et le corps est d'abord mis en mémoire tampon. Le TimeSpan par défaut désigne une fenêtre de 300 secondes, pas une absence de vérification.

Le nom de l'événement se trouve dans event, pas dans type, et le résultat est un dictionnaire. Consultez Vérification de signature.

Erreurs

Intercepter d'abord l'erreur précise
try
{
    await wajub.Payments.CreateAsync(parameters, cancellationToken: ct);
}
catch (InvalidRequestError e)
{
    // e.Errors is {"amount": "The amount must be at least 25."}
    return Results.ValidationProblem(
        e.Errors?.ToDictionary(x => x.Key, x => new[] { x.Value }) ?? []);
}
catch (RateLimitError e)
{
    return Results.StatusCode(503);
}
catch (WajubError e)
{
    logger.LogError("wajub failed code={Code} status={Status}", e.Code, e.HttpStatus);
    throw;
}
ClasseCause
AuthenticationError401
PermissionError403
NotFoundError404
InvalidRequestError400 et 422
RateLimitError429, avec RetryAfter en secondes
WajubErrorTout autre statut et classe parente de toutes les erreurs ci-dessus
ApiConnectionErrorAucune réponse, problème réseau ou délai d'expiration
WebhookSignatureVerificationErrorÉchec de vérification d'un webhook

WajubError expose Code, HttpStatus, Errors et Raw.

ApiConnectionError contient uniquement un message

Contrairement aux autres erreurs, elle ne possède ni statut ni Raw, car aucune réponse n'a été reçue. Son Code vaut toujours "network_error".

Tester sans appeler l'API

L'URL de base est une constante sans remplacement par l'environnement. Le point de substitution est donc le HttpClient. Un HttpMessageHandler personnalisé permet d'utiliser tout le SDK hors ligne.

Un test xUnit sans réseau
sealed class StubHandler : HttpMessageHandler
{
    protected override Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, CancellationToken ct) =>
        Task.FromResult(new HttpResponseMessage(HttpStatusCode.Created)
        {
            Content = new StringContent("""
                {"code":201,
                 "authorization_url":"https://pay.wajub.com/tok_test",
                 "authorization_token":"tok_test",
                 "transaction":{"id":"trx_test","status":"pending","amount":25000}}
                """, Encoding.UTF8, "application/json"),
        });
}

var wajub = WajubClient.Create(new WajubConfig
{
    ApiKey = "sk_test.fake",
    HttpClient = new HttpClient(new StubHandler()),
});

var payment = await wajub.Payments.CreateAsync(
    new Dictionary<string, object?> { ["amount"] = 25000, ["currency"] = "XAF" });

Assert.Equal("tok_test", payment.AuthorizationToken);

Débiter sans la page hébergée

Requête push Mobile Money
var payment = await wajub.Payments.CreateAsync(new Dictionary<string, object?>
{
    ["amount"] = 25000,
    ["currency"] = "XAF",
    ["phone"] = "+237670000000",
}, cancellationToken: ct);

await wajub.Payments.ProcessAsync(payment.Id, new Dictionary<string, object?>
{
    ["channel"] = "cm.mtn",
    ["phone"] = "+237670000000",
}, cancellationToken: ct);

Un canal suit le format country.operator. cm.mtn désigne donc MTN Mobile Money au Cameroun. La liste complète figure dans Moyens de paiement et canaux. Le payeur doit encore approuver sur son téléphone. Le résultat arrive donc dans le webhook.

Effectuer un payout

Un transfert vers un numéro de téléphone
var transfer = await wajub.Transfers.CreateAsync(
    new Dictionary<string, object?>
    {
        ["amount"] = 100000,
        ["currency"] = "XAF",
        ["beneficiary"] = new Dictionary<string, object?>
        {
            ["name"] = "Amina Diallo",
            ["channel"] = "cm.mtn",
            ["phone"] = "+237670000000",
        },
        ["reference"] = "payout-892",
    },
    new RequestOptions { IdempotencyKey = "payout-892" },
    ct);

beneficiary accepte aussi l'identifiant ben_… d'un bénéficiaire enregistré. Préférez cette forme dès que vous payez deux fois la même personne.

Développement local

Deux terminaux
wajub listen --forward-to localhost:5000/webhooks/wajub
wajub trigger payment.succeeded

Consultez la CLI pour plus de détails.

Que pensez-vous de ce contenu ?