Steadfast.Courier 1.0.0

dotnet add package Steadfast.Courier --version 1.0.0
                    
NuGet\Install-Package Steadfast.Courier -Version 1.0.0
                    
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="Steadfast.Courier" Version="1.0.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Steadfast.Courier" Version="1.0.0" />
                    
Directory.Packages.props
<PackageReference Include="Steadfast.Courier" />
                    
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 Steadfast.Courier --version 1.0.0
                    
#r "nuget: Steadfast.Courier, 1.0.0"
                    
#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 Steadfast.Courier@1.0.0
                    
#: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=Steadfast.Courier&version=1.0.0
                    
Install as a Cake Addin
#tool nuget:?package=Steadfast.Courier&version=1.0.0
                    
Install as a Cake Tool

Steadfast.Courier

ci live-integration NuGet License: MIT

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 into SteadfastClientOptions in your integration layer. Never hard-code credentials.
  • Never log credentials. The SDK itself performs no logging and never writes the Api-Key/Secret-Key headers anywhere; keep them out of your own request and exception logs.
  • Steadfast issues one Api-Key/Secret-Key pair 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: check item.IsSuccess or partition with results.Successes() / results.Failures().
  • cod_amount tolerance: the API mixes numeric and string amounts; the SDK maps both to decimal.
  • 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":{...}} with per_page: 10 — so ListAsync returns the first page only (full pagination support is post-v1 backlog). GetAsync parses the response root directly. Fields the SDK does not model are preserved verbatim in ReturnRequest.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: ListAsync returns 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 in Payment.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_amount dual typing (number or string) → always decimal.
  • Open-ended status vocabularies → unknown wire values map to Unknown enum members, never throw.
  • Returns XOR identifiers → enforced by ReturnRequestIdentifier at 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 → SteadfastApiException always captures the raw body and surfaces best-effort ApiStatus/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

MIT

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
1.0.0 119 8/22/2026
0.1.0-alpha.1 79 8/22/2026