For developers
API documentation
Connect your website, ERP or order system to Courier Tales. This page covers only what works today, checked against our code; planned features are marked as planned.
Introduction
What you can build with the Courier Tales API, and how a typical integration works.
Courier Tales is a courier aggregation platform for Indian sellers. The Courier Tales API lets your website, ERP, OMS or custom panel create orders, compare couriers, book shipments, download labels, cancel shipments and receive status updates, all with the same rules, prices and wallet as the seller panel.
Everything on this page has been checked against the Courier Tales code and its automated tests. The example responses come from those tests and use synthetic data.
What you can do
| Capability | Status | Section |
|---|---|---|
| API keys with permissions, live and test mode | Available | Authentication, Test mode |
| Create orders | Available | Orders |
| Compare couriers at your own rates, and book (a chosen courier or your Courier Priority) | Available | Quotes and booking |
| AWB, shipment details, scan history, list with filters | Available | Shipments and labels |
| Shipping label PDF | Available | Shipments and labels |
| Cancel a shipment that is not yet picked up | Available | Cancellation |
| NDR actions: re-attempt, new address, new phone, return to origin | Available | NDR actions |
| Reverse pickups (returns from buyers) | Available | Reverse pickups |
| B2B cargo (multi-box LR shipments) | Available | B2B cargo |
| International shipments | Available | International |
| Webhooks for every status change | Available | Webhooks |
| Public rate estimate (no key) | Available | Rate estimate |
| Order import from your HTTPS endpoint | Available | Custom API channel |
| Weight disputes, bulk booking and manifests through the API | Planned | Planned |
A typical integration
- Create a test key in the seller panel (Settings → API & Webhooks) and build against test mode: nothing is booked or charged.
- Create an order:
POST /v1/orders. - Get courier options at your rates:
POST /v1/quotes. - Book:
POST /v1/shipmentswith anIdempotency-Key. You get the AWB in the response. - Download the label:
GET /v1/shipments/{shipmentId}/label. - Receive status updates on your webhook endpoint, or read them with
GET /v1/shipments/{shipmentId}. - Cancel if needed, before pickup:
POST /v1/shipments/{shipmentId}/cancel. - Switch to a live key. Live bookings use your wallet, exactly like the panel.
Getting started
Base URL, request format, units, response envelope and versioning.
Base URL
https://api.couriertales.com/api/v1
The same URL serves live and test mode: the API key decides which one.
Call from your server, not from a browser
API keys are secrets. Call the API from your back end, never from a website, mobile app or browser code. Browsers are blocked from calling the API from other websites (CORS).
Request format
- HTTPS only. Send JSON with
Content-Type: application/json. Request bodies are limited to 100 KB. - Money is in Indian rupees (INR) as decimal numbers, for example
80.24. Shipment charges include GST. - Weights are in kilograms. Dimensions are in centimetres.
- Pincodes are 6-digit strings. Phone numbers are 10 digits, without
+91or spaces. - Timestamps are ISO 8601 in UTC, for example
2026-10-19T09:30:00.000Z.
Response envelope
{ "success": true, "data": { } }
{
"success": false,
"error": { "code": "VALIDATION_ERROR", "message": "deliveryAddress.pincode must be 6 digits" },
"requestId": "1f6c0e2a-7b8f-4a59-9a43-2d1f0b6c9e11"
}
Every response has an X-Request-Id header, and error bodies also carry requestId. Quote it when you contact support. Branch on the HTTP status and error.code, never on message.
Versioning
This is version 1 (/v1). Within v1 we never remove or rename a documented field, and never add a new required request field. New optional fields, response fields and event types can appear at any time, so ignore fields you do not know. Breaking changes will get a new version, announced in the changelog.
Authentication
AvailableAPI keys created by the account owner, sent as a Bearer token, with permissions, an optional IP allowlist and instant revocation.
Create a key
In the seller panel, the account owner opens Settings → API & Webhooks and creates a key: a name, the mode (Test or Live) and its permissions. Employees cannot create or see keys.
The full key is shown once. Courier Tales stores only a hash of it, so a lost key cannot be recovered; create a new one and revoke the old one. A seller can have up to 10 active keys.
| Key starts with | Mode |
|---|---|
ct_test_ | Test mode: simulated couriers, nothing booked or charged |
ct_live_ | Live: real bookings, charged to your wallet. Requires approved KYC. |
Send it with every request
curl https://api.couriertales.com/api/v1/me \
-H "Authorization: Bearer $CT_API_KEY"
{ "success": true, "data": { "customerId": "CT001", "mode": "live", "scopes": ["rates:read", "orders:write", "orders:read", "shipments:read", "shipments:write", "shipments:cancel", "labels:read", "ndr:write"], "livemode": true } }
Permissions (scopes)
| Scope | Allows |
|---|---|
orders:write | POST /v1/orders |
orders:read | GET /v1/orders/{orderId}, GET /v1/pickup-addresses |
rates:read | POST /v1/quotes |
shipments:write | POST /v1/shipments (booking; uses your wallet in live mode), test-mode simulate |
shipments:read | GET /v1/shipments, GET /v1/shipments/{shipmentId} |
labels:read | GET /v1/shipments/{shipmentId}/label |
shipments:cancel | POST /v1/shipments/{shipmentId}/cancel |
ndr:write | NDR actions and delivery instructions |
shipments:read, shipments:write, shipments:cancel and labels:read cover reverse pickups, B2B cargo and international shipments in the same way (read, create/book/pay, cancel, documents). rates:read covers their quotes and rates.
New keys get all scopes unless you untick some. Give each system only what it needs: for example, a reporting tool needs only shipments:read.
IP allowlist and revocation
A key can be limited to up to 20 IPv4 addresses (requests from other addresses get 403 IP_NOT_ALLOWED). Revoke in the panel takes effect immediately.
Authentication errors
| HTTP | error.code | Meaning |
|---|---|---|
| 401 | UNAUTHENTICATED | No Authorization: Bearer header |
| 401 | INVALID_API_KEY | Unknown or revoked key. Repeated failures from one IP are rate limited. |
| 403 | INSUFFICIENT_SCOPE | The key lacks the permission for this route |
| 403 | IP_NOT_ALLOWED | Request from an IP outside the key’s allowlist |
| 403 | KYC_NOT_APPROVED | Live keys work once the account’s KYC is approved; test keys work before |
| 403 | ACCOUNT_DISABLED | The seller account is not active |
Never use your panel email and password for integrations, and never share them with a developer: create an API key, or an employee login in Settings → Employee Login.
Test mode
AvailableBuild and test with ct_test_ keys - same requests and responses, simulated couriers, nothing booked or charged.
A ct_test_ key runs every request in test mode:
- orders and shipments are stored separately from your real ones; live keys and the seller panel never see them, and test keys never see real data;
- quotes use Courier Tales standard-plan prices (live quotes use your own plan);
- booking never calls a courier and never touches the wallet; Shipping IDs start with
CTTEST, AWBs withTESTAWB, andcharges.chargedisfalse; - labels are sample PDFs marked “TEST LABEL - NOT FOR SHIPPING”;
- webhooks go to your test endpoints only, with
"livemode": false.
Test keys work before KYC approval. Your pickup addresses are read (never changed) so quotes use your real origin pincode.
Simulate a status change
/v1/test/shipments/{shipmentId}/simulateTest mode only (live keys get 403 TEST_MODE_ONLY). Scope shipments:write. Moves a test shipment to a status and sends the matching webhook.
curl -X POST https://api.couriertales.com/api/v1/test/shipments/CTTEST61878900/simulate \
-H "Authorization: Bearer $CT_TEST_KEY" -H "Content-Type: application/json" \
-d '{"status":"In Transit"}'
status | Effect | Webhook |
|---|---|---|
Pickup | Picked up (can no longer be cancelled) | shipment.picked_up |
In Transit | In transit | shipment.in_transit |
Out for Delivery | Out for delivery | shipment.out_for_delivery |
Delivered | Delivered | shipment.delivered |
NDR | Failed delivery attempt (status unchanged, ndr filled) | shipment.ndr_raised |
RTO, RTO In Transit, RTO Delivered | Return to origin | shipment.rto_initiated, shipment.rto_in_transit, shipment.rto_delivered |
Exception | Exception | shipment.exception |
Reverse pickups, B2B and international in test mode
The same endpoints work with test keys and keep everything separate from real data:
- B2B shipments get a
TESTLR…LR number at once (stateMANIFESTED). - Reverse pickups get a
TESTAWB…waybill (stateBOOKED). - International requests start in
REQUESTED. - Refs start with
TEST-B2B-,TEST-RVP-andTEST-INT-. Documents are sample PDFs, and nothing is charged.
Move them through any state with:
| Endpoint | state values |
|---|---|
POST /v1/test/b2b/shipments/{ref}/simulate | any B2B state after CREATED (for example PICKED_UP, IN_TRANSIT, DELIVERED, RTO_IN_TRANSIT) |
POST /v1/test/reverse-pickups/{ref}/simulate | any reverse pickup state after CREATED (for example PICKED_UP, DELIVERED_TO_WAREHOUSE) |
POST /v1/test/international/shipments/{ref}/simulate | any international state (for example RECEIVED, PRICE_CONFIRMED, BOOKED, DELIVERED; ON_HOLD_PAYMENT sets amountDue to 250 so you can test pay) |
Each change sends the matching *.updated webhook to your test endpoints.
NDR in test mode: simulate NDR on a test shipment, then use the NDR actions endpoints. Actions are recorded (courier.state: "saved"). rto moves the test shipment to RTO and sends shipment.rto_initiated.
Example: a booked test shipment
{
"success": true,
"data": {
"shipment": {
"shipmentId": "CTTEST61878900",
"orderId": "T-1001",
"awbNumber": "TESTAWB8544092526",
"courier": "Amazon Shipping",
"mode": "Surface",
"status": "Pending",
"paymentMode": "Prepaid",
"codAmount": 0,
"charges": { "freight": 80.24, "cod": 0, "total": 80.24, "currency": "INR", "includesGst": true, "charged": false },
"chargeableWeightKg": 0.5,
"zone": "zone4",
"bookedAt": "2026-10-19T15:21:02.000Z",
"estimatedDeliveryDate": null,
"deliveredAt": null,
"ndr": null,
"rto": null,
"cancellable": true,
"trackingUrl": null,
"updatedAt": "2026-10-19T15:21:02.000Z",
"livemode": false,
"events": [
{ "time": "2026-10-19T15:21:01.999Z", "status": "Pending", "statusCode": "BOOKED", "location": "Courier Tales (test mode)", "message": "Simulated booking (test mode, nothing charged)" }
]
}
}
} Orders
AvailableCreate an order with the same rules as the seller panel, read it back, and list your pickup addresses.
Create an order
/v1/ordersScope orders:write. Creates a Not Booked order (nothing is booked or charged). The order also appears in the seller panel under Booked Orders, channel API. You can send an optional Idempotency-Key header. Your own orderId is unique per account anyway: sending the same orderId twice returns 409 DUPLICATE_ORDER_ID with the existing order, so a retried request never creates a copy.
curl -X POST https://api.couriertales.com/api/v1/orders \
-H "Authorization: Bearer $CT_API_KEY" -H "Content-Type: application/json" \
-d '{
"orderId": "API-1001",
"orderDate": "2026-10-19T09:30:00.000Z",
"paymentMode": "Prepaid",
"deliveryAddress": {
"lines": [{ "line1": "12, Synthetic Lane", "line2": "Near Demo Park" }],
"city": "Mumbai", "state": "Maharashtra", "pincode": "400001",
"customerName": "Test Buyer"
},
"deliveryPhones": [{ "phoneNumber": "9000000001", "phoneType": "Primary" }],
"invoice": { "invoiceDate": "2026-10-19T09:30:00.000Z", "invoiceNumber": "INV-API-1001" },
"ebn": { "hasEBN": false },
"products": [{ "productName": "Cotton T-shirt", "quantity": 1, "sku": "TS-BLU-M", "orderValue": 499 }],
"physicalWeight": 0.4,
"dimensions": { "length": 10, "breadth": 10, "height": 10 }
}'
201 Created:
{
"success": true,
"data": {
"order": {
"id": "6ad635562e9d5975f64b382e",
"orderId": "API-1001",
"status": "not_booked",
"shipmentId": null,
"paymentMode": "Prepaid",
"codAmount": 0,
"orderValue": 499,
"physicalWeight": 0.4,
"dimensions": { "length": 10, "breadth": 10, "height": 10 },
"deliveryPincode": "400001",
"createdAt": "2026-10-19T15:20:54.262Z",
"livemode": true
}
}
}
Order request fields
| Field | Type | Required | Rules |
|---|---|---|---|
orderId | string | yes | Your order number, unique per account. Up to 50 characters: letters, digits and - _ / # .; must start with a letter, digit or #. |
orderDate | ISO date | yes | |
paymentMode | string | yes | Prepaid or COD (exact case) |
deliveryAddress.lines | array | yes | 1 or more { line1, line2? } |
deliveryAddress.city, .state, .customerName | string | yes | |
deliveryAddress.pincode | string | yes | 6 digits |
deliveryAddress.landmark | string | no | |
deliveryAddress.email | string | no | Buyer email |
deliveryAddress.gstNumber | string | no | Valid GSTIN or "" |
deliveryPhones | array | yes | 1-2 of { phoneNumber, phoneType }; phoneNumber 10 digits; phoneType Primary or Secondary |
invoice.invoiceNumber | string | yes | Unique per account (409 DUPLICATE_INVOICE_NUMBER) |
invoice.invoiceDate | ISO date | yes | |
invoice.shippingCharge, invoice.discount | number | no | Order-level shipping charged to the buyer, and discount (INR) |
ebn.hasEBN | boolean | yes | E-way bill: when true, ebnNumber and ebnExpiryDate are required |
products | array | yes | 1 or more |
products[].productName | string | yes | |
products[].quantity | number | yes | Whole number ≥ 1. Decimals (e.g. 1.5 m) only if Allow decimal quantity is on in Settings → Custom Rules. |
products[].orderValue | number | yes | Value of the whole line in INR (> 0; 0 only with isFreeSample: true) |
products[].sku, products[].hsn | string | no | |
physicalWeight | number | yes | Kilograms, > 0 |
dimensions.length, .breadth, .height | number | yes | Centimetres, > 0 |
Unknown fields, and fields only Courier Tales may set (for example status or shipmentId), are refused with 422 VALIDATION_ERROR. For COD orders the amount collected from the buyer is the order total (codAmount in the response).
Read an order
/v1/orders/{orderId}Scope orders:read. {orderId} is your own order number (URL-encode # and /). status is not_booked, booking_in_progress, booked, cancelled or outcome_unknown (see booking).
Pickup addresses
/v1/pickup-addressesScope orders:read. Your pickup addresses (managed in Settings → Pickup Address): id, contactName, lines, city, state, pincode, isDefault. Quotes and booking use the default pickup address unless you send pickupAddressId.
Quotes and booking
AvailableCompare couriers at your own rates, then book with a chosen courier or your Courier Priority. Idempotent, charged once, never re-sent automatically.
Get courier options
/v1/quotesScope rates:read. Prices from your rate plan, the same as the panel’s courier comparison, including GST. Nothing is booked or charged.
| Field | Type | Required | Notes |
|---|---|---|---|
orderId | string | yes | Your order number |
pickupAddressId | string | no | Default: your default pickup address |
mode | string | no | Surface or Air; omit for both |
{
"success": true,
"data": {
"orderId": "API-1001",
"pickupAddressId": "6ad635562e9d5975f64b384f",
"bookable": true,
"options": [
{ "courierId": "6ad635562e9d5975f64b3862", "courier": "Amazon Shipping", "mode": "Surface", "available": true, "total": 80.24, "freight": 80.24, "cod": 0, "gstRate": 0.18, "zone": "zone4", "chargeableWeightKg": 0.5 },
{ "courierId": "6ad635562e9d5975f64b3861", "courier": "Shadowfax", "mode": "Surface", "available": true, "total": 82.6, "freight": 82.6, "cod": 0, "gstRate": 0.18, "zone": "zone4", "chargeableWeightKg": 0.5 },
{ "courierId": "6ad635562e9d5975f64b3862", "courier": "Amazon Shipping", "mode": "Air", "available": false, "reason": "No rate for this weight and mode" }
],
"currency": "INR",
"includesGst": true
}
}
Available options come first, cheapest first. total is what booking charges: freight plus COD charge, including GST. bookable is false (with a reason) when the order cannot be booked at all, for example because it is already booked.
Book a shipment
/v1/shipmentsScope shipments:write. Requires an Idempotency-Key header. In live mode this books with the courier and charges your wallet, exactly like Ship in the panel.
| Field | Type | Required | Notes |
|---|---|---|---|
orderId | string | yes | Your order number |
courier | object or "auto" | yes | { "courierId", "mode", "expectedTotal" } from a quote, or "auto" |
courier.courierId | string | yes (object) | From POST /v1/quotes |
courier.mode | string | yes (object) | Surface or Air |
courier.expectedTotal | number | recommended | The quoted total. If the price changed, nothing is booked: 409 PRICE_CHANGED. |
pickupAddressId | string | no | Default: your default pickup address |
rtoAddressId | string | no | Another of your pickup addresses to receive returns |
"auto" uses your Courier Priority (set in the seller panel): couriers are tried in your order and the first one that accepts books the shipment. The response lists what was tried.
curl -X POST https://api.couriertales.com/api/v1/shipments \
-H "Authorization: Bearer $CT_API_KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: book-API-1001" \
-d '{"orderId":"API-1001","courier":{"courierId":"6ad635562e9d5975f64b3862","mode":"Surface","expectedTotal":80.24}}'
// Node.js 18+, server side
const res = await fetch("https://api.couriertales.com/api/v1/shipments", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.CT_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": `book-${order.id}`, // one key per order you book
},
body: JSON.stringify({ orderId: order.id, courier: { courierId, mode: "Surface", expectedTotal } }),
});
const body = await res.json();
if (res.status === 201) saveShipment(order.id, body.data.shipment); // store shipmentId + awbNumber
else if (res.status === 202) markForCheck(order.id); // outcome unknown: do not book again
else throw new Error(`${res.status} ${body.error.code}: ${body.error.message}`);
201 Created:
{
"success": true,
"data": {
"shipment": {
"shipmentId": "CT1000001",
"orderId": "API-1001",
"awbNumber": "AWBAMAZONSHIPPING0001",
"courier": "Amazon Shipping",
"mode": "Surface",
"status": "Pending",
"paymentMode": "Prepaid",
"codAmount": 0,
"charges": { "freight": 80.24, "cod": 0, "total": 80.24, "currency": "INR", "includesGst": true },
"chargeableWeightKg": 0.5,
"zone": "zone4",
"bookedAt": "2026-10-19T15:20:54.778Z",
"estimatedDeliveryDate": "2026-10-24T15:20:54.782Z",
"deliveredAt": null,
"ndr": null,
"rto": null,
"cancellable": true,
"trackingUrl": "https://couriertales.com/tracking/?awb=AWBAMAZONSHIPPING0001",
"updatedAt": "2026-10-19T15:20:54.781Z",
"livemode": true
}
}
}
Store shipmentId (the Courier Tales Shipping ID) and awbNumber with your order.
How booking handles money and failures
- Booking is synchronous: the courier answers within the request.
- The charge is reserved just before the courier is called. If the courier refuses, the charge is reversed automatically.
- An order can be booked only once, also when requests arrive at the same moment.
- If the courier gives no clear answer (timeout), Courier Tales keeps the charge and locks the order:
202 BOOKING_OUTCOME_UNKNOWN. It is never re-sent automatically, because the courier may have created the shipment. CheckGET /v1/orders/{orderId}later, or contact support. Do not book the order with another courier.
Booking errors
| HTTP | error.code | Meaning |
|---|---|---|
| 400 | IDEMPOTENCY_KEY_REQUIRED | Send an Idempotency-Key header |
| 402 | INSUFFICIENT_WALLET_BALANCE | Recharge the wallet, then retry with a new key |
| 404 | NOT_FOUND | No such order |
| 409 | ORDER_ALREADY_BOOKED | Already booked; the existing shipment is included |
| 409 | PRICE_CHANGED | expectedTotal differs from today’s price (newTotal included); nothing charged |
| 409 | BOOKING_IN_PROGRESS, ORDER_CANCELLED | Another request is booking it / the order is cancelled |
| 202 | BOOKING_OUTCOME_UNKNOWN | See above |
| 422 | BOOKING_FAILED, or a more specific code such as PINCODE_NOT_SERVICEABLE, COURIER_NOT_AVAILABLE, INVALID_COURIER | The courier cannot carry this order, or refused it; nothing charged (message has the reason). Try another option. |
| 422 | PRIORITY_NOT_SET | "auto" without a Courier Priority |
| 422 | PICKUP_ADDRESS_REQUIRED, PICKUP_ADDRESS_NOT_FOUND | No default pickup address / unknown id |
PRICE_CHANGED example:
{
"success": false,
"error": { "code": "PRICE_CHANGED", "message": "Price changed from Rs 85.24 to Rs 80.24. Review the new price and ship again.", "expectedTotal": 85.24, "newTotal": 80.24 },
"requestId": "1f6c0e2a-7b8f-4a59-9a43-2d1f0b6c9e11"
}
Idempotency-Key and safe retries
Send a unique Idempotency-Key (8-100 characters: letters, digits, - _ . :) per booking, for example book-<your order id>.
- The first request runs. Its answer is stored for 24 hours and returned for every retry with the same key and the same body, with header
Idempotent-Replayed: true. The courier is never called twice. - While the first request is still running, a retry gets
409 REQUEST_IN_PROGRESS: wait a few seconds and retry with the same key. - The same key with a different body gets
422 IDEMPOTENCY_KEY_REUSED. - Answers with status 500 or above are not stored; a retry with the same key runs again.
- A stored error stays the answer for that key. After you fix the cause (recharge, new price), use a new key.
After a network timeout, retry with the same key. You then get either the original result or REQUEST_IN_PROGRESS, never a second shipment. POST /v1/orders and the cancel endpoint accept an Idempotency-Key too (optional).
Shipments and labels
AvailableShipment details with the scan history, a filtered and paginated list, and the label PDF.
Get a shipment
/v1/shipments/{shipmentId}Scope shipments:read. {shipmentId} is the Courier Tales Shipping ID from booking. The response is the shipment (fields below) plus events, newest first.
{
"success": true,
"data": {
"shipment": {
"shipmentId": "CT1000001",
"orderId": "API-2001",
"awbNumber": "AWBAMAZONSHIPPING0001",
"courier": "Amazon Shipping",
"mode": "Surface",
"status": "Pending",
"paymentMode": "Prepaid",
"codAmount": 0,
"charges": { "freight": 80.24, "cod": 0, "total": 80.24, "currency": "INR", "includesGst": true },
"chargeableWeightKg": 0.5,
"zone": "zone4",
"bookedAt": "2026-10-19T15:20:55.824Z",
"estimatedDeliveryDate": "2026-10-24T15:20:55.827Z",
"deliveredAt": null,
"ndr": null,
"rto": null,
"cancellable": true,
"trackingUrl": "https://couriertales.com/tracking/?awb=AWBAMAZONSHIPPING0001",
"updatedAt": "2026-10-19T15:20:55.826Z",
"livemode": true,
"events": [
{ "time": "2026-10-19T15:20:55.827Z", "status": "Pending", "statusCode": "PENDING", "location": "Origin", "message": "Shipment created and pending pickup" }
]
}
}
}
| Field | Meaning |
|---|---|
status | Pending (booked, awaiting pickup), Pickup, In Transit, Out for Delivery, Delivered, Partially Delivered, Exception, Rescheduled, Lost, Damaged, RTO, RTO In Transit, RTO Delivered, RTO Lost, Cancelled |
charges | What booking charged, in INR, including GST (total = freight + cod) |
codAmount | Amount the courier collects from the buyer (COD), else 0 |
ndr | Latest failed delivery attempt: date, reason, attempt, status; or null |
rto | Return to origin: date, reason; or null |
cancellable | true while the shipment can still be cancelled |
trackingUrl | Courier Tales tracking page for your buyer |
events[] | time, status, statusCode, location, message: courier scans and Courier Tales events (such as cancellation requests) |
Courier scans reach Courier Tales from the couriers with a delay, typically minutes, sometimes longer. Use webhooks instead of polling.
List shipments
/v1/shipments?orderId=&awb=&status=&from=&to=&limit=&cursor=Scope shipments:read. Newest first, without events. All filters are optional: orderId (your order number), awb, status, from / to (booking time, ISO 8601), limit (1-100, default 20). The response has shipments and nextCursor; pass cursor=<nextCursor> to get the next page, until nextCursor is null.
Download the label
/v1/shipments/{shipmentId}/labelScope labels:read. Returns application/pdf: the same label as the panel, with your Print Settings (paper size, labels per page, logo, hidden phone numbers). For Amazon Shipping it is Amazon’s own label. Downloading marks the label as downloaded in the panel. A cancelled shipment has no label (409 SHIPMENT_CANCELLED).
curl -o label_CT1000001.pdf https://api.couriertales.com/api/v1/shipments/CT1000001/label \
-H "Authorization: Bearer $CT_API_KEY"
Shipments of other accounts, and of the other mode (live or test), are never visible: they return 404 NOT_FOUND.
Cancellation
AvailableCancel a shipment that has not been picked up; refunded once to the wallet when the courier accepts.
/v1/shipments/{shipmentId}/cancelScope shipments:cancel. No body. The same process as cancelling in the panel:
- Only a shipment with status
Pending(booked, not yet picked up) can be cancelled. Once picked up, the answer is409 CANCEL_NOT_ALLOWED. Use the panel’s NDR and RTO tools instead. - Courier Tales asks the courier. The request is synchronous.
- If the courier accepts, the shipment becomes
Cancelled, the booking charge is refunded to your wallet once, and the order returns tonot_booked, so it can be booked again. - If the courier refuses or cannot be reached, nothing changes and nothing is refunded.
curl -X POST https://api.couriertales.com/api/v1/shipments/CT1000001/cancel \
-H "Authorization: Bearer $CT_API_KEY"
200 OK (shortened):
{
"success": true,
"data": {
"shipment": { "shipmentId": "CT1000001", "orderId": "API-4001", "awbNumber": "AWBAMAZONSHIPPING0001", "status": "Cancelled", "cancellable": false, "livemode": true },
"refunded": true
}
}
Cancelling an already cancelled shipment returns 200 with "alreadyCancelled": true and refunds nothing, so repeating the request is safe.
| HTTP | error.code | Meaning | What to do |
|---|---|---|---|
| 409 | CANCEL_NOT_ALLOWED | Not Pending any more (current status included) | Nothing; it can no longer be cancelled |
| 422 | COURIER_REFUSED | The courier refused; its reason is in message | Contact support if needed |
| 502 | COURIER_UNAVAILABLE | The courier could not be reached; nothing changed | Retry later |
| 409 | CANCEL_NOT_SUPPORTED | This courier’s shipments cannot be cancelled through Courier Tales | Contact support |
| 404 | NOT_FOUND | No such shipment in this account and mode |
{
"success": false,
"error": { "code": "CANCEL_NOT_ALLOWED", "message": "Only shipments that are not yet picked up can be cancelled (status: In Transit)", "status": "In Transit" },
"requestId": "1f6c0e2a-7b8f-4a59-9a43-2d1f0b6c9e11"
}
A cancellation also sends the webhook shipment.cancelled. If the order came from Shopify or WooCommerce, the store is updated as when cancelling in the panel.
NDR actions
AvailableSee failed delivery attempts and tell the courier what to do - re-attempt, new address, new phone or return to origin.
When a courier cannot deliver (buyer not available, wrong address, refused…), the shipment gets an NDR (non-delivery report). You receive the webhook shipment.ndr_raised, and you can act through the API exactly as in the panel’s NDR page.
List NDRs
/v1/ndr?status=action_required&limit=50Scope shipments:read. Shipments with a failed delivery attempt, latest first. status (optional): action_required, action_taken, delivered, rto, closed. limit 1-200 (default 50).
{
"success": true,
"data": {
"ndr": [
{
"shipmentId": "N0000001",
"awbNumber": "AWB-NDR-1",
"orderId": "ORD-N1",
"courier": "Xpress Bees",
"status": "Exception",
"ndrStatus": "action_required",
"attempts": 1,
"latest": { "date": "2026-10-19T16:03:56.152Z", "reason": "Customer not available", "attempt": 1, "status": "Pending", "sellerAction": null, "sellerActionAt": null, "message": null },
"availableActions": ["reattempt", "change_address", "change_phone"],
"sentToCourier": false,
"livemode": true
}
]
}
}
availableActions are the actions this courier accepts. sentToCourier says whether Courier Tales sends them to the courier automatically for this courier. When it is false, the action is recorded, and the Courier Tales team relays it.
/v1/shipments/{shipmentId}/ndrScope shipments:read. The same view for one shipment (404 NDR_NOT_FOUND when it has no failed attempt).
Take an action
/v1/shipments/{shipmentId}/ndr/actionsScope ndr:write. One action per failed attempt, while ndrStatus is action_required.
action | Extra fields | Effect |
|---|---|---|
reattempt | reattemptDate (required, future date YYYY-MM-DD) | Deliver again on that date |
change_address | address: lines (1-2 of { line1, line2? }), pincode (6 digits; city and state are filled in from it), landmark?, customerName?; optional reattemptDate | Deliver to the corrected address |
change_phone | phone (10-digit mobile), alternatePhone?; optional reattemptDate | Courier calls the new number |
rto | — | Return the shipment to you (RTO charges apply as in the panel) |
All actions accept an optional remarks (up to 500 characters).
curl -X POST https://api.couriertales.com/api/v1/shipments/N0000001/ndr/actions \
-H "Authorization: Bearer $CT_API_KEY" -H "Content-Type: application/json" \
-d '{"action":"reattempt","reattemptDate":"2026-10-21"}'
{
"success": true,
"data": {
"result": "applied",
"courier": { "state": "saved", "message": "Saved in Courier Tales; not sent to courier" },
"ndr": { "shipmentId": "N0000001", "ndrStatus": "action_taken", "latest": { "sellerAction": "Reschedule", "sellerActionAt": "2026-10-19T16:03:56.558Z" } }
}
}
resultisapplied, oralready_actionedwhen the same attempt was already handled. Repeating the request is safe.courier.state:saved: recorded, not sent automatically;accepted: the courier’s API took it;rejected: the courier refused, with its reason inmessage;unknown: no clear answer; the team checks it.
| HTTP | error.code | Meaning |
|---|---|---|
| 409 | NDR_NOT_ACTIONABLE | Nothing is waiting for your action (delivered, returning, closed or already actioned) |
| 422 | ACTION_NOT_SUPPORTED | This courier does not accept this action through its API |
| 422 | VALIDATION_ERROR | Missing or invalid fields (for example a past reattemptDate) |
| 404 | NDR_NOT_FOUND | The shipment has no failed delivery attempt |
Delivery instruction
/v1/shipments/{shipmentId}/ndr/messageScope ndr:write. Body { "text": "Call before delivery" } (1-250 characters). It is saved on the latest attempt, shown to the Courier Tales team, and sent with a re-attempt where the courier supports it. No courier accepts a stand-alone message, so the answer says sentToCourier: false.
Reverse pickups
AvailablePick up a return from your buyer and bring it back to your warehouse.
Reverse pickups are booked with Delhivery and charged to your wallet, as in the panel (Reverse Order). Every record has a ref such as RVP-261019-D094F66.
1. Check a delivered shipment (optional)
/v1/reverse-pickups/eligibility/{shipmentId}Scope shipments:read. {shipmentId} is the Shipping ID or AWB of one of your shipments. Only delivered shipments can be returned, once. When eligible, the answer contains a prefill with the buyer’s address, items, package and your warehouse, taken from the original order:
{
"success": true,
"data": {
"eligible": true,
"forwardShipmentId": "P0000001",
"prefill": {
"customer": { "name": "Anita Sharma", "line1": "Flat 4B, Green Residency", "city": "Mumbai", "state": "Maharashtra", "pincode": "400001", "phone": "9876511111" },
"warehouseAddressId": "6ad63f684e40e5e9707ddc9c",
"items": [{ "name": "Cotton Kurta", "description": "Cotton Kurta", "sku": "KUR-M", "quantity": 2, "unitValue": 799 }],
"package": { "lengthCm": 30, "widthCm": 25, "heightCm": 5, "weightKg": 0.5 },
"sellerOrderId": "ORD-77"
}
}
}
Not eligible: "eligible": false with code NOT_DELIVERED, RETURN_EXISTS or NOT_FOUND.
2. Quote
/v1/reverse-pickups/quotesScope rates:read. The same body as booking (below). Returns price (your plan, incl. GST) and zone. Nothing is saved or charged.
3. Book
/v1/reverse-pickupsScope shipments:write. Requires an Idempotency-Key. Charges your wallet, then books with Delhivery.
| Field | Type | Required | Notes |
|---|---|---|---|
forwardShipmentId | string | no | Shipping ID or AWB of the delivered shipment. Links the return to it and blocks a second return. |
customer | object | yes | Where to pick up: name, phone (10 digits), line1, line2?, pincode, city, state (city/state are filled in from the pincode), email? |
warehouseAddressId | string | no | Your pickup address that receives the return. Default: your default pickup address. |
returnReason | string | yes | Up to 200 characters |
package | object | yes | lengthCm, widthCm, heightCm, weightKg (> 0) |
items | array | yes | 1-20 of { name, sku?, quantity, unitValue?, description? } |
mode | string | no | Surface (default) or Air |
sellerOrderId | string | no | Your reference |
expectedTotal | number | recommended | The quoted price.total; a different price returns 409 PRICE_CHANGED with nothing charged |
201 Created (shortened):
{
"success": true,
"data": {
"reversePickup": {
"ref": "RVP-261019-D094F66",
"state": "BOOKED",
"forwardShipmentId": "P0000001",
"sellerOrderId": "ORD-77",
"returnReason": "Size too small",
"mode": "Surface",
"awbNumber": "90000001",
"price": { "total": 63.72, "currency": "INR", "includesGst": true, "lines": [{ "code": "FREIGHT", "label": "Base freight", "amount": 54 }], "gst": { "rate": 0.18, "amount": 9.72 }, "chargeableWeightKg": 1, "zone": "zone4" },
"charged": true,
"refunded": false,
"pickupFrom": { "city": "Mumbai", "state": "Maharashtra", "pincode": "400001" },
"warehouse": { "city": "New Delhi", "state": "Delhi", "pincode": "110020" },
"cancellable": true,
"livemode": true
},
"created": true
}
}
If Delhivery refuses, the charge is refunded and you get the error with the record in error.reversePickup (state FAILED). A record saved but not booked (for example after INSUFFICIENT_WALLET_BALANCE) can be booked later with POST /v1/reverse-pickups/{ref}/book (body { "expectedTotal"? }).
Reverse pickup states
CREATED → CHARGED → SUBMITTING → BOOKED → PICKUP_PENDING → PICKED_UP → IN_TRANSIT → OUT_FOR_DELIVERY → DELIVERED_TO_WAREHOUSE.
Other states:
PICKUP_CANCELLED,CANCELLED;FAILED: refused, refunded;NEEDS_CHECK: unclear courier answer; Courier Tales staff resolve it;LOST.
You get reverse_pickup.updated on every change.
Read, list and cancel reverse pickups
GET /v1/reverse-pickups/{ref}: details, with customer, items and events.GET /v1/reverse-pickups?state=&forwardShipmentId=&sellerOrderId=&from=&to=&limit=&cursor=: list, newest first.POST /v1/reverse-pickups/{ref}/cancel: allowed whilecancellableistrue.- Before the pickup is sent to Delhivery, it is cancelled and refunded at once.
- Once booked, Delhivery is asked first. If Delhivery refuses because the agent is already out for pickup, a cancellation request stays open (
cancelRequested: true), and the refund follows automatically when Delhivery reports the pickup cancelled.
B2B cargo
AvailableMulti-box cargo shipments (LR) with Delhivery B2B - quote, book, LR number and box labels, cancel.
B2B shipments carry one or more boxes under one LR (lorry receipt), booked with Delhivery B2B and charged to your wallet, as in the panel (B2B Cargo). Every record has a ref such as B2B-261019-70B782B.
B2B quote
/v1/b2b/quotesScope rates:read. The same body as booking. Nothing is saved or charged.
{
"success": true,
"data": {
"price": {
"total": 931.22, "currency": "INR", "includesGst": true,
"lines": [
{ "code": "FREIGHT", "label": "Base freight", "amount": 455.8 },
{ "code": "AWB", "label": "AWB", "amount": 100 },
{ "code": "FIRST_MILE", "label": "First mile", "amount": 40 },
{ "code": "GREEN", "label": "Green", "amount": 50 },
{ "code": "ROV_OWNER", "label": "ROV owner", "amount": 75 },
{ "code": "FSC", "label": "Fuel surcharge", "amount": 68.37 }
],
"gst": { "rate": 0.18, "amount": 142.05 },
"chargeableWeightKg": 43,
"zone": { "from": "N1", "to": "W2" }
},
"regions": { "from": "N1", "to": "W2" },
"serviceability": { "pickup": true, "delivery": true, "oda": false, "cod": null }
}
}
Book B2B cargo
/v1/b2b/shipmentsScope shipments:write. Requires an Idempotency-Key.
| Field | Type | Required | Notes |
|---|---|---|---|
pickupAddressId | string | no | Your pickup warehouse. Default: your default pickup address. |
consignee | object | yes | name, company?, phone (10 digits), line1, line2?, pincode, city, state (filled in from the pincode), email?, gstin? |
description | string | yes | What the goods are (up to 200 characters) |
boxes | array | yes | { count (≥ 1, default 1), lengthCm, widthCm, heightCm (≤ 1000), weightKg (≤ 5000 per box) }; at most 999 boxes in total |
invoices | array | yes | { number, amount, date?, ewaybill?, fileKey? }. An e-way bill (12 digits) is required on each invoice when the total invoice value is above ₹50,000. |
paymentMode | string | yes | PREPAID or COD (COD only if enabled for your account) |
codAmount | number | COD | Amount to collect |
riskType | string | yes | OWNER (owner’s risk) or CARRIER (carrier’s risk) |
fmPickup | boolean | yes | true: Delhivery picks up from your warehouse; false: you drop the boxes at Delhivery |
freightMode | string | no | PAID (default) or TO_PAY, if enabled for your account |
insurance, appointment | boolean | no | |
sellerOrderId | string | no | Your reference |
expectedTotal | number | recommended | The quoted price.total; a different price returns 409 PRICE_CHANGED with nothing charged |
If your account requires invoice copies, upload each one first with POST /v1/b2b/invoice-files (multipart field file; PDF, PNG or JPG up to 10 MB). Then send the returned fileKey with its invoice.
201 Created (shortened):
{
"success": true,
"data": {
"b2bShipment": {
"ref": "B2B-261019-70B782B",
"state": "SUBMITTED",
"paymentMode": "PREPAID",
"boxes": 3,
"invoiceValue": 42000,
"price": { "total": 931.22, "currency": "INR", "includesGst": true },
"lrNumber": null,
"waybills": [],
"destination": { "city": "Mumbai", "state": "Maharashtra", "pincode": "400001" },
"documents": { "labels": false, "lrCopy": false },
"charged": true,
"refunded": false,
"cancellable": false,
"livemode": true
},
"created": true
}
}
The LR number comes a little later.
- Delhivery accepts the request first (
SUBMITTED) and issues the LR asynchronously. - Courier Tales checks automatically, first after about a minute, and moves the shipment to
MANIFESTEDwithlrNumberand onewaybillsentry per box. - You get
b2b_shipment.updated, or you can readGET /v1/b2b/shipments/{ref}.
If something goes wrong:
- If Delhivery refuses, the charge is refunded and the error includes the record (
error.b2bShipment, stateFAILED). - An unclear answer becomes
NEEDS_CHECK. Courier Tales staff verify it with Delhivery and never book it twice. - A record saved but not booked (for example after
INSUFFICIENT_WALLET_BALANCE) can be booked withPOST /v1/b2b/shipments/{ref}/book.
B2B documents
GET /v1/b2b/shipments/{ref}/labels: Delhivery’s box labels (PDF), once the LR exists (else409 NO_LR).GET /v1/b2b/shipments/{ref}/lr-copy: the LR copy (PDF).
Scope labels:read.
B2B states
CREATED → CHARGED → SUBMITTING → SUBMITTED → MANIFESTED → PICKUP_SCHEDULED → PICKED_UP → IN_TRANSIT → OUT_FOR_DELIVERY → DELIVERED.
Other states:
- delivery problems:
NOT_PICKED,UNDELIVERED_ATTEMPT,PARTIALLY_DELIVERED; - returns:
RTO_IN_TRANSIT,RTO_DELIVERED(RTO charges apply as in the panel); LOST;FAILED: refused, refunded;NEEDS_CHECK,CANCELLED.
Read, list and cancel B2B shipments
GET /v1/b2b/shipments/{ref}: details, with addresses, boxes, invoices and events.GET /v1/b2b/shipments?state=&sellerOrderId=&from=&to=&limit=&cursor=: list, newest first.POST /v1/b2b/shipments/{ref}/cancel:- possible before submission, or once the LR exists until pickup (
cancellable); refunded when cancelled; - between submission and LR:
409 CANCEL_NOT_POSSIBLE_YET; - Delhivery refusal:
422 COURIER_REFUSED; - no confirmation:
502 CANCEL_OUTCOME_UNKNOWN. Check the state again later.
- possible before submission, or once the LR exists until pickup (
International
AvailableShip a parcel abroad through Courier Tales - request, send it to our warehouse, final price after measurement, tracking.
International shipments are handled by Courier Tales: you request one, send the parcel to the Courier Tales warehouse, and we book it with the carrier after checking it, as in the panel (International). Every record has a ref such as INT-261019-148BBA0.
Availability. GET /v1/international/options returns "enabled" and "acceptingRequests". When Courier Tales has not opened international shipping for requests yet, creating one returns 403 SERVICE_DISABLED or 503 NOT_CONFIGURED, and nothing is charged.
How international shipping works
- Rates:
POST /v1/international/rateswith{ "countryCode": "US", "parcel": { "lengthCm", "widthCm", "heightCm", "weightKg" } }. Returns every carrier’s price (incl. GST) at your plan. - Request:
POST /v1/international/shipments. The estimate is debited from your wallet (stateREQUESTED). - Send the parcel to the Courier Tales warehouse, with the inbound label
GET /v1/international/shipments/{ref}/inbound-label(PDF) on it. - Measurement: the team weighs and measures it (
RECEIVED). The final price is set (PRICE_CONFIRMED): any difference is debited or refunded automatically. If your wallet does not cover an increase, the shipment waits inON_HOLD_PAYMENTwithamountDue. Recharge, then pay withPOST /v1/international/shipments/{ref}/pay. - Booking and tracking: Courier Tales books it with the carrier (
BOOKED,trackingNumber). ThenIN_TRANSIT,DELIVERED(orRETURNED,EXCEPTION), thenCLOSED. The carrier label is atGET …/{ref}/carrier-labelonce booked.
REFUSED means the parcel cannot be shipped (for example a prohibited item); everything held is refunded. You get international_shipment.updated on every change.
International request fields
/v1/international/shipmentsScope shipments:write. Requires an Idempotency-Key.
| Field | Type | Required | Notes |
|---|---|---|---|
carrier | string | yes | One of carriers from /v1/international/options |
senderAddressId | string | no | Your pickup address used as sender. Default: your default pickup address. |
receiver | object | yes | name, line1, line2?, city, state?, postalCode?, countryCode (ISO, not IN), phone (with country code), email?, company? |
parcel | object | yes | lengthCm, widthCm, heightCm, weightKg (≤ 300) |
contents | array | yes | { description (≥ 3 characters), hsCode? (6-10 digits), quantity (whole number), unitValue, originCountry? (default IN) } |
currency | string | no | Currency of the values: INR (default), USD, EUR, GBP, AED, AUD, CAD, SGD, SAR, JPY, CHF, NZD, MYR, HKD |
customs.purpose | string | yes | SALE, GIFT, SAMPLE, RETURN, PERSONAL, OTHER. For SALE, customs.invoiceNumber is required. |
customs.iec | string | no | Importer-Exporter Code (10 characters) |
dutiesAcknowledged | boolean | yes | Must be true: destination duties and taxes are not included |
sellerOrderId | string | no | Your reference |
expectedTotal | number | recommended | The estimate you were shown; a different price returns 409 PRICE_CHANGED |
201 Created (shortened):
{
"success": true,
"data": {
"internationalShipment": {
"ref": "INT-261019-148BBA0",
"state": "REQUESTED",
"carrier": "FedEx",
"destination": { "countryCode": "US", "countryName": "United States", "city": "San Francisco" },
"parcel": { "lengthCm": 20, "widthCm": 20, "heightCm": 10, "weightKg": 0.9 },
"declaredValue": 45,
"currency": "USD",
"estimate": { "total": 2400.12, "currency": "INR", "includesGst": true, "lines": [{ "code": "FREIGHT", "label": "Base freight", "amount": 1695 }, { "code": "FSC", "label": "Fuel surcharge", "amount": 339 }], "gst": { "rate": 0.18, "amount": 366.12 }, "chargeableWeightKg": 1, "zone": "US" },
"final": null,
"amountDue": 0,
"trackingNumber": null,
"cancellable": true,
"livemode": true
},
"created": true
}
}
Read, list and cancel international shipments
GET /v1/international/shipments/{ref}: details, with receiver, contents, customs, measurements,payments(wallet entries),statusHistoryandtrackingEvents.GET /v1/international/shipments?state=&carrier=&sellerOrderId=&from=&to=&limit=&cursor=: list.POST /v1/international/shipments/{ref}/cancel: only whileREQUESTED(before the parcel reaches the warehouse); everything is refunded. Later:409 CANCEL_NOT_ALLOWED, contact support.
Webhooks
AvailableSigned HTTPS notifications for every shipment status change, retried for about two days.
Set up an endpoint
In Settings → API & Webhooks the account owner adds an HTTPS URL, choosing Live (real shipments) or Test (test mode shipments). Up to 5 endpoints per mode. The signing secret (whsec_…) is shown once; store it on your server. Send test queues a webhook.test event. New secret replaces the secret immediately.
The URL must be a public https:// address on port 443. Courier Tales refuses private, local and cloud-metadata addresses, also when a hostname resolves to one. Query strings are removed.
Events
type | When |
|---|---|
shipment.booked | A shipment was booked (API or panel) |
shipment.picked_up | Status Pickup |
shipment.in_transit | Status In Transit (again after a return to transit) |
shipment.out_for_delivery | Status Out for Delivery |
shipment.delivered | Status Delivered or Partially Delivered |
shipment.ndr_raised | A failed delivery attempt was recorded (shipment.ndr) |
shipment.rto_initiated, shipment.rto_in_transit, shipment.rto_delivered | Return to origin |
shipment.cancelled | Cancelled (API, panel or courier) |
shipment.exception | Exception, Rescheduled, Lost, Damaged, RTO Lost (exact value in shipment.status) |
b2b_shipment.updated | A B2B shipment changed state (data.state, data.b2bShipment), for example SUBMITTED → MANIFESTED when the LR is issued |
reverse_pickup.updated | A reverse pickup changed state (data.state, data.reversePickup) |
international_shipment.updated | An international shipment changed state (data.state, data.internationalShipment) |
webhook.test | Send test in the panel |
Live endpoints receive events for all of your shipments, whether booked through the API or the panel, from the moment the endpoint is added; earlier changes are not replayed. Changes are detected about every 30 seconds.
Request
POST /courier-tales/webhooks HTTP/1.1
Content-Type: application/json
User-Agent: CourierTales-Webhooks/1.0
X-CT-Event-Id: evt_da991b7cce0ee594a4182be7
X-CT-Event-Type: shipment.delivered
X-CT-Delivery-Attempt: 1
X-CT-Signature: t=1792423263,v1=a0fa9cea2a96f07e7403eb81079dddcd4083c39fef8c00a9aaeec16ca01ca3ce
{
"id": "evt_da991b7cce0ee594a4182be7",
"type": "shipment.delivered",
"created": "2026-10-19T15:21:03.693Z",
"livemode": true,
"data": {
"shipment": {
"shipmentId": "CT1000001",
"orderId": "L-1001",
"awbNumber": "AWBAMAZONSHIPPING0001",
"courier": "Amazon Shipping",
"mode": "Surface",
"status": "Delivered",
"paymentMode": "Prepaid",
"codAmount": 0,
"charges": { "freight": 80.24, "cod": 0, "total": 80.24, "currency": "INR", "includesGst": true },
"chargeableWeightKg": 0.5,
"zone": "zone4",
"bookedAt": "2026-10-19T15:21:03.284Z",
"estimatedDeliveryDate": "2026-10-24T15:21:03.288Z",
"deliveredAt": "2026-10-19T15:21:03.672Z",
"ndr": { "date": "2026-10-19T15:21:03.560Z", "reason": "Customer not available", "attempt": 1, "status": "Pending" },
"rto": null,
"cancellable": false,
"trackingUrl": "https://couriertales.com/tracking/?awb=AWBAMAZONSHIPPING0001",
"updatedAt": "2026-10-19T15:21:03.684Z",
"livemode": true
}
}
}
data.shipment has the same fields as Get a shipment, without events. The *.updated events carry the short form of the record (as in its list endpoint) plus data.state. Buyer names, addresses and phone numbers are never included.
Verify the signature
X-CT-Signature is t=<unix seconds>,v1=<HMAC-SHA256 hex>, computed over <t>.<raw request body> with your signing secret. Verify it on the raw body, before parsing the JSON, and reject timestamps older than 5 minutes.
// Node.js (Express): keep the raw body for this route
import crypto from "node:crypto";
app.post("/courier-tales/webhooks", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("X-CT-Signature") || "";
const t = /t=(\d+)/.exec(header)?.[1];
const v1 = /v1=([0-9a-f]{64})/.exec(header)?.[1];
const expected = crypto.createHmac("sha256", process.env.CT_WEBHOOK_SECRET).update(`${t}.${req.body}`).digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300;
if (!t || !v1 || !fresh || !crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected))) return res.sendStatus(400);
const event = JSON.parse(req.body);
if (alreadyProcessed(event.id)) return res.sendStatus(200); // duplicates are possible
queueForProcessing(event); // answer fast, work later
return res.sendStatus(200);
});
import hmac, hashlib, time
def verify(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
expected = hmac.new(secret.encode(), f"{parts['t']}.".encode() + raw_body, hashlib.sha256).hexdigest()
return abs(time.time() - int(parts["t"])) < 300 and hmac.compare_digest(expected, parts.get("v1", ""))
Delivery and retries
- Answer with any 2xx status within 10 seconds. Redirects are not followed.
- Anything else (error status, timeout, connection failure) is retried after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h and 24 h: 8 attempts over about 45 hours. Then the delivery is marked failed. Send again in the panel re-sends it with the same event id.
- An endpoint that fails 200 attempts in a row is disabled; Enable it again in the panel.
- Each event has a unique
id, the same on every retry. Process eachidonce: duplicates can happen. - Ordering is not guaranteed. Use
data.shipment.updatedAt, or fetchGET /v1/shipments/{shipmentId}for the current state. - The panel shows the last 50 deliveries with their status, attempts and your endpoint’s last answer.
Rate estimate
AvailableIndicative charges per courier for one shipment, with serviceability, weight and a full price breakdown including GST.
/common/rate-calculator/v2/common/rate-calculator/v2?…Returns every courier option for one shipment. Each option shows whether the courier serves the route, the chargeable weight and the full charge breakdown. This is the same calculator used on our rate estimate page.
- Authentication: none.
- Prices: always the Courier Tales standard plan, the same for every caller. Your account’s own rates can be lower. The amount charged at booking depends on your plan, the courier you pick and the final weight. Treat these numbers as estimates.
- Side effects: none. Nothing is booked or charged, so the call is safe to retry.
- Rate limit: shared with other public requests, 300 requests per 5 minutes per IP address. See Rate limits.
POST takes a JSON body. GET takes the same fields as query parameters (B2B boxes[] needs POST).
Request fields
| Field | Type | Required | Example | Rules and notes |
|---|---|---|---|---|
service | string | yes | "B2C" | B2C (forward parcel), RP (reverse pickup; REVERSE and RVP also accepted), B2B (cargo), INTERNATIONAL (INTL). Not case-sensitive. |
originPin | string | yes, except INTERNATIONAL | "110020" | Pickup pincode, 6 digits. Also accepted as pickupPincode. |
destPin | string | yes, except INTERNATIONAL | "411028" | Delivery pincode, 6 digits. Also accepted as deliveryPincode. |
weightKg | number | yes, unless boxes is sent | 1.2 | Actual weight in kg. Greater than 0 and at most 10000. Also accepted as weight. |
l, b, h | number | no | 20, 15, 10 | Length, breadth and height in cm, used for volumetric weight (L × B × H ÷ 5000). Also accepted as length, breadth, height. If omitted, only the actual weight counts. |
boxes | array | no | see below | Several boxes (mainly B2B). Each item: lengthCm, widthCm, heightCm, weightKg (all greater than 0) and count (whole number 1–999, default 1). When boxes is sent, weightKg, l, b and h are ignored. |
paymentMode | string | no | "PREPAID" | PREPAID (default) or COD. Any other value is treated as PREPAID. |
codAmount | number | when COD for B2C and B2B | 1499 | Amount to collect from the buyer, in INR. shipmentValue is also accepted. |
invoiceValue | number | B2B only | 25000 | Invoice value in INR (B2B risk charges are a percentage of it). shipmentValue is also accepted. |
mode | string | no | "Surface" | Air or Surface (also accepted as serviceMode). Omit it to get both. Used for B2C and RP only. |
country | string | INTERNATIONAL only | "AE" | Destination country code or name (also accepted as countryCode). |
toPay, appointment, mall, csd, cheque, sunday, floor, reattempt | boolean | no | false | B2B only: optional delivery services that add charges. |
riskType | string | no | "OWNER" | B2B only: OWNER (default) or CARRIER. |
storageDays | number | no | 0 | B2B only: expected storage days at destination. |
The top-level dimension fields are l/b/h or length/breadth/height. The lengthCm, widthCm and heightCm names only work inside boxes. Unknown fields are ignored.
Example request
cURL
curl -X POST "https://api.couriertales.com/api/common/rate-calculator/v2" \
-H "Content-Type: application/json" \
-d '{
"service": "B2C",
"originPin": "110020",
"destPin": "411028",
"weightKg": 1.2,
"l": 20, "b": 15, "h": 10,
"paymentMode": "PREPAID",
"mode": "Surface"
}'
JavaScript (Node.js 18+, server side)
const res = await fetch("https://api.couriertales.com/api/common/rate-calculator/v2", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
service: "B2C",
originPin: "110020",
destPin: "411028",
weightKg: 1.2,
l: 20, b: 15, h: 10,
paymentMode: "PREPAID",
mode: "Surface",
}),
});
const body = await res.json();
if (!res.ok || !body.success) {
throw new Error(`${res.status} ${body.error?.code}: ${body.error?.message}`);
}
const options = body.data.options.filter((o) => o.available); // cheapest first
Python
import requests
r = requests.post(
"https://api.couriertales.com/api/common/rate-calculator/v2",
json={"service": "B2C", "originPin": "110020", "destPin": "411028",
"weightKg": 1.2, "l": 20, "b": 15, "h": 10,
"paymentMode": "PREPAID", "mode": "Surface"},
timeout=30,
)
body = r.json()
available = [o for o in body["data"]["options"] if o["available"]] if body.get("success") else []
Example response
200 OK. This response comes from our automated test with test rate data, shortened to two options. Live prices differ.
{
"success": true,
"message": "Success",
"data": {
"service": "B2C",
"plan": { "name": "Bronze", "code": "BRONZE", "via": "STANDARD" },
"paymentMode": "PREPAID",
"zone": "zone6",
"origin": { "pincode": "110020", "city": "New Delhi", "state": "Delhi" },
"destination": { "pincode": "411028", "city": "Pune", "state": "Maharashtra" },
"options": [
{
"courier": "Xpress Bees",
"courierId": "6ad6260d7a8e91405e79fb7e",
"mode": "Surface",
"plan": "Bronze",
"available": true,
"serviceability": {
"serviceable": true, "status": "serviceable", "source": "file",
"pickup": true, "delivery": true, "cod": true, "oda": null
},
"weight": {
"actualKg": 1.2,
"volumetricKg": 0.6,
"chargeableKg": 1.5,
"explanation": "Actual 1.2 kg, volumetric 0.6 kg (L×B×H ÷ 5000), larger of total actual/total volumetric = 1.2 kg → chargeable 1.5 kg"
},
"breakdown": {
"base": 86, "cod": 0, "fsc": 0, "peak": 0, "other": 0,
"charges": [
{ "code": "FREIGHT", "label": "Base freight", "amount": 86, "basis": "zone6, slab up to 1.5 kg: ₹86.00" },
{ "code": "FSC", "label": "Fuel surcharge", "amount": 0, "basis": "0% of freight ₹86.00 = ₹0.00" },
{ "code": "PEAK", "label": "Peak surcharge", "amount": 0, "basis": "₹0.00 per shipment = ₹0.00" }
],
"subtotal": 86,
"gstRate": 0.18,
"gst": 15.48,
"total": 101.48
},
"freightCharge": 101.48,
"codCharge": 0
},
{
"courier": "Delhivery",
"courierId": "6ad6260d7a8e91405e79fb7f",
"mode": "Surface",
"plan": "Bronze",
"available": false,
"reason": "PIN CODE NOT SERVICEABLE",
"serviceability": {
"serviceable": false, "status": "not_serviceable", "source": "file",
"pickup": false, "delivery": false, "cod": null, "oda": null,
"reason": "PIN CODE NOT SERVICEABLE"
}
}
],
"currency": "INR",
"note": "All rates exclude GST; GST is added on top (shown separately)."
}
}
Response fields
| Field | Type | Meaning |
|---|---|---|
service, paymentMode | string | The service and payment mode that were priced, after normalisation. |
plan | object or null | The price plan used: always the standard plan (via: "STANDARD"). |
zone | string or object | B2C/RP: zone1 within city, zone2 within state, zone3 within cluster, zone4 metro to metro, zone5 special location, zone6 rest of India. B2B: { "from", "to" } regions, or null. Not returned for INTERNATIONAL. |
origin, destination | object | B2C/RP only: pincode, city and state. |
country | object | INTERNATIONAL only: { "code", "name" }. |
options[] | array | One entry per courier and mode. Available options come first, cheapest first. |
options[].available | boolean | true if the courier can carry this shipment. If false, reason says why, sometimes with a code. Couriers that are temporarily paused are listed as unavailable. |
options[].serviceability | object | serviceable, status (serviceable, not_serviceable or unknown), source (where the answer came from), pickup, delivery, cod and oda (true, false or null when not known). |
options[].weight | object | actualKg, volumetricKg, chargeableKg (after rounding to the rate slab) and a readable explanation. |
options[].breakdown | object | Charges in INR: base, cod, fsc (fuel), peak, other (B2C/RP) or dph (B2B/INTERNATIONAL), each line in charges[], subtotal, gstRate, gst, and total = subtotal + gst. |
options[].freightCharge, options[].codCharge | number | B2C/RP: the freight part and the COD part of total, each including GST. |
options[].courierId | string or null | Internal identifier. Do not store it or rely on it. |
currency, note | string | Always INR, plus a note about GST. The note says rates exclude GST: that applies to subtotal. total includes GST. |
Errors for this endpoint
| HTTP | error.code | When |
|---|---|---|
| 422 | VALIDATION_ERROR | A field is missing or invalid. error.details[] lists each field with a message. |
| 422 | ZONE_UNKNOWN | A pincode is not in the Courier Tales pincode list. |
| 422 | other pricing codes | The shipment cannot be priced at all. Problems with one courier never fail the request; they appear on that option instead. |
| 429 | RATE_LIMIT_EXCEEDED | Too many requests. See Rate limits. |
| 500 | SERVER_ERROR | Unexpected error. Retry later with backoff. |
Validation error example (422):
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "codAmount: enter the amount to collect",
"details": [{ "field": "codAmount", "message": "enter the amount to collect" }]
}
}
COD example
curl -X POST "https://api.couriertales.com/api/common/rate-calculator/v2" \
-H "Content-Type: application/json" \
-d '{"service":"B2C","originPin":"110020","destPin":"411028","weightKg":0.5,"paymentMode":"COD","codAmount":1499}'
COD charges appear as breakdown.cod and in codCharge. An option is unavailable when the delivery pincode does not accept COD.
Custom API channel (order import)
AvailableCourier Tales fetches your orders from an HTTPS endpoint you host, with a key and secret you choose. Orders arrive ready to book.
Use this channel to send orders from your own website, ERP, OMS or panel into Courier Tales. You host the endpoint and Courier Tales calls it. You never have to give anyone your Courier Tales password.
Imported orders appear in the seller panel as Not Booked. You choose the courier and book them in the panel. Importing never books a shipment and never charges your wallet.
How it works
- Build an HTTPS endpoint that returns your ready-to-ship orders as JSON, in the format below.
- Create a random API key and API secret, at least 32 characters each. Your endpoint must reject any request without them.
- In the seller panel, open Settings → Channel, choose Custom API, then enter the API key, API secret and API URL.
- On Booked Orders, click Import from Channel. Courier Tales calls your endpoint and adds each new order once.
Your endpoint: the request Courier Tales sends
GET https://orders.example.com/ct/orders?limit=100&offset=0&since_id=SO-10044
Content-Type: application/json
X-API-Key: <your API key>
X-API-Secret: <your API secret>
| Detail | Behaviour |
|---|---|
| Method | GET |
| URL | The API URL you entered. It must be a public https:// address. Private, local and plain http:// addresses are refused when you connect and on every call. |
| Headers | X-API-Key and X-API-Secret, exactly as you entered them. Check both on every request, using a constant-time comparison. |
limit, offset | Always 100 and 0. One import reads at most 100 orders. |
since_id | Sent from the second import onwards: the id of the last order in the list you returned last time. Return only orders after it, oldest first. |
since | Sent only when there is no since_id but an earlier import happened: ISO 8601 time of that import. |
| Timeout | 30 seconds. Redirects are not followed, so answer at the exact URL. |
| Errors | Any non-2xx answer fails that import, and the error includes your status code. Answer 401/403 when the key or secret is wrong. |
If more than 100 orders are waiting, run the import again until no new orders arrive.
Your endpoint: the response
Return 200 with JSON in any of these shapes:
{ "orders": [ { "id": "SO-10045" } ] }
The other accepted shapes are a bare array [ … ] and { "data": [ … ] }.
Example order (synthetic data):
{
"orders": [
{
"id": "SO-10045",
"order_number": "SO-10045",
"created_at": "2026-10-19T09:30:00Z",
"payment_method": "cod",
"shipping_address": {
"name": "Test Buyer",
"line1": "12, Synthetic Lane",
"line2": "Near Demo Park",
"landmark": "Opp. Sample Mall",
"city": "Pune",
"state": "Maharashtra",
"zip": "411028",
"phone": "9000000001"
},
"weight": 1.2,
"dimensions": { "length": 20, "width": 15, "height": 10 },
"line_items": [
{ "name": "Cotton T-shirt", "sku": "TS-BLU-M", "hsn": "6109", "quantity": 2, "price": 499 }
],
"shipping_charge": 50,
"cod_charge": 30,
"total": 1078,
"invoice_number": "INV-10045",
"invoice_date": "2026-10-19"
}
]
}
This imports as a COD order with a collectable amount of ₹1,078 (2 × 499 + 50 + 30), 1.2 kg and 20 × 15 × 10 cm.
Order fields
Send every field marked send. If one is missing, Courier Tales fills in a placeholder. The order still imports but may be refused at booking, or priced on the wrong weight.
| Field | Type | Send? | Accepted alternatives | If missing |
|---|---|---|---|---|
id | string | send | order_id | — This is your order ID in Courier Tales. It must be unique among your orders. |
order_number | string | optional | number | id |
created_at | ISO 8601 | optional | date, order_date | time of import |
payment_method | string | send | payment_mode, payment_type, or cod: true | Contains cod or cash → COD, anything else → prepaid |
shipping_address.name | string | send | customer_name | "Customer" |
shipping_address.line1 | string | send | address1, address.line1 | "Address not provided" |
shipping_address.line2 | string | optional | address2, address.line2 | — |
shipping_address.landmark | string | optional | address.landmark | — |
shipping_address.city | string | send | address.city | "City not provided" |
shipping_address.state | string | send | province, address.state | "State not provided" |
shipping_address.zip | string, 6 digits | send | postal_code, pincode, address.pincode | "000000" |
shipping_address.phone | string, 10 digits | send | customer.phone, phone | "0000000000" |
gst_number | string | optional | gst | — |
weight | number, kg | send | — | 0.5 |
dimensions.length, .width, .height | number, cm | send | .breadth; or top-level length, width/breadth, height | 10 each |
line_items[] | array | send | items, products | one item worth ₹0 |
line_items[].name | string | send | title, product_name | "Product" |
line_items[].sku | string | send | — | generated SKU-<number> |
line_items[].hsn | string | optional | hsn_code | — |
line_items[].quantity | number | send | — | 1 (decimals are kept) |
line_items[].price or .total | number, INR | send | — | price = unit price; total = whole line value (used if both are sent) |
line_items[].discount | number, INR | optional | — | 0 |
discount, coupon_code | number, string | optional | — | order-level discount |
shipping_charge | number, INR | optional | — | 0 |
cod_charge | number, INR | optional, COD only | — | 0 |
total | number, INR | send | — | calculated from the items. For COD this is the amount collected from the buyer. |
invoice_number, invoice_date | string, date | optional | invoice_id | — |
Prices are treated as including tax. Amounts are in INR.
Duplicates and edits
- An order is identified by
id. Importing the same order again never creates a copy, and it does not update the order already imported. To change an imported order, edit it in the panel. - If another of your channels already uses the same order ID, the Custom API order is saved as
CU-<id>. - Only one Custom API connection is allowed per account. To change the URL, key or secret, remove the channel in Settings → Channel and connect it again.
Automatic import
On the connected channel in Settings → Channel, tick Import new orders automatically (every 15 min). After 401/403 it stops until you switch it on again. After 429 it waits for Retry-After, or 15 minutes. After other errors it waits longer each time, up to 6 hours. If the switch has no effect on your account, use Import from Channel and contact us.
Tracking
AvailableTrack shipments through the API or webhooks, and share the tracking page with buyers.
In your systems
- Webhooks (recommended): every status change arrives on your endpoint within about a minute. See Webhooks.
- On demand:
GET /v1/shipments/{shipmentId}returns the current status and the full scan history, andGET /v1/shipments?awb=…finds a shipment by AWB. See Shipments and labels.
Tracking links for your buyers
Every shipment has a trackingUrl. It opens the Courier Tales tracking page, which shows the status and scan history without the buyer’s name, address or phone number:
https://couriertales.com/tracking/?awb=<AWB number>
?awb=looks the shipment up straight away.?shipmentId=<Shipping ID>and?orderId=<your order number>fill in the form and then ask for the buyer’s delivery phone number. Shipping IDs follow a sequence, so a phone number is required to stop people guessing them.
The tracking page uses an internal endpoint built for the website. It is rate-limited per visitor and may change without notice: do not call it from your systems. Use the API above instead.
Errors
HTTP status codes, error codes and what to do about each.
Error format
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "weightKg: must be a positive number (kg)",
"details": [{ "field": "weightKg", "message": "must be a positive number (kg)" }]
}
}
code: a stable, machine-readable value. Branch on this.message: a readable explanation. Show or log it, but do not parse it.details: added by the rate estimate for validation errors, one entry per invalid field./v1errors also carryrequestId, and some carry extra fields (for examplenewTotal,shipmentorstatus).
Status codes
| HTTP | error.code | Meaning | What to do |
|---|---|---|---|
| 200, 201 | — | Success. success is true. | Read data. |
| 202 | BOOKING_OUTCOME_UNKNOWN | The courier gave no clear answer; the order is locked. | Do not book again; check the order later (booking). |
| 400 | IDEMPOTENCY_KEY_REQUIRED | POST /v1/shipments without the header. | Add an Idempotency-Key. |
| 401, 403 | see Authentication | Missing, invalid or insufficient API key. | Fix the key or its permissions. |
| 402 | INSUFFICIENT_WALLET_BALANCE | Not enough wallet balance to book. | Recharge, then retry with a new key. |
| 404 | NOT_FOUND | Unknown order, shipment or route, or one of another account or mode. | Check the id and the key’s mode. |
| 409 | DUPLICATE_ORDER_ID, ORDER_ALREADY_BOOKED, PRICE_CHANGED, REQUEST_IN_PROGRESS, CANCEL_NOT_ALLOWED, … | The request conflicts with the current state. | Read message; see each section. |
| 422 | VALIDATION_ERROR | One or more fields are missing or invalid. | Fix the fields named in message / details. Do not retry unchanged. |
| 422 | BOOKING_FAILED, COURIER_REFUSED, IDEMPOTENCY_KEY_REUSED, … | The courier or the request cannot do this. | See booking and cancellation. |
| 422 | ZONE_UNKNOWN and other pricing codes | The request is valid but cannot be priced, for example an unknown pincode. | Check the input. Do not retry unchanged. |
| 429 | RATE_LIMIT_EXCEEDED | Rate limit reached. | Wait until the time in the RateLimit-Reset header, then retry. |
| 500 | SERVER_ERROR | Unexpected error on our side. | Retry with exponential backoff. If it persists, contact us. |
| 502 | COURIER_UNAVAILABLE | Cancellation: the courier could not be reached; nothing changed. | Retry later. |
| 502, 503, 504 | — | Temporarily unavailable. The body may not be JSON. | Retry with exponential backoff (bookings: same Idempotency-Key). |
Errors inside a successful response
For the rate estimate, a problem with one courier (not serviceable, COD not available, courier paused) does not fail the request. That option comes back with "available": false and a reason, sometimes with a code.
Rate limits and retries
How many requests you can make, the headers that tell you where you stand, and safe retry rules.
Limits
| What | Limit |
|---|---|
/v1 requests with an API key | 1000 requests per 5 minutes per key |
/v1 requests with an invalid key | 20 failures per 15 minutes per IP address, then all requests from that IP are refused for the rest of the window |
| Rate estimate and other public requests | 300 requests per 5 minutes per IP address |
| Public tracking page lookups | 30 per 5 minutes per IP address (website use only) |
Public limits are counted per IP address, so every server behind one outgoing IP shares one allowance; /v1 limits are counted per API key. We may change limits to protect the service. If your use case needs more, contact us.
Rate-limit headers
Limited responses carry the standard headers:
RateLimit-Limit: 300
RateLimit-Remaining: 287
RateLimit-Reset: 194
RateLimit-Reset is the number of seconds until the window resets. When you receive 429, wait at least that long.
Retrying safely
- Rate estimate: read-only, so retrying is always safe. Retry
429,500,502,503,504and network timeouts with exponential backoff, for example 1 s, 2 s, 4 s, 8 s, plus random jitter, up to 5 attempts. /v1reads (GET) are always safe to retry.- Booking (
POST /v1/shipments): retry only with the sameIdempotency-Key, so you get the first answer instead of a second shipment. Never retry202 BOOKING_OUTCOME_UNKNOWN. See booking. - Cancellation is safe to repeat: an already cancelled shipment answers
200withalreadyCancelled: true, and it is refunded only once. - Do not retry
422without changing the request. - Cache estimates. Prices for the same route, weight and payment mode rarely change within a day. Caching them, for example for 1 hour, keeps you well within the limit.
- Custom API channel: importing is safe to repeat. Orders are matched by
id, so an order is never imported twice.
async function withRetry(fn, attempts = 5) {
for (let i = 0; ; i++) {
const res = await fn().catch(() => null); // network error -> retry
const retryable = !res || res.status === 429 || res.status >= 500;
if (!retryable || i === attempts - 1) return res;
const reset = Number(res?.headers.get("RateLimit-Reset"));
const wait = res?.status === 429 && reset ? reset * 1000 : 2 ** i * 1000 + Math.random() * 250;
await new Promise((r) => setTimeout(r, wait));
}
} Security best practices
Protecting your API keys, your webhook and order endpoints, and your buyers' data.
API keys
- Keep keys on your server, in environment variables or a secrets manager. Never put them in browser code, mobile apps, URLs, logs or Git.
- Use test keys while building, and a separate live key per system, each with only the scopes it needs.
- Set an IP allowlist on live keys when your servers have fixed outgoing addresses.
- Revoke a key at once in Settings → API & Webhooks if it may have leaked, and create a new one. Revocation is immediate.
- Store the
shipmentIdandawbNumberyou get back; never book an order again just because you lost the answer. Use the sameIdempotency-Key, orGET /v1/orders/{orderId}.
Your webhook endpoint
- Verify
X-CT-Signatureon the raw body and reject timestamps older than 5 minutes (see Webhooks). - Treat events as notifications: for important decisions, read the current state with
GET /v1/shipments/{shipmentId}. - Process each event
idonce, answer2xxfast, and do the work afterwards. - Rotate the signing secret with New secret if it may have leaked.
Your Custom API endpoint
- Serve it over HTTPS only, with a valid certificate.
- Generate the API key and secret randomly, at least 32 characters each. Do not reuse passwords.
- Check
X-API-KeyandX-API-Secreton every request. Compare with a constant-time function, such ascrypto.timingSafeEqualin Node.js orhmac.compare_digestin Python. Answer401when they don’t match. - Return only orders that are ready to ship, and only the fields listed in Order fields. Do not include payment card details, passwords or anything Courier Tales does not need.
- Read-only: the endpoint should never change or delete data because of a request from Courier Tales.
- Rotate the secret if it may have leaked: change it on your side, then remove and reconnect the channel in the panel.
- Rate-limit and log access to the endpoint like any other endpoint that holds personal data.
// Node.js (Express): check the Courier Tales headers
import crypto from "node:crypto";
const same = (a, b) => {
const x = Buffer.from(String(a ?? "")), y = Buffer.from(String(b ?? ""));
return x.length === y.length && crypto.timingSafeEqual(x, y);
};
app.get("/ct/orders", (req, res) => {
if (!same(req.get("X-API-Key"), process.env.CT_PULL_KEY) || !same(req.get("X-API-Secret"), process.env.CT_PULL_SECRET)) {
return res.status(401).json({ error: "unauthorized" });
}
// return ready-to-ship orders after req.query.since_id, oldest first, at most 100
});
Credentials
- Keep keys and secrets in environment variables or a secrets manager, never in source code or Git.
- Never put credentials in browser JavaScript, mobile apps, URLs or query strings.
- Never share your Courier Tales panel password. Give team members their own employee login instead.
Buyers’ data
Orders contain buyers’ names, addresses and phone numbers. Collect and share only what delivery needs, keep it only as long as you need it, and follow applicable Indian data protection law.
Reporting a security issue
If you find a vulnerability, email the details to team@couriertales.com with “Security” in the subject. Please do not test against other sellers’ data.
Planned
PlannedCapabilities not yet in the API. Do not build against them.
Not available in the API yet. These work in the seller panel today. Endpoints, fields and dates are not final; this list shows direction only and is not a commitment.
| Capability | Today |
|---|---|
| Weight disputes and evidence upload | Seller panel |
| Booking many orders in one request | Book one order per request; the panel offers bulk booking |
| Manifests | Seller panel |
Want one of these through the API? Tell us what you need. Your answers decide the order in which we build them.
Changelog
Changes to the documented API and to this documentation.
| Date | Change |
|---|---|
| 2026-10-19 | NDR actions, reverse pickups, B2B cargo and international shipments in the API, with test mode, simulated states and *.updated webhooks; new scope ndr:write. |
| 2026-10-19 | API v1: API keys (live and test mode), orders, quotes, booking with Idempotency-Key, shipments, labels, cancellation, webhooks for every status change, and test mode with simulated statuses. |
| 2026-10-19 | Rate-limited responses (429) now carry error.code RATE_LIMIT_EXCEEDED (previously SERVER_ERROR). |
| 2026-10-19 | First published: rate estimate API, Custom API channel (order import), tracking links, errors, rate limits, security guidance and the planned API list. |
We will announce removed or renamed fields here before they change. New fields can appear at any time without notice.
Support
Get help with an integration or request a capability.
Email team@couriertales.com with “API” in the subject. Please include:
- your Courier Tales customer ID (shown in the seller panel), if you have an account;
- what you are building, and which section of this page it concerns;
- for a failed request: the endpoint, the time (with time zone), the HTTP status and the
error.code. Never include passwords, API secrets or buyers’ personal data.
To request a planned capability, tell us which one, your expected volume and how you would use it.
You can also call +91 70008-94678.