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.

Updated · OpenAPI 3.1 file · Changelog

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

CapabilityStatusSection
API keys with permissions, live and test modeAvailableAuthentication, Test mode
Create ordersAvailableOrders
Compare couriers at your own rates, and book (a chosen courier or your Courier Priority)AvailableQuotes and booking
AWB, shipment details, scan history, list with filtersAvailableShipments and labels
Shipping label PDFAvailableShipments and labels
Cancel a shipment that is not yet picked upAvailableCancellation
NDR actions: re-attempt, new address, new phone, return to originAvailableNDR actions
Reverse pickups (returns from buyers)AvailableReverse pickups
B2B cargo (multi-box LR shipments)AvailableB2B cargo
International shipmentsAvailableInternational
Webhooks for every status changeAvailableWebhooks
Public rate estimate (no key)AvailableRate estimate
Order import from your HTTPS endpointAvailableCustom API channel
Weight disputes, bulk booking and manifests through the APIPlannedPlanned

A typical integration

  1. Create a test key in the seller panel (Settings → API & Webhooks) and build against test mode: nothing is booked or charged.
  2. Create an order: POST /v1/orders.
  3. Get courier options at your rates: POST /v1/quotes.
  4. Book: POST /v1/shipments with an Idempotency-Key. You get the AWB in the response.
  5. Download the label: GET /v1/shipments/{shipmentId}/label.
  6. Receive status updates on your webhook endpoint, or read them with GET /v1/shipments/{shipmentId}.
  7. Cancel if needed, before pickup: POST /v1/shipments/{shipmentId}/cancel.
  8. 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 +91 or 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

Available

API 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 withMode
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)

ScopeAllows
orders:writePOST /v1/orders
orders:readGET /v1/orders/{orderId}, GET /v1/pickup-addresses
rates:readPOST /v1/quotes
shipments:writePOST /v1/shipments (booking; uses your wallet in live mode), test-mode simulate
shipments:readGET /v1/shipments, GET /v1/shipments/{shipmentId}
labels:readGET /v1/shipments/{shipmentId}/label
shipments:cancelPOST /v1/shipments/{shipmentId}/cancel
ndr:writeNDR 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

HTTPerror.codeMeaning
401UNAUTHENTICATEDNo Authorization: Bearer header
401INVALID_API_KEYUnknown or revoked key. Repeated failures from one IP are rate limited.
403INSUFFICIENT_SCOPEThe key lacks the permission for this route
403IP_NOT_ALLOWEDRequest from an IP outside the key’s allowlist
403KYC_NOT_APPROVEDLive keys work once the account’s KYC is approved; test keys work before
403ACCOUNT_DISABLEDThe 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

Available

Build 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 with TESTAWB, and charges.charged is false;
  • 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

POST/v1/test/shipments/{shipmentId}/simulate

Test 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"}'
statusEffectWebhook
PickupPicked up (can no longer be cancelled)shipment.picked_up
In TransitIn transitshipment.in_transit
Out for DeliveryOut for deliveryshipment.out_for_delivery
DeliveredDeliveredshipment.delivered
NDRFailed delivery attempt (status unchanged, ndr filled)shipment.ndr_raised
RTO, RTO In Transit, RTO DeliveredReturn to originshipment.rto_initiated, shipment.rto_in_transit, shipment.rto_delivered
ExceptionExceptionshipment.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 (state MANIFESTED).
  • Reverse pickups get a TESTAWB… waybill (state BOOKED).
  • International requests start in REQUESTED.
  • Refs start with TEST-B2B-, TEST-RVP- and TEST-INT-. Documents are sample PDFs, and nothing is charged.

Move them through any state with:

Endpointstate values
POST /v1/test/b2b/shipments/{ref}/simulateany B2B state after CREATED (for example PICKED_UP, IN_TRANSIT, DELIVERED, RTO_IN_TRANSIT)
POST /v1/test/reverse-pickups/{ref}/simulateany reverse pickup state after CREATED (for example PICKED_UP, DELIVERED_TO_WAREHOUSE)
POST /v1/test/international/shipments/{ref}/simulateany 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

Available

Create an order with the same rules as the seller panel, read it back, and list your pickup addresses.

Create an order

POST/v1/orders

Scope 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

FieldTypeRequiredRules
orderIdstringyesYour order number, unique per account. Up to 50 characters: letters, digits and - _ / # .; must start with a letter, digit or #.
orderDateISO dateyes
paymentModestringyesPrepaid or COD (exact case)
deliveryAddress.linesarrayyes1 or more { line1, line2? }
deliveryAddress.city, .state, .customerNamestringyes
deliveryAddress.pincodestringyes6 digits
deliveryAddress.landmarkstringno
deliveryAddress.emailstringnoBuyer email
deliveryAddress.gstNumberstringnoValid GSTIN or ""
deliveryPhonesarrayyes1-2 of { phoneNumber, phoneType }; phoneNumber 10 digits; phoneType Primary or Secondary
invoice.invoiceNumberstringyesUnique per account (409 DUPLICATE_INVOICE_NUMBER)
invoice.invoiceDateISO dateyes
invoice.shippingCharge, invoice.discountnumbernoOrder-level shipping charged to the buyer, and discount (INR)
ebn.hasEBNbooleanyesE-way bill: when true, ebnNumber and ebnExpiryDate are required
productsarrayyes1 or more
products[].productNamestringyes
products[].quantitynumberyesWhole number ≥ 1. Decimals (e.g. 1.5 m) only if Allow decimal quantity is on in Settings → Custom Rules.
products[].orderValuenumberyesValue of the whole line in INR (> 0; 0 only with isFreeSample: true)
products[].sku, products[].hsnstringno
physicalWeightnumberyesKilograms, > 0
dimensions.length, .breadth, .heightnumberyesCentimetres, > 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

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

GET/v1/pickup-addresses

Scope 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

Available

Compare 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

POST/v1/quotes

Scope rates:read. Prices from your rate plan, the same as the panel’s courier comparison, including GST. Nothing is booked or charged.

FieldTypeRequiredNotes
orderIdstringyesYour order number
pickupAddressIdstringnoDefault: your default pickup address
modestringnoSurface 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

POST/v1/shipments

Scope 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.

FieldTypeRequiredNotes
orderIdstringyesYour order number
courierobject or "auto"yes{ "courierId", "mode", "expectedTotal" } from a quote, or "auto"
courier.courierIdstringyes (object)From POST /v1/quotes
courier.modestringyes (object)Surface or Air
courier.expectedTotalnumberrecommendedThe quoted total. If the price changed, nothing is booked: 409 PRICE_CHANGED.
pickupAddressIdstringnoDefault: your default pickup address
rtoAddressIdstringnoAnother 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. Check GET /v1/orders/{orderId} later, or contact support. Do not book the order with another courier.

Booking errors

HTTPerror.codeMeaning
400IDEMPOTENCY_KEY_REQUIREDSend an Idempotency-Key header
402INSUFFICIENT_WALLET_BALANCERecharge the wallet, then retry with a new key
404NOT_FOUNDNo such order
409ORDER_ALREADY_BOOKEDAlready booked; the existing shipment is included
409PRICE_CHANGEDexpectedTotal differs from today’s price (newTotal included); nothing charged
409BOOKING_IN_PROGRESS, ORDER_CANCELLEDAnother request is booking it / the order is cancelled
202BOOKING_OUTCOME_UNKNOWNSee above
422BOOKING_FAILED, or a more specific code such as PINCODE_NOT_SERVICEABLE, COURIER_NOT_AVAILABLE, INVALID_COURIERThe courier cannot carry this order, or refused it; nothing charged (message has the reason). Try another option.
422PRIORITY_NOT_SET"auto" without a Courier Priority
422PICKUP_ADDRESS_REQUIRED, PICKUP_ADDRESS_NOT_FOUNDNo 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

Available

Shipment details with the scan history, a filtered and paginated list, and the label PDF.

Get a shipment

GET/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" }
      ]
    }
  }
}
FieldMeaning
statusPending (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
chargesWhat booking charged, in INR, including GST (total = freight + cod)
codAmountAmount the courier collects from the buyer (COD), else 0
ndrLatest failed delivery attempt: date, reason, attempt, status; or null
rtoReturn to origin: date, reason; or null
cancellabletrue while the shipment can still be cancelled
trackingUrlCourier 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

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

GET/v1/shipments/{shipmentId}/label

Scope 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

Available

Cancel a shipment that has not been picked up; refunded once to the wallet when the courier accepts.

POST/v1/shipments/{shipmentId}/cancel

Scope shipments:cancel. No body. The same process as cancelling in the panel:

  1. Only a shipment with status Pending (booked, not yet picked up) can be cancelled. Once picked up, the answer is 409 CANCEL_NOT_ALLOWED. Use the panel’s NDR and RTO tools instead.
  2. Courier Tales asks the courier. The request is synchronous.
  3. If the courier accepts, the shipment becomes Cancelled, the booking charge is refunded to your wallet once, and the order returns to not_booked, so it can be booked again.
  4. 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.

HTTPerror.codeMeaningWhat to do
409CANCEL_NOT_ALLOWEDNot Pending any more (current status included)Nothing; it can no longer be cancelled
422COURIER_REFUSEDThe courier refused; its reason is in messageContact support if needed
502COURIER_UNAVAILABLEThe courier could not be reached; nothing changedRetry later
409CANCEL_NOT_SUPPORTEDThis courier’s shipments cannot be cancelled through Courier TalesContact support
404NOT_FOUNDNo 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

Available

See 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

GET/v1/ndr?status=action_required&limit=50

Scope 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.

GET/v1/shipments/{shipmentId}/ndr

Scope shipments:read. The same view for one shipment (404 NDR_NOT_FOUND when it has no failed attempt).

Take an action

POST/v1/shipments/{shipmentId}/ndr/actions

Scope ndr:write. One action per failed attempt, while ndrStatus is action_required.

actionExtra fieldsEffect
reattemptreattemptDate (required, future date YYYY-MM-DD)Deliver again on that date
change_addressaddress: lines (1-2 of { line1, line2? }), pincode (6 digits; city and state are filled in from it), landmark?, customerName?; optional reattemptDateDeliver to the corrected address
change_phonephone (10-digit mobile), alternatePhone?; optional reattemptDateCourier 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" } }
  }
}
  • result is applied, or already_actioned when 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 in message;
    • unknown: no clear answer; the team checks it.
HTTPerror.codeMeaning
409NDR_NOT_ACTIONABLENothing is waiting for your action (delivered, returning, closed or already actioned)
422ACTION_NOT_SUPPORTEDThis courier does not accept this action through its API
422VALIDATION_ERRORMissing or invalid fields (for example a past reattemptDate)
404NDR_NOT_FOUNDThe shipment has no failed delivery attempt

Delivery instruction

POST/v1/shipments/{shipmentId}/ndr/message

Scope 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

Available

Pick 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)

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

POST/v1/reverse-pickups/quotes

Scope rates:read. The same body as booking (below). Returns price (your plan, incl. GST) and zone. Nothing is saved or charged.

3. Book

POST/v1/reverse-pickups

Scope shipments:write. Requires an Idempotency-Key. Charges your wallet, then books with Delhivery.

FieldTypeRequiredNotes
forwardShipmentIdstringnoShipping ID or AWB of the delivered shipment. Links the return to it and blocks a second return.
customerobjectyesWhere to pick up: name, phone (10 digits), line1, line2?, pincode, city, state (city/state are filled in from the pincode), email?
warehouseAddressIdstringnoYour pickup address that receives the return. Default: your default pickup address.
returnReasonstringyesUp to 200 characters
packageobjectyeslengthCm, widthCm, heightCm, weightKg (> 0)
itemsarrayyes1-20 of { name, sku?, quantity, unitValue?, description? }
modestringnoSurface (default) or Air
sellerOrderIdstringnoYour reference
expectedTotalnumberrecommendedThe 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 while cancellable is true.
    • 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

Available

Multi-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

POST/v1/b2b/quotes

Scope 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

POST/v1/b2b/shipments

Scope shipments:write. Requires an Idempotency-Key.

FieldTypeRequiredNotes
pickupAddressIdstringnoYour pickup warehouse. Default: your default pickup address.
consigneeobjectyesname, company?, phone (10 digits), line1, line2?, pincode, city, state (filled in from the pincode), email?, gstin?
descriptionstringyesWhat the goods are (up to 200 characters)
boxesarrayyes{ count (≥ 1, default 1), lengthCm, widthCm, heightCm (≤ 1000), weightKg (≤ 5000 per box) }; at most 999 boxes in total
invoicesarrayyes{ 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.
paymentModestringyesPREPAID or COD (COD only if enabled for your account)
codAmountnumberCODAmount to collect
riskTypestringyesOWNER (owner’s risk) or CARRIER (carrier’s risk)
fmPickupbooleanyestrue: Delhivery picks up from your warehouse; false: you drop the boxes at Delhivery
freightModestringnoPAID (default) or TO_PAY, if enabled for your account
insurance, appointmentbooleanno
sellerOrderIdstringnoYour reference
expectedTotalnumberrecommendedThe 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 MANIFESTED with lrNumber and one waybills entry per box.
  • You get b2b_shipment.updated, or you can read GET /v1/b2b/shipments/{ref}.

If something goes wrong:

  • If Delhivery refuses, the charge is refunded and the error includes the record (error.b2bShipment, state FAILED).
  • 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 with POST /v1/b2b/shipments/{ref}/book.

B2B documents

  • GET /v1/b2b/shipments/{ref}/labels: Delhivery’s box labels (PDF), once the LR exists (else 409 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.

International

Available

Ship 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

  1. Rates: POST /v1/international/rates with { "countryCode": "US", "parcel": { "lengthCm", "widthCm", "heightCm", "weightKg" } }. Returns every carrier’s price (incl. GST) at your plan.
  2. Request: POST /v1/international/shipments. The estimate is debited from your wallet (state REQUESTED).
  3. Send the parcel to the Courier Tales warehouse, with the inbound label GET /v1/international/shipments/{ref}/inbound-label (PDF) on it.
  4. 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 in ON_HOLD_PAYMENT with amountDue. Recharge, then pay with POST /v1/international/shipments/{ref}/pay.
  5. Booking and tracking: Courier Tales books it with the carrier (BOOKED, trackingNumber). Then IN_TRANSIT, DELIVERED (or RETURNED, EXCEPTION), then CLOSED. The carrier label is at GET …/{ref}/carrier-label once 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

POST/v1/international/shipments

Scope shipments:write. Requires an Idempotency-Key.

FieldTypeRequiredNotes
carrierstringyesOne of carriers from /v1/international/options
senderAddressIdstringnoYour pickup address used as sender. Default: your default pickup address.
receiverobjectyesname, line1, line2?, city, state?, postalCode?, countryCode (ISO, not IN), phone (with country code), email?, company?
parcelobjectyeslengthCm, widthCm, heightCm, weightKg (≤ 300)
contentsarrayyes{ description (≥ 3 characters), hsCode? (6-10 digits), quantity (whole number), unitValue, originCountry? (default IN) }
currencystringnoCurrency of the values: INR (default), USD, EUR, GBP, AED, AUD, CAD, SGD, SAR, JPY, CHF, NZD, MYR, HKD
customs.purposestringyesSALE, GIFT, SAMPLE, RETURN, PERSONAL, OTHER. For SALE, customs.invoiceNumber is required.
customs.iecstringnoImporter-Exporter Code (10 characters)
dutiesAcknowledgedbooleanyesMust be true: destination duties and taxes are not included
sellerOrderIdstringnoYour reference
expectedTotalnumberrecommendedThe 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), statusHistory and trackingEvents.
  • GET /v1/international/shipments?state=&carrier=&sellerOrderId=&from=&to=&limit=&cursor=: list.
  • POST /v1/international/shipments/{ref}/cancel: only while REQUESTED (before the parcel reaches the warehouse); everything is refunded. Later: 409 CANCEL_NOT_ALLOWED, contact support.

Webhooks

Available

Signed 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

typeWhen
shipment.bookedA shipment was booked (API or panel)
shipment.picked_upStatus Pickup
shipment.in_transitStatus In Transit (again after a return to transit)
shipment.out_for_deliveryStatus Out for Delivery
shipment.deliveredStatus Delivered or Partially Delivered
shipment.ndr_raisedA failed delivery attempt was recorded (shipment.ndr)
shipment.rto_initiated, shipment.rto_in_transit, shipment.rto_deliveredReturn to origin
shipment.cancelledCancelled (API, panel or courier)
shipment.exceptionException, Rescheduled, Lost, Damaged, RTO Lost (exact value in shipment.status)
b2b_shipment.updatedA B2B shipment changed state (data.state, data.b2bShipment), for example SUBMITTED → MANIFESTED when the LR is issued
reverse_pickup.updatedA reverse pickup changed state (data.state, data.reversePickup)
international_shipment.updatedAn international shipment changed state (data.state, data.internationalShipment)
webhook.testSend 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 each id once: duplicates can happen.
  • Ordering is not guaranteed. Use data.shipment.updatedAt, or fetch GET /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

Available

Indicative charges per courier for one shipment, with serviceability, weight and a full price breakdown including GST.

POST/common/rate-calculator/v2
GET/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

FieldTypeRequiredExampleRules and notes
servicestringyes"B2C"B2C (forward parcel), RP (reverse pickup; REVERSE and RVP also accepted), B2B (cargo), INTERNATIONAL (INTL). Not case-sensitive.
originPinstringyes, except INTERNATIONAL"110020"Pickup pincode, 6 digits. Also accepted as pickupPincode.
destPinstringyes, except INTERNATIONAL"411028"Delivery pincode, 6 digits. Also accepted as deliveryPincode.
weightKgnumberyes, unless boxes is sent1.2Actual weight in kg. Greater than 0 and at most 10000. Also accepted as weight.
l, b, hnumberno20, 15, 10Length, 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.
boxesarraynosee belowSeveral 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.
paymentModestringno"PREPAID"PREPAID (default) or COD. Any other value is treated as PREPAID.
codAmountnumberwhen COD for B2C and B2B1499Amount to collect from the buyer, in INR. shipmentValue is also accepted.
invoiceValuenumberB2B only25000Invoice value in INR (B2B risk charges are a percentage of it). shipmentValue is also accepted.
modestringno"Surface"Air or Surface (also accepted as serviceMode). Omit it to get both. Used for B2C and RP only.
countrystringINTERNATIONAL only"AE"Destination country code or name (also accepted as countryCode).
toPay, appointment, mall, csd, cheque, sunday, floor, reattemptbooleannofalseB2B only: optional delivery services that add charges.
riskTypestringno"OWNER"B2B only: OWNER (default) or CARRIER.
storageDaysnumberno0B2B 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

FieldTypeMeaning
service, paymentModestringThe service and payment mode that were priced, after normalisation.
planobject or nullThe price plan used: always the standard plan (via: "STANDARD").
zonestring or objectB2C/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, destinationobjectB2C/RP only: pincode, city and state.
countryobjectINTERNATIONAL only: { "code", "name" }.
options[]arrayOne entry per courier and mode. Available options come first, cheapest first.
options[].availablebooleantrue 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[].serviceabilityobjectserviceable, 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[].weightobjectactualKg, volumetricKg, chargeableKg (after rounding to the rate slab) and a readable explanation.
options[].breakdownobjectCharges 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[].codChargenumberB2C/RP: the freight part and the COD part of total, each including GST.
options[].courierIdstring or nullInternal identifier. Do not store it or rely on it.
currency, notestringAlways INR, plus a note about GST. The note says rates exclude GST: that applies to subtotal. total includes GST.

Errors for this endpoint

HTTPerror.codeWhen
422VALIDATION_ERRORA field is missing or invalid. error.details[] lists each field with a message.
422ZONE_UNKNOWNA pincode is not in the Courier Tales pincode list.
422other pricing codesThe shipment cannot be priced at all. Problems with one courier never fail the request; they appear on that option instead.
429RATE_LIMIT_EXCEEDEDToo many requests. See Rate limits.
500SERVER_ERRORUnexpected 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)

Available

Courier 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

  1. Build an HTTPS endpoint that returns your ready-to-ship orders as JSON, in the format below.
  2. Create a random API key and API secret, at least 32 characters each. Your endpoint must reject any request without them.
  3. In the seller panel, open Settings → Channel, choose Custom API, then enter the API key, API secret and API URL.
  4. 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>
DetailBehaviour
MethodGET
URLThe 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.
HeadersX-API-Key and X-API-Secret, exactly as you entered them. Check both on every request, using a constant-time comparison.
limit, offsetAlways 100 and 0. One import reads at most 100 orders.
since_idSent 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.
sinceSent only when there is no since_id but an earlier import happened: ISO 8601 time of that import.
Timeout30 seconds. Redirects are not followed, so answer at the exact URL.
ErrorsAny 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.

FieldTypeSend?Accepted alternativesIf missing
idstringsendorder_id— This is your order ID in Courier Tales. It must be unique among your orders.
order_numberstringoptionalnumberid
created_atISO 8601optionaldate, order_datetime of import
payment_methodstringsendpayment_mode, payment_type, or cod: trueContains cod or cash → COD, anything else → prepaid
shipping_address.namestringsendcustomer_name"Customer"
shipping_address.line1stringsendaddress1, address.line1"Address not provided"
shipping_address.line2stringoptionaladdress2, address.line2—
shipping_address.landmarkstringoptionaladdress.landmark—
shipping_address.citystringsendaddress.city"City not provided"
shipping_address.statestringsendprovince, address.state"State not provided"
shipping_address.zipstring, 6 digitssendpostal_code, pincode, address.pincode"000000"
shipping_address.phonestring, 10 digitssendcustomer.phone, phone"0000000000"
gst_numberstringoptionalgst—
weightnumber, kgsend—0.5
dimensions.length, .width, .heightnumber, cmsend.breadth; or top-level length, width/breadth, height10 each
line_items[]arraysenditems, productsone item worth ₹0
line_items[].namestringsendtitle, product_name"Product"
line_items[].skustringsend—generated SKU-<number>
line_items[].hsnstringoptionalhsn_code—
line_items[].quantitynumbersend—1 (decimals are kept)
line_items[].price or .totalnumber, INRsend—price = unit price; total = whole line value (used if both are sent)
line_items[].discountnumber, INRoptional—0
discount, coupon_codenumber, stringoptional—order-level discount
shipping_chargenumber, INRoptional—0
cod_chargenumber, INRoptional, COD only—0
totalnumber, INRsend—calculated from the items. For COD this is the amount collected from the buyer.
invoice_number, invoice_datestring, dateoptionalinvoice_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

Available

Track 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, and GET /v1/shipments?awb=… finds a shipment by AWB. See Shipments and labels.

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. /v1 errors also carry requestId, and some carry extra fields (for example newTotal, shipment or status).

Status codes

HTTPerror.codeMeaningWhat to do
200, 201—Success. success is true.Read data.
202BOOKING_OUTCOME_UNKNOWNThe courier gave no clear answer; the order is locked.Do not book again; check the order later (booking).
400IDEMPOTENCY_KEY_REQUIREDPOST /v1/shipments without the header.Add an Idempotency-Key.
401, 403see AuthenticationMissing, invalid or insufficient API key.Fix the key or its permissions.
402INSUFFICIENT_WALLET_BALANCENot enough wallet balance to book.Recharge, then retry with a new key.
404NOT_FOUNDUnknown order, shipment or route, or one of another account or mode.Check the id and the key’s mode.
409DUPLICATE_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.
422VALIDATION_ERROROne or more fields are missing or invalid.Fix the fields named in message / details. Do not retry unchanged.
422BOOKING_FAILED, COURIER_REFUSED, IDEMPOTENCY_KEY_REUSED, …The courier or the request cannot do this.See booking and cancellation.
422ZONE_UNKNOWN and other pricing codesThe request is valid but cannot be priced, for example an unknown pincode.Check the input. Do not retry unchanged.
429RATE_LIMIT_EXCEEDEDRate limit reached.Wait until the time in the RateLimit-Reset header, then retry.
500SERVER_ERRORUnexpected error on our side.Retry with exponential backoff. If it persists, contact us.
502COURIER_UNAVAILABLECancellation: 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

WhatLimit
/v1 requests with an API key1000 requests per 5 minutes per key
/v1 requests with an invalid key20 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 requests300 requests per 5 minutes per IP address
Public tracking page lookups30 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, 504 and network timeouts with exponential backoff, for example 1 s, 2 s, 4 s, 8 s, plus random jitter, up to 5 attempts.
  • /v1 reads (GET) are always safe to retry.
  • Booking (POST /v1/shipments): retry only with the same Idempotency-Key, so you get the first answer instead of a second shipment. Never retry 202 BOOKING_OUTCOME_UNKNOWN. See booking.
  • Cancellation is safe to repeat: an already cancelled shipment answers 200 with alreadyCancelled: true, and it is refunded only once.
  • Do not retry 422 without 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 shipmentId and awbNumber you get back; never book an order again just because you lost the answer. Use the same Idempotency-Key, or GET /v1/orders/{orderId}.

Your webhook endpoint

  • Verify X-CT-Signature on 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 id once, answer 2xx fast, 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-Key and X-API-Secret on every request. Compare with a constant-time function, such as crypto.timingSafeEqual in Node.js or hmac.compare_digest in Python. Answer 401 when 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

Planned

Capabilities 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.

CapabilityToday
Weight disputes and evidence uploadSeller panel
Booking many orders in one requestBook one order per request; the panel offers bulk booking
ManifestsSeller 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.

DateChange
2026-10-19NDR 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-19API 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-19Rate-limited responses (429) now carry error.code RATE_LIMIT_EXCEEDED (previously SERVER_ERROR).
2026-10-19First 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.