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.
Wajub
Stable · GANuGet
- Version
- 1.1.1
- Runtime
- .NET 8+
Covers
- Payments
- Billing
- Transfers
- Sync
- Shield
- Tax
Install
dotnet add package WajubCreate 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 |
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
Register WajubClient as a singleton. Do not wrap it in a using inside a request handler: like
any HttpClient, disposing it on every call exhausts the server's ports.
The first call
Every method is asynchronous, ends in Async and takes a CancellationToken.
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.
Amount is a whole number
Payment decodes six properties, and Amount is a long, which suits XAF, XOF and every
currency without decimals. For a currency with decimals, such as GHS 12.50, read
payment.Raw["amount"].
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, 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.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.
Paging through a list
var page = await wajub.Payments.ListAsync(new Dictionary<string, object?>
{
["status"] = "succeeded",
["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,
so it holds every row in memory before returning.
Idempotency and retries
Failed requests are retried automatically, and a retry never creates anything twice. When the same order may be submitted again, pass your own key.
var payment = await wajub.Payments.CreateAsync(
parameters,
new RequestOptions { IdempotencyKey = $"order-{orderId}" },
ct);To set your own timeout, hand over your own HttpClient.
Do not stack Polly on top
Failed requests are already retried, so add no retry policy, Polly or another, to the
HttpClient you pass.
Acting for a connected account
await wajub.Payments.CreateAsync(
parameters,
new RequestOptions { Sync = seller.WajubAccountId },
ct);Sync takes the connected account's id. 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 synchronous.
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
Binding the body into a class and serialising it again changes the bytes, so the signature stops
matching. Copying request.Body gives you the body as delivered, which is what ConstructEvent
wants.
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 of plain values:
string, long or double, bool, null, a nested Dictionary<string, object?> or a
List<object?>, so evt["event"] as string reads the type. A missing signature or timestamp header
throws WebhookSignatureVerificationError, like a wrong one. 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. Its Code is "network_error" |
WebhookSignatureVerificationError | A webhook that did not verify |
WajubError exposes Code, HttpStatus, Errors and Raw. Errors holds the first message of
each field, and Raw["errors"] holds them all.
Testing without touching the API
Pass an HttpClient built on your own HttpMessageHandler, and the whole SDK runs 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/test_xxxxx",
"authorization_token":"test_xxxxx",
"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("test_xxxxx", 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
Mobile Money. 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.