Steadfast.Courier
1.0.0
dotnet add package Steadfast.Courier --version 1.0.0
NuGet\Install-Package Steadfast.Courier -Version 1.0.0
<PackageReference Include="Steadfast.Courier" Version="1.0.0" />
<PackageVersion Include="Steadfast.Courier" Version="1.0.0" />
<PackageReference Include="Steadfast.Courier" />
paket add Steadfast.Courier --version 1.0.0
#r "nuget: Steadfast.Courier, 1.0.0"
#:package Steadfast.Courier@1.0.0
#addin nuget:?package=Steadfast.Courier&version=1.0.0
#tool nuget:?package=Steadfast.Courier&version=1.0.0
Steadfast.Courier
Unofficial, community-maintained .NET SDK for the Steadfast Courier Limited API V1
(https://portal.packzy.com/api/v1).
Disclaimer: this project is unofficial and not affiliated with, endorsed by, or sponsored by Steadfast Courier Ltd. "Steadfast" is used solely to identify the compatible API.
Status
v0.1.0-alpha — feature-complete for the API's 13 documented endpoints: orders (single + bulk),
status tracking, returns, payments, account balance and the police-station directory, plus a
client facade, DI registration and a multi-merchant factory. Read-only response schemas were
live-verified where the verification account had data (see Provisional schemas);
the package ships as net10.0 with zero third-party runtime dependencies beyond
Microsoft.Extensions.* abstractions. Treat minor versions as breaking until 1.0.
Installation
dotnet add package Steadfast.Courier --prerelease
Or try the whole SDK hands-on via the console demo.
Getting started
All snippets below assume a resolved ISteadfastCourierClient client — obtain one in three ways:
Direct construction (one client per Steadfast account):
using SteadfastCourierClient client = new(new SteadfastClientOptions
{
ApiKey = Environment.GetEnvironmentVariable("STEADFAST__API_KEY"),
SecretKey = Environment.GetEnvironmentVariable("STEADFAST__SECRET_KEY"),
});
Without an injected HttpClient the client owns its transport (honoring
SteadfastClientOptions.Timeout) and disposes it on Dispose; passing your own HttpClient
keeps the transport lifecycle with you — disposal is then a no-op.
DI, single account (ASP.NET Core / generic host):
builder.Services.AddSteadfastCourier(options =>
{
options.ApiKey = builder.Configuration["Steadfast:ApiKey"];
options.SecretKey = builder.Configuration["Steadfast:SecretKey"];
});
ISteadfastCourierClient client = host.Services.GetRequiredService<ISteadfastCourierClient>();
Credentials are validated at host start. The client's HTTP pipeline comes from
IHttpClientFactory under the name SteadfastCourier — configure that named client to add
handlers (logging, retries, …):
builder.Services.AddHttpClient(SteadfastCourierServiceCollectionExtensions.HttpClientName)
// consumer-side package Microsoft.Extensions.Http.Resilience
.AddStandardResilienceHandler();
DI, multi-merchant factory (per-merchant runtime credentials — e.g. a multi-tenant platform storing each merchant's Steadfast keys):
builder.Services.AddSteadfastCourierFactory();
ISteadfastCourierClientFactory factory = host.Services.GetRequiredService<ISteadfastCourierClientFactory>();
ISteadfastCourierClient merchantClient = factory.Create(new SteadfastClientOptions
{
ApiKey = merchant.SteadfastApiKey, // loaded from your per-merchant secret store
SecretKey = merchant.SteadfastSecretKey,
});
Authentication is stateless (Api-Key/Secret-Key headers per request, no tokens), so created
clients are cheap and share nothing. Configure the named SteadfastCourier pipeline once and it
applies to every merchant.
Credential management
- Prefer environment variables or a secrets store (user-secrets, Key Vault, AWS Secrets
Manager) → surface them through
IConfiguration→ bind intoSteadfastClientOptionsin your integration layer. Never hard-code credentials. - Never log credentials. The SDK itself performs no logging and never writes the
Api-Key/Secret-Keyheaders anywhere; keep them out of your own request and exception logs. - Steadfast issues one
Api-Key/Secret-Keypair per account from the merchant dashboard — treat them like passwords and support rotation by re-reading configuration rather than caching values in code.
Console demo
examples/Steadfast.Courier.ConsoleDemo is a runnable,
menu-driven tour of the SDK (balance → create order → status by invoice/tracking code → return
request) against the live API — Steadfast publishes no sandbox. Side-effecting actions
require an explicit confirmation before anything is sent:
cd examples/Steadfast.Courier.ConsoleDemo
dotnet user-secrets set ApiKey "<your api key>"
dotnet user-secrets set SecretKey "<your secret key>"
dotnet run
Credentials are read from the STEADFAST__API_KEY/STEADFAST__SECRET_KEY environment variables
first, then from the user-secrets store shown above.
Endpoint coverage
All 13 documented API V1 endpoints, authenticated with static Api-Key/Secret-Key headers on
every request:
| API endpoint | SDK surface | Notes |
|---|---|---|
Header auth (Api-Key/Secret-Key) |
attached by the SDK to every request | stateless, no tokens |
POST /create_order |
client.Orders.CreateAsync |
offline pre-validation, typed Consignment |
POST /create_order/bulk-order |
client.Orders.CreateBulkAsync |
double-encoded payload + dual response shapes handled; ≤ 500 per call |
GET /status_by_cid/{id} |
client.Orders.GetStatusByConsignmentIdAsync |
|
GET /status_by_invoice/{invoice} |
client.Orders.GetStatusByInvoiceAsync |
route value URL-escaped |
GET /status_by_trackingcode/{code} |
client.Orders.GetStatusByTrackingCodeAsync |
route value URL-escaped |
GET /get_balance |
client.Account.GetBalanceAsync |
number-or-string tolerated |
POST /create_return_request |
client.Returns.CreateAsync |
exactly-one-of identifier enforced at the type level |
GET /get_return_request/{id} |
client.Returns.GetAsync |
|
GET /get_return_requests |
client.Returns.ListAsync |
first page only (Laravel pagination; see quirks) |
GET /payments |
client.Payments.ListAsync |
envelope verified live; item shape provisional |
GET /payments/{id} |
client.Payments.GetAsync |
provisional |
GET /police_stations |
client.Directory.GetPoliceStationsAsync |
district-grouped shape, verified live |
Orders
Creating a single order
CreateOrderRequest request = new()
{
Invoice = "Aa12-das4", // your unique id — letters/digits/-/_ only
RecipientName = "John Smith", // <= 100 chars
RecipientPhone = "01234567890", // exactly 11 digits
RecipientAddress = "House# 17/1, Road# 3/A, Dhanmondi, Dhaka-1209", // <= 250 chars
CodAmount = 1060m, // BDT, >= 0
Note = "Deliver within 3 PM", // optional — omitted when null
ItemDescription = "T-Shirt", // optional
DeliveryType = SteadfastDeliveryType.HomeDelivery, // or PointDelivery (hub pickup)
};
Consignment consignment = await client.Orders.CreateAsync(request);
Requests are pre-validated offline (invoice characters, name/address lengths, 11-digit phones,
COD >= 0) and throw SteadfastValidationException before any HTTP call. On success you get the
consignment with all three identifiers — persist Invoice, ConsignmentId and
TrackingCode (per the API's integration notes, any of them can drive later status checks and
the invoice is your uniqueness key; losing them means losing the handle to the consignment).
Bulk creation — quirks handled for you
IReadOnlyList<BulkOrderResultItem> results = await client.Orders.CreateBulkAsync(orders);
foreach (BulkOrderResultItem failure in results.Failures())
{
Console.WriteLine($"order {failure.Invoice} failed");
}
- Double-encoded payload: the API expects
{"data":"<JSON string>"}— the SDK encodes the array for you; consumers never see the quirk. - Two response shapes: a bare array on success,
{"data":[...]}on partial error — both parsed. Failed items are returned, not thrown: checkitem.IsSuccessor partition withresults.Successes()/results.Failures(). cod_amounttolerance: the API mixes numeric and string amounts; the SDK maps both todecimal.- Limit 500 per request (enforced client-side with batching guidance; the SDK does not auto-chunk). Batch larger sets yourself:
foreach (CreateOrderRequest[] batch in orders.Chunk(500))
{
IReadOnlyList<BulkOrderResultItem> results = await client.Orders.CreateBulkAsync(batch);
// handle per-batch results before sending the next batch
}
Errors from the API surface as SteadfastApiException with the HTTP status, best-effort
ApiStatus/ApiMessage and the untouched RawBody.
Status tracking
Any of the three identifiers returned at creation drives a status lookup:
DeliveryStatusInfo byId = await client.Orders.GetStatusByConsignmentIdAsync(consignment.ConsignmentId);
DeliveryStatusInfo byInvoice = await client.Orders.GetStatusByInvoiceAsync("Aa12-das4");
DeliveryStatusInfo byTracking = await client.Orders.GetStatusByTrackingCodeAsync("15BAEB8A");
DeliveryStatus status = byInvoice.DeliveryStatus;
if (status.IsTerminal()) { /* delivered / partial_delivered / cancelled — stop polling */ }
if (status.IsApprovalPending()) { /* delivered/cancelled awaiting admin approval */ }
The API's status vocabulary is open-ended: every documented status maps to a DeliveryStatus
member, and any unrecognized string maps to DeliveryStatus.Unknown instead of throwing —
pollers should treat Unknown as non-terminal and keep (bounded) retrying. Route values are
URL-escaped for you.
Delivery status vocabulary
| Wire value | DeliveryStatus |
Terminal | Approval pending |
|---|---|---|---|
pending |
Pending |
||
delivered_approval_pending |
DeliveredApprovalPending |
✓ | |
partial_delivered_approval_pending |
PartialDeliveredApprovalPending |
✓ | |
cancelled_approval_pending |
CancelledApprovalPending |
✓ | |
unknown_approval_pending |
UnknownApprovalPending |
✓ | |
delivered |
Delivered |
✓ | |
partial_delivered |
PartialDelivered |
✓ | |
cancelled |
Cancelled |
✓ | |
hold |
Hold |
||
in_review |
InReview |
||
unknown — or any unrecognized value |
Unknown |
Return request status vocabulary
| Wire value | ReturnRequestStatus |
|---|---|
pending |
Pending |
approved |
Approved |
processing |
Processing |
completed |
Completed |
cancelled |
Cancelled |
| any unrecognized value | Unknown |
Account balance
decimal balance = await client.Account.GetBalanceAsync();
The balance is returned as a decimal; the API's number form is mapped directly and a numeric
string is tolerated.
Returns
Request a return for an existing consignment, then track it. The API takes exactly one of the
three consignment identifiers — ReturnRequestIdentifier enforces that at the type level and only
the chosen key is sent:
ReturnRequest created = await client.Returns.CreateAsync(
ReturnRequestIdentifier.OfConsignmentId(consignment.ConsignmentId),
"Customer requested return"); // optional reason; omitted when null
ReturnRequest fetched = await client.Returns.GetAsync(created.Id);
IReadOnlyList<ReturnRequest> all = await client.Returns.ListAsync();
Return statuses map to ReturnRequestStatus (see the table above); unrecognized status strings
map to ReturnRequestStatus.Unknown instead of throwing.
Provisional schema: only the create-response shape is documented. The live-observed list envelope (2026-08-22) is Laravel-paginated —
{"data":[...], "links":{...}, "meta":{...}}withper_page: 10— soListAsyncreturns the first page only (full pagination support is post-v1 backlog).GetAsyncparses the response root directly. Fields the SDK does not model are preserved verbatim inReturnRequest.ExtraData; the item shape itself remains unobserved (the verification account had no return requests).
Payments
List the account's payments and fetch a single payment with its consignments:
IReadOnlyList<Payment> payments = await client.Payments.ListAsync();
PaymentWithConsignments detail = await client.Payments.GetAsync(paymentId);
Envelope verified live (2026-08-22), item shape still provisional:
ListAsyncreturns the{"status":1,"payments":[...]}envelope observed against the real API (plus bare-array and{"data":[...]}tolerance). The verification account had no payments, so the item fields are still best guesses — every unrecognized field is preserved verbatim inPayment.ExtraData/PaymentWithConsignments.ExtraData. Re-run the observed-schema dump fixture once the account has payments to finalize the item model.
Police stations
The directory endpoint returns police stations grouped by district (shape verified live on 2026-08-22 — 65 districts, 654 stations):
IReadOnlyList<PoliceStationDistrict> districts = await client.Directory.GetPoliceStationsAsync();
PoliceStation? station = districts
.FirstOrDefault(d => d.Name == "Dhaka")?.PoliceStations
.FirstOrDefault(s => s.Name.Contains("Dhanmondi", StringComparison.OrdinalIgnoreCase));
Each station carries its Id, Name, DistrictId, HubId, PsType, BigParcel flag,
contact fields and timestamps; unrecognized fields are preserved in PoliceStation.ExtraData.
Note: the live endpoint intermittently answers HTTP 502 from its own gateway — transient, retry
(a standard resilience handler on the named pipeline, shown under
Getting started, covers this).
API quirks normalized for you
- Static header auth on every request (including GETs) — nothing to manage, no tokens.
- snake_case wire JSON with optional fields omitted when null, never sent as JSON null.
- Bulk double-encoding (
{"data":"<JSON string>"}) and the two bulk response shapes (bare array /{"data":[...]}) — handled internally. cod_amountdual typing (number or string) → alwaysdecimal.- Open-ended status vocabularies → unknown wire values map to
Unknownenum members, never throw. - Returns XOR identifiers → enforced by
ReturnRequestIdentifierat the type level. - Provisional schemas for the undocumented get/list shapes: typed models carry
[JsonExtensionData]overflow (ExtraData) so no live field is dropped; payment items and return items are the two shapes still awaiting first live data. - Error bodies are undocumented →
SteadfastApiExceptionalways captures the raw body and surfaces best-effortApiStatus/ApiMessage; unparseable bodies never crash the mapper.
Known API gaps (not SDK limitations): no sandbox, no pricing API, no webhooks, no documented error-body schema.
Error handling
try
{
Consignment consignment = await client.Orders.CreateAsync(request);
}
catch (SteadfastValidationException ex) { /* your request failed offline pre-validation */ }
catch (SteadfastApiException ex)
{
// ex.StatusCode, ex.ApiStatus, ex.ApiMessage, ex.RawBody (exactly as received)
}
Contributing
Issues and pull requests are welcome at
boniky-dev/steadfast-courier-sdk.
Unit-testable changes need mocked-HTTP unit tests (see tests/Steadfast.Courier.Tests); anything
touching live behavior should note how it was verified. Conventional commits keep the changelog
readable. Never commit credentials.
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.DependencyInjection.Abstractions (>= 10.0.11)
- 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 | 119 | 8/22/2026 |
| 0.1.0-alpha.1 | 79 | 8/22/2026 |