Skip to content

C# / .NET

The Wajub NuGet package, its services, and the async surface it exposes.

The Wajub package is the server side of a Wajub integration. It holds your secret key, creates payments, reads their real status and verifies webhook signatures. Nullable reference types are enabled, every call is awaitable and takes a CancellationToken.

Wajub

Stable · GA

NuGet

Version
1.1.1
Runtime
.NET 8+, no package dependency

Covers

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

Install

dotnet add package Wajub

The assembly targets net8.0 and leans on System.Text.Json and HttpClient from the framework, so there is nothing else to pull in.

Create the client

WajubClient.Create is the entry point. It falls back to the environment, so an empty config is usually enough.

Two ways to build it
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"),
});
PropertyWhat it does
ApiKeyYour sk. or sk_test. key. Falls back to WAJUB_API_KEY
WebhookSecretThe whsec_ secret. Falls back to WAJUB_WEBHOOK_SECRET
IdempotencyKeyPrefixPrefix of the generated key. Defaults to "wajub"
HttpClientYour own HttpClient, from IHttpClientFactory or a test handler

An empty key throws ArgumentException: wajub: api key is required at construction, so a bad configuration fails at start-up.

Registered in 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"),
    }));

The first call

Every method is asynchronous and ends in Async.

Create a payment
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);

Parameters go in as a Dictionary<string, object?> with the API's exact field names. The result is a record, so it is immutable and compares by value.

Everything past the six decoded properties
var reference = payment.Raw.GetValueOrDefault("reference")?.ToString();
var createdAt = payment.Raw.GetValueOrDefault("created_at")?.ToString();

Every service on the client

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

Paging through a list

One page, or all of them
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);

Rows are Dictionary<string, object?>, not typed records. AutoPagingIterAsync returns a list rather than an IAsyncEnumerable, so it holds every row in memory before returning.

Idempotency and retries

Every POST and PUT carries a generated Idempotency-Key, which is what makes the automatic retry safe.

An order number is the best key
var payment = await wajub.Payments.CreateAsync(
    parameters,
    new RequestOptions { IdempotencyKey = $"order-{orderId}" },
    ct);
WhatValue
Retried statuses429, 500, 502, 503, 504, and any network failure
Attempts3 in total, one original plus two retries
BackoffExponential with jitter, honouring Retry-After on a 429
Timeout30 seconds, on the HttpClient the SDK builds

The retry count is fixed. To change the timeout, hand over your own HttpClient.

Do not stack Polly on top

The SDK already retries transient failures. Adding a Polly policy to the HttpClient you inject multiplies the two, so a 503 can mean nine attempts instead of three. Pick one layer, and the SDK's is the one that already knows about idempotency keys.

Acting for a connected account

One call, one seller
await wajub.Payments.CreateAsync(
    parameters,
    new RequestOptions { Sync = seller.WajubAccountId },
    ct);

Sync becomes the X-Sync header. Setup is on Sync.

Webhooks

ConstructEvent verifies the signature and returns the parsed event. It takes the body as bytes, exactly as delivered, and it is the one synchronous method on the client.

A minimal API endpoint
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 takes a ReadOnlySpan<byte>, which is why the call cannot sit inside an async expression and the body is buffered first. The default TimeSpan means the 300 second window, not an absent check.

The event name is in event, not in type, and the result is a dictionary. Details on Signature verification.

Errors

Catch the specific one first
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;
}
ClassThrown on
AuthenticationError401
PermissionError403
NotFoundError404
InvalidRequestError400 and 422
RateLimitError429, with RetryAfter in seconds
WajubErrorEvery other status, and the parent of all of the above
ApiConnectionErrorNo response at all, network or timeout
WebhookSignatureVerificationErrorA webhook that did not verify

WajubError exposes Code, HttpStatus, Errors and Raw.

ApiConnectionError carries only a message

Unlike its siblings it has no status and no Raw, because there was no response to read. Its Code is always "network_error".

Testing without touching the API

The base URL is a constant with no environment override, so the seam is the HttpClient. A custom HttpMessageHandler makes the whole SDK offline.

An xUnit test with no network
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);

Charging without the hosted page

Mobile Money push
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);

A channel is country.operator, so cm.mtn is MTN Mobile Money in Cameroon. The full list is on Payment methods. The payer still approves on their handset, so the outcome arrives on the webhook.

Paying out

A transfer to a phone number
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 also takes the ben_… id of a saved beneficiary, which is the better shape once you pay the same person twice.

Local development

Two terminals
wajub listen --forward-to localhost:5000/webhooks/wajub
wajub trigger payment.succeeded

More on the CLI.

What did you think of this content?