API Reference

Courier Uncle Public API v1

Quote every courier on your account, book, print labels, track, act on failed deliveries and reconcile COD from your own systems. The API runs on the same pricing and validation engine as the dashboard, so an API booking costs exactly what the dashboard would charge.

Base URL https://app.courieruncle.com/api/ext/v1

Introduction

The Public API is for stores, ERPs and warehouse systems that want to ship through Courier Uncle without using the dashboard. Everything the dashboard can do to a shipment, the API can do: quote, book, label, track, cancel, reattempt or return a failed delivery, and read the COD money coming back.

It is a REST API over HTTPS with JSON bodies. Authentication is a single header. There is one version, v1, and changes to it are additive: new fields and endpoints appear, existing ones do not change meaning. Breaking changes, if ever needed, will ship as v2 with a migration window.

You want toCall
compare prices before bookingPOST /rates/ with the real weight, dimensions and COD amount
bookPOST /shipments/, then fetch the PDF at the returned label_url
know where a parcel issubscribe to webhooks; poll GET /shipments/{awb}/ only as a fallback
reconcileGET /shipments/ and GET /cod/remittances/

Node.js SDK

The official client for Node 18 and newer: every endpoint typed, retries on 429 and on idempotent calls, one error class, and webhook verification. No dependencies; ESM and CommonJS.

bash
npm install courieruncle

Availability: the package is being published to the public npm registry. Until npm install courieruncle resolves, ask support for the tarball (courieruncle-0.1.0.tgz) and install it with npm install ./courieruncle-0.1.0.tgz; the API is identical.

typescript
import { CourierUncle, CourierUncleError, parseWebhook } from 'courieruncle';

const cu = new CourierUncle({ apiKey: process.env.CU_KEY! });

const quote  = await cu.rates({ origin_pincode: '122001', destination_pincode: '400001',
                                weight_kg: 0.5, payment_mode: 'COD', cod_amount: 1299 });
const booked = await cu.shipments.create({ order_id: 'ORD-1042', /* ... */ courier_code: quote.couriers[0].courier_code });
const pdf    = await cu.shipments.label(booked.tracking_id);            // Uint8Array, write to disk

// webhooks: verify on the raw body, deduplicate on event_id
const event = parseWebhook(rawBody, req.get('X-CU-Signature'), process.env.CU_WEBHOOK_SECRET!);

Errors are a CourierUncleError with status, code, body and helpers such asisDuplicate (the existing tracking_id is in body). Full guide in the package README on npm. Python and PHP clients are planned; until then the examples on this page are the reference.

Base URL and environments

https://app.courieruncle.com/api/ext/v1

One base URL, two kinds of key. The key decides the environment: a sandbox key runs the same validation and pricing as live and simulates the outcome; a live key creates real shipments, debits the wallet and hands the parcel to the courier.

Sandbox key cu_test_…Live key cu_live_…
Quotes, serviceability, pincodesReal, on your rate cardReal, on your rate card
Reads: shipments, wallet, couriers, addresses, CODReal account dataReal account data
POST /shipments/Validated and priced; returns a CUTEST… id; nothing stored, nothing chargedCreates the shipment, debits the wallet, dispatches to the courier
Cancel, NDR actionsSimulatedReal
Labels404 (no shipments exist)PDF
WebhooksNot sentSent on every status change
Rate limit60 requests a minute300 requests a minute
The sandbox refuses what live would refuse: a courier that does not serve the pincodes, no COD on that lane, no rate card for that courier on your account, a wallet below the floor is not checked (nothing is charged), an address too short to deliver. If a booking works in sandbox it will work live with the same body.

merchant.courieruncle.com also serves the API for existing integrations and will keep doing so; build against app.courieruncle.com.

Authentication

Create keys in Dashboard → Settings → API Keys. A key is shown once, at creation. Send it on every request in the X-API-Key header. Live keys are issued once KYC on the account is approved.

bash
curl https://app.courieruncle.com/api/ext/v1/ \
  -H "X-API-Key: cu_test_XXXXXXXXXXXXXXXXXXXXXXXX"

{ "name": "Courier Uncle Public API", "version": "v1", "mode": "sandbox",
  "merchant": "Your Company", "endpoints": { ... }, "docs": "https://courieruncle.com/api-docs" }
RuleWhy
Server-side onlyA key in browser or app code is a key anyone can copy. Call the API from your backend.
One key per systemRotate or revoke one integration without touching the others.
Revoke on suspicionRevocation is immediate: the next request with that key is a 401.
Keys carry the whole accountThere are no scopes. Treat a live key like the dashboard password.

Conventions

TopicConvention
TransportHTTPS only. HTTP is redirected and will not carry a key.
BodiesJSON in, JSON out. Send Content-Type: application/json. Labels are the one exception: application/pdf.
Timestamps in responsesISO 8601 with offset, e.g. 2026-09-12T10:12:00+05:30.
Timestamps in webhooksYYYY-MM-DD HH:MM:SS in IST (Asia/Kolkata).
AmountsJSON numbers in rupees, two decimals. Quotes are before GST; the GST line is shown alongside.
Weights and dimensionsKilograms (up to three decimals) and centimetres. Volumetric weight is L × W × H / 5000.
PincodesSix-digit strings. Always strings, never numbers, so leading zeros survive.
Identifierstracking_id is the Courier Uncle AWB (CU…) and the handle for every shipment call. courier_awb is the carrier’s own number and is what the label barcode carries. order_id is yours.
IdempotencyPass your order_id on booking. A second booking with the same order_id returns 409 with the existing tracking_id instead of a second shipment. A cancelled shipment frees its order_id.
PaginationList endpoints take page and page_size (max 100) and return count and has_more.
Trailing slashEvery path ends in a slash. Without it you get a redirect that drops the POST body.
Versioningv1 is additive. New fields may appear in any response; ignore what you do not use.

Rate limits

Per key, per minute: 60 on a sandbox key, 300 on a live key. Every response carries the live quota:

http
X-RateLimit-Limit: 300
X-RateLimit-Remaining: 287
X-RateLimit-Reset: 1789456123        # unix time the window resets

Over the limit is a 429 with a body telling you how long to wait. Back off for that long; do not retry in a tight loop.

json
{ "error": "Rate limit exceeded", "retry_after_seconds": 21, "docs": "https://courieruncle.com/api-docs#rate-limits" }

A store that books as orders arrive never approaches the limit. A batch that re-quotes a catalogue can; space the calls, or ask for a higher limit with your volume.

Quickstart

Quote a lane, book with the courier you like, fetch the label. Three calls, shown in curl, Python and Node.

1. Quote

bash
curl -X POST https://app.courieruncle.com/api/ext/v1/rates/ \
  -H "X-API-Key: $CU_KEY" -H "Content-Type: application/json" \
  -d '{"origin_pincode":"122001","destination_pincode":"400001",
       "weight_kg":0.5,"length_cm":20,"width_cm":15,"height_cm":10,
       "payment_mode":"COD","cod_amount":1299}'

2. Book

python
import requests

API = "https://app.courieruncle.com/api/ext/v1"
H = {"X-API-Key": os.environ["CU_KEY"]}

order = {
    "order_id": "ORD-1042",
    "pickup_address_uid": "8c1e…",                      # from GET /pickup-addresses/
    "recipient_name": "Asha Verma", "recipient_phone": "9812345678",
    "delivery_address": "Flat 12, Sea View Apartments, 12 MG Road, Fort",
    "delivery_city": "Mumbai", "delivery_state": "Maharashtra", "delivery_pincode": "400001",
    "weight_kg": 0.5, "length_cm": 20, "width_cm": 15, "height_cm": 10,
    "payment_mode": "COD", "cod_amount": 1299, "order_value": 1299,
    "items": [{"name": "Blue-light glasses", "sku": "BLG-01", "qty": 1, "unit_price": 1299}],
    "courier_code": "ekart",                             # optional; omit to let your courier rules pick
}
r = requests.post(f"{API}/shipments/", json=order, headers=H, timeout=30)
if r.status_code == 409:                                 # already booked under this order_id
    awb = r.json()["tracking_id"]
else:
    r.raise_for_status()
    awb = r.json()["tracking_id"]

3. Label

javascript
const res = await fetch(`https://app.courieruncle.com/api/ext/v1/shipments/${awb}/label/?size=thermal_4x6`, {
  headers: { 'X-API-Key': process.env.CU_KEY },
});
if (!res.ok) throw new Error(`label ${res.status}`);
await fs.promises.writeFile(`${awb}.pdf`, Buffer.from(await res.arrayBuffer()));

Then subscribe to webhooks and you never need to poll.

Account

What your account can do right now: which couriers, from which addresses, with how much money.

GET/

Index: verifies the key and returns the mode and endpoint map.

json
{ "name": "Courier Uncle Public API", "version": "v1", "mode": "live",
  "merchant": "Your Company", "endpoints": { ... }, "docs": "https://courieruncle.com/api-docs" }

GET/wallet/

Balance, the floor a booking needs, and whether you can book right now.

json
{ "balance": 386.06, "currency": "INR", "min_booking_balance": 250.0, "can_book": true }

Check before a batch instead of catching 402s. Freight and GST are debited from the wallet at booking; the COD fee is deducted from the remittance.

GET/couriers/

The couriers your account can book, with the codes and services to pass at booking.

json
{ "couriers": [
    { "code": "ekart",        "name": "Ekart",        "mode": "SURFACE",     "services": [{ "code": "SURFACE", "label": "Surface" }] },
    { "code": "parcel_uncle", "name": "Parcel Uncle", "mode": "SURFACE",     "services": [{ "code": "NDD", "label": "Next day" }, { "code": "SDD", "label": "Same day" }] },
    { "code": "delhivery",    "name": "Delhivery",    "mode": "SURFACE_AIR", "services": [{ "code": "SURFACE", "label": "Surface" }, { "code": "AIR", "label": "Air" }] }
] }

Only couriers with a rate card on your account, enabled in your courier selection, and with a working integration are listed. A courier missing here will be refused at booking.

POST/pickup-addresses/

Save a pickup address; the uid comes back for pickup_address_uid.

json
{ "label": "Warehouse 1", "contact_name": "Store Ops", "contact_phone": "9876543210",
  "address_line_1": "Warehouse 4, Udyog Vihar Phase 1", "address_line_2": "", "city": "Gurgaon", "state": "Haryana", "pincode": "122001", "is_default": false }

201 { "uid": "8c1e…", ...the same fields, "is_active": true }
400 { "contact_phone": ["This field is required."] }          // per-field

GET/pickup-addresses/

Saved pickup addresses. Pass a uid as pickup_address_uid when booking instead of the six pickup_* fields.

json
{ "addresses": [
    { "uid": "8c1e…", "label": "Warehouse 1", "is_default": true,
      "contact_name": "Store Ops", "contact_phone": "9876543210",
      "address_line_1": "Warehouse 4, Udyog Vihar Phase 1", "address_line_2": "",
      "city": "Gurgaon", "state": "Haryana", "pincode": "122001" }
] }

Serviceability and rates

GET/pincode/{pincode}/

City and state for a six-digit pincode. 404 for an unknown one.

json
{ "pincode": "110001", "city": "New Delhi", "state": "Delhi" }

GET/serviceability/?origin=&destination=&payment_mode=

Which of your couriers serve a route, with the zone and each courier’s services.

QueryRequiredNotes
originyesPickup pincode
destinationyesDelivery pincode
payment_modenoPREPAID (default) or COD. COD answers whether cash collection is available at the destination.
json
GET https://app.courieruncle.com/api/ext/v1/serviceability/?origin=110001&destination=400001&payment_mode=COD

{
  "origin": "110001", "destination": "400001",
  "zone": "C", "zone_name": "Metro to Metro", "payment_mode": "COD",
  "serviceable": true,
  "couriers": [
    { "code": "ekart",        "name": "Ekart",        "estimated_days": 3, "services": [{ "code": "SURFACE", "label": "Surface", "estimated_days": 3 }] },
    { "code": "parcel_uncle", "name": "Parcel Uncle", "estimated_days": 1, "services": [{ "code": "NDD", "label": "Next day", "estimated_days": 1 }, { "code": "SDD", "label": "Same day", "estimated_days": 1 }] }
  ]
}

Zones: A within city, B within state, C metro to metro, D rest of India, E North East, J&K, Himachal and the islands. Each courier maps the pair on its own zone table, so a lane can be C for one courier and D for another; the per-courier zone is on the rate quote.

POST/rates/

Compare every courier on your rate card for one parcel. One row per courier service.

FieldRequiredNotes
origin_pincodeyes
destination_pincodeyes
weight_kgyesDead weight of the packed parcel
payment_modeyesPREPAID or COD
cod_amountfor CODThe COD fee is the higher of a flat amount and a percentage of this. Send it and the quote equals the booking.
length_cm, width_cm, height_cmnoOuter dimensions. Omit and the parcel is priced as dense; the hub will measure the box.
json
{
  "origin_pincode": "122001", "destination_pincode": "400001",
  "zone": "D", "zone_name": "Rest of India", "payment_mode": "COD",
  "chargeable_weight_kg": 0.6, "volumetric_weight_kg": 0.6, "gst_percent": 18.0,
  "couriers": [
    { "courier_code": "ekart", "courier_name": "Ekart", "service": "SURFACE", "service_label": "Surface",
      "display_name": "Ekart Surface", "zone": "D",
      "total": 98.7, "freight": 63.7, "cod_charge": 35.0, "gst_amount": 17.77, "total_with_gst": 116.47,
      "rto_charge": 63.7, "estimated_days": 2, "recommended": true },
    { "courier_code": "parcel_uncle", "courier_name": "Parcel Uncle", "service": "NDD", "service_label": "Next day",
      "display_name": "Parcel Uncle Next day", "zone": "A",
      "total": 85.0, "freight": 85.0, "cod_charge": 0.0, "gst_amount": 15.3, "total_with_gst": 100.3,
      "rto_charge": 85.0, "estimated_days": 1 },
    { "courier_code": "parcel_uncle", "courier_name": "Parcel Uncle", "service": "SDD", "service_label": "Same day", ... }
  ]
}
FieldMeaning
serviceWhat to pass as courier_service at booking. A courier that sells several services (Parcel Uncle NDD and SDD; Delhivery Surface and Air) appears once per service, priced separately.
totalFreight plus COD fee, before GST. This is what the wallet is debited, plus GST.
freightThe slab rate for the chargeable weight and zone, on your card.
cod_chargeZero on prepaid. On COD, the higher of the flat fee and the percentage of cod_amount.
rto_chargeWhat a return would cost if the parcel comes back, on this courier.
recommendedOn the first row: the best score across cost, speed, delivery success and RTO history. Your courier rules may override it at booking.

An empty couriers list means no courier on your account serves that lane for that parcel. If every lane is empty, no rate card is assigned yet; contact support.

Shipments

POST/shipments/

Book a shipment.

Sandbox keys validate, price and return a simulated AWB; nothing is stored and nothing is charged. Live keys create the shipment, debit freight plus GST from the wallet, and dispatch to the courier.

FieldRequiredNotes
order_idrecommendedYour reference. Makes the call idempotent (409 on repeat). Printed on the label.
pickup_address_uidone ofA saved address from /pickup-addresses/
pickup_contact_name, pickup_contact_phone, pickup_address, pickup_city, pickup_pincodeone ofOr the pickup address inline
recipient_name, recipient_phoneyes
recipient_emailno
delivery_addressyesHouse or flat number, street and locality. A bare street name or a very short line is a 400: couriers reject it.
delivery_city, delivery_pincodeyes
delivery_stateno
weight_kgyesDead weight, packed, up to three decimals
length_cm, width_cm, height_cmnoOuter dimensions of the packed parcel
payment_modeyesPREPAID or COD
cod_amountfor CODWhat the rider collects. Can be less than order_value (partial COD).
order_valuenoDeclared value, printed on the label
itemsnoList of { name, sku, qty, unit_price }; printed on the label
product_descriptionno
courier_codenoFrom /couriers/. Omit to let your courier rules choose.
courier_servicenoFrom the rate quote’s service. Omit for the courier’s default.
shipment_typenoPACKAGE (default) or REVERSE. DOCUMENT is paused.
channel, tagsnoFor your own reporting
json
201 Created, live key
{
  "sandbox": false,
  "tracking_id": "CU0000000042",
  "order_id": "ORD-1042",
  "courier": "Ekart", "courier_code": "ekart", "courier_service": "SURFACE", "courier_awb": "LEKP0000000169",
  "tracking_url": "https://courieruncle.com/track?id=CU0000000042",
  "label_url": "https://app.courieruncle.com/api/ext/v1/shipments/CU0000000042/label/",
  "shipping_charge": 98.7, "zone": "D", "chargeable_weight_kg": 0.6,
  "status": "CREATED"
}

201 Created, sandbox key
{ "sandbox": true, "message": "Sandbox booking simulated — no shipment created, no wallet debit",
  "tracking_id": "CUTEST8422519", "courier": "Ekart", "courier_code": "ekart", "courier_service": "SURFACE",
  "shipping_charge": 98.7, "zone": "D", "chargeable_weight_kg": 0.6 }

What is checked before a live booking, in order: the address, KYC on the account, a rate card for the courier, that the courier serves both pincodes for this parcel and payment mode, the wallet floor and balance. Each failure is a specific error; see Errors.

GET/shipments/

Your shipments, newest first.

QueryNotes
statusComma-separated, e.g. IN_TRANSIT,OUT_FOR_DELIVERY
order_idExact match on your reference
from, toBooked-on dates, YYYY-MM-DD, inclusive, IST
page, page_sizepage_size up to 100, default 50
json
{
  "count": 62, "page": 1, "page_size": 50, "has_more": true,
  "results": [
    { "tracking_id": "CU0000000042", "order_id": "ORD-1042", "status": "IN_TRANSIT",
      "courier": "Ekart", "courier_code": "ekart", "courier_service": "SURFACE", "courier_awb": "LEKP0000000169",
      "tracking_url": "…", "label_url": "…",
      "payment_mode": "COD", "cod_amount": 1299.0, "shipping_charge": 98.7,
      "recipient_name": "Asha Verma", "delivery_city": "Mumbai", "delivery_pincode": "400001",
      "created_at": "2026-09-12T10:12:00+05:30", "delivered_at": null }
  ]
}

GET/shipments/{awb}/

One shipment: status, our timeline, and the carrier’s own scan trail.

json
{
  "tracking_id": "CU0000000042", "order_id": "ORD-1042", "status": "IN_TRANSIT",
  "courier": "Ekart", "courier_code": "ekart", "courier_service": "SURFACE", "courier_awb": "LEKP0000000169",
  "tracking_url": "https://courieruncle.com/track?id=CU0000000042",
  "label_url": "https://app.courieruncle.com/api/ext/v1/shipments/CU0000000042/label/",
  "zone": "D", "payment_mode": "COD", "cod_amount": 1299.0, "shipping_charge": 98.7,
  "created_at": "2026-09-12T10:12:00+05:30", "delivered_at": null,
  "timeline": [                                       // our status changes, newest first
    { "status": "IN_TRANSIT", "notes": "Bag added to trip", "location": "Delhi Hub", "timestamp": "2026-09-12T22:05:47+05:30" },
    { "status": "PICKED_UP",  "notes": "Picked up",         "location": "Gurgaon",   "timestamp": "2026-09-12T16:40:11+05:30" },
    { "status": "CREATED",    "notes": "Booked via API",    "location": "",          "timestamp": "2026-09-12T10:12:00+05:30" }
  ],
  "scans": [                                          // the carrier's trail, in its words, newest first
    { "at": "2026-09-12T22:05:47+05:30", "status": "In Transit", "description": "Bag added to trip", "location": "Delhi_Hub (Delhi)" }
  ]
}
StatusMeaning
CREATED, PICKUP_SCHEDULEDBooked; courier notified
PICKED_UPWith the courier
IN_TRANSITMoving between hubs
OUT_FOR_DELIVERYWith the delivery rider
DELIVEREDDone; COD collected if applicable
DELIVERY_ATTEMPTED, FAILEDA delivery attempt failed: act on it (NDR)
RTO_INITIATED, RTO_IN_TRANSIT, RETURNEDComing back to you; RETURNED when it has arrived
CANCELLEDCancelled before pickup; freight refunded

POST/shipments/{awb}/cancel/

Cancel before the courier has it. Freight is refunded to the wallet.

json
{ "reason": "Customer changed mind" }                 // optional

200 { "message": "Shipment cancelled", "tracking_id": "CU0000000042", "refunded": 98.7,
      "courier_cancelled": true, "courier_error": null }
400 { "error": "Cannot cancel shipment with status DELIVERED" }
409 { "error": "CU0000000042 is already cancelled", "tracking_id": "CU0000000042" }
409 { "error": "Ekart refused to cancel AWB LEKP0000000169, so this shipment has NOT been cancelled ...",
      "courier_error": "..." }                          // still live; nothing refunded

Once the parcel is picked up, cancellation is not possible; use the NDR action to redirect or return it instead.

POST/shipments/track/

Status of up to 100 shipments in one call. For a poll loop; webhooks are still the better answer.

json
{ "tracking_ids": ["CU0000000042", "CU0000000043", "CU0000000000"] }

{ "count": 3, "found": 2,
  "results": [
    { "tracking_id": "CU0000000042", "found": true, "order_id": "ORD-1042", "status": "IN_TRANSIT",
      "courier": "Ekart", "courier_code": "ekart", "courier_awb": "LEKP…",
      "last_scan": "Bag added to trip", "last_scan_at": "2026-09-12T22:05:47+05:30", "delivered_at": null, "updated_at": "…" },
    { "tracking_id": "CU0000000000", "found": false }
] }

POST/shipments/{awb}/retry/

Re-submit a shipment the courier refused at booking.

The shipment exists and is paid for; only the courier’s acceptance is missing (no courier_awb yet). Retrying asks the courier again instead of cancelling and re-booking. 400 when the shipment already has a courier AWB or is past pickup; 502 with the courier’s message when it refuses again.

Labels

GET/shipments/{awb}/label/

The shipping label as a PDF, one page, laid out for the paper you name.

QueryValuesNotes
sizethermal_4x6, thermal_4x4, a5, a4_1, a4_2, a4_4The PDF page is exactly that paper, so print at 100% with no scaling. Thermal sizes are roll stickers; a5 and a4_1 are one label per page for plain-paper printers; a4_2 and a4_4 are N-up on A4 (A5 and A6 labels). Omit it and the label prints on the paper set in Dashboard → Settings → Label, the same as the panel’s print button.
sourcecourierThe carrier’s own label instead of your designed one. 404 when the carrier supplies none; fall back to the default.
bash
curl "https://app.courieruncle.com/api/ext/v1/shipments/CU0000000042/label/?size=thermal_4x6" \
  -H "X-API-Key: $CU_KEY" -o CU0000000042.pdf
# → 200  Content-Type: application/pdf  Content-Disposition: inline; filename="CU0000000042-label.pdf"

The label is your label: the logo, footer message, barcode type and every field toggle from Dashboard → Settings → Label apply to the API label exactly as they do to the one the panel prints, from the same template and renderer. Change a setting in the portal and the next API label reflects it; nothing is cached and there is no separate API default. The response header X-Label-Size says which paper was used.

It carries the courier AWB as a Code 128 barcode, your order id, the COD amount to collect and the items. Generate it after booking: the courier AWB is assigned at booking. ?source=courier is the exception, being the carrier’s own artwork rather than yours.

Failed deliveries (NDR)

When a delivery attempt fails the shipment moves to DELIVERY_ATTEMPTED or FAILED and a shipment.ndr webhook fires with the courier’s reason. You can ask for another attempt or send the parcel back.

POST/shipments/{awb}/ndr/

Reattempt, or initiate RTO. Pushed to the courier’s API where integrated.

json
{ "action": "reattempt" }                              // reattempt | rto

200 { "message": "Reattempt scheduled — Ekart notified via API", "status": "OUT_FOR_DELIVERY", "courier_notified": true }
400 { "error": "action must be reattempt or rto" }
404 { "error": "NDR case not found" }                   // the shipment is not in DELIVERY_ATTEMPTED / FAILED

NDR automation in the dashboard can reattempt the first attempts automatically and escalate only the rest; the API action is for your own workflow, for example after your support team has reached the buyer.

Manifests (pickup)

A manifest is the pickup handover sheet: the parcels of one courier from one pickup pincode that are booked and not yet picked up. Open one, print the PDF, hand it to the rider, and close it to schedule the pickup. Parcels move to PICKUP_SCHEDULED on close. A parcel can be on one manifest only.

GET/manifests/

Your manifests, newest first, and what is awaiting pickup per courier.

json
GET https://app.courieruncle.com/api/ext/v1/manifests/?status=OPEN            // OPEN | CLOSED | PICKED_UP, optional

{
  "awaiting_pickup": [{ "courier_code": "ekart", "courier": "Ekart", "count": 14 }],
  "results": [
    { "uid": "3e9f…", "manifest_number": "MF62434944", "courier": "Ekart", "courier_code": "ekart",
      "pickup_pincode": "122001", "status": "OPEN", "shipment_count": 14,
      "pdf_url": "https://app.courieruncle.com/api/ext/v1/manifests/3e9f…/pdf/", "closed_at": null, "created_at": "2026-09-12T16:02:11+05:30" }
  ]
}

POST/manifests/

Open a manifest for one courier’s parcels awaiting pickup.

json
{ "courier_code": "ekart", "tracking_ids": ["CU0000000042", "CU0000000043"] }   // omit tracking_ids to take every eligible parcel

201 { ...manifest, "total_weight_kg": 7.5, "total_cod": 12990.0,
      "pickup": { "label": "Warehouse 1", "contact_name": "…", "contact_phone": "…", "address": "…", "city": "Gurgaon", "state": "Haryana", "pincode": "122001" },
      "shipments": [{ "tracking_id": "CU0000000042", "courier_awb": "LEKP…", "order_id": "ORD-1042", "recipient_name": "…",
                      "delivery_city": "Mumbai", "delivery_pincode": "400001", "weight_kg": 0.5, "payment_mode": "COD", "cod_amount": 1299.0, "status": "CREATED" }],
      "not_included": ["CU0000000043"] }        // ids that were not eligible (picked up, cancelled, another courier, or already manifested)
400 { "error": "No Ekart parcels are awaiting pickup…", "code": "nothing_to_manifest" }

Eligible means: booked with that courier, accepted by the courier (has a courier AWB), status before pickup, and not already on a manifest.

GET/manifests/{uid}/

The manifest with its parcels and the pickup block for the sheet.

The same body as the 201 above.

GET/manifests/{uid}/pdf/

The pickup sheet: A4 PDF with the parcel table, totals and signature lines.

bash
curl "https://app.courieruncle.com/api/ext/v1/manifests/3e9f…/pdf/" -H "X-API-Key: $CU_KEY" -o MF62434944.pdf
# → 200  Content-Type: application/pdf

POST/manifests/{uid}/close/

Close the manifest: pickup is scheduled for every parcel on it.

json
200 { "message": "MF62434944 closed — pickup scheduled for 14 shipments", ...manifest, "status": "CLOSED" }
400 { "error": "Manifest already CLOSED" }

Weight disputes

Couriers reweigh and measure every parcel at the hub. When the measured chargeable weight lands in a higher slab than what you declared, a dispute is raised on the shipment with both sets of figures, the courier’s evidence, and the slab difference held. You have until auto_accept_by (seven days) to accept or dispute; silence is acceptance and the held amount is charged.

GET/weight-disputes/

Disputes on your shipments, newest first.

json
GET https://app.courieruncle.com/api/ext/v1/weight-disputes/?status=ACTION_REQUIRED,DISPUTED&page=1&page_size=50

{ "count": 3, "page": 1, "page_size": 50, "has_more": false, "open_amount_on_hold": 1000.0,
  "results": [
    { "uid": "7d2a…", "tracking_id": "CU0000000042", "courier_awb": "LEKP…", "order_id": "ORD-1042",
      "courier": "Ekart", "courier_code": "ekart", "zone": "D",
      "applied_weight_kg": 0.5, "applied_dims": "20x15x10", "charged_weight_kg": 1.2, "charged_dims": "30x25x10",
      "amount_on_hold": 40.0, "courier_evidence": "Hub scanner reading …", "status": "ACTION_REQUIRED",
      "auto_accept_by": "2026-09-19T10:12:00+05:30", "auto_accept_days_left": 6,
      "merchant_remark": "", "merchant_evidence_url": "", "deducted_at": null, "resolved_at": null, "created_at": "…" }
] }
statusMeaning
ACTION_REQUIREDWaiting for you; the amount is held, not charged
DISPUTEDYou disputed; with the courier
ACCEPTEDAccepted (by you, or by the deadline); charged
RESOLVED_SELLERDispute upheld; hold released, nothing charged
RESOLVED_COURIERDispute rejected; charged
CLOSEDClosed by ops

POST/weight-disputes/{uid}/action/

Accept, or dispute with proof.

json
{ "action": "accept" }

{ "action": "dispute",
  "remark": "Declared 0.48 kg, 20x15x10 cm (chargeable 0.6). Scale photo, dimensions and packing video in the folder; courier reading of 1.2 kg does not match the parcel.",
  "evidence_url": "https://drive.google.com/drive/folders/…" }        // public: anyone with the link can view

200 { "message": "Updated", ...the dispute row, "status": "DISPUTED" }
400 { "error": "A public link to your proof is required: a Google Drive folder set to "Anyone with the link can view", holding the manifest, the packing video and a photo of the parcel on a scale showing the reading." }

Evidence that resolves a dispute: the sealed parcel on a scale with the reading and the AWB legible, the outer dimensions against a tape, the packing video or dated photos, and the pickup manifest. Dated on or before pickup.

COD remittances

COD cash collected at the door is remitted on your plan: D+7 (no fee), D+3 or D+1 (a small early-remittance percentage), counted from delivery. Each payout lists the shipments inside it.

GET/cod/remittances/

Payouts, newest first. Add ?uid= for one payout with its shipments.

json
GET https://app.courieruncle.com/api/ext/v1/cod/remittances/
{ "results": [
    { "uid": "9bae…", "reference": "CODR42417119", "status": "PAID",
      "period_start": "2026-08-12", "period_end": "2026-08-12",
      "gross_cod": 299.0, "cod_fee": 1.5, "adjustments": 29.0, "net_payable": 268.5,
      "utr": "AXISN12345", "paid_at": "2026-08-18T12:51:34+05:30", "shipment_count": 1, "created_at": "…" }
] }

GET https://app.courieruncle.com/api/ext/v1/cod/remittances/?uid=9bae…
{ …the row above, "shipments": [{ "tracking_id": "CU0000000131", "order_id": "8260668255", "cod_amount": 299.0 }] }

To reconcile: list delivered COD shipments for the period, join to the shipments inside each payout on tracking_id, and chase what falls out. Adjustments are weight-discrepancy charges, RTO freight or wallet top-ups netted against the payout; each traces to a shipment or an invoice.

Wallet ledger and invoices

GET/wallet/transactions/

Every credit and debit on the wallet, newest first.

QueryNotes
typeComma-separated: RECHARGE, SHIPMENT, REFUND, COD_SETTLEMENT, ADJUSTMENT, PROMO_BONUS
from, toYYYY-MM-DD, inclusive
page, page_sizepage_size up to 100
json
{ "count": 75, "page": 1, "page_size": 50, "has_more": true,
  "results": [
    { "uid": "…", "type": "SHIPMENT",   "amount": -116.47, "balance_after": 269.59, "description": "Shipment CU0000000042 — freight 98.70 + GST 17.77", "reference_id": "SHIP-CU0000000042", "created_at": "…" },
    { "uid": "…", "type": "ADJUSTMENT", "amount": -40.0,   "balance_after": 386.06, "description": "Weight discrepancy CU0000000031", "reference_id": "DISPUTE-7d2a…", "created_at": "…" },
    { "uid": "…", "type": "RECHARGE",   "amount": 5000.0,  "balance_after": 426.06, "description": "Razorpay", "reference_id": "pay_…", "created_at": "…" }
] }

Amounts are signed: negative is a debit. reference_id ties a line to its shipment (SHIP-<awb>), dispute (DISPUTE-<uid>) or gateway payment, which is what a reconciliation joins on.

GET/invoices/

Weekly freight invoices: subtotal, GST, total and shipment count per week.

json
{ "invoices": [
    { "invoice_number": "INV-2026-037", "period_start": "2026-09-08", "period_end": "2026-09-14", "period_label": "08 Sep – 14 Sep 2026",
      "subtotal": 4210.5, "gst": 757.89, "total": 4968.39, "shipments": 42, "status": "PAID", "due_date": "2026-09-21" }
  ],
  "summary": { ... } }

Freight is debited from the wallet at booking, so these are records for your books (GST input credit), not bills to pay.

Webhooks

Register endpoints in Dashboard → Settings → Webhooks, choose the events, and we POST every status change to you as it happens, including scans the carriers push to us. You never need to poll.

Events

EventFires on
shipment.createdBooked, or pickup scheduled
shipment.picked_upPICKED_UP
shipment.in_transitIN_TRANSIT
shipment.out_for_deliveryOUT_FOR_DELIVERY
shipment.deliveredDELIVERED
shipment.ndrDELIVERY_ATTEMPTED or FAILED
shipment.rtoRTO_INITIATED, RTO_IN_TRANSIT, or RETURNED
shipment.cancelledCANCELLED
weight.dispute_raisedA courier reported a heavier parcel than declared; the difference is on hold
cod.remittedA COD remittance was paid out (bank UTR or wallet settlement)

One shipment event can cover several statuses; the exact one is always in current_status. The two account events carry their own bodies, shown below the shipment payload.

Delivery

http
POST https://yourstore.com/webhooks/courieruncle
Content-Type: application/json
User-Agent: CourierUncle-Webhook/1.0
X-CU-Signature: sha256=8f2a1c…

{
  "event": "shipment.out_for_delivery",
  "event_id": "evt_918273",
  "order_id": "ORD-1042",
  "awb_number": "CU0000000042",
  "carrier": "Ekart", "courier_code": "ekart", "courier_service": "SURFACE", "courier_awb": "LEKP0000000169",
  "shipment_type": "PACKAGE",
  "current_status": "OUT_FOR_DELIVERY",
  "status_time": "2026-09-12 14:22:31",
  "delivery_name": "Asha Verma", "delivery_city": "Mumbai", "delivery_pincode": "400001",
  "payment_mode": "COD", "cod_amount": 1299.0,
  "notes": "Out for delivery", "location": "Mumbai Andheri Hub",
  "tracking_link": "https://courieruncle.com/track?id=CU0000000042",
  "webhook_id": "3f1c…"
}
FieldNotes
event_idOne per status change. Identical on our retry. Deduplicate on this.
webhook_idIdentifies your endpoint configuration, not the event.
status_timeIST, YYYY-MM-DD HH:MM:SS.
notes, locationThe courier’s wording for the scan, where supplied.

Account events

weight.dispute_raised and cod.remitted are signed and delivered the same way; only the body differs.

json
// weight.dispute_raised
{
  "event": "weight.dispute_raised",
  "event_id": "evt_wd_128",
  "dispute_uid": "cb75285d-7711-4d90-bfcc-be3e60ecb351",
  "awb_number": "CU0000000128", "order_id": "6905826490",
  "courier_code": "delhivery", "courier_awb": "1234567890123",
  "applied_weight_kg": 0.5, "charged_weight_kg": 1.5, "charged_dims": "30×20×10 cm",
  "amount_on_hold": 84.30, "courier_evidence": "Courier hub weighing-scale reading",
  "auto_accept_by": "2026-09-22 21:50:49", "status": "OPEN",
  "webhook_id": "…"
}

// cod.remitted
{
  "event": "cod.remitted",
  "event_id": "evt_cod_42",
  "reference": "CODR42417119", "status": "PAID",
  "period_start": "2026-09-08", "period_end": "2026-09-14",
  "gross_cod": 12990.0, "cod_fee": 259.8, "adjustments": 0.0, "net_payable": 12730.2,
  "payout_method": "BANK", "utr": "AXISN12345678", "paid_at": "2026-09-15 11:02:10",
  "shipments": [{ "awb_number": "CU0000003221", "order_id": "6474630269", "cod_amount": 1299.0 }],
  "webhook_id": "…"
}

Act on weight.dispute_raised with POST /weight-disputes/{uid}/action/ (accept or dispute with proof) before auto_accept_by; the dispute list endpoint shows the same fields.

Signature

The X-CU-Signature header is the literal prefix sha256= followed by the hex HMAC-SHA256 of the raw request body, keyed by the endpoint’s cu_whsec_… secret. Verify on the raw bytes, before parsing, and compare the whole string in constant time. The secret is generated when you add the endpoint in Settings → Webhooks and shown once; you may instead type your own (16 to 64 characters) in the same form. For compatibility the bare digest is also sent as X-CourierUncle-Signature; new integrations should verify X-CU-Signature.

python
import hmac, hashlib
expected = 'sha256=' + hmac.new(SECRET.encode(), raw_body, hashlib.sha256).hexdigest()
ok = hmac.compare_digest(expected, request.headers.get('X-CU-Signature', ''))
javascript
const expected = 'sha256=' + crypto.createHmac('sha256', SECRET).update(rawBody).digest('hex');
const given = req.get('X-CU-Signature') || '';
const ok = given.length === expected.length && crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected));

Retries and ordering

BehaviourDetail
TimeoutRespond 2xx within 5 seconds. Queue heavy work; do not do it in the handler.
RetryOne immediate retry on a network failure. So: at most twice per event. Non-2xx responses are not retried further.
OrderingEvents are sent as statuses change, but two can arrive out of order under retry. Use status_time, or the timeline on GET /shipments/{awb}/, when order matters.
MonitoringThe last delivery result per endpoint is shown in Settings → Webhooks.

Testing

Webhooks are not sent for sandbox bookings, because nothing happens to a simulated shipment. To test your handler, post the example body above to it with a signature computed from your secret, or book one live shipment to your own address and watch it move.

Errors

Every error body has a human-readable error. Validation errors may instead be per field. Some errors carry a machine-readable code.

StatusMeaningBody
400Validation failed, or the request cannot be fulfilled as asked{ "error": "…" } or { "delivery_address": ["…"] }; may carry a code (below)
401Missing, invalid or revoked key
402Wallet cannot cover the bookingerror, balance, required; below_minimum: true when under the floor
403Account not readyKYC incomplete, or no rate card for that courier
404Not foundUnknown AWB (or not yours), unknown pincode, no courier label, no NDR case
409ConflictDuplicate order_id, already cancelled, or the courier refused the cancel
429Rate limitedretry_after_seconds
503No serviceable courierNone of your couriers serve that lane for that parcel
5xxOur faultRetry with backoff; report if persistent

Error codes

codeStatusMeaning
duplicate: true409order_id already booked; tracking_id in the body is the existing shipment
profile_incomplete403KYC not approved; live bookings blocked
nothing_to_manifest400No parcels of that courier awaiting pickup (or none of the given tracking ids are eligible)
no_pickup_address400Neither pickup_address_uid nor the pickup fields were given
invalid_delivery_pincode400Not a known six-digit pincode
invalid_recipient_phone400Not a valid ten-digit Indian mobile number
invalid_weight, weight_too_high400Weight missing, zero, or above what parcel services carry
no_rate_card403No commercial rate card for that courier on your account
courier_not_serviceable400The named courier does not serve the pincodes for this parcel or payment mode; available lists those that do
service_not_offered400The named courier_service is not sold on this lane; available lists the services
courier_pin_blocked400The courier has temporarily blocked the delivery pincode
document_paused400DOCUMENT shipments are paused
below_minimum402Wallet under the booking floor
json
402
{ "error": "Insufficient wallet balance — this booking needs ₹116.47 (freight ₹98.70 + GST ₹17.77) but the wallet has ₹40.00. Recharge ₹76.47 or more to book.",
  "balance": 40.0, "required": 116.47 }

409
{ "error": "A shipment for order ORD-1042 already exists", "duplicate": true, "tracking_id": "CU0000000042" }

400
{ "error": "Parcel Uncle does not serve 122001 to 400001 for this parcel on COD. Quote the lane to see which couriers do.",
  "code": "courier_not_serviceable", "available": ["ekart", "shadowfax"] }

Go-live checklist

Check
1KYC approved on the account; a cu_live_ key created in Settings → API Keys and stored server-side.
2GSTIN added in Settings, so shipment invoices carry it and the 18% is claimable.
3Wallet recharged above the floor; GET /wallet/ returns can_book: true.
4Pickup address saved and its uid in your config, or the pickup fields validated once by a sandbox booking.
5Your booking body passes in sandbox for a Zone A, a Zone D and a COD order.
6Label PDF prints at actual size on your printer; barcode scans.
7Webhook endpoint verifies X-CU-Signature and deduplicates on event_id.
8One live booking to your own address, tracked to delivery, cancelled or delivered.

Changelog

DateChange
15 Sep 2026Sandbox booking resolves pickup_address_uid exactly as live does (it had quoted from a blank origin and refused every booking sent that way). Bad addresses and phones now return coded errors: invalid_pickup_address, no_pickup_address, invalid_recipient_phone.
15 Sep 2026Webhooks: the Settings page text matched the reference again (X-CU-Signature, sha256= prefix); the bare digest is also sent as X-CourierUncle-Signature; you may supply your own signing secret; Test really posts a webhook.test event; weight.dispute_raised and cod.remitted are now delivered.
14 Sep 2026New label size a4_1: one large label per A4 page, for plain-paper printers. Also in Settings → Label.
14 Sep 2026The label endpoint accepts a5, which the portal has always offered; the accepted sizes now come from the renderer so the two can not drift.
13 Sep 2026The label endpoint prints the paper set in Settings → Label when ?size= is omitted (it was fixed at thermal_4x6); the response says which in X-Label-Size.
12 Sep 2026Weight disputes (list, accept or dispute with proof), wallet ledger, weekly invoices, bulk track for up to 100 ids, retry a courier-refused shipment, and saving a pickup address.
12 Sep 2026Manifests on the public API: list, open, detail, close, and the pickup sheet as an A4 PDF.
12 Sep 2026courier_service on booking, list, detail and webhook payloads: the key from a completed shipment back to the quote row it was priced on (Parcel Uncle NDD versus SDD).
12 Sep 2026Node.js SDK (courieruncle on npm; tarball from support until the registry listing is live). Label errors are JSON even when only application/pdf is accepted.
12 Sep 2026Rate quotes carry service, service_label, display_name, per-courier zone, GST and a recommended flag. Serviceability lists one row per courier with its services and takes payment_mode. A named courier_code at booking is refused if it does not serve the lane (courier_not_serviceable), in sandbox and live.
12 Sep 2026New: GET /shipments/ (list), POST /shipments/{awb}/ndr/, GET /couriers/, GET /pickup-addresses/, GET /wallet/, GET /cod/remittances/. Shipment detail carries the carrier scan trail.
12 Sep 2026New: GET /shipments/{awb}/label/ returns a PDF (thermal_4x6 default). Booking and detail responses carry courier_code, courier_awb, tracking_url, zone, chargeable_weight_kg. Webhooks carry event_id, courier_code, courier_awb. Base URL is app.courieruncle.com.
Aug 2026Rate limits per key with X-RateLimit headers; sandbox keys.

Support

Integration questions, higher rate limits, or a gap in this page: tech@lekyalogistics.com. Include the tracking_id or the request and response bodies; every charge and every courier call is logged against the shipment and can be checked.

Status of a specific parcel: the public tracking page shows the same timeline the API returns.