RedX.Courier
1.0.0
dotnet add package RedX.Courier --version 1.0.0
NuGet\Install-Package RedX.Courier -Version 1.0.0
<PackageReference Include="RedX.Courier" Version="1.0.0" />
<PackageVersion Include="RedX.Courier" Version="1.0.0" />
<PackageReference Include="RedX.Courier" />
paket add RedX.Courier --version 1.0.0
#r "nuget: RedX.Courier, 1.0.0"
#:package RedX.Courier@1.0.0
#addin nuget:?package=RedX.Courier&version=1.0.0
#tool nuget:?package=RedX.Courier&version=1.0.0
RedX.Courier
Unofficial, community-maintained SDK for the RedX courier OpenAPI. Not affiliated with, or endorsed by, RedX.
A .NET SDK (net10.0) for the RedX OpenAPI: typed clients for parcels (create, info, track,
update, cancel), delivery areas, pickup stores and charge calculation — with static
bearer-token authentication and typed webhook payload models. Zero third-party runtime
dependencies beyond BCL + Microsoft.Extensions.* abstractions.
Status: v0.1.0-alpha — feature-complete for the 11 documented operations plus the webhook
payload contract: the core layer (bespoke API-ACCESS-TOKEN bearer auth, typed errors, tolerant
converters), the delivery-areas and charge-calculator clients, the parcels client
(create / info / track / update / cancel), the typed webhook payload models, the
pickup-stores client, and the unified RedXCourierClient facade with DI registration and
the multi-merchant factory below. A runnable console demo lives in
examples/RedX.Courier.ConsoleDemo. See
TRACKER.md for the phase-by-phase history.
Install
dotnet add package RedX.Courier --prerelease
Targets net10.0 only. Nightly-friendly: CI builds and unit tests run on every push; sandbox
integration tests run on a manual-dispatch and nightly workflow.
Endpoint coverage
| API endpoint | SDK surface | Notes |
|---|---|---|
Header auth (API-ACCESS-TOKEN: Bearer) |
attached by the SDK to every request | bespoke header, not Authorization |
GET /areas |
Areas.ListAsync |
typed DeliveryArea list |
GET /areas?post_code= |
Areas.GetByPostCodeAsync |
several areas can share a post code |
GET /areas?district_name= |
Areas.GetByDistrictNameAsync |
name is URL-escaped for you |
GET /charge/charge_calculator |
Pricing.CalculateAsync |
camelCase charge fields mapped; read-only |
POST /parcel |
Parcels.CreateAsync |
singular path; curl-mirroring body (see below) |
GET /parcel/info/{trackingId} |
Parcels.GetInfoAsync |
full parcel details incl. pickup location |
GET /parcel/track/{trackingId} |
Parcels.TrackAsync |
bilingual (EN/BN) event log |
PATCH /parcels |
Parcels.UpdateAsync / Parcels.CancelAsync |
plural path; generic entity/property envelope |
POST /pickup/store |
PickupStores.CreateAsync |
response is the store at root, without created_at |
GET /pickup/stores |
PickupStores.ListAsync |
pickup_stores[] wrapper; tolerant ids |
GET /pickup/store/info/{pickupStoreId} |
PickupStores.GetAsync |
pickup_store{} wrapper |
| Webhook callback payload | RedX.Webhooks.ParcelStatusWebhook + RedXWebhookJsonContext |
models only — no receiver/signature (see below) |
Creating a client
One RedXCourierClient (or its IRedXCourierClient facade) serves one RedX merchant account.
Direct construction — fine for scripts, tools and tests:
using RedXCourierClient client = new(new RedXClientOptions
{
BaseUrl = RedXUrls.Sandbox, // https://sandbox.redx.com.bd/v1.0.0-beta (Production is the default)
AccessToken = Environment.GetEnvironmentVariable("REDX_ACCESS_TOKEN")!,
});
// client.Parcels, client.Areas, client.PickupStores, client.Pricing
When constructed without an HttpClient, the client owns its transport (honoring
RedXClientOptions.Timeout) and disposes it; inject one and its lifecycle stays with you.
DI — single account (ASP.NET Core / generic host)
builder.Services.AddRedXCourier(options =>
{
options.AccessToken = builder.Configuration["RedX:AccessToken"];
// options.BaseUrl defaults to RedXUrls.Production; point at RedXUrls.Sandbox for integration tests
});
// anywhere: IRedXCourierClient is registered transient, over IHttpClientFactory
public class CheckoutService(IRedXCourierClient redx)
{
public async Task<string> ShipAsync(...) => (await redx.Parcels.CreateAsync(...)).TrackingId;
}
Options are validated at host start (ValidateOnStart) — a missing AccessToken or an
invalid BaseUrl fails fast. The HTTP pipeline is registered with IHttpClientFactory under
the name RedXCourierServiceCollectionExtensions.HttpClientName ("RedXCourier"), so you can
append handlers/retry policy once for every client:
builder.Services.AddHttpClient(RedXCourierServiceCollectionExtensions.HttpClientName)
.AddStandardResilienceHandler(); // e.g. Microsoft.Extensions.Http.Resilience
DI — multi-merchant factory
Serving many merchants, each with their own dashboard-issued token? Register the factory and create clients at request time — the token is stateless (attached per request), so created clients are cheap and hold no session state:
builder.Services.AddRedXCourierFactory();
public class FulfillmentService(IRedXCourierClientFactory factory, MerchantStore merchants)
{
public async Task<string> ShipAsync(string merchantId, ...)
{
MerchantCredentials credentials = await merchants.GetRedXCredentialsAsync(merchantId);
IRedXCourierClient merchantClient = factory.Create(new RedXClientOptions
{
BaseUrl = RedXUrls.Production,
AccessToken = credentials.AccessToken,
});
return (await merchantClient.Parcels.CreateAsync(...)).TrackingId;
}
}
Factory-created clients draw their transport from the same named RedXCourier pipeline (or
own it when no IHttpClientFactory is available); you can also pass an explicit HttpClient
to Create when you need full control. Both registrations compose — AddRedXCourier for your
own platform account plus AddRedXCourierFactory for your merchants.
Managing credentials
The access token is a secret: it authenticates every RedX API call for the merchant. In order of preference:
- Environment variables / user-secrets in dev (
REDX_ACCESS_TOKEN,dotnet user-secrets), and GitHub/secrets-manager variables in CI — never in source control. - Configuration (
IConfiguration) for single-account hosts, sourced from a secret store (Azure Key Vault, AWS Secrets Manager, etc.) rather thanappsettings.json. - An integration layer for multi-tenant hosts: keep per-merchant tokens in your database
or secret store and materialize them into
RedXClientOptionsviaIRedXCourierClientFactory.Createat request time — the SDK deliberately holds no persistence and no token lifecycle (D6).
The SDK never logs the token (it logs nothing), but avoid echoing options or exception data into your own logs. If a token is compromised, revoke/rotate it from the RedX merchant dashboard.
Areas
RedX addresses parcels by flat delivery areas (no city/zone hierarchy) — the Id is the
delivery_area_id/pickup_area_id the rest of the API speaks in, so resolve it before creating
parcels or pricing them:
IReadOnlyList<DeliveryArea> all = await client.Areas.ListAsync(); // cache this — it is large and stable
IReadOnlyList<DeliveryArea> byPostCode = await client.Areas.GetByPostCodeAsync(1206);
IReadOnlyList<DeliveryArea> inDhaka = await client.Areas.GetByDistrictNameAsync("Dhaka");
Each DeliveryArea carries Id, Name, PostCode, DivisionName and ZoneId. The
{"areas":[...]} envelope is unwrapped for you (a bare root array is tolerated), and a
non-areas payload surfaces as RedXApiException with the raw body.
Parcels
Creating a parcel needs a delivery area (both its Id and its exact Name — resolve them
via Areas.ListAsync first). Optional fields are simply omitted from the request when null:
ParcelCreated created = await client.Parcels.CreateAsync(new CreateParcelRequest
{
CustomerName = "Test Customer",
CustomerPhone = "01987654321", // 11-digit 01XXXXXXXXX
DeliveryArea = area.Name, // must match DeliveryAreaId
DeliveryAreaId = area.Id,
CustomerAddress = "House 1, Road 2, Mirpur DOHS",
MerchantInvoiceId = "ACBD1234TEST", // optional
CashAmount = 13293m, // BDT to collect on delivery
ParcelWeight = 500, // grams — always whole grams, never kg
Value = 1500m, // declared value
Instruction = "Handle with care", // optional
// Type = "reverse", // optional, mainly for reverse shipments
// IsClosedBox = true, // optional, provisional field
// PickupStoreId = 7, // optional
// ParcelDetails = [new ParcelDetailItem("T-shirt", "clothing", 1000)], // optional
});
Parcel info = await client.Parcels.GetInfoAsync(created.TrackingId);
// info.Status, info.Charge, info.CashAmount, info.PickupLocation, ...
IReadOnlyList<TrackingEvent> events = await client.Parcels.TrackAsync(created.TrackingId);
// events[0].MessageEn / MessageBn / Time — bilingual chronological log
Info vs track: GetInfoAsync returns the parcel's current snapshot (charges, status,
addresses, pickup location); TrackAsync returns the chronological event log with English and
Bengali messages. Unknown status/delivery-type strings map to ParcelStatus.Unknown /
DeliveryType.Unknown instead of throwing — RedX's vocabulary is open-ended and its own docs
already return statuses (pickup-pending) missing from the webhook table.
Request wire format: RedX's parameter table and its curl sample disagree on field types;
the SDK serializes exactly like the curl sample — cash_collection_amount as a JSON string,
parcel_weight/value/pickup_store_id as JSON numbers, is_closed_box as a string — so you
never think about it. The sandbox E2E test locks this format against reality. Responses are
parsed tolerantly either way (money and ids from a JSON number or a numeric string — the info
sample returns value as the string "0").
All inputs are pre-validated client-side (phone digits, non-negative amounts, weight >= 1 g,
positive area/store ids, non-empty required strings) and throw RedXValidationException
before any HTTP call.
Updating & cancelling parcels
RedX updates parcels through a generic entity/property envelope (PATCH /parcels — note
the plural path, unlike the singular create), and applies changes asynchronously: a
success:true receipt ("Request Accepted") means the update was queued, not that it is
already visible — confirm via GetInfoAsync/TrackAsync when it matters:
// Cancelling — the typed convenience for the documented status -> cancelled update
UpdateResult cancel = await client.Parcels.CancelAsync(created.TrackingId, "Customer changed the order");
if (cancel.Success) { /* accepted — RedX applies the cancellation asynchronously */ }
// Any other documented property, e.g. delivery_address, via the generic update
UpdateResult move = await client.Parcels.UpdateAsync(new ParcelUpdate
{
EntityType = "parcel-tracking-id", // the documented entity type
EntityId = created.TrackingId,
PropertyName = "delivery_address",
NewValue = "House 9, Road 3, Mirpur DOHS",
// Reason = "customer called in a change", // optional, omitted when null
});
UpdateResult.Success == false maps a rejected request (still HTTP 200) without throwing —
check it explicitly; HTTP-level failures still throw RedXApiException.
Webhooks
RedX can POST parcel status changes to a callback URL you configure in the merchant dashboard. The SDK ships typed payload models + a public source-generated JSON context so you can bind pushes without touching raw JSON — deliberately nothing more (no receiver middleware, no signature validation, because RedX documents no signing scheme):
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using RedX.Courier.Webhooks;
// e.g. in a minimal ASP.NET Core controller
[ApiController]
[Route("redx/callback")]
public sealed class RedXCallbackController : ControllerBase
{
[HttpPost]
[Consumes("application/json")]
public async Task<IActionResult> Post()
{
ParcelStatusWebhook? webhook = await JsonSerializer.DeserializeAsync(
Request.Body, RedXWebhookJsonContext.Default.ParcelStatusWebhook);
if (webhook?.TrackingNumber is not { Length: > 0 } trackingId)
{
return BadRequest(); // tolerant binding — validate the payload yourself
}
// webhook.Status (ParcelStatus), webhook.MessageEn / MessageBn,
// webhook.Timestamp (string — RedX does not document the format; parse defensively),
// webhook.InvoiceNumber (null when the parcel has no invoice), webhook.DeliveryType
return Ok();
}
}
Field names diverge from the REST API (tracking_number / invoice_number, not
tracking_id / merchant_invoice_id) — mapped for you. Unrecognized status/delivery-type
strings bind as Unknown, and missing string members bind as null (tolerant binding —
validate TrackingNumber in your handler, as above).
Security: RedX sends credentials in the callback URL's query parameters and documents no
payload signature, so your only protection is an unguessable HTTPS callback URL (a long
random token in the query string, e.g. https://example.com/redx/callback?token=<128-bit-secret>).
Treat the payload's contents as unauthenticated input.
Charge calculator
Price a hypothetical parcel without creating anything:
ParcelCharge charge = await client.Pricing.CalculateAsync(new ParcelChargeRequest(
DeliveryAreaId: deliveryArea.Id,
PickupAreaId: pickupArea.Id,
CashAmount: 1500m, // BDT to collect on delivery
Weight: 500)); // grams — RedX weights are whole grams everywhere
This is the one documented endpoint whose response is camelCase (deliveryCharge/codCharge)
rather than snake_case; both map to decimal and tolerate a numeric string. Requests are
pre-validated offline (positive area ids, cash >= 0, weight >= 1 g) and throw
RedXValidationException before any HTTP call.
Pickup stores
Parcels are collected from pickup stores. Creating one needs a delivery area id (resolve
via Areas.ListAsync); the returned store id then feeds
CreateParcelRequest.PickupStoreId:
PickupStore store = await client.PickupStores.CreateAsync(new CreatePickupStoreRequest
{
Name = "Boniky Flagship",
Phone = "8801898000999", // pickup-store phones carry the 880 prefix (13 digits)
Address = "House 1, Road 2, Mohammadpur, Dhaka",
AreaId = area.Id,
});
// store.Id, store.AreaName — note: no store.CreatedAt here (only list/details carry it)
IReadOnlyList<PickupStore> all = await client.PickupStores.ListAsync();
PickupStore details = await client.PickupStores.GetAsync(store.Id);
// details.CreatedAt, details.AreaName, ...
The three endpoints use three different response shapes — list answers
{"pickup_stores":[...]}, details answer {"pickup_store":{...}}, and create returns the
store body at the root — all unwrapped for you. Ids parse from a JSON number or a numeric
string (RedX's own list sample renders them as placeholder strings), and CreatedAt is null
on the create response by design. Inputs are pre-validated (non-empty name/address, phone
11–13 digits, positive area id) before any HTTP call.
Examples
examples/RedX.Courier.ConsoleDemo is a runnable,
menu-driven sandbox tour — areas → charge calculator → create parcel → track → info → cancel:
cd examples/RedX.Courier.ConsoleDemo
dotnet user-secrets set "RedX:AccessToken" "<sandbox-token>" # or export REDX_SANDBOX__ACCESS_TOKEN
dotnet run
Side-effect calls (create, cancel) show a summary and require typing yes before anything is
sent; the token is read from REDX_SANDBOX__ACCESS_TOKEN or the RedX:AccessToken
user-secret and is never printed. RedX:BaseUrl overrides the sandbox default.
API quirks normalized for you
- Bespoke auth header: every request (GETs included) carries
API-ACCESS-TOKEN: Bearer <token>— a dashboard-issued static token, never the standardAuthorizationheader. - Base URLs carry the full versioned prefix (
/v1.0.0-beta) — the curl samples in RedX's docs repeat the segment (double prefix), which does not exist on the wire: the sandbox answers 404 for the doubled path and 401 (auth required) for the single prefix.RedXUrlsconstants are verified;BaseUrlstays config-overridable. - CamelCase charge fields (
deliveryCharge/codCharge) mapped explicitly amid otherwise snake_case JSON. - Tolerant money parsing: decimals read from a JSON number or numeric string.
- Create-parcel typing conflicts (doc table vs curl sample): the SDK mirrors the curl —
cash_collection_amountquoted,parcel_weight/value/pickup_store_idnumeric,is_closed_boxquoted and omitted when null — locked by body-snapshot tests and the sandbox E2E run. - Singular/plural paths: create is
POST /parcel(singular) while update isPATCH /parcels(plural) — encoded exactly. - Pickup-store response shapes differ per endpoint: list wraps in
pickup_stores[], details inpickup_store{}, create returns the store at the root — each unwrapped; ids tolerate a JSON number or numeric string (the documented list sample renders them as placeholder strings) andcreated_atis missing from the create response. - Asynchronous PATCH semantics:
PATCH /parcelsanswers{"success":true,"message": "Request Accepted"}— an acceptance, not a synchronous application.UpdateResultsurfaces this; confirm application viaGetInfoAsync/TrackAsync. - Webhook field naming diverges from the API: the callback payload uses
tracking_number/invoice_number(nottracking_id/merchant_invoice_id) — mapped explicitly onParcelStatusWebhook. - Weights are grams (integers) everywhere — no kg conversion.
Status vocabulary
ParcelStatus (hyphenated lowercase on the wire; ParcelStatusExtensions.IsTerminal() /
IsInFlight() categorize them):
| Wire string | ParcelStatus member |
Meaning |
|---|---|---|
pickup-pending |
PickupPending |
created, not yet collected from the merchant (seen in the info sample; absent from the webhook table) |
ready-for-delivery |
ReadyForDelivery |
received from the merchant at a RedX facility |
delivery-in-progress |
DeliveryInProgress |
dispatched to a rider for delivery |
delivered |
Delivered |
delivered (terminal) |
agent-hold |
AgentHold |
on hold with the rider |
agent-returning |
AgentReturning |
return in progress |
returned |
Returned |
returned to the merchant (terminal) |
agent-area-change |
AgentAreaChange |
an area change was requested and is in progress |
paid |
Paid |
the parcel amount was paid out to the merchant (terminal) |
| anything else | Unknown |
unrecognized status — mapped, never thrown |
Delivery-type vocabulary
DeliveryType (same hyphenated wire format and Unknown fallback):
| Wire string | DeliveryType member |
Meaning |
|---|---|---|
regular |
Regular |
regular forward delivery |
reverse |
Reverse |
regular reverse delivery (return pickup) |
exchange-delivery |
ExchangeDelivery |
forward exchange parcel |
exchange-return |
ExchangeReturn |
reverse exchange parcel |
partial-delivery |
PartialDelivery |
partial delivery parcel |
partial-return |
PartialReturn |
partial return parcel |
| anything else | Unknown |
unrecognized type — mapped, never thrown |
Contributing
Issues and PRs are welcome at boniky-dev/redx-courier-sdk.
The ground rules: builds must stay warning-free (dotnet build -c Release with
warnings-as-errors), unit tests must stay green (dotnet test excludes the Sandbox
category), public members need XML docs, and only endpoints documented in RedX's OpenAPI are
in scope — see TRACKER.md for the current phase state and the post-v1 backlog.
Never commit tokens; sandbox tests read REDX_SANDBOX__ACCESS_TOKEN from the environment.
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.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 | 117 | 8/23/2026 |
| 0.1.0-alpha.1 | 83 | 8/23/2026 |