CourierSaathiAPI Docs v1 Get help Get your API key

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.

REST + JSONSimple HTTPS endpoints, JSON in and out, one API key.
Multi-courierDelhivery, Blue Dart, DTDC, Ekart, XpressBees, Shadowfax and more — one integration.
Signed webhooksGet shipment events pushed to your server. Verified with HMAC-SHA256.
Same rates as dashboardYour plan, custom rate card and wallet apply automatically.

Overview

Base URLhttps://couriersaathi.com/api/v1
AuthenticationAuthorization: Bearer ship_live_xxxxxxxx_xxxxxxxx…
FormatJSON request bodies (Content-Type: application/json), JSON responses, dates in ISO 8601 UTC
Weights / sizeskilograms (deadWeightKg) and centimetres (lengthCm, widthCm, heightCm)
MoneyIndian rupees (₹) as numbers, e.g. 118.5
AccessEvery Courier Saathi account can create keys. KYC must be approved before you can create shipments.
Prefer a ready-made store connection? Shopify and WooCommerce work without any code — see Store integrations. Use the API when you have a custom website, ERP, app or marketplace.
Alternative URL form: every endpoint also works as https://couriersaathi.com/api/index.php?_route=v1/rates. Use whichever your platform likes better.

Quick start — live in 5 steps

  1. Create an account & finish KYC. Sign up, then complete KYC under Dashboard → KYC. Shipment creation is blocked until KYC is approved.
  2. Add money to your wallet. Booking deducts the shipping charge (courier rate + 18% GST) from your wallet. Recharge under Dashboard → Wallet.
  3. Add a pickup address. Dashboard → Address Book → Add address. Note its ID (you can also fetch it via GET /v1/pickup-addresses).
  4. Generate your API key. Dashboard → API Keys & Webhooks → Generate. Copy the key immediately — it is shown only once. Details ↓
  5. 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.

  1. Log in at couriersaathi.com/login (or create a free account).
  2. Open Dashboard → API Keys & Webhooks (direct link: couriersaathi.com/developer-api).
  3. Enter an App name (for example “My Website” or “ERP”) and, optionally, your Webhook URL (https only). Click Generate API Key.
  4. 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.
  5. If you set a webhook URL, copy the Webhook signing secret (whsec_…) too — you need it to verify events.
Lost your key? It cannot be recovered. Generate a new key, update your integration, then revoke the old one in the same screen. You can have several keys (for example one per website) and revoke each independently.
Keep the key secret. Use it only on your server. Never put it in browser JavaScript, a mobile app, a public GitHub repo or a screenshot. If it leaks, revoke it immediately.

Open API Keys & Webhooks →

Authentication

Send your key in the Authorization header of every request:

Authorization: Bearer ship_live_a1b2c3d4_0123456789abcdef0123456789abcdef01234567

If your hosting, proxy or API gateway strips the Authorization header, send the same key as X-API-Key: ship_live_… instead — we accept both.

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." }
HTTPMeaningWhat to do
200 / 201Success (201 = shipment created)—
400Validation problem (bad phone, pincode not serviceable, unknown courierCode, insufficient wallet balance…)Read error, fix the request. Do not retry unchanged.
401Missing, invalid or revoked API keyCheck the header format and that the key is active.
403KYC is not approved yet (creating shipments needs approved KYC)Complete KYC in the dashboard, or contact support.
404Unknown endpoint, or the AWB is not on your accountCheck the URL and AWB.
409Duplicate orderId, or courier refused the cancellationUse a new orderId, or read error.
500 / 501Temporary problem on our side / cancellation not supported automatically for that courierRetry GET calls after a few seconds. For POST /shipments first check by orderId before retrying.
Good practice: set a client timeout of 30–60 seconds for 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.

GET/v1/rates
Query parameterTypeDescription
destinationPincode *string6-digit delivery pincode
deadWeightKgnumberActual weight in kg. Default 0.5
lengthCm, widthCm, heightCmnumberParcel size. Default 10 each. Volumetric weight = L×W×H ÷ 5000 (air) or ÷ 4500 (surface); you are charged the higher of dead and volumetric weight.
paymentModestringPREPAID (default) or COD
codAmountnumberAmount to collect, for COD
declaredValuenumberInvoice 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).

GET/v1/pickup-addresses
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.

POST/v1/shipments
Requires KYC approved and enough wallet balance (≥ totalRate). orderId must be unique in your account.
FieldTypeDescription
orderId *stringYour own order number. Must be unique — a repeat returns 409. This is your safety net against double-booking.
pickupAddressId *stringID from /v1/pickup-addresses
courierCodestringcode from /v1/rates. Always send it in production. An unknown code returns 400.
consigneeName *stringCustomer name
consigneePhone *string10-digit Indian mobile number (+91/spaces/dashes are stripped)
consigneeAddress *stringFull delivery address
consigneePincode *string6-digit pincode. City & state are filled in automatically if you omit them.
consigneeCity, consigneeStatestringOptional (auto-detected from pincode)
consigneeEmailstringOptional
alternateContactPhonestringOptional 10-digit number
paymentMode *stringPREPAID or COD
codAmountnumberFor COD: amount to collect from the customer (defaults to declaredValue)
declaredValue *numberInvoice value of the goods in ₹
productNamestringItem description printed on the label. Default “General Merchandise”.
ewayBillNumberstringMandatory when the goods value is above ₹50,000
deadWeightKg *numberActual weight (kg)
lengthCm, widthCm, heightCm *numberPacked size in cm. Be accurate — wrong sizes cause weight disputes.
rtoWarehouseIdstringOptional 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.

GET/v1/shipments/{awb}
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.

GET/v1/shipments
Query parameterDescription
orderIdExact match on your order number
awbExact match on AWB
statusOne of the statuses, e.g. DELIVERED
page, limitPage 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

POST/v1/shipments/{awb}/cancel
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

GET/v1/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:

StatusMeaning
BOOKEDShipment created, AWB generated, waiting for pickup
PICKED_UPCourier collected the parcel from your pickup address
IN_TRANSITMoving through the courier network
OUT_FOR_DELIVERYWith the delivery agent
DELIVEREDDelivered to the customer (COD is collected and later remitted to you)
NDRDelivery attempt failed — action needed (re-attempt / change address) in Dashboard → NDR
RTOReturning to your warehouse
CANCELLEDCancelled 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.

  1. Create a public HTTPS endpoint on your server, e.g. https://yourstore.com/webhooks/courier-saathi. It must accept POST with a JSON body and answer HTTP 2xx within 10 seconds.
  2. Add the URL in Dashboard → API Keys & Webhooks — when generating a key, or later with Edit webhook on an existing key.
  3. Copy the signing secret (whsec_…, shown next to the key) into your server configuration.
  4. Click Send test event. You should see it succeed in the delivery log on the same screen.
  5. Verify the signature on every request (code below) and process the event.
Requirements: HTTPS with a valid certificate (self-signed certificates and plain HTTP are rejected), a public address (localhost / private IPs are not allowed), and no redirects (we do not follow 301/302).

Webhook events

EventSent when
shipment.createdA new shipment is booked (AWB generated)
shipment.picked_upCourier picked the parcel up
shipment.in_transitParcel is moving through the network
shipment.out_for_deliveryParcel is out for delivery
shipment.deliveredDelivered to the customer
shipment.ndrDelivery failed / needs action (non-delivery report)
shipment.rtoShipment is being returned to you
shipment.cancelledShipment was cancelled
shipment.status_updatedAny other status change
webhook.testSent 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

HeaderDescription
Content-Typeapplication/json
X-CourierSaathi-EventEvent name, e.g. shipment.delivered
X-CourierSaathi-Event-IdUnique event ID (evt_…) — same value as id in the body. Use it to de-duplicate.
X-CourierSaathi-DeliveryID of this delivery attempt record (whd_…)
X-CourierSaathi-TimestampUnix time (seconds) the request was signed
X-CourierSaathi-Signaturesha256= + HMAC-SHA256 hex digest (see below)
User-AgentCourierSaathi-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
Use the raw, unmodified request body (the exact bytes we sent) — not a re-serialised JSON object. Re-encoding changes spacing/ordering and the signature will not match. This is the #1 cause of “signature mismatch”.
# 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", 200

Retries, ordering & delivery log

We do not publish a fixed list of sender IP addresses (they can change). Rely on the signature, not IP allow-lists.

Test & debug webhooks

  1. 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 3000 to reach your laptop (use the https URL it gives you).
  2. Press Send test event on your key. A webhook.test event is sent immediately and the result (HTTP code, response, time) appears at once.
  3. Book a tiny test shipment, then cancel it — you will receive shipment.created and shipment.cancelled (cancelled before pickup = refunded).
  4. 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)

ShopifyConnect your store and new orders import automatically, ready to ship. Step-by-step guide →
WooCommerceConnect with your REST keys; orders sync and AWB/tracking can be pushed back. Step-by-step guide →
Custom website / ERP / appMagento, OpenCart, PrestaShop, Wix, a Laravel/Django/Node app, Zoho, Tally, a marketplace panel — use this API. Follow the integration flow below.

You can use a store connection and the API together — webhooks fire for all shipments regardless of how they were created.

Recommended integration flow

  1. Checkout / rate display (optional): call GET /v1/rates with the customer’s pincode and your packed weight/size to show a shipping charge or choose a courier.
  2. Order confirmed → create shipment: POST /v1/shipments with orderId = your order number. Save shipment.awb and shipment.courierName on the order. If you get 409, the order is already booked — look it up with GET /v1/shipments?orderId=….
  3. Print the label from Dashboard → Shipments (bulk print supported) and hand over to the courier at pickup.
  4. Stay in sync with webhooks: on each event update your order status, notify the customer (SMS/WhatsApp/email with trackingUrl), mark COD as collected on shipment.delivered, and trigger a return flow on shipment.rto.
  5. Fallback: once a day, GET /v1/shipments?status=IN_TRANSIT (and other open statuses) to catch anything missed.
  6. Cancel from your system with POST /v1/shipments/{awb}/cancel when 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

Troubleshooting — error messages & fixes

You seeCauseFix
401 “Invalid or missing API key…”Header missing/typo, key revoked, spaces/newlines in the key, key sent in the URLHeader 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 approvedFinish KYC in the dashboard.
400 “Insufficient wallet balance. Needed ₹X, available ₹Y”Wallet too lowRecharge the wallet, then retry.
400 “Unknown courierCode …”Typo or a courier not available for your accountUse a code returned by GET /v1/rates.
409 “Duplicate Order Number…”This orderId already has a shipmentUse a new orderId, or fetch the existing one by orderId.
400 “No pickup address found…”Wrong/missing pickupAddressIdUse an ID from GET /v1/pickup-addresses.
400 “Consignee phone must be a valid 10-digit Indian mobile number.”Phone has fewer/more digitsSend 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 billSend ewayBillNumber.
400 pincode not serviceable / courier unavailableCourier does not serve that pincode or weightCheck /v1/rates and the Pincode Checker in your dashboard; choose another courier.
Empty rates listCouriers disabled in your Shipping Rules, or pincode unservedEnable couriers in Dashboard → Shipping Rules, verify the pincode.
404 on a valid-looking URLWrong path or AWB belongs to another accountCheck /api/v1/… spelling; use the AWB returned to your account.
Webhook never arrivesURL not https / not public / returns non-2xx / certificate invalid / webhook pausedPress 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 concatenationUse the raw body, the whsec_… secret and timestamp + "." + body.
500 “Something went wrong…”Temporary issue on our side or at the courierCheck 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.

⬇ Download Postman collection

After importing, open the collection’s Variables tab and paste your key into apiKey (and a pickup address ID into pickupAddressId).

Frequently asked questions

Getting started
Where do I get my API key?
Log in → Dashboard → API Keys & Webhooks (couriersaathi.com/developer-api) → enter an app name → Generate API Key. It is instant; no approval is needed. Copy it right away because it is shown only once.
Do I need to contact support to get API access?
No. Every account can generate keys itself. You only need an account; to create shipments your KYC must also be approved.
Is there a cost for using the API?
There is no separate API fee. Shipments booked through the API are charged exactly like dashboard bookings: the courier rate plus 18% GST, deducted from your wallet.
I lost / leaked my API key. What now?
A key cannot be shown again. Generate a new key, switch your integration to it, then revoke the old key in the same screen. Revoked keys stop working immediately.
Can I have more than one key?
Yes — create one per website/app so you can revoke one without breaking the others. Each key can have its own webhook URL.
Is there a sandbox / test mode?
There is no separate sandbox: the API is live. Read-only calls (/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?
Approved KYC, wallet balance higher than the shipment charge, and at least one saved pickup address.
Which couriers are supported?
Delhivery, Blue Dart, DTDC, Ekart, XpressBees, Shadowfax, Amazon Shipping and others. Which ones you can use depends on your account, your Shipping Rules and the destination pincode — 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?
Don't. Your API key would be visible to anyone. Call the API from your server (PHP, Node, Python, Java, .NET…) and let your website/app talk to your own server.
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.
Authentication problems
I get 401 “Invalid or missing API key”.
Check: (1) header is exactly 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.
POST needs 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?
No. They work until you revoke them or your account is suspended.
Rates & booking
Are API rates the same as the dashboard?
Yes. The same engine is used, including your membership plan, any custom rate card assigned to you and your Shipping Rules. totalRate is exactly what will be charged.
How is the chargeable weight calculated?
The higher of 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?
Strongly recommended. If you omit it we book a default Delhivery Surface service for that weight. An unknown code returns a 400 error so a typo never books the wrong courier.
How do I ship COD?
Set 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?
For goods valued above ₹50,000 — send ewayBillNumber, otherwise booking fails.
What if my request times out? Could I double-book?
Booking calls the courier live and can take up to ~20 seconds, so use a 30–60 s timeout. If it still times out, don't retry blindly: call 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?
Send one 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?
Pickup is arranged automatically for the next available slot according to each courier's cut-off time. Pickup status is visible on the shipment in your dashboard.
Where do I get the shipping label?
Print it from Dashboard → Shipments (single or bulk, 4×6 thermal or A4). A label-download endpoint is not part of v1 yet. Ask us if you need it for your workflow.
Can I change a shipment after booking?
Not through the API. Cancel (before pickup) and rebook with a new orderId.
What does the pickup address ID look like and where is it?
Something like 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?
No — they are detected from the consignee pincode. You may still send them.
Can I ship to any pincode?
Only pincodes the chosen courier serves. If a courier cannot serve it, /v1/rates won't list it, and booking returns a clear 400 error.
Tracking & cancellation
How do I track a shipment?
Three ways: 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?
Don't poll for every shipment — use webhooks. If you must poll, once every few hours per open shipment is plenty.
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?
You receive 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.
Webhooks
What are webhooks and do I need them?
A webhook is a message we send to your server when a shipment is created or its status changes, so you don't need to keep asking. They are optional, but recommended for live order status, customer notifications and COD reconciliation.
How do I turn webhooks on?
In Dashboard → API Keys & Webhooks, set a webhook URL on your key (when generating, or Edit webhook later). Copy the whsec_… signing secret, click Send test event, and watch the Delivery log.
Do webhooks cover shipments not created via the API?
Yes. Every shipment on your account triggers events — API, dashboard, bulk upload and store-synced orders.
My webhook isn't firing. Why?
Open the Delivery log. Typical causes: URL isn't HTTPS or has an invalid/self-signed certificate; the host is localhost/private; your server returns non-2xx or takes more than 10 s; the webhook is paused; or there is simply no new status change yet. Send test event tells you immediately.
Signature verification fails.
Verify against the raw body bytes (not parsed-and-re-serialised JSON) with message = 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?
Yes. The API key (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?
We retry up to 6 times over about 8 hours (1 min, 5 min, 30 min, 2 h, 6 h). After that the delivery shows as FAILED in the log and you can press Resend once your server is back.
Can I receive the same event twice?
Yes (at-least-once delivery). De-duplicate using the X-CourierSaathi-Event-Id header / id field.
Are events delivered in order?
Not guaranteed, especially after retries. Compare updatedAt, or only move statuses forward, or re-fetch the shipment.
How fast should my endpoint answer?
With any 2xx within 10 seconds. Save the event, return 200 immediately, and process asynchronously.
Which IP addresses do webhooks come from?
They can change, so we don't publish an allow-list. Verify the HMAC signature instead — it is stronger than IP filtering.
Can I test webhooks on localhost?
Our servers can't reach localhost. Use a tunnel such as ngrok (use its https URL) or a request catcher such as webhook.site while developing.
Can I send webhooks to multiple URLs?
Each API key has one webhook URL, and every active key with a webhook receives the events. Create a second key with another URL if you need two destinations.
How do I rotate the webhook secret?
Click Rotate secret on the key. New events are signed with the new secret immediately — update your server at once (or temporarily accept both).
Can I pause webhooks?
Yes, with Pause on the key. Events that happen while paused are not queued; when you resume, only new changes are sent.
Stores & platforms
I use Shopify or WooCommerce. Do I need the API?
No. Connect your store from Dashboard → Integrations and orders flow in automatically — see the step-by-step guide.
I use Magento / OpenCart / PrestaShop / Wix / a custom site / an ERP.
Use this API. Typical flow: rates at checkout → 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?
Not yet — the REST API is intentionally simple. The docs include ready-to-use cURL, Node.js, PHP and Python samples and a Postman collection.
Can my developer / agency integrate for me?
Yes. Create a dedicated key for them (name it clearly) and revoke it when the work is done. Never share your account password.
Account, billing & support
How do I recharge the wallet?
Dashboard → Wallet. Recharge is not available through the API.
Where do I see what was charged?
In Dashboard → Wallet (transactions) and on each shipment (shippingCost, gstAmount). GST invoices are available in Wallet → Invoices.
Is there a rate limit?
No hard quota today, but please keep traffic reasonable (a few requests per second) and cache rates briefly. Abusive traffic may be throttled.
Will the API change and break my integration?
We keep v1 backward-compatible: we may add fields/events, but won't rename or remove existing ones. Ignore unknown fields and unknown webhook events in your code. Breaking changes would ship as a new version (/v2).
Is my data secure?
Everything is HTTPS. We store only a hash of your API key, and webhooks are HMAC-signed. Revoke keys immediately if you suspect a leak.
Who do I contact if I'm stuck?
Raise a ticket in Dashboard → Support with category API & Integration, or email support@couriersaathi.com. Include the endpoint, the full response body, time, and (never!) your key.
Ready? Generate your API key and make your first call in minutes. Need help? Open a support ticket.