.NET SDK
Create Qasimha checkouts, confirm orders and verify webhooks from a .NET backend with Qasimha.Sdk.
Qasimha.Sdk is the .NET server SDK for the Qasimha merchant API. It uses HttpClient and System.Text.Json, and all calls are async.
Requirements
| Item | Requirement |
|---|---|
| Runtime | .NET 6 or .NET 8 (net6.0, net8.0) |
| Dependencies | Microsoft.Extensions.Http, only for the optional dependency-injection registration |
| Keys | A secret key (sk_test_... for UAT). Optional: a publishable key (pk_test_...) for plan quotes, and a webhook secret (whsec_...) |
| API release | Return URLs, per-merchant webhook endpoints, GetByReferenceAsync and GetPlansAsync need the merchant API release of 2026-09-25 or later |
Install
dotnet add package Qasimha.SdkConfigure
Create keys in the Merchant Portal under Settings → API Keys. Keep them in environment variables or your secrets store:
QASIMHA_API_KEY=sk_test_...
QASIMHA_PUBLISHABLE_KEY=pk_test_...
QASIMHA_WEBHOOK_SECRET=whsec_...Create one QasimhaClient per API key and reuse it; it is thread-safe.
using Qasimha.Sdk;
var client = new QasimhaClient(new QasimhaClientOptions
{
ApiKey = Environment.GetEnvironmentVariable("QASIMHA_API_KEY"),
WebhookSecret = Environment.GetEnvironmentVariable("QASIMHA_WEBHOOK_SECRET"),
PublishableKey = Environment.GetEnvironmentVariable("QASIMHA_PUBLISHABLE_KEY"),
Language = "en", // or "ar"
UserAgentSuffix = "my-store/1.4.0",
});With ASP.NET Core, register it on IHttpClientFactory instead (redirects are disabled for you):
using Qasimha.Sdk.Extensions.DependencyInjection;
builder.Services.AddQasimha(options =>
{
options.ApiKey = builder.Configuration["Qasimha:ApiKey"];
options.WebhookSecret = builder.Configuration["Qasimha:WebhookSecret"];
});The SDK picks the environment from the key prefix: sk_test_ uses UAT (sandbox), sk_live_ uses production. See Environments.
| Option | Default | Notes |
|---|---|---|
ApiKey | — | Required. sk_test_... or sk_live_... |
Environment | from key prefix | QasimhaEnvironment.Sandbox or Production. A value that contradicts the key throws ConfigurationException |
BaseUrl | per environment | Test-server override. https:// only; http:// only for localhost / 127.0.0.1 |
Timeout | 30 seconds | Per attempt (connect + read) |
MaxRetries | 2 | 0–5 |
WebhookSecret | — | Default secret for Webhooks.ConstructEvent |
PublishableKey | — | For Checkout.GetPlansAsync; same environment as ApiKey |
Language | "en" | "en" or "ar" |
UserAgentSuffix | — | Appended to the SDK User-Agent |
Logger | none | (level, message, fields) callback |
Delay | built-in | Replace in tests to make retries instant |
If you pass your own HttpClient or HttpMessageHandler, set AllowAutoRedirect = false on the primary handler.
Create a checkout session
Send the basket exactly as the shopper sees it. The amounts below add up: items 899.00 + 135.00 + 120.00 = 1154.00, plus tax 173.10, plus shipping 25.00, minus discount 50.00 = 1302.10.
try
{
var session = await client.Checkout.CreateSessionAsync(new CreateSessionRequest
{
OrderReferenceId = "ORDER-12345",
TotalAmount = 1302.10m,
TaxAmount = 173.10m,
ShippingAmount = 25.00m,
DiscountAmount = 50.00m,
Description = "Order #12345",
Items = new[]
{
new CheckoutItem { ReferenceId = "SKU-1", Name = "Wireless Headphones", Type = ItemType.Physical, Sku = "SKU-1", Quantity = 1, UnitPrice = 899.00m, TotalAmount = 899.00m },
new CheckoutItem { ReferenceId = "SKU-2", Name = "USB-C Cable", Type = ItemType.Physical, Sku = "SKU-2", Quantity = 3, UnitPrice = 45.00m, TotalAmount = 135.00m },
new CheckoutItem { ReferenceId = "SKU-3", Name = "Extended Warranty", Type = ItemType.Digital, Sku = "SKU-3", Quantity = 1, UnitPrice = 120.00m, TotalAmount = 120.00m },
},
Consumer = new Consumer { FirstName = "Noura", LastName = "Al-Shammari", PhoneNumber = "+9665XXXXXXXX", Email = "[email protected]" },
ShippingAddress = new Address { FirstName = "Noura", LastName = "Al-Shammari", Line1 = "King Fahd Rd", City = "Riyadh", CountryCode = "SA" },
MerchantUrl = new MerchantUrl
{
Success = "https://store.example/checkout/return?order=12345",
Failure = "https://store.example/checkout/failed?order=12345",
Cancel = "https://store.example/cart",
},
});
await SaveQasimhaOrderIdAsync("ORDER-12345", session.OrderId); // every later call uses this id
return Results.Redirect(session.PaymentUrl!);
}
catch (DuplicateOrderReferenceException ex) when (ex.ExistingPaymentUrl is not null)
{
return Results.Redirect(ex.ExistingPaymentUrl); // double submit
}
catch (OrderDeclinedException ex)
{
// ex.OrderId exists but was not approved: offer another payment method
}
catch (ValidationException ex)
{
foreach (var error in ex.Errors) Console.WriteLine($"{error.Field}: {error.Message}");
}Amounts are decimal with at most two decimals. ShippingAddress is required, even for digital baskets. The SDK checks the basket before sending, to the halala, and throws ValidationException naming the line:
item.TotalAmount = Quantity × UnitPrice − item.DiscountAmountTotalAmount = Σ item.TotalAmount + TaxAmount + ShippingAmount − DiscountAmount
If your project also imports System.ComponentModel.DataAnnotations, alias the SDK's exception: using QasimhaValidationException = Qasimha.Sdk.ValidationException;.
MerchantUrl sends the shopper back to Success, Failure or Cancel after the hosted checkout, which expires after two hours. See Checkout flows.
Confirm the order on the return route
The hosted checkout sends the shopper to your MerchantUrl with orderId, orderReferenceId and status appended. These parameters are not signed. Read the order before you mark it paid:
app.MapGet("/checkout/return", async (string orderId, QasimhaClient client) =>
{
var order = await client.Orders.GetAsync(orderId);
if (order.Status == OrderStatus.Confirmed)
{
// Paid its first instalment: mark your order paid. Call FulfillAsync when you ship.
return Results.Redirect("/thank-you");
}
return Results.Redirect("/checkout?payment=failed");
});If you only hold your own reference, use client.Orders.GetByReferenceAsync("ORDER-12345"). It returns the same Order, matches exactly (case-sensitive), and throws NotFoundException for an unknown reference. See Return URLs.
Handle webhooks
Verify every delivery with the raw request body — the bytes as received, never a bound and re-serialised model. Then confirm state with Orders.GetAsync before you act.
app.MapPost("/webhooks/qasimha", async (HttpRequest request, QasimhaClient client) =>
{
using var reader = new StreamReader(request.Body);
var rawBody = await reader.ReadToEndAsync();
WebhookEvent evt;
try
{
evt = client.Webhooks.ConstructEvent(rawBody, name => request.Headers[name]);
}
catch (WebhookSignatureException)
{
return Results.BadRequest();
}
// De-duplicate on evt.Id, enqueue evt for processing, and answer quickly.
return Results.Ok();
});If middleware has already read the body, call request.EnableBuffering() earlier in the pipeline and reset request.Body.Position = 0 before reading. Do not bind the body to a model: any change in whitespace or number format breaks the signature.
ConstructEvent also accepts a byte[] body with the signature and timestamp headers: ConstructEvent(rawBytes, signatureHeader, timestampHeader). On failure, WebhookSignatureException.Reason is MissingHeader, MalformedHeader, TimestampOutsideTolerance or SignatureMismatch. The tolerance is 300 seconds by default. During a secret rotation, the old and new secrets both verify.
WebhookEvent exposes Id, Type, OrderId, OrderReferenceId, Status, Amount, Currency, OccurredAt, DeliveryAttempt and Raw. If OrderId is empty, confirm with Orders.GetByReferenceAsync(evt.OrderReferenceId). See Webhooks and Webhook signatures.
Fulfil, refund and cancel
await client.Orders.FulfillAsync(orderId, new FulfillRequest
{
ShippedAmount = 1200.00m, // omit to fulfil everything outstanding; 0 is rejected
ShippingCompany = "Aramex",
TrackingNumber = "ARX123",
});
await client.Orders.RefundAsync(orderId, new RefundRequest { Amount = 300.00m, Reason = "Customer return" });
await client.Orders.CancelAsync(orderId); // voids an order that has not been fulfilledCaptureAsync is only for New / Approved orders. A hosted-checkout order is already Confirmed, and capture answers 400 INVALID_STATUS. order.RefundableAmount is what the customer has paid minus refunds so far. See Order lifecycle.
Quote plans with the publishable key
Checkout.GetPlansAsync returns the plans your policy offers for an amount. It sends the publishable key (X-Public-Key) only, never the secret key.
var quote = await client.Checkout.GetPlansAsync(1200.00m, new GetPlansOptions { Lang = "ar" });
foreach (var plan in quote.Items)
{
Console.WriteLine($"{plan.Instalments} x {plan.InstalmentAmount} {quote.Currency}, total {plan.TotalPayable}");
}quote.Items is empty, not an error, when the amount is outside [MinAmount, MaxAmount] or the account has no active policy. Without a publishable key the call throws ConfigurationException before any request. You can also pass PublishableKey per call in GetPlansOptions. See Plans widget.
Errors
Every failure is a QasimhaException with Code, HttpStatus, RequestId, Errors (field errors) and RawEnvelope. Message is the API's own text in your Language.
| Exception | When |
|---|---|
ConfigurationException | Client misconfigured, or no webhook secret or publishable key available |
ValidationException | Any 400, or a request refused before sending |
AuthenticationException | 401: bad or revoked key |
PermissionException | 403: the key lacks the scope |
NotFoundException | 404 |
DuplicateOrderReferenceException | 409 DUPLICATE_ORDER_REFERENCE; ExistingOrderId, ExistingPaymentUrl |
ConflictException | Any other 409 (MERCHANT_SUSPENDED, NOT_OPEN, INVALID_STATE) |
OrderDeclinedException | 422 ORDER_DECLINED; OrderId |
RateLimitException | 429 after retries; RetryAfter |
ServerException | 5xx (extends ApiException) |
ApiException | Anything else, including a 2xx whose envelope says success: false |
ConnectionException | DNS, TLS, socket or timeout failure; see InnerException |
WebhookSignatureException | Webhook verification failed; see Reason |
Retries: connection failures, 429, 502, 503, 504 and 409 REQUEST_IN_PROGRESS, up to MaxRetries. Every POST carries a UUID v4 Idempotency-Key; override it with RequestOptions { IdempotencyKey = "..." }. See Errors and Idempotency.
Logging and security
options.Logger = (level, message, fields) =>
logger.Log(level switch
{
QasimhaLogLevel.Debug => LogLevel.Debug,
QasimhaLogLevel.Info => LogLevel.Information,
QasimhaLogLevel.Warn => LogLevel.Warning,
_ => LogLevel.Error,
}, "{Message} {@Fields}", message, fields);Fields are method, path (no query string), status, durationMs, requestId, attempt and code. There is no logging by default.
- Keep the secret key and webhook secret in a secrets manager, never in source. Never send them to a browser or mobile app.
- The SDK never logs keys, the webhook secret, query strings or customer data.
client.MaskedKey(sk_live_…AbCd) andclient.ToString()are safe to log. - TLS only. Certificate checks cannot be disabled through the SDK. The runtime's proxy settings (
HTTPS_PROXY) apply. - No card data passes through your server; customers pay on the hosted checkout, so you stay in PCI DSS SAQ-A scope.
Next steps
Updated 3 days ago
