Courier Saathi API Documentation
Connect your website, ERP, app or marketplace to Courier Saathi and automate shipping: compare live courier rates, book shipments, get the AWB, track parcels, cancel orders and receive real-time webhooks when a shipment status changes.
Overview
| Base URL | https://couriersaathi.com/api/v1 |
|---|---|
| Authentication | Authorization: Bearer ship_live_xxxxxxxx_xxxxxxxx… |
| Format | JSON request bodies (Content-Type: application/json), JSON responses, dates in ISO 8601 UTC |
| Weights / sizes | kilograms (deadWeightKg) and centimetres (lengthCm, widthCm, heightCm) |
| Money | Indian rupees (₹) as numbers, e.g. 118.5 |
| Access | Every Courier Saathi account can create keys. KYC must be approved before you can create shipments. |
https://couriersaathi.com/api/index.php?_route=v1/rates. Use whichever your platform likes better.Quick start — live in 5 steps
- Create an account & finish KYC. Sign up, then complete KYC under Dashboard → KYC. Shipment creation is blocked until KYC is approved.
- Add money to your wallet. Booking deducts the shipping charge (courier rate + 18% GST) from your wallet. Recharge under Dashboard → Wallet.
- Add a pickup address. Dashboard → Address Book → Add address. Note its ID (you can also fetch it via GET /v1/pickup-addresses).
- Generate your API key. Dashboard → API Keys & Webhooks → Generate. Copy the key immediately — it is shown only once. Details ↓
- Make your first call. Check the connection, then fetch rates and book:
curl "https://couriersaathi.com/api/v1/wallet/balance" \
-H "Authorization: Bearer ship_live_YOUR_KEY"const res = await fetch("https://couriersaathi.com/api/v1/wallet/balance", {
headers: { Authorization: "Bearer " + process.env.CS_API_KEY },
});
console.log(await res.json()); // { success: true, walletBalance: 2500 }<?php
$ch = curl_init("https://couriersaathi.com/api/v1/wallet/balance");
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("CS_API_KEY")]]);
echo curl_exec($ch);import os, requests
r = requests.get("https://couriersaathi.com/api/v1/wallet/balance",
headers={"Authorization": "Bearer " + os.environ["CS_API_KEY"]})
print(r.json()) # {'success': True, 'walletBalance': 2500.0}A response of {"success": true, "walletBalance": 2500} means your key works. Next: fetch rates → create a shipment → set up webhooks.
Get your API key
API keys are created by you, instantly, from your own dashboard — no approval or email to us is needed.
- Log in at couriersaathi.com/login (or create a free account).
- Open Dashboard → API Keys & Webhooks (direct link: couriersaathi.com/developer-api).
- Enter an App name (for example “My Website” or “ERP”) and, optionally, your Webhook URL (https only). Click Generate API Key.
- Copy the key now. It looks like
ship_live_a1b2c3d4_0123456789abcdef0123456789abcdef01234567. For your security we store only a hash, so it can never be shown again. - If you set a webhook URL, copy the Webhook signing secret (
whsec_…) too — you need it to verify events.
Authentication
Send your key in the Authorization header of every request:
Authorization: Bearer ship_live_a1b2c3d4_0123456789abcdef0123456789abcdef01234567If your hosting, proxy or API gateway strips the Authorization header, send the same key as X-API-Key: ship_live_… instead — we accept both.
- Keys never expire; they stop working only when you revoke them or your account is suspended.
- Do not send the key in the URL query string.
- A missing/invalid/revoked key returns
401.
Requests, responses & errors
Every response is JSON with a success boolean. Failures include a human-readable error:
{ "success": false, "error": "Duplicate Order Number — \"ORD-1001\" already has a booked shipment. Use a different Order ID, or check All Shipments for the existing booking." }| HTTP | Meaning | What to do |
|---|---|---|
200 / 201 | Success (201 = shipment created) | — |
400 | Validation problem (bad phone, pincode not serviceable, unknown courierCode, insufficient wallet balance…) | Read error, fix the request. Do not retry unchanged. |
401 | Missing, invalid or revoked API key | Check the header format and that the key is active. |
403 | KYC is not approved yet (creating shipments needs approved KYC) | Complete KYC in the dashboard, or contact support. |
404 | Unknown endpoint, or the AWB is not on your account | Check the URL and AWB. |
409 | Duplicate orderId, or courier refused the cancellation | Use a new orderId, or read error. |
500 / 501 | Temporary problem on our side / cancellation not supported automatically for that courier | Retry GET calls after a few seconds. For POST /shipments first check by orderId before retrying. |
POST /shipments (we call the courier live while booking), log every response body, and never retry a booking blindly — see “What if my request times out?”Fair use: there is no hard per-minute quota today, but please keep it reasonable (a few requests per second) and cache rates for a short time. Heavy or abusive traffic may be throttled.
Get shipping rates
Returns every courier available for your account to a destination, with the exact price you will be charged (your plan, custom rate card and Shipping Rules already applied). Call this before booking to pick a courierCode.
| Query parameter | Type | Description |
|---|---|---|
destinationPincode * | string | 6-digit delivery pincode |
deadWeightKg | number | Actual weight in kg. Default 0.5 |
lengthCm, widthCm, heightCm | number | Parcel size. Default 10 each. Volumetric weight = L×W×H ÷ 5000 (air) or ÷ 4500 (surface); you are charged the higher of dead and volumetric weight. |
paymentMode | string | PREPAID (default) or COD |
codAmount | number | Amount to collect, for COD |
declaredValue | number | Invoice value of the goods |
curl -G "https://couriersaathi.com/api/v1/rates" \
-H "Authorization: Bearer $CS_API_KEY" \
--data-urlencode "destinationPincode=560001" \
--data-urlencode "deadWeightKg=0.5" \
--data-urlencode "lengthCm=20" --data-urlencode "widthCm=15" --data-urlencode "heightCm=10" \
--data-urlencode "paymentMode=PREPAID"const qs = new URLSearchParams({ destinationPincode: "560001", deadWeightKg: "0.5",
lengthCm: "20", widthCm: "15", heightCm: "10", paymentMode: "PREPAID" });
const res = await fetch("https://couriersaathi.com/api/v1/rates?" + qs, { headers: { Authorization: "Bearer " + process.env.CS_API_KEY } });
const { rates } = await res.json();
const cheapest = rates[0]; // already sorted by price, lowest first
console.log(cheapest.code, cheapest.totalRate);<?php
$qs = http_build_query(["destinationPincode" => "560001", "deadWeightKg" => 0.5,
"lengthCm" => 20, "widthCm" => 15, "heightCm" => 10, "paymentMode" => "PREPAID"]);
$ch = curl_init("https://couriersaathi.com/api/v1/rates?" . $qs);
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("CS_API_KEY")]]);
$rates = json_decode(curl_exec($ch), true)["rates"];
echo $rates[0]["code"] . " " . $rates[0]["totalRate"];import os, requests
r = requests.get("https://couriersaathi.com/api/v1/rates", headers={"Authorization": "Bearer " + os.environ["CS_API_KEY"]},
params={"destinationPincode": "560001", "deadWeightKg": 0.5,
"lengthCm": 20, "widthCm": 15, "heightCm": 10, "paymentMode": "PREPAID"})
rates = r.json()["rates"]
print(rates[0]["code"], rates[0]["totalRate"])Response
{
"success": true,
"rates": [
{
"code": "delhivery_surface_0_5kg",
"name": "Delhivery Surface 0.5 Kg",
"serviceType": "SURFACE",
"expectedDays": "3-5 Days",
"deadWeight": 0.5,
"chargedWeight": 0.6,
"freightCharge": 75,
"codCharge": 0,
"costPrice": 75,
"gstAmount": 13.5,
"totalRate": 88.5,
"maxWeightKg": null,
"codAvailable": true,
"isCheapest": true,
"isFastest": false,
"isRecommended": true
}
]
}totalRate is exactly what will be deducted from your wallet (rate + 18% GST). Use code as courierCode when creating a shipment. An empty rates list means no courier serves that pincode/weight for your account (check your Shipping Rules and the pincode).
Pickup addresses
Lists the pickup addresses saved in your Address Book. You need an address id to create a shipment. Add or edit addresses in the dashboard (Address Book).
curl "https://couriersaathi.com/api/v1/pickup-addresses" -H "Authorization: Bearer $CS_API_KEY"{
"success": true,
"pickupAddresses": [
{ "id": "addr_1", "locationName": "Primary Mumbai Warehouse", "contactPerson": "Ramesh Patel",
"phone": "+91 98765 43210", "addressLine1": "Unit 402, Solitaire Corporate Park, Chakala",
"addressLine2": "Andheri East", "pincode": "400093", "city": "Mumbai", "state": "Maharashtra",
"isDefault": true }
]
}Create shipment
Books a shipment with the courier, generates the AWB, and deducts the charge from your wallet — all in one call.
totalRate). orderId must be unique in your account.| Field | Type | Description |
|---|---|---|
orderId * | string | Your own order number. Must be unique — a repeat returns 409. This is your safety net against double-booking. |
pickupAddressId * | string | ID from /v1/pickup-addresses |
courierCode | string | code from /v1/rates. Always send it in production. An unknown code returns 400. |
consigneeName * | string | Customer name |
consigneePhone * | string | 10-digit Indian mobile number (+91/spaces/dashes are stripped) |
consigneeAddress * | string | Full delivery address |
consigneePincode * | string | 6-digit pincode. City & state are filled in automatically if you omit them. |
consigneeCity, consigneeState | string | Optional (auto-detected from pincode) |
consigneeEmail | string | Optional |
alternateContactPhone | string | Optional 10-digit number |
paymentMode * | string | PREPAID or COD |
codAmount | number | For COD: amount to collect from the customer (defaults to declaredValue) |
declaredValue * | number | Invoice value of the goods in ₹ |
productName | string | Item description printed on the label. Default “General Merchandise”. |
ewayBillNumber | string | Mandatory when the goods value is above ₹50,000 |
deadWeightKg * | number | Actual weight (kg) |
lengthCm, widthCm, heightCm * | number | Packed size in cm. Be accurate — wrong sizes cause weight disputes. |
rtoWarehouseId | string | Optional return address ID (defaults to the pickup address) |
curl -X POST "https://couriersaathi.com/api/v1/shipments" \
-H "Authorization: Bearer $CS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"orderId": "ORD-1001",
"pickupAddressId": "addr_1",
"courierCode": "delhivery_surface_0_5kg",
"consigneeName": "Rahul Sharma",
"consigneePhone": "9876543210",
"consigneeAddress": "12 MG Road",
"consigneePincode": "560001",
"paymentMode": "PREPAID",
"declaredValue": 999,
"productName": "Cotton T-shirt",
"deadWeightKg": 0.5, "lengthCm": 20, "widthCm": 15, "heightCm": 10
}'const res = await fetch("https://couriersaathi.com/api/v1/shipments", {
method: "POST",
headers: { Authorization: "Bearer " + process.env.CS_API_KEY, "Content-Type": "application/json" },
body: JSON.stringify({
orderId: "ORD-1001", pickupAddressId: "addr_1", courierCode: "delhivery_surface_0_5kg",
consigneeName: "Rahul Sharma", consigneePhone: "9876543210",
consigneeAddress: "12 MG Road", consigneePincode: "560001",
paymentMode: "PREPAID", declaredValue: 999, productName: "Cotton T-shirt",
deadWeightKg: 0.5, lengthCm: 20, widthCm: 15, heightCm: 10,
}),
signal: AbortSignal.timeout(60000),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error); // 400 / 401 / 409 …
console.log("AWB:", data.shipment.awb);<?php
$payload = ["orderId" => "ORD-1001", "pickupAddressId" => "addr_1", "courierCode" => "delhivery_surface_0_5kg",
"consigneeName" => "Rahul Sharma", "consigneePhone" => "9876543210",
"consigneeAddress" => "12 MG Road", "consigneePincode" => "560001",
"paymentMode" => "PREPAID", "declaredValue" => 999, "productName" => "Cotton T-shirt",
"deadWeightKg" => 0.5, "lengthCm" => 20, "widthCm" => 15, "heightCm" => 10];
$ch = curl_init("https://couriersaathi.com/api/v1/shipments");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 60,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("CS_API_KEY"), "Content-Type: application/json"]]);
$body = json_decode(curl_exec($ch), true);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($code !== 201) { throw new Exception($body["error"] ?? "HTTP $code"); }
echo "AWB: " . $body["shipment"]["awb"];import os, requests
r = requests.post("https://couriersaathi.com/api/v1/shipments", timeout=60,
headers={"Authorization": "Bearer " + os.environ["CS_API_KEY"]},
json={"orderId": "ORD-1001", "pickupAddressId": "addr_1", "courierCode": "delhivery_surface_0_5kg",
"consigneeName": "Rahul Sharma", "consigneePhone": "9876543210",
"consigneeAddress": "12 MG Road", "consigneePincode": "560001",
"paymentMode": "PREPAID", "declaredValue": 999, "productName": "Cotton T-shirt",
"deadWeightKg": 0.5, "lengthCm": 20, "widthCm": 15, "heightCm": 10})
data = r.json()
if r.status_code != 201:
raise Exception(data.get("error"))
print("AWB:", data["shipment"]["awb"])Success response — 201 Created
{ "success": true, "shipment": {
"id": "shp_1760800000123_ab12cd",
"awb": "DLH948201842",
"orderId": "ORD-1001",
"courierCode": "delhivery_surface_0_5kg",
"courierName": "Delhivery Surface 0.5 Kg",
"status": "BOOKED",
"pickupAddressId": "addr_1",
"pickupPincode": "400093",
"pickupCity": "Mumbai",
"consigneeName": "Rahul Sharma",
"consigneePhone": "9876543210",
"consigneeAddress": "12 MG Road",
"consigneePincode": "560001",
"consigneeCity": "Bengaluru",
"consigneeState": "Karnataka",
"paymentMode": "PREPAID",
"declaredValue": 999,
"codAmount": 0,
"deadWeightKg": 0.5,
"lengthCm": 20, "widthCm": 15, "heightCm": 10,
"volumetricWeightKg": 0.6,
"chargedWeightKg": 0.6,
"shippingCost": 88.5,
"gstAmount": 13.5,
"shippingRateExclGst": 75,
"trackingHistory": [
{ "timestamp": "2026-10-18T09:30:00Z", "status": "BOOKED", "statusText": "Shipment booked", "location": "400093", "remark": "" }
],
"createdAt": "2026-10-18T09:30:00Z",
"updatedAt": "2026-10-18T09:30:00Z"
} }Save awb against your order. The courier’s label and pickup are handled automatically (print the label from Dashboard → Shipments).
Track / get a shipment
Returns the shipment with its full tracking history. We refresh from the courier live when possible (live: true), so this is always current.
curl "https://couriersaathi.com/api/v1/shipments/DLH948201842" -H "Authorization: Bearer $CS_API_KEY"{ "success": true, "shipment": { "awb": "DLH948201842", "status": "IN_TRANSIT", "trackingHistory": [ … ], … }, "live": true, "liveSource": "courier" }Your customers can also track without any API at https://couriersaathi.com/trk/{awb}. To avoid polling, use webhooks.
List shipments
Newest first. Use it to reconcile orders or to check whether an orderId was already booked.
| Query parameter | Description |
|---|---|
orderId | Exact match on your order number |
awb | Exact match on AWB |
status | One of the statuses, e.g. DELIVERED |
page, limit | Page number (from 1) and page size (default 25, max 100) |
curl -G "https://couriersaathi.com/api/v1/shipments" -H "Authorization: Bearer $CS_API_KEY" --data-urlencode "orderId=ORD-1001"{ "success": true, "page": 1, "limit": 25, "total": 1, "shipments": [ { "awb": "DLH948201842", "orderId": "ORD-1001", "status": "BOOKED", … } ] }Cancel shipment
- Before the courier picks it up → cancelled with the courier and the full amount is refunded to your wallet instantly.
- After pickup → the courier cancels, but the parcel is already in the network, so it is treated as RTO and billed as such (no refund).
- Already
DELIVERED,RTOorCANCELLEDshipments cannot be cancelled again (400).
curl -X POST "https://couriersaathi.com/api/v1/shipments/DLH948201842/cancel" -H "Authorization: Bearer $CS_API_KEY"{ "success": true, "outcome": "CANCELLED", "refundAmount": 88.5, "shipment": { "awb": "DLH948201842", "status": "CANCELLED", … } }outcome is CANCELLED (refunded) or RTO (picked up, billed as RTO). A courier that refuses returns 409 with the reason, and nothing changes on your account.
Wallet balance
curl "https://couriersaathi.com/api/v1/wallet/balance" -H "Authorization: Bearer $CS_API_KEY"{ "success": true, "walletBalance": 2500 }Check this before a batch of bookings. Recharge the wallet from Dashboard → Wallet (recharge is not available through the API).
Shipment object & statuses
Shipments returned by the API (and the data.shipment object inside webhooks) use these status values:
| Status | Meaning |
|---|---|
BOOKED | Shipment created, AWB generated, waiting for pickup |
PICKED_UP | Courier collected the parcel from your pickup address |
IN_TRANSIT | Moving through the courier network |
OUT_FOR_DELIVERY | With the delivery agent |
DELIVERED | Delivered to the customer (COD is collected and later remitted to you) |
NDR | Delivery attempt failed — action needed (re-attempt / change address) in Dashboard → NDR |
RTO | Returning to your warehouse |
CANCELLED | Cancelled before pickup |
Key fields: awb, orderId, status, courierCode/courierName, chargedWeightKg, shippingCost (total deducted incl. GST), gstAmount, shippingRateExclGst, trackingHistory[] (timestamp, status, statusText, location, remark), createdAt, updatedAt. Treat unknown extra fields as additive — we may add fields without notice, but we will not rename or remove existing ones.
Webhooks — set up
Instead of polling, let us tell your server the moment something happens. When one of your shipments is created or changes status, we send an HTTPS POST with a signed JSON body to your webhook URL. It covers every shipment on your account — created through the API, the dashboard, bulk upload or a connected store.
- Create a public HTTPS endpoint on your server, e.g.
https://yourstore.com/webhooks/courier-saathi. It must acceptPOSTwith a JSON body and answer HTTP 2xx within 10 seconds. - Add the URL in Dashboard → API Keys & Webhooks — when generating a key, or later with Edit webhook on an existing key.
- Copy the signing secret (
whsec_…, shown next to the key) into your server configuration. - Click Send test event. You should see it succeed in the delivery log on the same screen.
- Verify the signature on every request (code below) and process the event.
301/302).Webhook events
| Event | Sent when |
|---|---|
shipment.created | A new shipment is booked (AWB generated) |
shipment.picked_up | Courier picked the parcel up |
shipment.in_transit | Parcel is moving through the network |
shipment.out_for_delivery | Parcel is out for delivery |
shipment.delivered | Delivered to the customer |
shipment.ndr | Delivery failed / needs action (non-delivery report) |
shipment.rto | Shipment is being returned to you |
shipment.cancelled | Shipment was cancelled |
shipment.status_updated | Any other status change |
webhook.test | Sent only when you press Send test event |
Handle unknown event names gracefully (ignore them and return 200) — we may add new ones later.
Payload & headers
Headers we send
| Header | Description |
|---|---|
Content-Type | application/json |
X-CourierSaathi-Event | Event name, e.g. shipment.delivered |
X-CourierSaathi-Event-Id | Unique event ID (evt_…) — same value as id in the body. Use it to de-duplicate. |
X-CourierSaathi-Delivery | ID of this delivery attempt record (whd_…) |
X-CourierSaathi-Timestamp | Unix time (seconds) the request was signed |
X-CourierSaathi-Signature | sha256= + HMAC-SHA256 hex digest (see below) |
User-Agent | CourierSaathi-Webhook/1.0 |
Body
{
"id": "evt_9f2c1a7b3d5e4a6f8c10",
"event": "shipment.delivered",
"createdAt": "2026-10-20T11:42:07Z",
"data": {
"shipment": {
"awb": "DLH948201842",
"orderId": "ORD-1001",
"status": "DELIVERED",
"previousStatus": "OUT_FOR_DELIVERY",
"courierCode": "delhivery_surface_0_5kg",
"courierName": "Delhivery Surface 0.5 Kg",
"paymentMode": "COD",
"codAmount": 999,
"consigneeName": "Rahul Sharma",
"consigneePincode": "560001",
"consigneeCity": "Bengaluru",
"consigneeState": "Karnataka",
"chargedWeightKg": 0.6,
"shippingCost": 88.5,
"lastEvent": {
"timestamp": "2026-10-20T11:40:00Z",
"status": "DELIVERED",
"statusText": "Delivered",
"location": "Bengaluru",
"remark": ""
},
"trackingUrl": "https://couriersaathi.com/trk/DLH948201842",
"createdAt": "2026-10-18T09:30:00Z",
"updatedAt": "2026-10-20T11:42:00Z"
}
}
}data.shipment.previousStatus is null for shipment.created. lastEvent is the newest courier scan. Call GET /v1/shipments/{awb} if you need the full history.
Verify the signature
Anyone can discover your webhook URL, so always verify that a request really came from Courier Saathi. We compute:
signature = "sha256=" + HMAC_SHA256( key = your_webhook_secret,
message = X-CourierSaathi-Timestamp + "." + raw_request_body ) // hex digest# Compute the expected signature in a shell to compare (debugging):
TS="1760960527"; BODY='{"id":"evt_…"}'; SECRET="whsec_YOUR_SECRET"
printf "%s.%s" "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET"// Express — note express.raw() so we keep the raw body
const crypto = require("crypto");
app.post("/webhooks/courier-saathi", express.raw({ type: "application/json" }), (req, res) => {
const ts = req.header("X-CourierSaathi-Timestamp");
const sig = req.header("X-CourierSaathi-Signature") || "";
const raw = req.body.toString("utf8");
const expected = "sha256=" + crypto.createHmac("sha256", process.env.CS_WEBHOOK_SECRET)
.update(ts + "." + raw).digest("hex");
const ok = sig.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
if (!ok) return res.status(401).send("bad signature");
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.status(400).send("stale"); // optional replay guard
const evt = JSON.parse(raw);
// TODO: skip if evt.id already processed (de-duplicate), then handle evt.event
res.status(200).send("ok"); // respond fast, do heavy work asynchronously
});<?php
$raw = file_get_contents("php://input"); // raw body
$ts = $_SERVER["HTTP_X_COURIERSAATHI_TIMESTAMP"] ?? "";
$sig = $_SERVER["HTTP_X_COURIERSAATHI_SIGNATURE"] ?? "";
$expected = "sha256=" . hash_hmac("sha256", $ts . "." . $raw, getenv("CS_WEBHOOK_SECRET"));
if (!hash_equals($expected, $sig)) { http_response_code(401); exit("bad signature"); }
if (abs(time() - (int)$ts) > 300) { http_response_code(400); exit("stale"); } // optional replay guard
$evt = json_decode($raw, true);
// TODO: skip if $evt["id"] was already processed, then switch on $evt["event"]
http_response_code(200); echo "ok";# Flask
import hmac, hashlib, os, time, json
from flask import Flask, request, abort
app = Flask(__name__)
@app.post("/webhooks/courier-saathi")
def hook():
raw = request.get_data() # raw bytes
ts = request.headers.get("X-CourierSaathi-Timestamp", "")
sig = request.headers.get("X-CourierSaathi-Signature", "")
expected = "sha256=" + hmac.new(os.environ["CS_WEBHOOK_SECRET"].encode(),
ts.encode() + b"." + raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, sig):
abort(401)
if abs(time.time() - int(ts)) > 300:
abort(400) # optional replay guard
evt = json.loads(raw)
# TODO: skip if evt["id"] already processed, then handle evt["event"]
return "ok", 200Retries, ordering & delivery log
- Success = your server answers any
2xxwithin 10 seconds. Anything else (timeout, connection error,3xx/4xx/5xx) counts as a failure. - Automatic retries: up to 6 attempts in total, spaced roughly 1 min → 5 min → 30 min → 2 h → 6 h after the previous failure. After that the delivery is marked FAILED.
- Delivery log: Dashboard → API Keys & Webhooks → Delivery log shows each event with the HTTP status, your server’s response snippet, the error, attempts and the exact payload we sent. Press Resend on any entry to push it again — handy after you fix a bug.
- At-least-once delivery: the same event can arrive twice (for example if your server answered after our timeout). De-duplicate with
X-CourierSaathi-Event-Id. - Order is not guaranteed. A retried older event can arrive after a newer one. Compare
data.shipment.updatedAt(or only move a status “forward”), or fetch the latest state with GET /v1/shipments/{awb}. - Be fast: store the event, return
200, process in the background. - Pause / rotate: you can pause a webhook, and rotate the signing secret at any time (old signatures stop being accepted by you once you switch — update your server first or accept both briefly).
- Timing: events are normally sent within a few minutes of the status change; courier scans themselves can lag behind the real world.
Test & debug webhooks
- No public server yet? Use a free request catcher such as webhook.site to see exactly what we send, or a tunnel like
ngrok http 3000to reach your laptop (use the https URL it gives you). - Press Send test event on your key. A
webhook.testevent is sent immediately and the result (HTTP code, response, time) appears at once. - Book a tiny test shipment, then cancel it — you will receive
shipment.createdandshipment.cancelled(cancelled before pickup = refunded). - If something fails, open the Delivery log; the error column tells you why (“Your server answered HTTP 500”, “SSL certificate problem”, “Connection timed out”…).
Store integrations (no code)
You can use a store connection and the API together — webhooks fire for all shipments regardless of how they were created.
Recommended integration flow
- Checkout / rate display (optional): call
GET /v1/rateswith the customer’s pincode and your packed weight/size to show a shipping charge or choose a courier. - Order confirmed → create shipment:
POST /v1/shipmentswithorderId= your order number. Saveshipment.awbandshipment.courierNameon the order. If you get409, the order is already booked — look it up withGET /v1/shipments?orderId=…. - Print the label from Dashboard → Shipments (bulk print supported) and hand over to the courier at pickup.
- Stay in sync with webhooks: on each event update your order status, notify the customer (SMS/WhatsApp/email with
trackingUrl), mark COD as collected onshipment.delivered, and trigger a return flow onshipment.rto. - Fallback: once a day,
GET /v1/shipments?status=IN_TRANSIT(and other open statuses) to catch anything missed. - Cancel from your system with
POST /v1/shipments/{awb}/cancelwhen the customer cancels before pickup.
Mini example: book + react to webhooks (Node.js)
const BASE = "https://couriersaathi.com/api/v1";
const H = { Authorization: "Bearer " + process.env.CS_API_KEY, "Content-Type": "application/json" };
async function bookOrder(order) {
const q = new URLSearchParams({ destinationPincode: order.pincode, deadWeightKg: order.weight,
lengthCm: order.l, widthCm: order.w, heightCm: order.h, paymentMode: order.cod ? "COD" : "PREPAID",
codAmount: order.cod ? order.total : 0 });
const { rates } = await (await fetch(`${BASE}/rates?${q}`, { headers: H })).json();
if (!rates.length) throw new Error("No courier serves this pincode");
const courier = rates[0]; // cheapest — or choose by your own rule
const res = await fetch(`${BASE}/shipments`, { method: "POST", headers: H, signal: AbortSignal.timeout(60000),
body: JSON.stringify({
orderId: order.number, pickupAddressId: process.env.CS_PICKUP_ID, courierCode: courier.code,
consigneeName: order.name, consigneePhone: order.phone, consigneeAddress: order.address,
consigneePincode: order.pincode, paymentMode: order.cod ? "COD" : "PREPAID",
codAmount: order.cod ? order.total : 0, declaredValue: order.total, productName: order.item,
deadWeightKg: order.weight, lengthCm: order.l, widthCm: order.w, heightCm: order.h }) });
const data = await res.json();
if (res.status === 409) return lookupExisting(order.number); // already booked
if (!res.ok) throw new Error(data.error);
return data.shipment; // save .awb on your order
}Go-live checklist
- ☐ KYC approved and wallet has enough balance for a day’s bookings.
- ☐ At least one pickup address saved; its ID configured in your integration.
- ☐ API key stored in an environment variable / secret manager — not in code or the browser.
- ☐
orderIdis unique per order and you handle409. - ☐ You send
courierCodefrom/v1/rates(not a hard-coded one that may be disabled for a pincode). - ☐ Phone numbers are 10-digit Indian mobiles; pincodes are 6 digits; weight in kg, size in cm.
- ☐
ewayBillNumbersent for goods above ₹50,000. - ☐ Timeout ≥ 30 s on booking, and you check
GET /v1/shipments?orderId=before retrying after a timeout. - ☐ Webhook endpoint is HTTPS, verifies the signature using the raw body, answers 2xx fast, and de-duplicates by event ID.
- ☐ You tested: rates → booking → webhook → cancel (refund) with one real small shipment.
- ☐ Errors are logged with the full response body so support can help quickly.
Troubleshooting — error messages & fixes
| You see | Cause | Fix |
|---|---|---|
401 “Invalid or missing API key…” | Header missing/typo, key revoked, spaces/newlines in the key, key sent in the URL | Header must be exactly Authorization: Bearer ship_live_…. If your host strips it use X-API-Key. Generate a fresh key if unsure. |
403 “…get your KYC approved…” | KYC not approved | Finish KYC in the dashboard. |
400 “Insufficient wallet balance. Needed ₹X, available ₹Y” | Wallet too low | Recharge the wallet, then retry. |
400 “Unknown courierCode …” | Typo or a courier not available for your account | Use a code returned by GET /v1/rates. |
409 “Duplicate Order Number…” | This orderId already has a shipment | Use a new orderId, or fetch the existing one by orderId. |
400 “No pickup address found…” | Wrong/missing pickupAddressId | Use an ID from GET /v1/pickup-addresses. |
400 “Consignee phone must be a valid 10-digit Indian mobile number.” | Phone has fewer/more digits | Send exactly 10 digits (country code is stripped automatically). |
400 “An E-way Bill number is mandatory for shipments valued over ₹50,000.” | High-value goods without e-way bill | Send ewayBillNumber. |
400 pincode not serviceable / courier unavailable | Courier does not serve that pincode or weight | Check /v1/rates and the Pincode Checker in your dashboard; choose another courier. |
Empty rates list | Couriers disabled in your Shipping Rules, or pincode unserved | Enable couriers in Dashboard → Shipping Rules, verify the pincode. |
404 on a valid-looking URL | Wrong path or AWB belongs to another account | Check /api/v1/… spelling; use the AWB returned to your account. |
| Webhook never arrives | URL not https / not public / returns non-2xx / certificate invalid / webhook paused | Press Send test event and read the Delivery log error. |
| Webhook “signature mismatch” | Verifying a re-serialised body, wrong secret (key vs webhook secret), wrong timestamp concatenation | Use the raw body, the whsec_… secret and timestamp + "." + body. |
500 “Something went wrong…” | Temporary issue on our side or at the courier | Check GET /v1/shipments?orderId= — if it exists, don’t rebook. Otherwise retry after a minute; if it persists raise a ticket. |
Still stuck? Raise a ticket in Dashboard → Support with category API & Integration, including the request (without your key!), the full response body and the time it happened — or email support@couriersaathi.com.
Postman collection
Import our ready-made collection to try every endpoint in Postman or Insomnia in a minute.
After importing, open the collection’s Variables tab and paste your key into apiKey (and a pickup address ID into pickupAddressId).
Frequently asked questions
Where do I get my API key?
Do I need to contact support to get API access?
Is there a cost for using the API?
I lost / leaked my API key. What now?
Can I have more than one key?
Is there a sandbox / test mode?
/rates, /pickup-addresses, /wallet/balance, GET shipments) cost nothing. To test booking safely, create one small shipment and cancel it before pickup — the full amount is refunded to your wallet instantly.What do I need before I can create shipments?
Which couriers are supported?
GET /v1/rates returns exactly the list available to you.Can I use the API from JavaScript in the browser or from a mobile app?
What is the base URL?
https://couriersaathi.com/api/v1 — for example https://couriersaathi.com/api/v1/rates. The long form https://couriersaathi.com/api/index.php?_route=v1/rates works too.I get 401 “Invalid or missing API key”.
Authorization: Bearer ship_live_… with one space after Bearer; (2) no extra spaces/newlines or quotes around the key; (3) the key wasn't revoked; (4) you're not sending it in the URL. If your hosting/proxy removes the Authorization header, send X-API-Key: ship_live_… instead.My key works for GET but POST fails.
Content-Type: application/json and a valid JSON body. Also, creating shipments needs approved KYC (403 otherwise) and enough wallet balance (400).Do keys expire?
Are API rates the same as the dashboard?
totalRate is exactly what will be charged.How is the chargeable weight calculated?
deadWeightKg and volumetric weight (L×W×H in cm ÷ 5000 for air, ÷ 4500 for surface). Send accurate sizes — wrong dimensions lead to weight-discrepancy charges later.Do I have to send courierCode?
How do I ship COD?
paymentMode to COD and codAmount to the amount to collect (defaults to declaredValue). COD charges are included in /v1/rates when you pass paymentMode=COD. Collected COD is remitted to you per the COD remittance schedule — see Dashboard → COD Remittance.When is an e-way bill needed?
ewayBillNumber, otherwise booking fails.What if my request times out? Could I double-book?
GET /v1/shipments?orderId=YOUR_ORDER. If a shipment exists, use it. If not, retry. Because orderId must be unique, a repeated attempt returns 409 instead of creating a second shipment.Can I book many shipments at once?
POST /v1/shipments per order (a few per second is fine). For a large CSV/Excel batch use Dashboard → Bulk Shipment.Can I choose the pickup date/time?
Where do I get the shipping label?
Can I change a shipment after booking?
orderId.What does the pickup address ID look like and where is it?
addr_1. Fetch it with GET /v1/pickup-addresses or find it in Address Book. Add/edit addresses in the dashboard.Do I need to send city and state?
Can I ship to any pincode?
/v1/rates won't list it, and booking returns a clear 400 error.How do I track a shipment?
GET /v1/shipments/{awb} (live), webhooks (push, best), or the public page https://couriersaathi.com/trk/{awb} you can send to customers.How often should I poll?
How do I cancel a shipment and do I get a refund?
POST /v1/shipments/{awb}/cancel. Before pickup: cancelled and fully refunded to your wallet. After pickup: treated as RTO and billed as RTO.How are NDR (failed delivery) and RTO handled?
shipment.ndr / shipment.rto webhooks. Take NDR actions (re-attempt, change address/phone) in Dashboard → NDR. RTO charges are billed per your rate card.What are the shipment statuses?
BOOKED, PICKED_UP, IN_TRANSIT, OUT_FOR_DELIVERY, DELIVERED, NDR, RTO, CANCELLED — see the status table.What are webhooks and do I need them?
How do I turn webhooks on?
whsec_… signing secret, click Send test event, and watch the Delivery log.Do webhooks cover shipments not created via the API?
My webhook isn't firing. Why?
Signature verification fails.
timestamp + "." + body, using the whsec_… signing secret (not your API key). Compare the full sha256=… string with a constant-time compare. See the code samples.What are my webhook signing secret and API key — are they different?
ship_live_…) authenticates your calls to us. The webhook secret (whsec_…) lets you verify our calls to you. Keep both private.What happens if my server is down?
Can I receive the same event twice?
X-CourierSaathi-Event-Id header / id field.Are events delivered in order?
updatedAt, or only move statuses forward, or re-fetch the shipment.How fast should my endpoint answer?
Which IP addresses do webhooks come from?
Can I test webhooks on localhost?
Can I send webhooks to multiple URLs?
How do I rotate the webhook secret?
Can I pause webhooks?
I use Shopify or WooCommerce. Do I need the API?
I use Magento / OpenCart / PrestaShop / Wix / a custom site / an ERP.
POST /v1/shipments on order confirmation → webhooks to update order status. See the integration flow. Any language that can make HTTPS requests works.Is there an SDK or plugin?
Can my developer / agency integrate for me?
How do I recharge the wallet?
Where do I see what was charged?
shippingCost, gstAmount). GST invoices are available in Wallet → Invoices.Is there a rate limit?
Will the API change and break my integration?
/v2).