icarry-sdk
v0.1.9
Published
Unofficial iCarry shipping & logistics API client for Node.js — type-safe, secure, zero dependencies
Maintainers
Readme
icarry-sdk
Unofficial, type-safe, secure client for the iCarry shipping & logistics API — zero dependencies.
⚠️ UNOFFICIAL PACKAGE — NOT AFFILIATED WITH iCARRY
This is an unofficial, community-created client for the iCarry Web API. It is not affiliated with, endorsed by, or maintained by iCarry. The iCarry API is documented only as a Postman collection with several known inconsistencies (see Known iCarry inconsistencies). Verify endpoint behavior against your own account before relying on it in production, and treat all response shapes for create/rate/track/payment operations as unverified until confirmed against a live tenant.
A lightweight, framework-agnostic client for the iCarry shipping API. Works with Next.js (server
routes / Server Actions), Express, Fastify, Hono, or any modern JavaScript runtime with fetch.
What it is
- A clean, camelCase, strongly-typed wrapper over iCarry's
/api-frontendendpoints. - Faithful to the wire contract — it preserves iCarry's exact (and sometimes misspelled) field names internally rather than "fixing" them, so requests actually work.
- Secure by default — passwords, tokens, and card data are redacted from errors, logs, and hooks; mutating and payment calls are never automatically retried.
Table of contents
- Installation · Runtime requirements · Quick start
- Configuration · Authentication · Base URL
- Merchant flow · Marketplace flow · On-demand flow
- Tracking · Cancellation · Packaging slips
- Error handling · Retry behavior · Timeouts & cancellation
- TypeScript usage · Security considerations
- ⚠️ Server-only payment warning
- Endpoint coverage matrix · Known iCarry inconsistencies
- Low-level request escape hatch · Observability hooks
- Testing · Contributing · License
Installation
npm install icarry-sdkRuntime requirements
- Node.js ≥ 18 is the declared minimum (
engines). Actively tested in CI on Node 18, 22, and 24. Node 18 is the declared legacy minimum and is now EOL, retained only for backward-compat testing; Node 20 is EOL and omitted. Prefer a supported LTS — 22 (supported LTS) or 24 (latest LTS) — in production. Uses the globalfetchandAbortController, or any runtime with a WHATWGfetch. You may inject a customfetchvia options. - Ships both ESM and CommonJS builds with full TypeScript declarations. Zero runtime dependencies.
Quick start
import { ICarryClient } from 'icarry-sdk';
const icarry = new ICarryClient({
baseUrl: process.env.ICARRY_BASE_URL!, // e.g. https://test.icarry.com
email: process.env.ICARRY_EMAIL!, // connector email
password: process.env.ICARRY_PASSWORD!, // connector password
});
const countries = await icarry.countries.list();
const tracking = await icarry.shipments.track('TRACKING_NUMBER');Get your connector email/password from your iCarry store → Settings → Connectors & Integration
(regional portals: lb.icarry.com, uae.icarry.com).
Configuration
new ICarryClient({
baseUrl: string, // required; origin or origin + /api-frontend
// --- authentication (choose one strategy) ---
email?: string,
password?: string,
token?: string, // pre-obtained bearer token
tokenProvider?: () => Promise<string | undefined>,
// --- transport ---
fetch?: typeof fetch, // inject a custom fetch (defaults to global)
timeoutMs?: number, // default 30000
retry?: boolean | { // default: retries enabled for idempotent GETs
maxRetries?: number, // default 2
baseDelayMs?: number, // default 300
maxDelayMs?: number, // default 5000
retryableStatuses?: number[], // default [408, 429, 500, 502, 503, 504]
},
headers?: Record<string, string>, // extra headers on every request
userAgent?: string,
hooks?: ICarryHooks, // redacted observability hooks
autoReauth?: boolean, // default true (re-auth once on 401)
redactEmail?: boolean, // default false
});Authentication
Four modes are supported:
// 1. Connector credentials (authenticates lazily on first protected call)
new ICarryClient({ baseUrl, email, password });
// 2. Explicit bearer token
new ICarryClient({ baseUrl, token });
// 3. Async token provider (e.g. from a secret store)
new ICarryClient({ baseUrl, tokenProvider: async () => loadTokenFromVault() });
// 4. Manual, after construction
const client = new ICarryClient({ baseUrl });
client.auth.setToken(await loadToken());Behavior:
- The token is acquired lazily and cached in memory (never persisted to disk or storage).
- Concurrent first calls trigger one authentication request (deduplicated).
- On a
401, if the SDK owns the credentials (email/password ortokenProvider) it re-authenticates once and retries. A caller-supplied statictokenis never silently refreshed — you get anICarryAuthenticationError. Disable auto-reauth withautoReauth: false. - The token is treated as opaque — the SDK makes no JWT/expiry assumptions.
await icarry.auth.getToken(); // force/inspect the token
icarry.auth.setToken('...'); // set manually
icarry.auth.clearToken(); // clear the cacheBase URL
baseUrl is required — the SDK never hardcodes an environment. Pass either the origin
(https://test.icarry.com) or a URL already including the API prefix
(https://test.icarry.com/api-frontend); the /api-frontend prefix is added idempotently. The known
test environment is https://test.icarry.com/api-frontend. Production/regional base URLs are not
clearly documented by iCarry — confirm yours before going live.
The base URL is validated strictly at construction (parsed with the WHATWG URL API). It must be
an absolute https URL with no embedded credentials, query string, or fragment; plain
http is accepted only for local development hosts (localhost, 127.0.0.1, [::1]). Anything
else (e.g. https://user:pass@host, https://host?token=…, //host, javascript:/data:/
file:/ftp: schemes) throws an ICarryConfigurationError. Control characters (CR, LF, tab,
NUL, DEL, …) are rejected on the original input before trimming, and a baseUrl that already
contains the /api-frontend prefix more than once as a path segment (e.g.
…/api-frontend/api-frontend) is rejected — a custom base path such as https://proxy.example.com/icarry
is preserved and resolves to …/icarry/api-frontend. Client inspection methods (getBaseUrl(),
toJSON(), toString()) always return a sanitized value with no credentials, query, or fragment.
Merchant flow
COD rate estimation and order creation using a country code + free-text drop-off location.
const rates = await icarry.merchant.estimateRates({
dropOffLocation: 'Beirut',
to: { latitude: 33.8938, longitude: 35.5018 },
actualWeight: '1.5',
packageType: 'parcel',
dimensions: { length: '30', width: '20', height: '10' },
dropAddress: { countryCode: 'LB', city: 'Beirut' },
parcels: [{ quantity: 1, weight: '1.5', length: '30', width: '20', height: '10' }],
cod: { amount: '25.00', currency: 'USD' },
});
const order = await icarry.merchant.createOrder({
parcels: [{ quantity: 1, weight: '1.5', length: '30', width: '20', height: '10' }],
dropOff: {
firstName: 'Test', lastName: 'Recipient', email: '[email protected]', phoneNumber: '0100',
country: 'lebanon', city: 'beirut', address1: 'Beirut, Lebanon',
},
actualWeight: '5', packageType: 'parcel',
dimensions: { length: '10', width: '50', height: '30' },
provider: 'Shipping.ICarry.example', methodId: 26, price: '3.60',
parcel: { quantity: 1, packageValue: '120', packageCurrency: 'USD' },
cod: { amount: '10', currency: 'USD' },
});Money and measurements accept
number | string; strings are serialized verbatim (no rounding). Units follow your iCarry account configuration — the SDK does not assume cm/inch or a currency.
Marketplace flow
Same as the merchant model plus a pickupLocation (your warehouse/pickup name). Marketplace
warehouse creation lives on client.warehouses:
await icarry.warehouses.createMarketplaceWarehouse({
name: 'Main Warehouse', isActive: true,
address: {
firstName: 'Sender', lastName: 'Name', email: '[email protected]',
country: 'lebanon', city: 'beirut', address1: 'Pickup St', phoneNumber: '0100',
},
});
const rates = await icarry.marketplace.estimateRates({ pickupLocation: 'Main Warehouse', /* ...merchant rate fields */ });
const order = await icarry.marketplace.createOrder({ pickupLocation: 'Main Warehouse', /* ...merchant order fields */ });Reading marketplace rates (opt-in)
estimateRates returns AmbiguousApiResult, because iCarry's documented example is empty. A live
call returns an array of rate options, so the SDK ships an opt-in reader rather than narrowing
the return type on one observation:
import { parseMarketplaceRates, toMarketplaceOrderFields } from 'icarry-sdk';
const result = await icarry.marketplace.estimateRates(rateInput);
const options = parseMarketplaceRates(result); // MarketplaceRateOption[]
const cheapest = [...options].sort((a, b) => a.rate - b.rate)[0];
if (cheapest) {
await icarry.marketplace.createOrder({ ...orderInput, ...toMarketplaceOrderFields(cheapest) });
}toMarketplaceOrderFields fills provider, methodId, methodDescription, and price from one
option. rate vs formattedPrice: the wire sends both a numeric Rate (2.121) and a
formatted Price string ("$2.12"); the option exposes them as rate: number and
formattedPrice?: string — display the second, send the first. Every field is on raw too.
parseMarketplaceRates never throws and never calls the API. It returns [] for any shape it does
not recognize, so treat an empty array as "no rates or unfamiliar response" and fall back to
reading result yourself. Merchant and on-demand rate responses have no equivalent helper — they
have not been observed live. See API_COVERAGE.md.
On-demand flow
A different model: country/state ids, From/To coordinates, and a nested dimensions
object. Shipment creation returns a shipment id used by the (server-only) payment step.
const rates = await icarry.onDemand.estimateRates({
pickup: { countryId: 234, stateProvinceId: 1841, geo: { latitude: 25.2, longitude: 55.3 } },
drop: { countryId: 234, stateProvinceId: 1841, geo: { latitude: 25.1, longitude: 55.2 } },
actualWeight: 5, packageType: 'documents',
dimensions: { length: 5, width: 5, height: 5, unit: 'cm' },
isVendor: false,
});
const shipment = await icarry.onDemand.createShipment({
pickupAddress: { firstName: 'A', lastName: 'B', email: '[email protected]', phoneNumber: '012',
countryId: 234, stateProvinceId: 1841, address1: 'Dubai' },
dropOffAddress: { firstName: 'C', lastName: 'D', email: '[email protected]', phoneNumber: '012',
countryId: 234, stateProvinceId: 1841, address1: 'Dubai' },
actualWeight: 5, packageType: 'documents',
dimensions: { length: 5, width: 5, height: 5 },
provider: 'Shipping.ICarry.example', methodName: 'Motor Express', price: 1.3,
parcel: { quantity: 1, currency: 'USD', packageValue: 0 },
});
// Then pay for it — SERVER-ONLY, see the payment warning below.Tracking
const tracking = await icarry.shipments.track('TRACKING_NUMBER');The tracking response shape is not documented by iCarry — it is parsed with expect: 'auto' and
typed as AmbiguousApiResult (it could be an object, array, string, etc.). Narrow it (e.g.
typeof result === 'object' && result !== null) before reading fields.
Cancellation
await icarry.shipments.cancel('TRACKING_NUMBER');⚠️ iCarry implements cancellation as a mutating
GET. The SDK treats it as mutating: it is never cached and never automatically retried.
Packaging slips
The endpoint is named "Pdf" but may return binary PDF or a JSON envelope. The result is a
discriminated union decided from the response Content-Type. The SDK never writes a file.
const slip = await icarry.shipments.getPackagingSlip(shipmentId);
if (slip.kind === 'binary') {
// slip.data: Uint8Array, slip.contentType, slip.filename?
} else {
// slip.data: unknown (JSON envelope — often a URL or encoded payload)
}Error handling
All errors extend ICarryError. The SDK sanitizes known sensitive values (passwords, bearer
tokens, card numbers, CVVs, and URLs carrying such data) in error messages, details, and the
error cause at its boundaries — including plain-text/JSON API error bodies and network-layer
messages. The cause is a minimal sanitized Error (name + redacted message + safe code), never
the raw thrown object. Narrow with instanceof:
import { ICarryApiError, ICarryAuthenticationError, ICarryTimeoutError } from 'icarry-sdk';
try {
await icarry.shipments.track('TRACKING_NUMBER');
} catch (error) {
if (error instanceof ICarryAuthenticationError) {
// invalid or expired credentials
} else if (error instanceof ICarryTimeoutError) {
// request timed out
} else if (error instanceof ICarryApiError) {
console.error(error.status, error.details?.code, error.message);
}
throw error;
}Hierarchy: ICarryError → ICarryConfigurationError, ICarryValidationError,
ICarryAuthenticationError, ICarryApiError, ICarryNetworkError, ICarryTimeoutError,
ICarryAbortError, ICarryResponseParseError. ICarryApiError.details carries a safe
{ status, method, path, code?, requestId?, details? } — path never includes the query string, so
payment query parameters cannot leak through errors.
iCarry returns two error body shapes (an RFC 7807 object or a bare JSON string); both are handled.
Retry behavior
Conservative and structural. Only side-effect-free, opted-in calls are ever retried, and only on
transient failures (network errors, 408, 429, and selected 5xx), with bounded exponential
backoff + full jitter and Retry-After support.
- Read-only GETs (
countries.*,warehouses.getById/list/listWithMeta,shipments.track,getPackagingSlip) are retried by default. - Rate estimates (
*.estimateRates) are not retried unless you pass{ retry: true }per call. - Never retried: order creation, shipment creation, all payment operations, MontyPay returns,
marketplace warehouse creation, and
shipments.cancel(a mutating GET).
Disable retries entirely with retry: false.
Timeouts & cancellation
Every request has a timeout (default 30s, override per call). You may also pass your own AbortSignal;
it is combined with the SDK's timeout. A timeout raises ICarryTimeoutError; a caller abort raises
ICarryAbortError.
const controller = new AbortController();
setTimeout(() => controller.abort(), 2000);
await icarry.countries.list({ signal: controller.signal, timeoutMs: 5000 });TypeScript usage
Fully typed. Import input/result types as needed:
import type { MerchantRateInput, Country, PackagingSlip, ICarryClientOptions } from 'icarry-sdk';Response types honestly reflect incomplete iCarry documentation:
- High-confidence endpoints (auth, countries, states, warehouses) return concrete typed objects.
- Endpoints parsed with
expect: 'auto'(rate/order/shipment creation, tracking, cancellation, payment, MontyPay) returnAmbiguousApiResult— the union of what the parser can actually produce:object | unknown[] | string | number | boolean | null | undefined. Narrow before use, e.g.:
const result = await icarry.shipments.track('TRACKING_NUMBER'); // AmbiguousApiResult
if (typeof result === 'object' && result !== null && !Array.isArray(result)) {
// safe to read fields here
} else if (typeof result === 'string') {
// plain-text response
} else if (result === undefined) {
// empty response
}TypeScript will reject result.someField without narrowing — this is intentional, not a defect.
Security considerations
See SECURITY.md for the full policy. In short:
- Keep connector
email/passwordon the server. Never ship them to a browser. - Never expose bearer tokens in client-side code; don't commit credentials — use env vars or a secret manager.
- The SDK sanitizes known sensitive values at its own logging and error boundaries (errors,
error causes, and observability hooks). It cannot control URL logging at the HTTP/infrastructure
layer, cannot protect original input values that you log yourself, and a custom
fetchyou inject must not log request URLs. - Credentials and tokens are held in runtime-private (
#) fields, which reduce accidental exposure viaconsole/JSON.stringify/util.inspect— but they do not protect against malicious code running in the same process. - Use TLS (
https) endpoints only. Rotate credentials immediately if exposed. - The SDK stores no card data and makes no PCI-compliance claim.
⚠️ Server-only payment warning
icarry.payments.createShipmentOrder(...) sends card data as query-string parameters — this is
iCarry's contract, not a design choice by this SDK. The SDK preserves it exactly, but:
- Call it only from a trusted server context. Never from a browser. Query strings are routinely logged by proxies, gateways, and servers.
- The SDK redacts every card parameter from hooks/errors and never exposes the serialized payment URL, but it cannot control logging at the HTTP/infrastructure layer.
- Payment calls are never retried, cached, or subject to telemetry. Use obvious placeholders in tests; never commit real card data.
// SERVER-ONLY. Placeholders only.
await icarry.payments.createShipmentOrder(shipmentId, {
card: {
cardNumber: 'XXXXXXXXXXXXXXXX', cardCvv: 'XXX', cardType: 'visa', cardName: 'CARDHOLDER',
cardExpirationMonth: '02', cardExpirationYear: '2039',
},
paymentMethodSystemName: 'Payments.MontyPay',
});MontyPay return operations (processMontyPaySuccess / processMontyPayCancellation) are thin wrappers
over iCarry's return URLs. The SDK performs no callback signature verification (iCarry documents
none) — never treat a return call as proof of payment; verify status server-side.
Endpoint coverage matrix
See API_COVERAGE.md for the full matrix (method · route · auth · retry class ·
sensitive-data class · documentation confidence · known ambiguity). All 21 documented routes are
implemented. The plugin sections (Shopify/WooCommerce/Magento/OpenCart/MANSATI) are prose in iCarry's
docs, not callable REST routes, and are intentionally not implemented.
Known iCarry inconsistencies
The SDK preserves iCarry's exact wire contract while giving you a clean camelCase surface. Notable quirks it handles for you:
| Public (camelCase) | iCarry wire | Note |
|---|---|---|
| address.country (warehouse) | County | Misspelled wire field that means country. |
| cod.currency (merchant/marketplace) | COdCurrency | Odd casing on these endpoints… |
| cod.currency (on-demand) | CODCurrency | …but proper casing here. |
| methodId (merchant/marketplace) | MethodId | Same concept, different name… |
| methodName (on-demand) | MethodName | …than here. |
| shipments.cancel | GET /CancelOrder | Mutating GET. |
| payments.createShipmentOrder | card in query string | Server-only; redacted. |
| getPackagingSlip | "Pdf" endpoint | May return binary or JSON. |
| countries.list / getById | PascalCase vs snake_case | Same entity, different casing per route (see below). |
| warehouses.list | { Data, recordsTotal, … } | GetAll returns a DataTables envelope, not an array. |
Casing split (live-verified 2026-07-30). iCarry serves the same entity in different casings
depending on the route: Country/GetAllCountry and Warehouse/GetAll answer in PascalCase,
while Country/GetById, Country/GetStatesByCountryId and Warehouse/GetById answer in
snake_case. The SDK's mappers read both spellings per field, so you get one stable camelCase
shape either way. Six Country fields are only sent by countries.list() and are absent from
countries.getById(): currencyCode, phoneCode, terms/termAliases,
requiresNationalShortAddress, hsCodeFormat and hsCodeFormatId.
Country search aliases. Terms arrives as a comma-separated, multilingual string with a
trailing comma ("uae,الإمارات العربية المتحدة,…,"). country.terms is the raw string;
country.termAliases is it split, trimmed and stripped of empty elements, so the trailing comma
costs you nothing. Aliases are not lowercased — they contain Arabic, French, Spanish and emoji,
so case folding is left to you.
Warehouse list envelope. Warehouse/GetAll wraps its records in a DataTables envelope.
warehouses.list() unwraps it for you; use listWithMeta() when you also want the counters:
const warehouses = await icarry.warehouses.list({ name: 'Baabda' }); // Warehouse[]
const page = await icarry.warehouses.listWithMeta({ name: 'Baabda' });
page.warehouses; // Warehouse[]
page.recordsTotal; // total before the name filter (undefined if a bare array came back)
page.recordsFiltered; // records matching the name filter — the filter is applied server-sideAdditionally: response schemas for create/rate/track/confirm/MontyPay are unverified (iCarry's "examples" are auto-generated echoes of the request), so they are returned as open records; error bodies may be a JSON object or a bare string; and no public Swagger/OpenAPI is available.
Low-level request escape hatch
For undocumented or future endpoints. It reuses authentication, timeout/abort, redaction, parsing, retry, and error handling. Prefer the typed resource methods where they exist.
const data = await icarry.request<MyType>({
method: 'GET',
path: '/SomeFutureEndpoint', // relative; NO query string or fragment in the path
query: { foo: 'bar' }, // put parameters here so they can be redacted from metadata
retryable: true, // opt in only for safe, idempotent calls
});A path containing a query string, fragment, absolute URL, or control character is rejected with
ICarryValidationError before any request runs — always pass parameters via query.
Observability hooks
Optional, best-effort hooks receive redacted, deep-frozen data (nested objects included). A
throwing hook never fails a request — errors are swallowed and routed, sanitized (as a
SafeHookError), to onHookError if provided. No telemetry runs by default.
new ICarryClient({
baseUrl,
token,
hooks: {
onRequest: (info) => console.debug(info.method, info.path), // headers/body/url already redacted
onResponse: (info) => console.debug(info.status, info.durationMs),
onRetry: (evt) => console.warn('retrying', evt.path, 'in', evt.delayMs, 'ms'),
},
});Testing
npm test # unit tests (mocked fetch; no live credentials)
npm run test:coverageAn optional live-contract suite is gated behind environment variables and disabled by default. It accepts either connector credentials or a pre-obtained token:
ICARRY_LIVE_TESTS=true ICARRY_BASE_URL=... ICARRY_EMAIL=... ICARRY_PASSWORD=... npm test
# or
ICARRY_LIVE_TESTS=true ICARRY_BASE_URL=... ICARRY_TOKEN=... npm testEvery included live check is read-only (auth, countries, states, warehouses; optional tracking
and packaging-slip checks run only when ICARRY_TEST_TRACKING_NUMBER / ICARRY_TEST_SHIPMENT_ID
are supplied). It never runs in CI and never uses real card data. Any future mutating or paid check
requires a second explicit opt-in (ICARRY_ALLOW_MUTATIONS=true / ICARRY_ALLOW_PAYMENT_TESTS=true)
and none ship enabled. The suite can log a summarizeShape of provisional responses to help tighten
the still-unverified response types over time. It is a defense-in-depth helper for optional live
tests, not a formal anonymizer — it makes no PCI-compliance or guaranteed-PII-detection claim, and
does not replace a privacy review. Its guarantees:
- Only explicitly allowlisted schema keys (
id,name,status,createdAt,trackingNumber,warehouseId, …) may appear verbatim. Every other property key is replaced by a structural category — arbitrary identifiers (CustomerABC123,MohammadZaytoun) and identifier-shaped secrets alike become[dynamic-key]/[token-like-key]; emails/phones/URLs/UUIDs/card-like keys become[email-key]/[phone-key]/… - Value kinds are aggregated per category (
Record<string, kind[]>) so colliding keys can't overwrite each other. Raw property processing is capped (own enumerable keys only) independently of the category cap, so a huge object is bounded and flaggedtruncated. - Sizes are coarse buckets (
empty/one/few/many), never exact counts. No response value, nested content, raw non-allowlisted key, or exact property/category count ever appears.
Contributing
See CONTRIBUTING.md. In short: npm ci, then npm run format:check && npm run
lint && npm run typecheck && npm test && npm run build before opening a PR.
License
MIT © Zaytoun Solutions. Unofficial — not affiliated with iCarry.
