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 to | Call |
|---|---|
| compare prices before booking | POST /rates/ with the real weight, dimensions and COD amount |
| book | POST /shipments/, then fetch the PDF at the returned label_url |
| know where a parcel is | subscribe to webhooks; poll GET /shipments/{awb}/ only as a fallback |
| reconcile | GET /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.
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.
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, pincodes | Real, on your rate card | Real, on your rate card |
| Reads: shipments, wallet, couriers, addresses, COD | Real account data | Real account data |
| POST /shipments/ | Validated and priced; returns a CUTEST… id; nothing stored, nothing charged | Creates the shipment, debits the wallet, dispatches to the courier |
| Cancel, NDR actions | Simulated | Real |
| Labels | 404 (no shipments exist) | |
| Webhooks | Not sent | Sent on every status change |
| Rate limit | 60 requests a minute | 300 requests a minute |
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.
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" }| Rule | Why |
|---|---|
| Server-side only | A key in browser or app code is a key anyone can copy. Call the API from your backend. |
| One key per system | Rotate or revoke one integration without touching the others. |
| Revoke on suspicion | Revocation is immediate: the next request with that key is a 401. |
| Keys carry the whole account | There are no scopes. Treat a live key like the dashboard password. |
Conventions
| Topic | Convention |
|---|---|
| Transport | HTTPS only. HTTP is redirected and will not carry a key. |
| Bodies | JSON in, JSON out. Send Content-Type: application/json. Labels are the one exception: application/pdf. |
| Timestamps in responses | ISO 8601 with offset, e.g. 2026-09-12T10:12:00+05:30. |
| Timestamps in webhooks | YYYY-MM-DD HH:MM:SS in IST (Asia/Kolkata). |
| Amounts | JSON numbers in rupees, two decimals. Quotes are before GST; the GST line is shown alongside. |
| Weights and dimensions | Kilograms (up to three decimals) and centimetres. Volumetric weight is L × W × H / 5000. |
| Pincodes | Six-digit strings. Always strings, never numbers, so leading zeros survive. |
| Identifiers | tracking_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. |
| Idempotency | Pass 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. |
| Pagination | List endpoints take page and page_size (max 100) and return count and has_more. |
| Trailing slash | Every path ends in a slash. Without it you get a redirect that drops the POST body. |
| Versioning | v1 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:
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.
{ "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
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
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
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.
{ "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.
{ "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.
{ "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.
{ "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-fieldGET/pickup-addresses/
Saved pickup addresses. Pass a uid as pickup_address_uid when booking instead of the six pickup_* fields.
{ "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.
{ "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.
| Query | Required | Notes |
|---|---|---|
| origin | yes | Pickup pincode |
| destination | yes | Delivery pincode |
| payment_mode | no | PREPAID (default) or COD. COD answers whether cash collection is available at the destination. |
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.
| Field | Required | Notes |
|---|---|---|
| origin_pincode | yes | |
| destination_pincode | yes | |
| weight_kg | yes | Dead weight of the packed parcel |
| payment_mode | yes | PREPAID or COD |
| cod_amount | for COD | The 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_cm | no | Outer dimensions. Omit and the parcel is priced as dense; the hub will measure the box. |
{
"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", ... }
]
}| Field | Meaning |
|---|---|
| service | What 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. |
| total | Freight plus COD fee, before GST. This is what the wallet is debited, plus GST. |
| freight | The slab rate for the chargeable weight and zone, on your card. |
| cod_charge | Zero on prepaid. On COD, the higher of the flat fee and the percentage of cod_amount. |
| rto_charge | What a return would cost if the parcel comes back, on this courier. |
| recommended | On 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.
| Field | Required | Notes |
|---|---|---|
| order_id | recommended | Your reference. Makes the call idempotent (409 on repeat). Printed on the label. |
| pickup_address_uid | one of | A saved address from /pickup-addresses/ |
| pickup_contact_name, pickup_contact_phone, pickup_address, pickup_city, pickup_pincode | one of | Or the pickup address inline |
| recipient_name, recipient_phone | yes | |
| recipient_email | no | |
| delivery_address | yes | House or flat number, street and locality. A bare street name or a very short line is a 400: couriers reject it. |
| delivery_city, delivery_pincode | yes | |
| delivery_state | no | |
| weight_kg | yes | Dead weight, packed, up to three decimals |
| length_cm, width_cm, height_cm | no | Outer dimensions of the packed parcel |
| payment_mode | yes | PREPAID or COD |
| cod_amount | for COD | What the rider collects. Can be less than order_value (partial COD). |
| order_value | no | Declared value, printed on the label |
| items | no | List of { name, sku, qty, unit_price }; printed on the label |
| product_description | no | |
| courier_code | no | From /couriers/. Omit to let your courier rules choose. |
| courier_service | no | From the rate quote’s service. Omit for the courier’s default. |
| shipment_type | no | PACKAGE (default) or REVERSE. DOCUMENT is paused. |
| channel, tags | no | For your own reporting |
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.
| Query | Notes |
|---|---|
| status | Comma-separated, e.g. IN_TRANSIT,OUT_FOR_DELIVERY |
| order_id | Exact match on your reference |
| from, to | Booked-on dates, YYYY-MM-DD, inclusive, IST |
| page, page_size | page_size up to 100, default 50 |
{
"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.
{
"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)" }
]
}| Status | Meaning |
|---|---|
| CREATED, PICKUP_SCHEDULED | Booked; courier notified |
| PICKED_UP | With the courier |
| IN_TRANSIT | Moving between hubs |
| OUT_FOR_DELIVERY | With the delivery rider |
| DELIVERED | Done; COD collected if applicable |
| DELIVERY_ATTEMPTED, FAILED | A delivery attempt failed: act on it (NDR) |
| RTO_INITIATED, RTO_IN_TRANSIT, RETURNED | Coming back to you; RETURNED when it has arrived |
| CANCELLED | Cancelled before pickup; freight refunded |
POST/shipments/{awb}/cancel/
Cancel before the courier has it. Freight is refunded to the wallet.
{ "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 refundedOnce 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.
{ "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.
| Query | Values | Notes |
|---|---|---|
| size | thermal_4x6, thermal_4x4, a5, a4_1, a4_2, a4_4 | The 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. |
| source | courier | The carrier’s own label instead of your designed one. 404 when the carrier supplies none; fall back to the default. |
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.
{ "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 / FAILEDNDR 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.
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.
{ "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.
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.
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.
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": "…" }
] }| status | Meaning |
|---|---|
| ACTION_REQUIRED | Waiting for you; the amount is held, not charged |
| DISPUTED | You disputed; with the courier |
| ACCEPTED | Accepted (by you, or by the deadline); charged |
| RESOLVED_SELLER | Dispute upheld; hold released, nothing charged |
| RESOLVED_COURIER | Dispute rejected; charged |
| CLOSED | Closed by ops |
POST/weight-disputes/{uid}/action/
Accept, or dispute with proof.
{ "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.
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.
| Query | Notes |
|---|---|
| type | Comma-separated: RECHARGE, SHIPMENT, REFUND, COD_SETTLEMENT, ADJUSTMENT, PROMO_BONUS |
| from, to | YYYY-MM-DD, inclusive |
| page, page_size | page_size up to 100 |
{ "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.
{ "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
| Event | Fires on |
|---|---|
| shipment.created | Booked, or pickup scheduled |
| shipment.picked_up | PICKED_UP |
| shipment.in_transit | IN_TRANSIT |
| shipment.out_for_delivery | OUT_FOR_DELIVERY |
| shipment.delivered | DELIVERED |
| shipment.ndr | DELIVERY_ATTEMPTED or FAILED |
| shipment.rto | RTO_INITIATED, RTO_IN_TRANSIT, or RETURNED |
| shipment.cancelled | CANCELLED |
| weight.dispute_raised | A courier reported a heavier parcel than declared; the difference is on hold |
| cod.remitted | A 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
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…"
}| Field | Notes |
|---|---|
| event_id | One per status change. Identical on our retry. Deduplicate on this. |
| webhook_id | Identifies your endpoint configuration, not the event. |
| status_time | IST, YYYY-MM-DD HH:MM:SS. |
| notes, location | The 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.
// 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.
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', ''))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
| Behaviour | Detail |
|---|---|
| Timeout | Respond 2xx within 5 seconds. Queue heavy work; do not do it in the handler. |
| Retry | One immediate retry on a network failure. So: at most twice per event. Non-2xx responses are not retried further. |
| Ordering | Events 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. |
| Monitoring | The 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.
| Status | Meaning | Body |
|---|---|---|
| 400 | Validation failed, or the request cannot be fulfilled as asked | { "error": "…" } or { "delivery_address": ["…"] }; may carry a code (below) |
| 401 | Missing, invalid or revoked key | |
| 402 | Wallet cannot cover the booking | error, balance, required; below_minimum: true when under the floor |
| 403 | Account not ready | KYC incomplete, or no rate card for that courier |
| 404 | Not found | Unknown AWB (or not yours), unknown pincode, no courier label, no NDR case |
| 409 | Conflict | Duplicate order_id, already cancelled, or the courier refused the cancel |
| 429 | Rate limited | retry_after_seconds |
| 503 | No serviceable courier | None of your couriers serve that lane for that parcel |
| 5xx | Our fault | Retry with backoff; report if persistent |
Error codes
| code | Status | Meaning |
|---|---|---|
| duplicate: true | 409 | order_id already booked; tracking_id in the body is the existing shipment |
| profile_incomplete | 403 | KYC not approved; live bookings blocked |
| nothing_to_manifest | 400 | No parcels of that courier awaiting pickup (or none of the given tracking ids are eligible) |
| no_pickup_address | 400 | Neither pickup_address_uid nor the pickup fields were given |
| invalid_delivery_pincode | 400 | Not a known six-digit pincode |
| invalid_recipient_phone | 400 | Not a valid ten-digit Indian mobile number |
| invalid_weight, weight_too_high | 400 | Weight missing, zero, or above what parcel services carry |
| no_rate_card | 403 | No commercial rate card for that courier on your account |
| courier_not_serviceable | 400 | The named courier does not serve the pincodes for this parcel or payment mode; available lists those that do |
| service_not_offered | 400 | The named courier_service is not sold on this lane; available lists the services |
| courier_pin_blocked | 400 | The courier has temporarily blocked the delivery pincode |
| document_paused | 400 | DOCUMENT shipments are paused |
| below_minimum | 402 | Wallet under the booking floor |
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 | |
|---|---|
| 1 | KYC approved on the account; a cu_live_ key created in Settings → API Keys and stored server-side. |
| 2 | GSTIN added in Settings, so shipment invoices carry it and the 18% is claimable. |
| 3 | Wallet recharged above the floor; GET /wallet/ returns can_book: true. |
| 4 | Pickup address saved and its uid in your config, or the pickup fields validated once by a sandbox booking. |
| 5 | Your booking body passes in sandbox for a Zone A, a Zone D and a COD order. |
| 6 | Label PDF prints at actual size on your printer; barcode scans. |
| 7 | Webhook endpoint verifies X-CU-Signature and deduplicates on event_id. |
| 8 | One live booking to your own address, tracked to delivery, cancelled or delivered. |
Changelog
| Date | Change |
|---|---|
| 15 Sep 2026 | Sandbox 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 2026 | Webhooks: 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 2026 | New label size a4_1: one large label per A4 page, for plain-paper printers. Also in Settings → Label. |
| 14 Sep 2026 | The 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 2026 | The 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 2026 | Weight 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 2026 | Manifests on the public API: list, open, detail, close, and the pickup sheet as an A4 PDF. |
| 12 Sep 2026 | courier_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 2026 | Node.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 2026 | Rate 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 2026 | New: 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 2026 | New: 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 2026 | Rate 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.
