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 · GANuGet
- Version
- 1.1.1
- Runtime
- .NET 8+, no package dependency
Covers
- Payments
- Billing
- Transfers
- Sync
- Shield
- Tax
Install
dotnet add package WajubThe 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.
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"),
});| Property | What it does |
|---|---|
ApiKey | Your sk. or sk_test. key. Falls back to WAJUB_API_KEY |
WebhookSecret | The whsec_ secret. Falls back to WAJUB_WEBHOOK_SECRET |
IdempotencyKeyPrefix | Prefix of the generated key. Defaults to "wajub" |
HttpClient | Your 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.
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"),
}));Singleton, and do not dispose it per request
WajubClient implements IDisposable and owns the HttpClient it builds. Registering it as a
singleton is correct. Wrapping it in a using inside a request handler disposes the socket pool
on every call and leads to port exhaustion, which is the classic HttpClient mistake.
The first call
Every method is asynchronous and ends in Async.
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.
Payment.Amount is a long and the API sends a decimal
Payment decodes six properties, and Amount is a long. Amounts travel in the major unit, so
GHS 12.50 lands as 12. Safe for XAF, XOF and every zero-decimal currency, lossy for the
rest. For a decimal currency read payment.Raw["amount"], which is the untouched value.
var reference = payment.Raw.GetValueOrDefault("reference")?.ToString();
var createdAt = payment.Raw.GetValueOrDefault("created_at")?.ToString();Every service on the client
| Service | Methods |
|---|---|
| client.Global | PingAsync, ChannelsAsync, CountriesAsync, CurrenciesAsync |
| client.Payments | CreateAsync, InitializeAsync, RetrieveAsync, ListAsync, CancelAsync, ProcessAsync, ProcessSplitAsync, ListRefundsAsync |
| client.Customers | CreateAsync, RetrieveAsync, UpdateAsync, DeleteAsync, ListAsync, BlockAsync, UnblockAsync, ActivateAsync, DeactivateAsync, ListTaxIdsAsync, CreateTaxIdAsync, DeleteTaxIdAsync |
| client.Refunds | CreateAsync, RetrieveAsync, ListAsync |
| client.Transfers | CreateAsync, RetrieveAsync, ListAsync |
| client.Beneficiaries | CreateAsync, RetrieveAsync, UpdateAsync, DeleteAsync, ListAsync |
| client.Links | CreateAsync, RetrieveAsync, UpdateAsync, DeleteAsync, ListAsync |
| client.Invoices | CreateAsync, RetrieveAsync, UpdateAsync, DeleteAsync, ListAsync, SendAsync, MarkPaidAsync, CancelAsync |
| client.Accounts | CreateAsync, RetrieveAsync, UpdateAsync, DeleteAsync, ListAsync, RegenerateTokenAsync |
| client.WebhookEndpoints | CreateAsync, RetrieveAsync, UpdateAsync, DeleteAsync, ListAsync, RotateSecretAsync |
| client.Balance | RetrieveAsync |
| client.Events | ListAsync, RetrieveAsync, ResendAsync |
| client.Disputes | ListAsync, RetrieveAsync, SubmitEvidenceAsync, AcceptAsync, CloseAsync, SendMessageAsync |
| client.Identity | ResolveAsync, ValidateAsync |
| client.Tax | GetSettingsAsync, UpdateSettingsAsync, RatesAsync, CalculateAsync, ReportsAsync, ListCodesAsync, RetrieveCodeAsync, ListRegistrationsAsync, CreateRegistrationAsync, RetrieveRegistrationAsync, UpdateRegistrationAsync, DeleteRegistrationAsync, JurisdictionsAsync, ThresholdsAsync, ThresholdAlertsAsync |
| client.Shield | GetSettingsAsync, UpdateSettingsAsync, StatsAsync, ListBlocklistAsync, AddToBlocklistAsync, RemoveFromBlocklistAsync |
| client.Listen | ConfigAsync, AuthAsync |
| client.Webhooks | ConstructEvent |
webhooks/ConstructEventruns locally, no HTTP call. All other services call the merchant REST API.links,invoices,taxandshieldare live only. A sandbox key gets403 This feature is only available in live mode.on every one of their methods.refundsandtransfersare create, retrieve and list only. The shared CRUD base also exposesupdateanddeleteon them, but the API serves no such route.
Paging through a list
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.
var payment = await wajub.Payments.CreateAsync(
parameters,
new RequestOptions { IdempotencyKey = $"order-{orderId}" },
ct);| What | Value |
|---|---|
| Retried statuses | 429, 500, 502, 503, 504, and any network failure |
| Attempts | 3 in total, one original plus two retries |
| Backoff | Exponential with jitter, honouring Retry-After on a 429 |
| Timeout | 30 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
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.
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();
});Read the stream, never bind a model
The signature covers {timestamp}.{raw body}. Binding the body into a class and re-serialising
changes key order and casing, so the hash stops matching. Copying request.Body gives you the
bytes as delivered, which is what ConstructEvent wants.
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
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;
}| Class | Thrown on |
|---|---|
AuthenticationError | 401 |
PermissionError | 403 |
NotFoundError | 404 |
InvalidRequestError | 400 and 422 |
RateLimitError | 429, with RetryAfter in seconds |
WajubError | Every other status, and the parent of all of the above |
ApiConnectionError | No response at all, network or timeout |
WebhookSignatureVerificationError | A webhook that did not verify |
WajubError exposes Code, HttpStatus, Errors and Raw.
Errors holds one message per field, not a list
The API sends an array of messages per field. The mapping keeps only the first, so Errors is
Dictionary<string, string>. The full arrays are still in Raw["errors"] when you need them.
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.
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
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
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
wajub listen --forward-to localhost:5000/webhooks/wajub
wajub trigger payment.succeededMore on the CLI.
Related pages
- SDK QuickstartThe same first call, in five languages side by side.
- WebhooksEvery event, and the delivery guarantees behind them.
- Naming conventionsWhy C# exposes PascalCase over a snake_case wire.
- IdempotencyWhat a key protects and for how long.
- Error handlingWhich failures to retry and which to surface.
- API referenceThe endpoints behind every method above.