DevSkill.SSLCommerz
4.0.1
dotnet add package DevSkill.SSLCommerz --version 4.0.1
NuGet\Install-Package DevSkill.SSLCommerz -Version 4.0.1
<PackageReference Include="DevSkill.SSLCommerz" Version="4.0.1" />
<PackageVersion Include="DevSkill.SSLCommerz" Version="4.0.1" />
<PackageReference Include="DevSkill.SSLCommerz" />
paket add DevSkill.SSLCommerz --version 4.0.1
#r "nuget: DevSkill.SSLCommerz, 4.0.1"
#:package DevSkill.SSLCommerz@4.0.1
#addin nuget:?package=DevSkill.SSLCommerz&version=4.0.1
#tool nuget:?package=DevSkill.SSLCommerz&version=4.0.1
DevSkill.SSLCommerz
A .NET library for integrating with the SSLCommerz payment gateway (API v4): payment session requests, order validation, IPN verify_sign verification, and transaction search — fully async, typed, and testable.
| Branch | Status |
|---|---|
| main | |
| develop |
What this SDK covers:
| Capability | API | Endpoint |
|---|---|---|
| Initiate a payment (get the hosted gateway page URL) | IGatewayClient.RequestSessionAsync |
POST {base}/gwprocess/v4/api.php |
| Order Validation — authoritative money-state check | IGatewayClient.ValidateTransactionAsync |
GET {base}/validator/api/validationserverAPI.php |
| Transaction search (pending sweeps, late completions) | IGatewayClient.QueryTransactionAsync |
GET {base}/validator/api/merchantTransIDvalidationAPI.php |
IPN verify_sign checksum verification |
VerifySignVerifier |
— (local computation) |
| IPN / user-return payload parsing (form or JSON) | PaymentNotificationParser |
— (local computation) |
{base} is https://sandbox.sslcommerz.com (Sandbox) or https://securepay.sslcommerz.com (Live).
Refunds are not part of this SDK (declined for the initial release — see the Integration Specification §11 referenced below).
Contents
- Related documentation
- Requirements · Installation
- How a payment works (end-to-end)
- Configuration · Registration
- Results and error handling
- API reference
- Handling the IPN webhook
- Handling the user return (success / fail / cancel)
- Payment status mapping
- Background reconciliation sweep
- Complete integration walkthrough
- Advanced usage
- Wire protocol details
- Security notes
- Troubleshooting
- Testing your integration
- Upgrading from 3.x
- Versioning and support · License
Related documentation
This README is self-contained: everything needed to integrate lives here — no external access required.
Note for NuGet consumers: the GitHub repository is private to the Dev Skill organization. The links below resolve only for Dev Skill members; everyone else should treat this README (and the Integration Specification §-numbers it paraphrases inline) as the complete reference.
For deeper context, the repo carries these documents:
| Document | When to read it |
|---|---|
| SSLCommerz Integration Specification | The full gateway contract from the integrating application's side: credentials & network prerequisites (incl. outbound-IP registration for the live validator APIs), exact initiation field set, the IPN trust chain, status mapping, expiry/late-completion/reconciliation mechanics, retry & failure policy, testing strategy, and the sandbox→production cutover checklist (§§4–17). Normative for the money path. |
| — §6 Payment Initiation, §7 IPN Webhook Contract | Sequence and exact semantics behind RequestSessionAsync and the IPN handling shown below. |
| — §12 Client Implementation, §13 Testing Strategy | The retry/failure policy this client implements and how to test the integration. |
| V4 Overhaul Plan | Why v4 looks the way it does (design decisions D1–D9 are binding for v4.x; deferred findings DF-* carry open caveats). Read before changing the library. |
| Upgrade Plan .NET 10 | The .NET 8 → .NET 10 migration record (targets, package pins, CI, Docker). |
| Demo application | A complete ASP.NET Core MVC reference app (EF Core persistence, Docker) consuming this package by NuGet version. |
Requirements
- .NET 10 SDK or later (the package targets
net10.0) - An SSLCommerz merchant account (sandbox credentials first, live credentials after cutover)
Installation
dotnet add package DevSkill.SSLCommerz
The package depends only on Microsoft.Extensions.Options and Newtonsoft.Json — it works in any .NET 10 application (ASP.NET Core, workers, consoles), not just web apps.
How a payment works (end-to-end)
Customer Your app SSLCommerz
│ │ │
│ 1. checkout │ │
│────────────────>│ │
│ │ 2. RequestSessionAsync │ (store credentials +
│ │───────────────────────────────>│ order fields, form POST)
│ │ <─── GatewayPageURL ──────────│
│ 3. redirect to │ │
│─────GatewayPageURL──────────────────────────────>│ customer pays on the
│ │ │ hosted gateway page
│ │ 4. IPN: POST to your ipn_url │ (server-to-server)
│ │<───────────────────────────────│
│ │ 5. verify_sign, then │
│ │ ValidateTransactionAsync │ (authoritative check)
│ │───────────────────────────────>│
│ │ <─── status/amount/... ───────│
│ │ 6. fulfil the order │
│ 7. redirect to │ (only if validation passes)
│─────success_url / fail_url / cancel_url──────────│
│<────────────────│ (re-validate there too — the
│ │ return POST is not proof of payment)
Golden rules of the money path:
- Never trust the browser. Both the IPN (step 4) and the user-return POST (step 7) are untrusted input until verified.
- A valid
verify_signis necessary, not sufficient. It proves the payload came from SSLCommerz (weak, provider-mandated MD5). Always re-check the money state viaValidateTransactionAsync(Order Validation) before fulfilling anything. - Fulfil on validation, not on redirect. The customer can close the browser mid-payment — the IPN is what tells you the outcome; the success return is a UX landing page that must re-validate.
Configuration
Bind SslCommerzSettings from configuration — this is the single credential source; the client stamps store_id/store_passwd onto every call at send time (credentials never appear on request models).
{
"SSLCommerz": {
"StoreId": "your_store_id",
"StorePassword": "your_store_password",
"Environment": "Sandbox",
"SandboxBaseUrl": null,
"LiveBaseUrl": null
}
}
| Setting | Required | Default | Notes |
|---|---|---|---|
StoreId |
yes | — | Store identifier from the SSLCommerz dashboard |
StorePassword |
yes | — | Stamped at send time, never logged by the SDK |
Environment |
yes | — | Sandbox (= 0) or Live (= 1), enum DevSkill.SSLCommerz.Enums.SslCommerzEnvironment |
SandboxBaseUrl |
no | https://sandbox.sslcommerz.com |
Override for proxies/test fakes. Must be absolute HTTPS without a query string, or GatewayClient construction throws InvalidOperationException |
LiveBaseUrl |
no | https://securepay.sslcommerz.com |
Override, same rules |
Switching sandbox → live is normally just Environment: "Live" plus the live credentials — the base URLs change automatically.
Registration
Standard (ASP.NET Core or any IServiceCollection host):
using DevSkill.SSLCommerz;
using DevSkill.SSLCommerz.Models;
builder.Services.Configure<SslCommerzSettings>(
builder.Configuration.GetSection("SSLCommerz"));
builder.Services.AddSingleton<IGatewayClient, GatewayClient>();
To configure the underlying HttpClient (timeouts, proxies, resilience handlers), register via the factory instead — the client accepts an injected HttpClient:
builder.Services.AddHttpClient<IGatewayClient, GatewayClient>()
.SetHandlerLifetime(TimeSpan.FromMinutes(5));
Manual construction (console apps, tests) — note the optional HttpClient; when omitted, an internal client with a 10-second timeout is created:
using Microsoft.Extensions.Options;
var settings = new SslCommerzSettings
{
StoreId = "test_box",
StorePassword = "qwerty",
Environment = SslCommerzEnvironment.Sandbox
};
IGatewayClient gateway = new GatewayClient(
Options.Create(settings),
new HttpClient { Timeout = TimeSpan.FromSeconds(30) });
Results and error handling
API calls never throw for transport, HTTP, or provider failures — they return SslCommerzResult<T>:
| Member | Meaning |
|---|---|
Success |
true when the call succeeded and the payload deserialized |
Value |
The parsed response. Also populated for ProviderFailed results, so the provider's failure payload (e.g. failedreason) stays reachable |
RawJson |
The raw response body, whenever one arrived (empty string for transport failures) |
Error |
Category + Message, plus HTTP StatusCode when applicable |
SslCommerzErrorCategory |
Meaning | Typical cause |
|---|---|---|
Transport |
Timeout or connection failure | SSLCommerz unreachable, DNS, TLS, timeout (default 10 s) |
HttpError |
Non-2xx HTTP status (e.g. a 500 HTML error page) | Provider outage or misconfigured base URL |
ProviderFailed |
HTTP 200 but provider status is FAILED — Value still populated |
Wrong credentials (Store Credential Error or Store is Inactive), duplicate tran_id, invalid field values |
InvalidResponse |
HTTP 200 body failed to deserialize | Provider returned HTML or an unexpected shape |
ProviderFailed applies to session requests and validation only; on the transaction-query path element statuses are transaction data, not call failures (see Query transactions).
Caller/programming errors throw synchronously before any I/O:
ArgumentNullException— null request objectsValidationException(System.ComponentModel.DataAnnotations) — request model fails data-annotation validation (missing required field, string too long,emi_optionoutside 0–1)ArgumentException— aTransactionQuerywith both or neither ofSessionKey/TransactionIdInvalidOperationException— atGatewayClientconstruction, when a base-URL override is not absolute HTTPS without query
One switch to handle everything an operation can return:
var result = await gateway.RequestSessionAsync(request, ct);
if (result.Success)
{
// happy path — result.Value is the parsed response
}
else
{
var error = result.Error!;
switch (error.Category)
{
case SslCommerzErrorCategory.Transport:
case SslCommerzErrorCategory.HttpError:
// transient — safe to retry the call (the session is idempotent per tran_id)
break;
case SslCommerzErrorCategory.ProviderFailed:
// provider rejected it — Value is still populated:
var reason = result.Value!.FailedReason; // e.g. "Store Credential Error or Store is Inactive"
break;
case SslCommerzErrorCategory.InvalidResponse:
// log RawJson and investigate; do not retry blindly
break;
}
}
API reference
1. Request a payment session (RequestSessionAsync)
Creates a payment session and returns the hosted Gateway Page URL the customer must be redirected to. Sent as POST {base}/gwprocess/v4/api.php, form-urlencoded, with store_id/store_passwd stamped automatically.
Task<SslCommerzResult<TransactionSessionResponse>> RequestSessionAsync(
TransactionSessionRequest request, CancellationToken cancellationToken = default);
Minimal example (digital goods — the common case)
using DevSkill.SSLCommerz;
using DevSkill.SSLCommerz.Models;
public sealed class CheckoutService(IGatewayClient gateway)
{
public async Task<string?> StartPaymentAsync(string transactionId, CancellationToken ct = default)
{
var request = new TransactionSessionRequestBuilder()
.AddIntegrationRequiredParameter(new IntegrationRequiredParameter(
totalAmount: 1750.50m,
currency: "BDT",
transactionId: transactionId,
successUrl: "https://yourapp.com/payment/success",
failUrl: "https://yourapp.com/payment/fail",
cancelUrl: "https://yourapp.com/payment/cancel")
{
IpnUrl = "https://yourapp.com/ipn" // optional per-transaction IPN override
})
.AddCustomerInformation(new CustomerInformation(
customerName: "Rahim Uddin",
customerEmail: "rahim@example.com",
customerAddress1: "12 Uttara Sector 4",
customerCity: "Dhaka",
customerPostcode: "1230",
customerCountry: "Bangladesh",
customerPhone: "+8801700000000"))
.AddShipmentInformation(new ShipmentInformation(shippingMethod: "NO"))
.AddProductInformation(new ProductInformation(
productName: "Dev Skill Course",
productCategory: "elearning",
productProfile: "non-physical-goods"))
.Build();
var result = await gateway.RequestSessionAsync(request, ct);
return result.Success ? result.Value!.GatewayPageUrl : null;
}
}
On success, redirect the customer (302 or Redirect(...)) to result.Value.GatewayPageUrl — that URL is the payment page; do not fetch it server-side.
With error handling, the controller pattern:
[HttpPost("/checkout")]
public async Task<IActionResult> Checkout(CheckoutInput input, CancellationToken ct)
{
var request = BuildSessionRequest(input); // as above
var result = await gateway.RequestSessionAsync(request, ct);
if (!result.Success || result.Value?.GatewayPageUrl is null)
{
_logger.LogWarning(
"Session request failed ({Category}): {Message}",
result.Error?.Category, result.Error?.Message);
ModelState.AddModelError(string.Empty,
result.Error?.Message ?? "The payment session request failed.");
return View(input);
}
// persist the order as Pending (tran_id + amount) BEFORE redirecting,
// so the IPN/callback can match it later
await _orders.MarkPendingAsync(request.TransactionId, request.TotalAmount, ct);
return Redirect(result.Value.GatewayPageUrl);
}
Building the request without the builder
The builder is sugar — TransactionSessionRequest is a plain mutable model, so object initializers work too (this is how the demo app does it):
var request = new TransactionSessionRequest
{
TotalAmount = 8000m,
Currency = "BDT",
TransactionId = transactionId, // generate a unique id per payment attempt
SuccessUrl = $"{baseUrl}/payment/succeeded",
FailUrl = $"{baseUrl}/payment/failed",
CancelUrl = $"{baseUrl}/payment/canceled",
IpnUrl = $"{baseUrl}/ipn",
EmiOption = 0, // always sent (required on the wire)
CustomerName = "Demo User",
CustomerEmail = "demo@example.com",
CustomerAddress1 = "Road 7, Dhanmondi",
CustomerCity = "Dhaka",
CustomerPostcode = "1212",
CustomerCountry = "Bangladesh",
CustomerPhone = "01700000000",
ShippingMethod = "NO",
ProductName = "Demo Product",
ProductCategory = "general",
ProductProfile = "general"
};
Request fields
The table marks wire name, type, max length, and whether required (missing/oversized required fields throw ValidationException before any I/O; optional null fields are omitted from the request, never sent as 0/empty).
Integration-required group (AddIntegrationRequiredParameter — first six via constructor, rest via initializer):
| Property | Wire field | Type | Max | Notes |
|---|---|---|---|---|
TotalAmount |
total_amount |
decimal |
— | Required. Serialized invariant, two decimals (1750.50) |
Currency |
currency |
string |
3 | Required. e.g. BDT, USD |
TransactionId |
tran_id |
string |
30 | Required. Must be unique per payment attempt — reusing a tran_id gets a FAILED session |
SuccessUrl |
success_url |
string |
255 | Required. Absolute URL |
FailUrl |
fail_url |
string |
255 | Required. Absolute URL |
CancelUrl |
cancel_url |
string |
255 | Required. Absolute URL |
IpnUrl |
ipn_url |
string? |
255 | Optional per-transaction IPN endpoint (can also be configured store-wide in the dashboard) |
MultiCardName |
multi_card_name |
string? |
30 | Optional. Restrict displayed channels — comma-separated gateway names (e.g. "brac_visa,dbblmobilebanking"); use the codes enabled for your store |
AllowedBin |
allowed_bin |
string? |
255 | Optional. Restrict to card BIN prefixes, comma-separated |
EMI group (AddEmi — equated monthly installments; only meaningful for stores where SSLCommerz enabled EMI):
| Property | Wire field | Type | Range | Notes |
|---|---|---|---|---|
EmiOption |
emi_option |
int |
0–1 | Required on the wire (always sent). 0 = EMI off, 1 = on |
EmiMaxInstOption |
emi_max_inst_option |
int? |
— | Max installments to offer |
EmiSelectedInst |
emi_selected_inst |
int? |
— | Pre-select an installment count |
EmiAllowOnly |
emi_allow_only |
int? |
0–1 | 1 = show EMI channels only |
.AddEmi(new Emi(emiOption: 1) { EmiMaxInstOption = 12, EmiSelectedInst = 6 })
Customer group (AddCustomerInformation — first seven via constructor; the rest optional via initializer):
| Property | Wire field | Required | Max |
|---|---|---|---|
CustomerName |
cus_name |
yes | 50 |
CustomerEmail |
cus_email |
yes (email format) | 50 |
CustomerAddress1 |
cus_add1 |
yes | 50 |
CustomerAddress2 |
cus_add2 |
no | 50 |
CustomerCity |
cus_city |
yes | 50 |
CustomerState |
cus_state |
no | 50 |
CustomerPostcode |
cus_postcode |
yes | 30 |
CustomerCountry |
cus_country |
yes | 50 |
CustomerPhone |
cus_phone |
yes | 20 |
CustomerFax |
cus_fax |
no | 20 |
Shipment group (AddShipmentInformation — ShippingMethod via constructor; "NO" means no shipping, typical for digital goods):
| Property | Wire field | Type |
|---|---|---|
ShippingMethod |
shipping_method |
string (required, max 50) — NO / YES / a courier code per your store setup |
NumberOfItem |
num_of_item |
int? |
ShippingName |
ship_name |
string? |
ShippingAddress1 |
ship_add1 |
string? |
ShippingAddress2 |
ship_add2 |
string? |
ShippingCity |
ship_city |
string? |
ShippingState |
ship_state |
string? |
ShippingPostcode |
ship_postcode |
string? |
ShippingCountry |
ship_country |
string? |
.AddShipmentInformation(new ShipmentInformation(shippingMethod: "YES", numberOfItem: 2)
{
ShippingName = "Rahim Uddin",
ShippingAddress1 = "12 Uttara Sector 4",
ShippingCity = "Dhaka",
ShippingPostcode = "1230",
ShippingCountry = "Bangladesh"
})
Product group (AddProductInformation — name/category/profile via constructor; the vertical-specific fields exist for stores whose product_profile calls for them):
| Property | Wire field | Required | Max | Vertical |
|---|---|---|---|---|
ProductName |
product_name |
yes | 255 | all |
ProductCategory |
product_category |
yes | 100 | all |
ProductProfile |
product_profile |
yes | 100 | all — e.g. general, physical-goods, non-physical-goods, travel, hotel-booking, mobile-topup; use the value(s) enabled for your store |
HoursTillDeparture |
hours_till_departure |
no | 30 | airline |
FlightType |
flight_type |
no | 30 | airline |
Pnr |
pnr |
no | 50 | airline |
JourneyFromTo |
journey_from_to |
no | 255 | airline |
ThirdPartyBooking |
third_party_booking |
no | 20 | airline |
HotelName |
hotel_name |
no | 255 | hotel |
LengthOfStay |
length_of_stay |
no | 30 | hotel |
CheckInTime |
check_in_time |
no | 30 | hotel |
HotelCity |
hotel_city |
no | 50 | hotel |
ProductType |
product_type |
no | 30 | mobile top-up |
TopupNumber |
topup_number |
no | 150 | mobile top-up |
CountryTopup |
country_topup |
no | 30 | mobile top-up |
Cart |
cart |
no | — | JSON cart mirroring the order lines |
ProductAmount |
product_amount |
no | — | decimal?, invariant F2 |
Vat |
vat |
no | — | decimal?, invariant F2 |
DiscountAmount |
discount_amount |
no | — | decimal?, invariant F2 |
ConvenienceFee |
convenience_fee |
no | — | decimal?, invariant F2 |
.AddProductInformation(new ProductInformation(
productName: "DAC→CXB Return", productCategory: "airline", productProfile: "travel")
{
HoursTillDeparture = "12",
FlightType = "Return",
Pnr = "ABC123",
JourneyFromTo = "DAC-CXB",
ThirdPartyBooking = "NO"
})
Additional pass-through group (AddAdditionalParameters) — four free-form echo fields. Whatever you set here comes back in the IPN, the validation response, and the query response, so they are the sanctioned way to thread your own context (e.g. order id, tenant) through the gateway:
.AddAdditionalParameters(new AdditionalParameter
{
ValueA = "order-6f2c",
ValueB = "tenant-bd"
})
Response: TransactionSessionResponse
| Property | Wire field | Meaning |
|---|---|---|
Status |
status |
"SUCCESS" or "FAILED" — a FAILED body is surfaced as a ProviderFailed result, with this value still populated |
FailedReason |
failedreason |
e.g. "Store Credential Error or Store is Inactive" |
SessionKey |
sessionkey |
The session id — keep it (it's one of the two query keys for QueryTransactionAsync) |
GatewayPageUrl |
GatewayPageURL |
The URL to redirect the customer to (note the provider's casing) |
Gateway |
gw |
Nested channel flags (Visa, MasterCard, AmericanExpress, OtherCards, InternetBanking, MobileBanking) |
StoreBanner / StoreLogo |
storeBanner / storeLogo |
Branding configured on the store |
Description |
desc |
List of GatewayDescription (Name, Type, Logo, Gateway, Rflag, RedirectGatewayUrl) — direct-channel entry points if you build your own channel picker |
2. Validate a transaction (Order Validation) (ValidateTransactionAsync)
The authoritative money-state check. Given the val_id that arrived in an IPN or success-return payload, it fetches the transaction straight from SSLCommerz's validator. Run it on every success/cancel/IPN callback before fulfilling anything.
Task<SslCommerzResult<ValidationResponse>> ValidateTransactionAsync(
ValidationRequest request, CancellationToken cancellationToken = default);
ValidationRequest has a single required property (val_id, max 50):
var validation = await gateway.ValidateTransactionAsync(
new ValidationRequest { ValidationId = valId }, ct);
Sent as GET {base}/validator/api/validationserverAPI.php?val_id=...&store_id=...&store_passwd=...&v=1&format=json — the protocol constants (v, format) are stamped by the client.
Acceptance checklist
A successful call is not a successful payment — verify the response against your own order row:
public async Task<bool> IsPaymentActuallyValidAsync(
string valId, string expectedTransactionId, decimal expectedAmount, CancellationToken ct)
{
var result = await gateway.ValidateTransactionAsync(
new ValidationRequest { ValidationId = valId }, ct);
if (!result.Success || result.Value is null)
{
return false; // transport/provider error — do NOT fulfil; retry or leave pending
}
var v = result.Value;
return v.Status is "VALID" or "VALIDATED" // VALIDATED = re-validation (idempotent)
&& v.TransactionId == expectedTransactionId // same order
&& v.Amount == expectedAmount // same amount as initiated
&& v.CurrencyType == "BDT" // expected currency
&& v.CurrencyAmount == expectedAmount; // no FX conversion happened
}
VALID— the payment is real and completed.VALIDATED— thisval_idwas already validated before; treat asVALID(validation is idempotent; expected on IPN redelivery).- Anything else (
FAILED, …) — not paid. Note that a body-levelstatus: "FAILED"is surfaced as aProviderFailederror result (withValuestill populated), so checkresult.Successfirst as above.
Response: ValidationResponse
Money/status fields you assert on: Status, ValidatedOn (validated_on), TransactionDate (tran_date), TransactionId (tran_id), ValidationId (val_id), Amount, StoreAmount (what your store receives after charges), CurrencyType, CurrencyAmount.
Payment-instrument fields for your records: CardType, CardNumber (masked), CardIssuer, CardBrand, CardIssuerCountry, CardIssuerCountryCode, BankTransactionId (bank_tran_id), ApiConnect (APIConnect — provider connectivity indicator, e.g. DONE).
EMI/discount fields: EmiInstalment, EmiAmount, DiscountAmount, DiscountPercentage, DiscountRemarks.
Risk fields: RiskLevel (0–1), RiskTitle — factor these into fulfilment decisions if your risk policy requires it.
Echo fields: ValueA–ValueD — your pass-through context from the session request.
3. Query transactions (QueryTransactionAsync)
Server-side transaction search by session key XOR transaction id — for pending-payment sweeps and late-completion hunts (see the Integration Specification §10 for the reconciliation loop this supports).
Task<SslCommerzResult<IReadOnlyList<TransactionQueryResult>>> QueryTransactionAsync(
TransactionQuery query, CancellationToken cancellationToken = default);
Exactly one of the two keys must be set — both set or both empty throws ArgumentException before any I/O:
// by transaction id (the tran_id you generated at checkout)
var result = await gateway.QueryTransactionAsync(
new TransactionQuery { TransactionId = "DSAB12CD" }, ct);
// or by the session key returned with the payment session
var result = await gateway.QueryTransactionAsync(
new TransactionQuery { SessionKey = "5E8A54C1B96A4C3F2B7F80E29A1234D6" }, ct);
if (result.Success)
{
foreach (var element in result.Value!)
{
// element.Status is transaction data: VALID / VALIDATED / PENDING /
// CANCELLED / FAILED / UNATTEMPTED / ... — your reconciliation logic
// decides on it; it never fails the *call*
//
// element.Shape mirrors ValidationResponse: Amount, StoreAmount,
// TransactionId, ValidationId, BankTransactionId, TransactionDate, ...
}
}
One tran_id can match multiple elements (retries create additional attempts), so always iterate the list rather than taking the first element. Element shape mirrors ValidationResponse (TransactionQueryResult); fields the provider omits stay at their defaults.
Note: element status: "FAILED" here does not become a ProviderFailed error — unlike session/validation, the query path maps nothing; statuses are yours to interpret (see Payment status mapping).
Handling the IPN webhook
SSLCommerz POSTs the payment outcome server-to-server to your configured IPN endpoint (ipn_url on the session request, or the store-wide setting in the dashboard). The payload arrives form-encoded (a JSON body is also possible — both are shown below).
The trust chain has two steps, in this order:
VerifySignVerifier— verify theverify_signchecksum over the raw posted fields (never over a re-serialized model — the digest is defined over exactly the bytes the provider sent).ValidateTransactionAsync— re-check the money state at the provider. A valid signature is necessary, not sufficient (MD5 is a weak, provider-mandated checksum).
using DevSkill.SSLCommerz;
using DevSkill.SSLCommerz.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
[ApiController]
public sealed class IpnController(
IOptions<SslCommerzSettings> settings,
IGatewayClient gateway) : ControllerBase
{
[HttpPost("/ipn")]
public async Task<IActionResult> Receive(CancellationToken ct)
{
// 0. Read the RAW fields — verification runs over exactly these bytes
var form = await Request.ReadFormAsync(ct);
var fields = form.ToDictionary(kv => kv.Key, kv => kv.Value.ToString());
// 1. Verify the checksum over the raw field dictionary (fail-closed)
var verifier = new VerifySignVerifier(settings.Value);
if (!verifier.Verify(fields))
{
return Unauthorized();
}
// 2. Parse the notification into a typed model
var notification = PaymentNotificationParser.Parse(fields);
if (string.IsNullOrWhiteSpace(notification.ValidationId))
{
return BadRequest("The payment notification carries no validation id.");
}
// 3. Necessary, NOT sufficient — re-check money state via Order Validation.
// Fast-ack pattern: respond 200 quickly; do the validation + fulfilment
// inline only if it's fast, otherwise queue it (IPN delivery retries on non-2xx).
var validation = await gateway.ValidateTransactionAsync(
new ValidationRequest { ValidationId = notification.ValidationId! }, ct);
if (validation.Success
&& validation.Value!.Status is "VALID" or "VALIDATED"
&& validation.Value.TransactionId == notification.TransactionId
&& validation.Value.Amount == expectedAmountFor(notification.TransactionId))
{
await FulfilOrderAsync(notification, validation.Value, ct); // MUST be idempotent
}
return Ok();
}
}
Make fulfilment idempotent — SSLCommerz redelivers IPNs, and VALIDATED (rather than VALID) on re-validation is the expected sign of a redelivery, not an error.
JSON-body notifications
If the provider posts a JSON body instead of a form, verify the same way (build the field dictionary from the parsed JSON) and parse with ParseJson:
using Newtonsoft.Json;
[HttpPost("/ipn-json")]
public async Task<IActionResult> ReceiveJson(CancellationToken ct)
{
using var reader = new StreamReader(Request.Body);
var json = await reader.ReadToEndAsync(ct);
// Build the raw field dictionary for verification, then parse
var dictionary = JsonConvert.DeserializeObject<Dictionary<string, string>>(json)!;
var verifier = new VerifySignVerifier(settings.Value);
if (!verifier.Verify(dictionary))
{
return Unauthorized();
}
var notification = PaymentNotificationParser.ParseJson(json);
// ... same validation chain as above
return Ok();
}
ParseJson coerces string-encoded numbers and dates (e.g. "1750.00"), and throws JsonException on malformed bodies and ArgumentNullException/ArgumentException on null/empty input.
VerifySignVerifier — algorithm and rules
public sealed class VerifySignVerifier
{
public VerifySignVerifier(SslCommerzSettings settings); // uses StorePassword
public VerifySignVerifier(string storePassword); // if settings aren't handy
public bool Verify(IReadOnlyDictionary<string, string> fields);
}
The check (per the provider's documented convention):
verify_keylists the field names covered by the signature (comma-separated).- Expected digest =
MD5(store_passwd + concat(values of the verify_key fields, in verify_key order)), lowercase hex. - Compared against
verify_signusing a fixed-time, ordinal comparison.
Fail-closed — Verify returns false (never throws, except ArgumentNullException for a null dictionary) when any of these hold:
verify_signorverify_keyis missing, empty, or whitespace- any
verify_keyentry is empty after trimming - any listed field is absent from the dictionary
Caveats (tracked as SC-VER-1/DF-3.1 in the V4 plan): the algorithm is the documented provider convention and this SDK's working assumption; byte-exact confirmation against the live sandbox happens in the consumer's integration verification, deliberately outside this library.
PaymentNotificationParser — overloads
| Overload | Input |
|---|---|
Parse(NameValueCollection) |
Request.Form or HttpUtility.ParseQueryString(...) output |
Parse(IReadOnlyDictionary<string, string>) |
The same raw dictionary you passed to Verify |
ParseJson(string) |
Raw JSON body |
PaymentNotification carries: Status, TransactionDate, TransactionId, ValidationId (this is the val_id you validate with), Amount, StoreAmount, CurrencyType, CurrencyAmount, CardType/CardNumber/CardIssuer/CardBrand/CardIssuerCountry(Code), BankTransactionId, ValueA–ValueD, RiskLevel/RiskTitle, plus VerifySign/VerifyKey (the raw trust fields — kept for audit, not for re-verification over the model).
Handling the user return (success / fail / cancel)
After paying (or failing/cancelling), the customer's browser is redirected to your success_url / fail_url / cancel_url with a form POST of the same payment fields. This return is a UX landing page, not proof of payment — treat its payload exactly like an IPN: verify, then validate:
[ApiController]
public sealed class ReturnController(IGatewayClient gateway) : ControllerBase
{
[HttpPost("/payment/succeeded")]
public async Task<IActionResult> Succeeded(CancellationToken ct) =>
await HandleReturnAsync("Payment complete — thank you.", ct);
[HttpPost("/payment/failed")]
public async Task<IActionResult> Failed(CancellationToken ct) =>
await HandleReturnAsync("Payment failed. Please try again.", ct);
[HttpPost("/payment/canceled")]
public IActionResult Canceled() =>
Ok("Payment canceled."); // no validation needed — nothing to fulfil
private async Task<IActionResult> HandleReturnAsync(string okMessage, CancellationToken ct)
{
var form = await Request.ReadFormAsync(ct);
var fields = form.ToDictionary(kv => kv.Key, kv => kv.Value.ToString());
var notification = PaymentNotificationParser.Parse(fields);
// Lost-IPN recovery: a success return carrying val_id + status VALID is
// run through the same validation chain as an IPN — the money is real
// even if the IPN never arrived.
if (notification.ValidationId is { } valId)
{
var validation = await gateway.ValidateTransactionAsync(
new ValidationRequest { ValidationId = valId }, ct);
if (validation.Success && validation.Value!.Status is "VALID" or "VALIDATED")
{
// fulfill / show receipt; verify tran_id + amount against your order row first
}
}
return Ok(okMessage);
}
}
(You may point all three URLs at one endpoint and branch on the posted status field.)
Payment status mapping
Provider statuses → what your order state machine should do (Integration Specification §7.2, decision D9):
Provider status |
Meaning | Action |
|---|---|---|
VALID |
Payment completed | Run Order Validation; on pass → successful / fulfil |
VALIDATED |
Re-validation of an already-validated payment | Same as VALID (idempotent — absorb if already fulfilled) |
FAILED |
Payment attempt failed | → failed (customer may retry with a new tran_id) |
CANCELLED |
Customer canceled on the gateway | → cancelled |
EXPIRED |
Session expired unpaid (default ~120 min) | → cancelled |
UNATTEMPTED |
Customer hasn't chosen a channel yet | Record the event, no transition — stays pending (a later IPN or the sweep resolves it) |
Background reconciliation sweep
IPNs can be delayed or lost; pending payments need a periodic sweep using the query API (Integration Specification §10):
public sealed class ReconciliationService(IGatewayClient gateway, IOrderStore orders)
{
// Run periodically (e.g. every 15 minutes) over rows still Pending
public async Task SweepPendingAsync(CancellationToken ct)
{
await foreach (var order in orders.GetPendingAsync(ct))
{
var result = await gateway.QueryTransactionAsync(
new TransactionQuery { TransactionId = order.TransactionId }, ct);
if (!result.Success)
{
continue; // transport/HTTP error — retry next sweep
}
foreach (var element in result.Value!)
{
switch (element.Status)
{
case "VALID" or "VALIDATED":
// late completion: validate (element.ValidationId) and fulfil
break;
case "EXPIRED":
// mark cancelled after your expiry window
break;
// PENDING / UNATTEMPTED / FAILED → leave for the next sweep or fail the order
}
}
}
}
}
Complete integration walkthrough
A minimal but complete ASP.NET Core integration in three files. (The demo app is the full-fat version with EF Core persistence and Docker.)
appsettings.json:
{
"SSLCommerz": {
"StoreId": "test_box",
"StorePassword": "qwerty",
"Environment": "Sandbox"
}
}
Program.cs:
using DevSkill.SSLCommerz;
using DevSkill.SSLCommerz.Models;
var builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<SslCommerzSettings>(
builder.Configuration.GetSection("SSLCommerz"));
builder.Services.AddHttpClient<IGatewayClient, GatewayClient>();
builder.Services.AddScoped<CheckoutService>();
var app = builder.Build();
app.MapControllers();
app.Run();
CheckoutController.cs (initiation + return; add the IpnController and the return handling from the sections above and you have the whole flow):
using DevSkill.SSLCommerz;
using DevSkill.SSLCommerz.Models;
using Microsoft.AspNetCore.Mvc;
[ApiController]
public sealed class CheckoutController(
IGatewayClient gateway,
IOrderStore orders) : ControllerBase
{
[HttpPost("/checkout")]
public async Task<IActionResult> Checkout(CheckoutInput input, CancellationToken ct)
{
var baseUrl = $"{Request.Scheme}://{Request.Host}";
var transactionId = Guid.NewGuid().ToString("N")[..20]; // unique per attempt, ≤ 30 chars
var request = new TransactionSessionRequestBuilder()
.AddIntegrationRequiredParameter(new IntegrationRequiredParameter(
totalAmount: input.Amount,
currency: "BDT",
transactionId: transactionId,
successUrl: $"{baseUrl}/payment/succeeded",
failUrl: $"{baseUrl}/payment/failed",
cancelUrl: $"{baseUrl}/payment/canceled")
{
IpnUrl = $"{baseUrl}/ipn"
})
.AddCustomerInformation(new CustomerInformation(
customerName: input.Name,
customerEmail: input.Email,
customerAddress1: input.Address,
customerCity: input.City,
customerPostcode: input.Postcode,
customerCountry: "Bangladesh",
customerPhone: input.Phone))
.AddShipmentInformation(new ShipmentInformation("NO"))
.AddProductInformation(new ProductInformation(
productName: "Dev Skill Course",
productCategory: "elearning",
productProfile: "non-physical-goods"))
.AddAdditionalParameters(new AdditionalParameter { ValueA = input.OrderId })
.Build();
var result = await gateway.RequestSessionAsync(request, ct);
if (!result.Success || result.Value?.GatewayPageUrl is null)
{
return Problem(result.Error?.Message ?? "Payment session request failed.");
}
// persist Pending BEFORE redirecting so callbacks can match the order
await orders.CreatePendingAsync(transactionId, input.Amount, input.OrderId, ct);
return Redirect(result.Value.GatewayPageUrl);
}
}
Advanced usage
Custom HttpClient: timeouts, proxies, resilience
The client is a thin service over an injectable HttpClient — everything HttpClientFactory supports works:
builder.Services.AddHttpClient<IGatewayClient, GatewayClient>(client =>
{
client.Timeout = TimeSpan.FromSeconds(30);
})
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
{
Proxy = new WebProxy("http://proxy.internal:8080"),
UseProxy = true
});
Base-URL overrides (proxy / contract-test fakes)
Point the client at a test double instead of the real gateway:
{ "SSLCommerz": { "Environment": "Sandbox", "SandboxBaseUrl": "https://localhost:8443/fake-gateway" } }
Rules: absolute HTTPS, no query string — violations throw InvalidOperationException at client construction. The environment-applicable override wins; the other is ignored.
Cancellation
Every operation takes a CancellationToken, honored through to the HTTP call (caller-requested cancellation propagates as OperationCanceledException; SDK-internal timeouts surface as Transport results, never as cancellations).
Direct instantiation without DI
See Registration — new GatewayClient(Options.Create(settings), httpClient?). VerifySignVerifier and PaymentNotificationParser are plain types usable anywhere.
Wire protocol details
What actually goes over the wire (useful when debugging with logs or a proxy):
| Operation | Method & body | Endpoint |
|---|---|---|
| Session request | POST, application/x-www-form-urlencoded |
{base}/gwprocess/v4/api.php |
| Order validation | GET, query string |
{base}/validator/api/validationserverAPI.php |
| Transaction query | GET, query string |
{base}/validator/api/merchantTransIDvalidationAPI.php |
store_idandstore_passwdare appended to every request by the client, at send time.- Validation calls stamp
v=1&format=json; query calls stampformat=json. - Only model properties with
[JsonProperty]of typestring,int, ordecimal(and their nullable forms) are serialized. Null and whitespace-only strings are omitted; null numerics are omitted; non-null ints are always sent. - Money fields (
total_amount,product_amount,vat,discount_amount,convenience_fee) format as invariant-culture two-decimal strings (1750.50) — never culture-sensitive, never scientific notation. - GET query parameters are properly URL-encoded (
Uri.EscapeDataString). - Response casing follows the provider, not C# conventions (e.g.
GatewayPageURL,APIConnect).
Security notes
- Keep
StorePasswordin a secret store (user-secrets / environment variables / key vault) — never in committedappsettings.json, never in logs. The SDK never logs it, but it is on every outbound request — don't capture request bodies in HTTP logs on production. RawJsonis the provider's raw response — safe to persist for audit, but treat it as external input.- Verify IPNs and returns over the raw posted fields, fail-closed; always follow with Order Validation.
- The verifier's digest is provider-mandated MD5 — that's exactly why it is treated as necessary-but-not-sufficient.
- For live validation/query APIs, SSLCommerz requires your outbound public IP(s) to be registered with them (Integration Specification §4.3) — unregistered IPs get rejected calls that look like provider outages.
- Live base URLs are HTTPS-only; the client refuses anything else.
Troubleshooting
| Symptom | Category / exception | Likely cause & fix |
|---|---|---|
Transport: The request timed out… |
Transport result |
Gateway slow/unreachable; raise HttpClient.Timeout, add resilience (e.g. retry via AddStandardResilienceHandler) |
Transport: Could not reach SSLCommerz… |
Transport result |
DNS/TLS/network failure, or (live validator) your outbound IP isn't registered with SSLCommerz |
SSLCommerz responded with HTTP 5xx… |
HttpError result |
Provider outage or wrong base URL; check status page, don't hammer retries |
FAILED: Store Credential Error or Store is Inactive (result.Value.FailedReason) |
ProviderFailed result |
Wrong StoreId/StorePassword, or sandbox credentials used against live (or vice versa) — check Environment |
FAILED on an otherwise-valid session request |
ProviderFailed result |
Most often a reused tran_id — generate a fresh unique id per payment attempt |
InvalidResponse: … could not be parsed |
InvalidResponse result |
Base URL points at an HTML page (wrong override), or the provider shape changed — inspect RawJson |
ValidationException at the call site |
thrown synchronously | A required field is missing/empty, a string exceeds its max length, or emi_option is outside 0–1 — check the field tables |
ArgumentException: Exactly one of SessionKey or TransactionId… |
thrown synchronously | TransactionQuery needs exactly one of the two keys |
InvalidOperationException at client construction |
thrown at new GatewayClient(...) |
Base-URL override isn't absolute HTTPS without a query string |
IPN always fails Verify |
returns false |
You're verifying over a re-serialized model (don't — use the raw field dictionary), or the store password differs from the store that sent the IPN |
Testing your integration
- Sandbox: use sandbox credentials (
Environment: "Sandbox"); the full flow — session, gateway page, IPN, validation, query — works against the sandbox. Track your verification pass in the Integration Specification's sandbox verification record (§16) and follow the cutover checklist (§15) before going live. - Reference app:
src/demois a runnable ASP.NET Core MVC checkout with persistence and Docker (docker-compose up). - Unit testing your own code: the client never does I/O you can't fake — register
IGatewayClientwith a stub, or pointSandboxBaseUrlat a local HTTP fake. This library's own 141-test suite (src/tests) does exactly that (a fakeHttpMessageHandler+ real provider payload strings) with no network access. - Idempotence check: replay a recorded IPN twice — the second pass must be a no-op (look for
VALIDATED).
Upgrading from 3.x
v4 is a breaking overhaul (see the V4 Overhaul Plan for the full rationale):
GatewayClientis now async behindIGatewayClient(RequestSessionAsync/ValidateTransactionAsync/QueryTransactionAsync) and returnsSslCommerzResult<T>instead of tuples.- Credentials come from
SslCommerzSettingsonly — theCredentialtype and the builder's credential argument are gone. Environments.Development→SslCommerzEnvironment.Sandbox.- Optional numeric request fields are nullable; unset fields are no longer sent as
0. - Transport/HTTP/provider failures return error results instead of throwing during deserialization.
Versioning and support
- Package:
DevSkill.SSLCommerz(latest: 4.0.1), targetingnet10.0only. - Breaking changes bump the major version with no compat shims (overhaul-plan decision D8) and update this README in the same change.
- CI (
.github/workflows/dotnetcore.yml) builds the library and runs the test suite on pushes/PRs tomainanddevelop.
License
See License.txt.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Microsoft.Extensions.Options (>= 10.0.0)
- Newtonsoft.Json (>= 13.0.3)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 4.0.1 | 222 | 8/19/2026 |
| 4.0.0 | 101 | 8/19/2026 |
| 2.0.105 | 556 | 1/11/2024 |
| 2.0.104 | 657 | 9/21/2023 |
| 2.0.103 | 260 | 9/21/2023 |
| 2.0.33 | 587 | 6/22/2023 |
| 2.0.31 | 2,131 | 3/2/2022 |
| 2.0.30 | 675 | 3/1/2022 |
| 2.0.29 | 1,120 | 1/17/2022 |
| 2.0.3 | 476 | 12/5/2022 |
| 1.0.39 | 571 | 11/18/2021 |
| 1.0.38 | 500 | 11/18/2021 |
| 1.0.37 | 502 | 11/18/2021 |
| 1.0.36 | 476 | 11/18/2021 |
| 1.0.26 | 543 | 12/12/2021 |
| 1.0.25 | 498 | 12/5/2021 |
| 1.0.23 | 498 | 12/5/2021 |
| 1.0.22 | 484 | 12/5/2021 |
| 1.0.21 | 506 | 12/5/2021 |
| 1.0.20 | 496 | 12/5/2021 |
v4.0.1 — docs-only: README rewritten as a comprehensive, self-contained SDK/API reference (end-to-end flow, full field tables for every request group, IPN verify_sign + parser usage, user-return handling, status mapping, reconciliation sweep, troubleshooting, wire-protocol details). No API changes.
v4.0.0 — breaking overhaul: async IGatewayClient with a typed SslCommerzResult result type (transport/HTTP/provider errors never throw); credentials stamped from SslCommerzSettings (single source); configurable sandbox/live base URLs; IPN verify_sign verifier and payment-notification parser; transaction query API; optional numeric request fields are nullable and unset fields are no longer sent; invariant-culture money formatting; SslCommerzEnvironment { Sandbox, Live } replaces Environments { Development, Live }.