Pathao.Courier
1.0.0
dotnet add package Pathao.Courier --version 1.0.0
NuGet\Install-Package Pathao.Courier -Version 1.0.0
<PackageReference Include="Pathao.Courier" Version="1.0.0" />
<PackageVersion Include="Pathao.Courier" Version="1.0.0" />
<PackageReference Include="Pathao.Courier" />
paket add Pathao.Courier --version 1.0.0
#r "nuget: Pathao.Courier, 1.0.0"
#:package Pathao.Courier@1.0.0
#addin nuget:?package=Pathao.Courier&version=1.0.0
#tool nuget:?package=Pathao.Courier&version=1.0.0
Pathao.Courier
An unofficial, community-maintained .NET SDK (net10.0) for the
Pathao Courier Merchant API — typed clients for stores,
orders, pricing and locations with automatic OAuth 2.0 token handling.
Disclaimer: This project is not affiliated with, endorsed by, or sponsored by Pathao. "Pathao" is a trademark of its respective owner. This is an independent, open-source SDK released under the MIT license.
Status
🚀 v0.1.0-alpha — feature-complete for v1: all 11 documented Merchant API endpoints are covered (OAuth 2.0 token lifecycle, city/zone/area reference data, delivery price estimation, merchant store management, consignment order creation — single, bulk and status info) plus the ASP.NET Core DI experience (single-account registration + per-merchant factory) and a runnable console demo. The public API may still change without notice until v1.0.
Installation
dotnet add package Pathao.Courier --prerelease
or reference the package directly (version 0.1.0-alpha.1 or later):
<PackageReference Include="Pathao.Courier" Version="0.1.0-alpha.1" />
Requires net10.0. Runtime dependencies are BCL-only apart from
Microsoft.Extensions.Http/Microsoft.Extensions.Options (DI convenience); JSON is handled by
source-generated System.Text.Json (trimming/AOT friendly).
Quickstart
using var client = new PathaoCourierClient(new PathaoClientOptions
{
BaseUrl = PathaoUrls.Sandbox, // or PathaoUrls.Production
ClientId = "...",
ClientSecret = "...",
Username = "...",
Password = "..."
});
Authentication & token lifecycle
The SDK implements the full Pathao OAuth 2.0 story; you normally never touch tokens yourself:
- Lazy acquisition — the first API call (and only the first) requests an access token via
the password grant against
/aladdin/api/v1/issue-token. - Persistence — tokens are kept in an
ITokenStore. The default is a thread-safe in-memory store; supply your own implementation (e.g. database-backed) to survive restarts or share tokens across processes. Tokens are keyed by a stable hash of the base URL, client id and username, so clients created with the same credentials reuse the same token. - Proactive refresh — the SDK tracks absolute expiry from the issued
expires_invalue (documented as 432000 s / 5 days; the live sandbox currently grants 90 days) and refreshes via the refresh-token grant 5 minutes before real expiry. - Single-flight — under concurrent requests, only one caller performs an acquire/refresh; the rest wait and reuse the result.
- Reactive retry — if the API still answers HTTP 401, the token is refreshed once and the
request retried once (request bodies are buffered so resending is safe). A second 401
surfaces as a
PathaoApiException. - Errors — token-endpoint failures throw
PathaoAuthException(a specialization ofPathaoApiException) carrying the HTTP status, best-effort business message and raw body.
Explicit control is available when you need it:
PathaoToken token = await client.Auth.IssueTokenAsync(); // password grant
PathaoToken fresh = await client.Auth.RefreshTokenAsync(); // refresh grant
Security note: credentials and tokens are only ever sent in the token request body and the
Authorization header respectively — never in URLs, never logged.
Dependency injection (single account)
For ASP.NET Core (or any Microsoft.Extensions.DependencyInjection host), register the
client once; options are validated at host start-up and every resolution gets a fresh
HttpClient from IHttpClientFactory (named PathaoCourier — append handlers to that
name to extend the pipeline):
builder.Services.AddPathaoCourier(options =>
{
options.BaseUrl = PathaoUrls.Production;
options.ClientId = "...";
options.ClientSecret = "...";
options.Username = "...";
options.Password = "...";
});
// inject anywhere
public class ShippingService(IPathaoCourierClient pathao)
{
public Task<PagedResult<StoreInfo>> MyStoresAsync() => pathao.Stores.ListAsync();
}
Multi-merchant factory (per-tenant credentials)
When every merchant brings its own Pathao account, register the factory instead and create clients from per-merchant credentials at request time:
builder.Services.AddPathaoCourierFactory();
public class FulfillmentService(IPathaoCourierClientFactory factory)
{
public async Task CreateConsignmentAsync(MerchantCredentials creds)
{
using var client = factory.Create(new PathaoClientOptions
{
BaseUrl = PathaoUrls.Production,
ClientId = creds.ClientId,
ClientSecret = creds.ClientSecret,
Username = creds.Username,
Password = creds.Password
});
await client.Orders.CreateAsync(BuildOrder());
}
}
Both registrations share one ITokenStore singleton: clients created with the same
credentials (across the factory, the DI container, or both) reuse the same cached token —
tokens are keyed by a stable hash of base URL + client id + username, never by the secret.
Token persistence (database-backed ITokenStore)
The SDK performs no persistence of its own. Register your own store before calling
AddPathaoCourier/AddPathaoCourierFactory and every client picks it up:
builder.Services.AddSingleton<ITokenStore>(new DbTokenStore(connectionString));
// sketch of a database-backed implementation
public sealed class DbTokenStore(string connectionString) : ITokenStore
{
public async Task<PathaoToken?> GetAsync(string credentialKey, CancellationToken ct = default)
{
// SELECT token_json FROM pathao_tokens WHERE credential_key = @credentialKey
// deserialize and return, or null when absent
}
public async Task SaveAsync(string credentialKey, PathaoToken token, CancellationToken ct = default)
{
// UPSERT pathao_tokens SET token_json = @token WHERE credential_key = @credentialKey
}
}
Store the serialized token (access, refresh, type, expires_in, acquired_at_utc) and treat
credentialKey as an opaque string. With a shared store, tokens survive restarts and are
deduplicated across processes; the SDK refreshes them proactively (5-minute skew) and
reactively (401 → refresh once → retry) as needed.
Locations
City, zone and area reference data — the building blocks for addresses, stores and orders:
IReadOnlyList<City> cities = await client.Locations.GetCitiesAsync();
City dhaka = cities.First(c => c.CityName == "Dhaka");
IReadOnlyList<Zone> zones = await client.Locations.GetZonesAsync(dhaka.CityId);
IReadOnlyList<Area> areas = await client.Locations.GetAreasAsync(zones[0].ZoneId);
// areas[0].HomeDeliveryAvailable / areas[0].PickupAvailable tell you what service is possible there
Pricing
Estimate the delivery price of a prospective consignment before creating an order:
PricePlan plan = await client.Pricing.EstimateAsync(new PricePlanRequest
{
StoreId = 150728,
ItemType = ItemType.Parcel,
DeliveryType = DeliveryType.Normal,
ItemWeight = 0.5m,
RecipientCity = dhaka.CityId,
RecipientZone = zones[0].ZoneId
});
// plan.Price, plan.Discount, plan.CodEnabled, plan.CodPercentage, plan.FinalPrice, ...
Quirk handled for you: the price-plan endpoint sends item_weight as a JSON number, while
the order endpoints send it as a string — the SDK serializes each form correctly for the
endpoint in use. cod_enabled arrives from the API as 1/0 and is normalized to a boolean.
item_weight is pre-validated (0.5–10 kg) before any HTTP call.
Stores
Create merchant stores and list the merchant's current stores:
StoreCreated created = await client.Stores.CreateAsync(new CreateStoreRequest
{
Name = "Demo Store",
ContactName = "Test Merchant",
ContactNumber = "01700000000",
Address = "House 123, Road 4, Sector 10, Uttara, Dhaka-1230, Bangladesh",
CityId = dhaka.CityId,
ZoneId = zones[0].ZoneId,
AreaId = areas[0].AreaId,
SecondaryContact = "01500000000", // optional; omitted from the payload when null
OtpNumber = null // optional; omitted from the payload when null
});
Newly created stores require about one hour of Pathao approval before they can be used for
orders. Listing is paginated Laravel-style; the SDK normalizes the response into a
PagedResult<StoreInfo>:
PagedResult<StoreInfo> page1 = await client.Stores.ListAsync(); // page defaults to 1
PagedResult<StoreInfo> page2 = await client.Stores.ListAsync(page: 2);
// page1.Items / .Page / .PerPage / .TotalPages / .TotalCount
Requests are pre-validated offline before any HTTP call: Name/ContactName 3–50 characters,
Address 15–120 characters, and phone fields (ContactNumber, and SecondaryContact/
OtpNumber when supplied) exactly 11 digits. is_active/is_default_store/
is_default_return_store arrive from the API as 1/0 (or booleans) and are normalized.
Orders
Create consignments, submit them in bulk, and read back short status info:
OrderCreationResult created = await client.Orders.CreateAsync(new CreateOrderRequest
{
StoreId = 150728,
MerchantOrderId = "M-42", // optional; omitted from the payload when null
RecipientName = "Demo Recipient",
RecipientPhone = "01700000000",
RecipientAddress = "House 123, Road 4, Sector 10, Uttara, Dhaka-1230, Bangladesh",
RecipientCity = dhaka.CityId, // optional — the API derives it from the
RecipientZone = zones[0].ZoneId, // address when omitted
DeliveryType = DeliveryType.Normal, // 48 Normal / 12 OnDemand
ItemType = ItemType.Parcel, // 1 Document / 2 Parcel
ItemQuantity = 1,
ItemWeight = 0.5m, // serialized as "0.5" per the order API
ItemDescription = "this is a Cloth item, price- 3000",
AmountToCollect = 900 // defaults to 0 for non-COD orders
});
// created.ConsignmentId / .MerchantOrderId / .OrderStatus / .DeliveryFee
OrderInfo info = await client.Orders.GetInfoAsync(created.ConsignmentId);
// info.OrderStatus, .OrderStatusSlug, .UpdatedAt, .InvoiceId, ...
Requests are pre-validated offline before any HTTP call: RecipientName 3–100 characters,
RecipientAddress 10–220 characters, phone fields exactly 11 digits, ItemWeight 0.5–10 kg,
and AmountToCollect non-negative. Optional fields (RecipientCity/RecipientZone/
RecipientArea, MerchantOrderId, RecipientSecondaryPhone, SpecialInstruction,
ItemDescription) are omitted from the payload when null — the API requires nulls to be
absent, not sent. Identifier fields that PHP APIs sometimes emit unquoted (consignment_id,
merchant_order_id, invoice_id) are normalized to strings.
Bulk quirk: CreateBulkAsync answers HTTP 202 with acceptance only — no consignment ids
are returned and creation completes asynchronously, so per-order results must be reconciled
later. Use single-order creation when you need the ids immediately:
BulkOrderAccepted accepted = await client.Orders.CreateBulkAsync([order1, order2]);
API coverage
All 11 endpoints documented for the Pathao Courier Merchant API:
| # | Endpoint | SDK surface |
|---|---|---|
| 1 | POST /aladdin/api/v1/issue-token (password grant) |
Auth.IssueTokenAsync + automatic via the token lifecycle |
| 2 | POST /aladdin/api/v1/issue-token (refresh grant) |
Auth.RefreshTokenAsync + automatic refresh/retry |
| 3 | POST /aladdin/api/v1/stores |
Stores.CreateAsync |
| 4 | POST /aladdin/api/v1/orders |
Orders.CreateAsync |
| 5 | POST /aladdin/api/v1/orders/bulk |
Orders.CreateBulkAsync |
| 6 | GET /aladdin/api/v1/orders/{id}/info |
Orders.GetInfoAsync |
| 7 | GET /aladdin/api/v1/city-list |
Locations.GetCitiesAsync |
| 8 | GET /aladdin/api/v1/cities/{id}/zone-list |
Locations.GetZonesAsync |
| 9 | GET /aladdin/api/v1/zones/{id}/area-list |
Locations.GetAreasAsync |
| 10 | POST /aladdin/api/v1/merchant/price-plan |
Pricing.EstimateAsync |
| 11 | GET /aladdin/api/v1/stores |
Stores.ListAsync |
API quirks the SDK normalizes
- Envelope unwrapping — every business response is
{ message, type, code, data }; the SDK unwrapsdataand surfacesmessageon typed errors (PathaoApiExceptionwith status, API code/message and the raw body; undocumented error shapes never crash parsing). - snake_case wire JSON mapped explicitly via source generation — no runtime reflection.
- Nulls are omitted, not sent — optional fields (
recipient_city/recipient_zone/recipient_area,secondary_contact,otp_number,recipient_secondary_phone,special_instruction,item_description,merchant_order_id) are absent from payloads whennull. item_weightdual typing — a string ("0.5") for order creation, a number (0.5) for price plans; the SDK serializes the correct form per endpoint (typeddecimaleither way).- Bulk 202 — bulk creation answers HTTP 202 with acceptance only and no consignment ids; per-order results must be reconciled later. Use single-order creation when you need ids now.
- Laravel pagination on store lists normalized into
PagedResult<T>. - PHP-style booleans/identifiers —
1/0flags and unquoted id fields are read tolerantly. - Offline pre-validation — documented field ranges (name/address lengths, 11-digit phones,
0.5–10 kg weight, non-negative amounts) are checked before any HTTP call and throw
PathaoValidationException.
Console demo
examples/Pathao.Courier.ConsoleDemo is a runnable,
menu-driven tour of the whole SDK against the sandbox: issue a token, browse cities → zones →
areas, estimate a delivery price, create an order with a synthetic recipient and poll its status.
cd examples/Pathao.Courier.ConsoleDemo
dotnet run
It reads the same environment variables as the sandbox test suite
(PATHAO_SANDBOX__CLIENT_ID, __CLIENT_SECRET, __USERNAME, __PASSWORD, optional
__STORE_ID) or user secrets (prefix Pathao:Sandbox:, e.g.
dotnet user-secrets set Pathao:Sandbox:ClientId ...).
Security notes
- Credentials and tokens are only ever sent in the token-request body and the
Authorizationheader — never in URLs, never logged by the SDK. - Never log
PathaoClientOptions,PathaoTokenvalues orAuthorizationheaders in your own pipelines either; the console demo masks tokens deliberately. - Supply credentials from a secret store (env vars, user secrets, key vault) — never hard-code them or commit them.
- Tokens live in an
ITokenStoreyou control; use a database-backed store to persist across restarts and revoke by clearing the store.
Sandbox integration tests
The live sandbox suite ([Category("Sandbox")]) is excluded from CI and regular dotnet test
runs. Set these environment variables to execute it:
PATHAO_SANDBOX__CLIENT_ID
PATHAO_SANDBOX__CLIENT_SECRET
PATHAO_SANDBOX__USERNAME
PATHAO_SANDBOX__PASSWORD
PATHAO_SANDBOX__STORE_ID // optional; pins the store for the price-plan and order tests
// (the order tests otherwise fall back to the first listed store)
PATHAO_SANDBOX__ALLOW_CREATE // optional; "true" enables the store creation test (side effects)
Note: the order tests create real consignments in the sandbox merchant account — that is their purpose and they run ungated once credentials are present.
dotnet test --filter "Category=Sandbox"
Releasing (maintainers)
Releases are git tags (MinVer derives the package version — prefix v, e.g. v0.1.0-alpha.1).
Pushing a tag runs release.yml: build + unit tests → dotnet pack → push .nupkg + .snupkg to NuGet.org → GitHub Release with generated notes and the
packages attached. The one-time prerequisite is a NuGet API key (scoped to the Pathao.Courier
package id) stored as the NUGET_API_KEY secret in the GitHub repo.
Contributing
Issues and pull requests are welcome at
boniky-dev/pathao-courier-sdk. Keep builds
warning-free (dotnet build -c Release treats warnings as errors), run the unit suite
(dotnet test) before opening a PR, and never commit sandbox credentials — live-sandbox tests
are opt-in via environment variables. For behavioral changes, cover them with unit tests against
mocked HTTP before touching the live sandbox.
License
MIT — see LICENSE.
| 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.Http (>= 10.0.11)
- Microsoft.Extensions.Options (>= 10.0.11)
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 |
|---|---|---|
| 1.0.0 | 107 | 8/21/2026 |
| 0.1.0-alpha.1 | 65 | 8/21/2026 |