An unofficial, fully typed, production-ready .NET SDK for the Paystack API — transactions, customers, subscriptions, plans, transfers, refunds, disputes, dedicated virtual accounts, terminals, storefronts, webhooks, and everything else under https://paystack.com/docs/api/. Not built, maintained, or endorsed by Paystack itself — see Contributing if interested in helping maintain it.
Built on Refit for typed HTTP clients, Microsoft.Extensions.Http.Resilience (Polly v8) for retries/timeouts/circuit breaking, and System.Text.Json. Every public method, type, and parameter has XML doc comments, so IntelliSense in Visual Studio/Rider/VS Code shows usage guidance inline while typing.
- Install
- Namespaces
- Quick start (DI)
- Quick start (no DI)
- Configuration
- Error handling —
ApiResponse<T>, never exceptions for API errors - Resilience: retries, timeouts, circuit breaker
- Idempotency keys
- Resource areas
- Webhooks
- Design notes
- Contributing, building, and publishing
dotnet add package PayStackDotNetThe NuGet package id is
PayStackDotNet, notPayStack.Net— that id was already registered by an unrelated package before this project claimed it. The namespace, assembly name (PayStack.Net), repo, and every type name are unaffected; only the package id used fordotnet add package/<PackageReference Include="...">differs.
Targets net8.0, net9.0, and net10.0.
| Namespace | Contains |
|---|---|
PayStack.Net |
IPayStackClient (the facade — one property per resource, e.g. client.Transactions), PayStackClient (.Create(...) for non-DI usage, DisposablePayStackClient), ServiceCollectionExtensions (AddPayStack), PayStackJsonOptions |
PayStack.Net.Configuration |
PayStackOptions, PayStackEnvironment |
PayStack.Net.Resources |
The 29 Refit resource interfaces (ITransactionsClient, ICustomersClient, ITransfersClient, ...). You normally don't reference these by name — you get them off IPayStackClient |
PayStack.Net.Models.Requests |
Every request DTO — InitializeTransactionRequest, CreatePlanRequest, InitiateTransferRequest, CreateRefundRequest, etc. |
PayStack.Net.Models.Responses |
Every response DTO — TransactionData, PlanData, TransferData, RefundData, etc. |
PayStack.Net.Models.Common |
PayStackResponse<T>, PayStackMeta, shared sub-objects (AuthorizationData, CustomerSummary), and every static class PayStack<Thing> of string constants for enum-like fields (PayStackChannel, PayStackTransactionStatus, PayStackPlanInterval, PayStackWebhookEventType, ...) |
PayStack.Net.Http |
PayStackCircuitOpenException, PayStackTimeoutException — the two exception types you'll catch. Everything else here is internal plumbing (auth/logging/idempotency handlers) |
PayStack.Net.Webhooks |
IPayStackWebhookParser, PayStackWebhookEvent, PayStackWebhookSignatureVerifier, and the HttpRequestWebhookExtensions.ReadPayStackWebhookEventAsync extension method |
A typical API-calling file needs:
using PayStack.Net;
using PayStack.Net.Models.Requests;
using PayStack.Net.Models.Responses;
using PayStack.Net.Models.Common; // for the PayStackChannel / PayStackTransactionStatus / etc. constantsA webhook receiver additionally needs:
using PayStack.Net.Webhooks;And anywhere you handle transient failures explicitly:
using PayStack.Net.Http; // PayStackCircuitOpenException, PayStackTimeoutExceptionusing PayStack.Net;
using PayStack.Net.Models.Requests;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddPayStack(options =>
{
options.SecretKey = builder.Configuration["PayStack:SecretKey"]!;
});
var app = builder.Build();
app.MapPost("/checkout", async (IPayStackClient payStack, CancellationToken ct) =>
{
var response = await payStack.Transactions.InitializeAsync(new InitializeTransactionRequest
{
Amount = "500000", // NGN 5,000.00, in kobo — Paystack amounts are always in the currency's subunit
Email = "customer@example.com",
}, ct);
if (!response.IsSuccessStatusCode || response.Content is not { Status: true } body)
{
return Results.Problem(response.Content?.Message ?? response.Error?.Message);
}
return Results.Ok(new { checkoutUrl = body.Data!.AuthorizationUrl });
});
app.Run();Or bind straight from configuration:
// appsettings.json: { "PayStack": { "SecretKey": "sk_test_..." } }
builder.Services.AddPayStack(builder.Configuration);IPayStackClient exposes one property per resource area (Transactions, Customers, Subscriptions, Transfers, Refunds, ...) — see Resource areas.
For scripts, console apps, and Azure Functions where wiring up a full IServiceCollection is overkill:
using PayStack.Net;
using PayStack.Net.Models.Requests;
using var payStack = PayStackClient.Create("sk_test_xxx");
var response = await payStack.Transactions.InitializeAsync(new InitializeTransactionRequest
{
Amount = "500000",
Email = "customer@example.com",
});PayStackClient.Create spins up a small internal ServiceProvider that owns the HTTP pipeline; using disposes it along with the underlying HttpClients.
All options live on PayStackOptions:
builder.Services.AddPayStack(options =>
{
options.SecretKey = "sk_test_xxx";
// Optional — see "Environments" below.
options.BaseUrlOverride = new Uri("https://my-proxy.internal/paystack");
// Optional — defaults shown.
options.MaxRetryAttempts = 3;
options.AttemptTimeout = TimeSpan.FromSeconds(30);
options.TotalTimeout = TimeSpan.FromSeconds(90);
options.CircuitBreakerFailureThreshold = 8;
options.CircuitBreakerBreakDuration = TimeSpan.FromSeconds(30);
options.AutoGenerateIdempotencyKeys = true;
});Paystack does not use different base URLs for test and live traffic — both go to https://api.paystack.co, and the environment is determined entirely by which secret key you use (sk_test_... vs sk_live_...). The SDK follows this: just swap SecretKey between environments (e.g. via configuration per deployment slot) and everything else — retries, logging, webhook verification — stays the same.
PayStackOptions.ResolveEnvironment() tells you which one you're pointed at, if you need it for logging/telemetry:
var env = options.ResolveEnvironment(); // PayStackEnvironment.Sandbox or .LiveBaseUrlOverride exists purely for testing against a local mock server, a corporate egress proxy, or a contract-testing harness — leave it unset in normal use.
Every resource method returns Task<ApiResponse<PayStackResponse<T>>> (from Refit / this SDK, respectively). Refit is configured to never throw for non-2xx responses (RefitSettings.ExceptionFactory always returns null — see PayStackRefitSettingsFactory), so you always get a value back and check it explicitly:
var response = await payStack.Refunds.CreateAsync(new CreateRefundRequest { Transaction = "T12345" });
if (!response.IsSuccessStatusCode)
{
// Network-level / HTTP-level failure (4xx/5xx). response.Error has the Refit ApiException details;
// response.Content may still be populated if Paystack returned a JSON error body alongside the status code.
logger.LogWarning("Refund request failed: {Status} {Message}", response.StatusCode, response.Content?.Message);
return;
}
var body = response.Content!;
if (!body.Status)
{
// HTTP 200 but Paystack reports a logical failure — this happens on some endpoints
// (e.g. transaction verify/charge always return 200; check `data.status` instead of `body.Status`).
logger.LogWarning("Paystack reported failure: {Message}", body.Message);
return;
}
var refund = body.Data!;Rules of thumb baked into every resource client:
- Check
response.IsSuccessStatusCodefirst (HTTP-level). - Then check
response.Content.Status(Paystack's logical status). - For
Transactions.VerifyAsync/ChargeAuthorizationAsync/PartialDebitAsync/Charge.*, Paystack returns HTTP 200 even for a declined charge — always inspectdata.status(PayStackTransactionStatusconstants) rather than stopping atbody.Status. response.Error(a RefitApiException) is populated for transport/HTTP failures; it is not thrown, just attached to the response for inspection/logging.
Transient failures (DNS/connection errors, timeouts, 5xx, 429, and the circuit breaker tripping) are handled by the resilience pipeline (below) and surface as one of two dedicated exception types — these do throw, because there's no HTTP response to hand back as an ApiResponse<T>:
using PayStack.Net.Http; // PayStackCircuitOpenException, PayStackTimeoutException
try
{
var response = await payStack.Transfers.InitiateAsync(request);
}
catch (PayStackCircuitOpenException)
{
// Paystack (or the network path to it) has been failing repeatedly; the circuit breaker is
// short-circuiting new calls for a while rather than piling on more load. Retry later.
}
catch (PayStackTimeoutException)
{
// No response within the configured timeout — also transient, not a rejection.
}Everything else — validation errors, declined payments, not-found, insufficient balance — comes back as a normal ApiResponse<T> with IsSuccessStatusCode == false or Content.Status == false, never an exception.
Every request goes through a Polly v8 pipeline (Microsoft.Extensions.Http.Resilience), configured from PayStackOptions:
- Per-attempt timeout (
AttemptTimeout, default 30s). - Retry (
MaxRetryAttempts, default 3) with exponential backoff + jitter, on transport exceptions, timeouts,408,429, and any5xx. - Circuit breaker (
CircuitBreakerFailureThreshold/CircuitBreakerBreakDuration) — opens after a burst of failures and fails fast (PayStackCircuitOpenException) instead of continuing to hammer a struggling endpoint. - Overall timeout (
TotalTimeout, default 90s) across all attempts combined.
Set MaxRetryAttempts = 0 to disable retries entirely (e.g. if you have your own outer retry/outbox layer and don't want double-retries).
Paystack deduplicates retried requests that carry the same Idempotency-Key header — critical for anything that moves money, since a network blip + naive retry must never risk a duplicate transfer, refund, or charge.
By default (PayStackOptions.AutoGenerateIdempotencyKeys = true), the SDK automatically attaches a fresh key to every POST/PUT request that doesn't already have one. Money-movement methods (Transfers.InitiateAsync, TransferRecipients.CreateAsync, Refunds.CreateAsync, Transactions.ChargeAuthorizationAsync, etc.) also expose an explicit idempotencyKey parameter so you can supply your own — e.g. derived from your own order id — so a retry from your application layer (not just the HTTP layer) is also safe:
var idempotencyKey = $"payout-{payoutId}";
var response = await payStack.Transfers.InitiateAsync(new InitiateTransferRequest
{
Source = "balance",
Amount = 1000000, // in subunits
Recipient = recipientCode,
Reference = idempotencyKey, // Paystack's own dedupe key on the resource; combine with the header for full protection
Reason = "Vendor payout",
}, idempotencyKey);IPayStackClient exposes one client per Paystack API resource group:
| Property | Covers |
|---|---|
Transactions |
Initialize, verify, list, fetch, charge authorization, timeline, totals, export, partial debit |
TransactionSplits |
Split a payment between multiple accounts |
Charge |
Direct channel charges: bank, USSD, mobile money, QR, EFT, Capitec Pay, OTP/PIN submission |
Preauthorization |
Reserve and capture/release funds (South Africa, ZAR) |
Customers |
Create/list/fetch/update/validate customers, authorizations, risk actions |
DirectDebit |
Trigger activation charges, list mandate authorizations |
DedicatedVirtualAccounts |
Create/assign/manage dedicated bank accounts |
ApplePay |
Register/list/unregister Apple Pay domains |
CapitecPay |
Requery Capitec Pay transactions (note: this endpoint authenticates with your public key, not the secret key used everywhere else — see the XML doc on ICapitecPayClient) |
Subaccounts |
Create/list/fetch/update subaccounts for split settlements |
Verification |
Resolve/validate bank accounts, resolve card BINs |
Miscellaneous |
Banks, countries, states/AVS lookups |
Plans |
Billing plans — pricing tiers for subscriptions |
Subscriptions |
Create/list/fetch/enable/disable recurring subscriptions, update links |
Products |
Create/list/fetch/update products |
Storefronts |
Create/manage storefronts, their products and orders |
Orders |
Create/list/fetch/validate storefront orders |
PaymentPages |
Create/manage hosted payment pages |
PaymentRequests |
Create/manage invoices — send, finalize, track totals |
Settlements |
Inspect settlements and their transactions |
TransferRecipients |
Create/manage payout destinations (bulk supported) |
Transfers |
Initiate/finalize/verify payouts (single and bulk) |
TransfersControl |
Balance, ledger, OTP requirement management |
BulkCharges |
Charge many customers in one batch |
Integration |
Account-level integration settings (session timeout) |
Disputes |
List/update/resolve chargebacks, submit evidence |
Refunds |
Create/retry/list/fetch refunds |
Terminal |
Manage POS terminals, send events |
VirtualTerminal |
Create/manage virtual terminals |
Every interface method's XML doc links back to the exact Paystack API reference page for that resource.
The examples below assume the standard
usingblock from Namespaces (PayStack.Net,PayStack.Net.Models.Requests,PayStack.Net.Models.Responses,PayStack.Net.Models.Common) is already in scope.
// 1. Create a plan (a pricing tier).
var plan = await payStack.Plans.CreateAsync(new CreatePlanRequest
{
Name = "Pro Monthly",
Amount = "1000000", // NGN 10,000.00/month
Interval = PayStackPlanInterval.Monthly,
});
// 2. Subscribe a customer to it (customer must have a reusable authorization on file already,
// e.g. from a prior Transactions.InitializeAsync charge).
var subscription = await payStack.Subscriptions.CreateAsync(new CreateSubscriptionRequest
{
Customer = customerCode,
Plan = plan.Content!.Data!.PlanCode,
});Paystack's API has no separate "free trial" endpoint — trials are modeled by delaying the first invoice (via the plan's invoice limit / a future subscription start date), which is what CreateSubscriptionRequest.StartDate is for; see its XML doc for details.
var response = await payStack.Refunds.CreateAsync(new CreateRefundRequest
{
Transaction = "T685409429",
Amount = 50000, // partial refund, in subunits; omit for a full refund
}, idempotencyKey: $"refund-{orderId}");Paystack signs every webhook delivery with x-paystack-signature: an HMAC-SHA512 of the raw request body, keyed with your secret key, hex-encoded. Always verify this before trusting the payload — never process a webhook body based on IP alone or without a valid signature.
using PayStack.Net.Webhooks;
using PayStack.Net.Models.Common;
app.MapPost("/webhooks/paystack", async (HttpRequest request, IPayStackWebhookParser parser, ILogger<Program> logger) =>
{
request.EnableBuffering();
var evt = await request.ReadPayStackWebhookEventAsync(parser);
if (evt is null)
{
return Results.Unauthorized(); // missing/invalid signature, or malformed body
}
switch (evt.Event)
{
case PayStackWebhookEventType.ChargeSuccess:
var transaction = evt.GetData<TransactionData>();
// reconcile the order...
break;
case PayStackWebhookEventType.TransferFailed:
case PayStackWebhookEventType.TransferReversed:
var transfer = evt.GetData<TransferData>();
// alert ops, retry payout, etc.
break;
case PayStackWebhookEventType.SubscriptionDisable:
var subscription = evt.GetData<SubscriptionData>();
// downgrade the account...
break;
}
return Results.Ok(); // ack fast — Paystack retries on anything other than 2xx
});IPayStackWebhookParser is registered by AddPayStack and reads WebhookSecretKey (falling back to SecretKey) from PayStackOptions.
Use the static, dependency-free verifier directly:
using PayStack.Net.Webhooks;
if (!PayStackWebhookSignatureVerifier.Verify(rawBody, signatureHeader, secretKey))
{
return; // reject
}
using var doc = JsonDocument.Parse(rawBody);
var eventType = doc.RootElement.GetProperty("event").GetString();
// ...PayStackWebhookEventType has a constant for every documented event (charge.success, transfer.success, subscription.create, invoice.payment_failed, refund.processed, charge.dispute.create, dedicatedaccount.assign.success, customeridentification.failed, ...). These are modeled as string constants rather than a C# enum (see Design notes) — PayStackWebhookEvent.Event is a plain string, so unrecognized future event types don't break deserialization; just compare against the constant or fall through to a default case.
Each event's data shape matches the corresponding resource's response DTO (e.g. charge.success → TransactionData, subscription.create → SubscriptionData, transfer.success → TransferData) — use evt.GetData<T>() (throws if the shape doesn't match) or evt.TryGetData<T>(out var data) (returns false instead).
Paystack cannot deliver webhooks to localhost. Use a tunnel (ngrok, VS/VSCode dev tunnels, etc.) and point your Paystack dashboard's webhook URL at the tunnel while developing.
- Enums as string constants, not C#
enums. Every open-ended API string field (transaction status, event type, payment channel, dispute status, ...) is typed asstring/string?on the DTO, paired with apublic static class PayStack<Thing>ofconst stringmembers for IntelliSense. A closed C#enumwould silently fail to deserialize (or worse, throw) the moment Paystack ships a new value — these constants keep the SDK forward-compatible without a breaking release. - Money is always
long(orstringon requests that document it as a string), neverdecimal/double. Amounts are in the currency's subunit (kobo, cents, pesewas, ...) per Paystack's convention — multiply your display amount by 100 before sending it. - Loosely-structured fields (metadata blobs, provider passthrough objects) are typed as
System.Text.Json.JsonElement?rather than guessed at, with convenienceGetXObject<T>()accessors on a few high-traffic ones (e.g.TransactionData.GetMetadataObject()). - Logging uses
Microsoft.Extensions.Logging.ILogger<T>(not a concrete logging framework) so the SDK stays sink-agnostic — wire up Serilog, or any other provider, in your host. Request/response logging never includes headers or bodies, so secret keys, card numbers, and customer PII never reach your log sink; only method, path, status code, and elapsed time are logged (Informationon success,Warningon non-2xx,Errorwith exception on transport failure). - No secrets in exceptions or logs.
PayStackAuthHandlerreads the secret key fresh fromIOptionsMonitorper request (supporting key rotation without restarting) and never logs it. - Packaging. Release builds always run with
ContinuousIntegrationBuild=trueand an explicitPathMap(seeDirectory.Build.props), so a package built from a local clone never embeds the machine's directory layout into the DLL/PDB — onlygithub.com/Godhanded/PayStack.Netsource links, once the repo has a commit for SourceLink to map against.
Bug reports, feature requests, and PRs are welcome — see CONTRIBUTING.md for project layout, build/test commands, code style, and the release process. Security issues go through SECURITY.md, not a public issue. See the Code of Conduct for community expectations.