DevSkill.SSLCommerz 4.0.1

dotnet add package DevSkill.SSLCommerz --version 4.0.1
                    
NuGet\Install-Package DevSkill.SSLCommerz -Version 4.0.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="DevSkill.SSLCommerz" Version="4.0.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="DevSkill.SSLCommerz" Version="4.0.1" />
                    
Directory.Packages.props
<PackageReference Include="DevSkill.SSLCommerz" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add DevSkill.SSLCommerz --version 4.0.1
                    
#r "nuget: DevSkill.SSLCommerz, 4.0.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package DevSkill.SSLCommerz@4.0.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=DevSkill.SSLCommerz&version=4.0.1
                    
Install as a Cake Addin
#tool nuget:?package=DevSkill.SSLCommerz&version=4.0.1
                    
Install as a Cake Tool

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 .NET 10
develop .NET 10

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

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:

  1. Never trust the browser. Both the IPN (step 4) and the user-return POST (step 7) are untrusted input until verified.
  2. A valid verify_sign is necessary, not sufficient. It proves the payload came from SSLCommerz (weak, provider-mandated MD5). Always re-check the money state via ValidateTransactionAsync (Order Validation) before fulfilling anything.
  3. 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 FAILEDValue 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 objects
  • ValidationException (System.ComponentModel.DataAnnotations) — request model fails data-annotation validation (missing required field, string too long, emi_option outside 0–1)
  • ArgumentException — a TransactionQuery with both or neither of SessionKey/TransactionId
  • InvalidOperationException — at GatewayClient construction, 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 (AddShipmentInformationShippingMethod 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 — this val_id was already validated before; treat as VALID (validation is idempotent; expected on IPN redelivery).
  • Anything else (FAILED, …) — not paid. Note that a body-level status: "FAILED" is surfaced as a ProviderFailed error result (with Value still populated), so check result.Success first 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: ValueAValueD — 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:

  1. VerifySignVerifier — verify the verify_sign checksum over the raw posted fields (never over a re-serialized model — the digest is defined over exactly the bytes the provider sent).
  2. 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):

  1. verify_key lists the field names covered by the signature (comma-separated).
  2. Expected digest = MD5(store_passwd + concat(values of the verify_key fields, in verify_key order)), lowercase hex.
  3. Compared against verify_sign using a fixed-time, ordinal comparison.

Fail-closedVerify returns false (never throws, except ArgumentNullException for a null dictionary) when any of these hold:

  • verify_sign or verify_key is missing, empty, or whitespace
  • any verify_key entry 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, ValueAValueD, 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 Registrationnew 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_id and store_passwd are appended to every request by the client, at send time.
  • Validation calls stamp v=1&format=json; query calls stamp format=json.
  • Only model properties with [JsonProperty] of type string, int, or decimal (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 StorePassword in a secret store (user-secrets / environment variables / key vault) — never in committed appsettings.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.
  • RawJson is 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/demo is 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 IGatewayClient with a stub, or point SandboxBaseUrl at a local HTTP fake. This library's own 141-test suite (src/tests) does exactly that (a fake HttpMessageHandler + 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):

  • GatewayClient is now async behind IGatewayClient (RequestSessionAsync / ValidateTransactionAsync / QueryTransactionAsync) and returns SslCommerzResult<T> instead of tuples.
  • Credentials come from SslCommerzSettings only — the Credential type and the builder's credential argument are gone.
  • Environments.DevelopmentSslCommerzEnvironment.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), targeting net10.0 only.
  • 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 to main and develop.

License

See License.txt.

Product 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

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
Loading failed

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 }.