# Tekipost Merchant API Base URL: https://api.tekipost.com Alias: https://webhook.tekipost.com (every path below is served on both) Auth: Authorization: Bearer . The account (sender_id) and role come from the verified token. No endpoint takes a sender_id parameter. Error envelope: {"error":"","message":"...","details":["..."]}. Match on `error`. /v1/documents/* instead answers {"message":"..."} on 400/404 and {"code":"...","message":"..."} on 409. Pagination: ?limit= (1-1000) and ?cursor=; response carries pagination.nextCursor. Pass it back as cursor. Do not construct one. ## Book a shipment with four calls. Collect the AWB on the fifth. Base URL https://api.tekipost.com. Every response is JSON. Every endpoint is scoped to your own account by the token you send — you cannot read another merchant's rows and nobody can read yours. 1. GET /v1/shipping/serviceable — which couriers serve the lane, and what each costs. 2. POST /v1/intake/order — create the order. You get an order_id. 3. POST /v1/shipping/book — request the booking. Answers 202, not the AWB. 4. GET /v1/orders/status?order_no=… — poll until state is no longer booking_in_progress. Usually the first poll, a few seconds later. 5. GET /v1/documents/label/{order_id} — the label PDF. webhook.tekipost.com remains a working alias for every path here, so existing integrations need no change. ## One bearer token, and it is the one you already have. Send Authorization: Bearer on every request. The token is a GoTrue JWT — the same credential the merchant portal signs in with. There is no separate API key to provision. The token carries your account (app_metadata.sender_id) and your role. Both are read from the verified token, never from anything in the request, which is why no endpoint takes a sender_id parameter: supplying one changes nothing. 🔴 The API keys on Settings ▸ Webhooks & APIs are not a credential for this API. They are accepted today only by the tracking API, and their secret is not yet verified by anything. Do not build against them here. A missing, expired or unverifiable token is 401 {"error":"Authentication required"} or {"error":"Invalid or expired token","code":"…"}. ## Six roles. Three of them cannot write. Your account owner (merchant) and merchant_admin can call everything below. merchant_ops can do orders, shipping, NDR, warehouses, courier ranking and label settings. merchant_finance, merchant_support and merchant_staff are read-only: any POST/PATCH/PUT/DELETE on order-ingestion is refused with 403 {"error":"Read-only role"} before the handler runs. A write your role is not granted answers 403 {"error":"forbidden","cap":"","message":"This role does not have the permission"}. The cap names exactly what was missing. 🔴 Known gap, stated rather than hidden: shipping-service does not mount the read-only refusal, so POST /v1/shipping/book, /book/sync, /cancel and /ndr currently accept a read-only token. Treat that as a defect to be closed, not a feature to rely on. ## Every failure names itself. The shape is {"error":"","message":"…"}, and "details":[…] when a validator rejected more than one thing. Match on error, never on message — the prose may improve, the code will not change. 400 your request; 401 the token; 403 your role or your scope; 404 no such row for you (not-found and not-yours are deliberately the same answer, so the API cannot be used to discover whether an id exists); 409 a real-world conflict, and the body says what; 422 the courier refused and the message is theirs; 5xx ours. One deliberate exception: /v1/documents/* answers {"message":"…"} with no error key on 400/404, and {"code":"…","message":"…"} on 409. ## Cursors, not page numbers. List endpoints take ?limit= (1–1000) and ?cursor=, and answer {"data":[…],"pagination":{"mode":"cursor","limit":N,"nextCursor":"…"}}. Pass nextCursor back as cursor for the next page. A nextCursor of null means you have the last page. A cursor you did not get from us is 400 {"error":"cursor: unrecognised shape"} — do not construct one. ## Create orders — One at a time, in bulk, or as a return. Creating does not book — see Booking & couriers. ### POST /v1/intake/order Create an order Requires capability: orders.write Refused for: merchant_finance, merchant_support, merchant_staff Creates a forward order. It does not book — call POST /v1/shipping/book next. Idempotent on customer_order_no: re-sending the same reference answers 200 {order_id, deduped:true} instead of creating a second order. order.pickup_address_id must be one of your own warehouses; an id that is not yours is 400 invalid_pickup_address, never a 403 that would confirm it exists. Body: customer_order_no (string, required) — Your reference. The idempotency key. channel (string) — Where the order came from. receiver (object, required) — `{name, address_1, address_2?, landmark?, city?, state?, pincode, mobile_no}`. product (object, required) — `{product_name, product_sku?, physical_weight, declared_order_total, cod_amount?, length?, width?, height?}`. order (object, required) — `{pickup_address_id (required), rto_address_id?, order_date?, order_type ("0" prepaid | "1" COD), order_from_source?}`. 201: {order_id}. A duplicate answers 200 {order_id, deduped:true}. 400 validation_failed: details lists every field that failed. 400 invalid_pickup_address: pickup_address_id is not one of your warehouses. 403 cannot create order for another sender: The body named a sender that is not yours. Example: curl -X POST "https://api.tekipost.com/v1/intake/order" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"customer_order_no":"ORD-4471","receiver":{"name":"Sharma","address_1":"12 MG Road","city":"Pune","state":"Maharashtra","pincode":"411001","mobile_no":"9876543210"},"product":{"product_name":"T-shirt","physical_weight":0.4,"declared_order_total":799},"order":{"pickup_address_id":6458,"order_type":"0"}}' ### POST /v1/intake/bulk Bulk create Requires capability: orders.write multipart/form-data. Answers 202 with a job_id; poll GET /v1/intake/jobs/{id}. Rows that need a human decision land in the job's review queue rather than failing the upload. Body: import_file (string, required) — The spreadsheet. Maximum 25 MB. sender_address_id (integer, required) — Pickup warehouse for every row in the file. 202: {job_id, status:"queued"}. 400 sender_address_id (pickup warehouse) required: Absent. 400 invalid_pickup_address: Not one of your warehouses. ### POST /v1/intake/reverse Create a reverse order Requires capability: orders.write Same block shape as a forward order, plus the original leg. Your reference is stored with a -R suffix so a return never collides with its forward order. Body: order (object, required) — As forward, plus `forward_source` (`tekipost` | `external`), `old_fwd_awb_no` (≤50 chars), `reverse_pickup_address_id?`, `return_address?`. 201: {order_id}. 400 validation_failed: e.g. forward_source must be 'tekipost' or 'external'. ### GET /v1/intake/jobs/{id} Bulk job status Progress and per-row outcome for a bulk upload. Parameters: id (path, integer, required) — The `job_id` from the upload. 200: The job object with counts and status. 404 not found: Not your job. ## Booking & couriers — Price a lane, book it, cancel it, and choose which couriers carry your parcels. ### GET /v1/shipping/serviceable Rate and serviceability Which couriers will carry this lane and what each charges, cheapest decided by you. dropped_couriers is the honest half: every courier that was considered and excluded, with the reason — no_rate_card, cod_config_missing, reverse_not_supported and so on. performance carries the delivered/RTO percentages and TAT for the lane where there is enough history; null where there is not. It is never invented. Parameters: from_pin (query, string, required) — Pickup pincode. — pattern: ^\d{6}$ to_pin (query, string, required) — Delivery pincode. — pattern: ^\d{6}$ physical_weight (query, number, required) — Kilograms, greater than zero. volumetric_weight (query, number) — Kilograms. Defaults to 0. order_type (query, string) — `"1"` for COD, anything else prepaid. — one of: 0 | 1 collectable_amount (query, number) — COD amount to collect. operation (query, string) — Set `reverse` to price the return leg. — one of: reverse 200: {data:[{logistic_id, courier_name, total, cod_charge, freight_charge, shipping_charge_tax, zone, weight, rto_charge, performance}], dropped_couriers:[{logistic_id, logistic_name, reason, detail}]}. 400 from_pin, to_pin and physical_weight are required: Any of the three is missing. 422 lane_not_priced: No courier on your rate card prices this lane. 503 rate_unavailable: The pricing engine is unreachable. Retry. Example: curl "https://api.tekipost.com/v1/shipping/serviceable?from_pin=411001&to_pin=560001&physical_weight=0.5" \ -H "Authorization: Bearer $TOKEN" ### POST /v1/shipping/book Book (asynchronous) Queues the booking and answers 202 immediately — the AWB is not in this response and will not be. That is deliberate: returning anything else restarts the courier-fallback loop and risks booking one parcel with two couriers and debiting the wallet twice. The AWB usually exists within 8 seconds; collect it from GET /v1/orders/status?order_no=…. Omit logistic_id for AUTO mode, where the courier is chosen by your ranking and rules. Supply it to force one. Body: order_id (integer, required) — From create. logistic_id (integer) — Force a courier. Omit for AUTO. 202: {status:"queued", idem_key} — or {status:"queued", mode:"auto"}. 400 order_id must be a positive integer: Missing or malformed. 403 cannot create booking for another sender: The order is not yours. Example: curl -X POST "https://api.tekipost.com/v1/shipping/book" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"order_id":918273}' ### POST /v1/shipping/book/sync Book (synchronous) Books inside the request and returns the AWB. It BLOCKS for the courier call — typically 30–40 seconds — so set your client timeout to at least 45s. Prefer the asynchronous POST /v1/shipping/book unless your integration genuinely cannot poll. A transient courier failure here is handed to the durable worker, so a timeout on your side does not mean the parcel was not booked — check GET /v1/orders/status before retrying. Body: order_id (integer, required) — From create. logistic_id (integer) — Force a courier. Omit for AUTO (forward only). operation (string) — Forward booking or a return. — one of: book | reverse 200: {status, awb, forward_id, logistic_id, order_id, courier_name, label_url}. 400 order_id and logistic_id must be positive integers: Malformed. 422 booking_failed: The courier refused. message is the courier's own words. ### POST /v1/shipping/cancel Cancel a shipment Cancels with the courier and releases the order number for reuse. refunded reports what went back to your wallet. Body: order_id (integer, required) — The order. logistic_id (integer, required) — The courier it was booked with. 200: {status, awb, forward_id, refunded, order_id, mode}. 403 cannot cancel for another sender: Not your order. 422 cancel_failed: The courier refused the cancellation. 503 cancel_unavailable: The courier is unreachable. Retry. ### POST /v1/shipping/ndr NDR action Tells the courier what to do with a parcel they could not deliver. Body: order_id (integer, required) — The order. logistic_id (integer, required) — The courier. ndr_act (string) — What to do. Defaults to `RE-ATTEMPT`. — one of: RE-ATTEMPT | DEFER_DLV | EDIT_DETAILS deferred_date (string) — Required for `DEFER_DLV`. 200: {status, awb, forward_id, order_id, ndr_act}. 400 invalid_ndr_act: Not one of the three values. 422 reattempt_failed: The courier refused. ### GET /v1/shipping/couriers Courier list and ranking Every courier on your rate card, whether it is enabled, its position in your AUTO ranking, and how many pincodes it serves. Parameters: shipment_type (query, string) — Forward or reverse. Defaults to `"0"`. — one of: 0 | 1 | 2 200: {data:[{logistic_id, logistic_name, enabled, priority, serviceable_pincodes}], strategy, unique_pincodes, rules, restrictions, shipments_30d}. 400 shipment_type must be 0 (forward), 1 or 2 (reverse): Anything else. ### PATCH /v1/shipping/couriers/priority Set courier priority Requires capability: courier.rank Refused for: merchant_finance, merchant_support, merchant_staff Reorder only. You must send the complete ranking as a permutation of 1..n over the couriers already on your active rate card — this cannot add or remove a courier. Body: shipment_type (string, required) — Forward or reverse. — one of: 0 | 1 order (array, required) — `[{logistic_id, priority}]` — every courier, each priority used once. 200: The re-read ranking. 400 incomplete_ranking: Not every courier on the card was included. 400 duplicate_logistic: A courier appears twice. 400 unknown_logistic: A courier that is not on your card. ## Orders & tracking — Read what you have created: status, lists, scans. Creating happens under Create orders. ### GET /v1/orders/status Booking status by your own reference The supported way to collect an AWB after POST /v1/shipping/book answers 202. Poll this, not the order row — awb_number on the pending-order table is never populated, by design. state is one of booked (done; awb_number and courier are filled), booking_in_progress (still on the worker — poll again in a few seconds), failed (it will not complete, and failure_reason says why in plain words), or not_shipped (the order exists, no ship was requested). Back off to every 10–15 seconds. A booking unsettled after a couple of minutes has almost certainly failed, and the response will say so. Parameters: order_no (query, string, required) — Your own reference, exactly as you sent it on create. 200: {order_no, state, awb_number, courier, status, failure_reason, created_at}. 400 order_no is required: The query parameter is absent or empty. 404 order_not_found: No order with that reference **on your account**. Never means "not booked yet". Example: curl "https://api.tekipost.com/v1/orders/status?order_no=ORD-4471" \ -H "Authorization: Bearer $TOKEN" ### GET /v1/orders List shipments Booked shipments, newest first, cursor-paginated. bucket is the lifecycle slice — the same slices the portal draws — and is the filter you usually want. Parameters: bucket (query, string) — Lifecycle slice. — one of: rts | pickup | manifest | transit | delivered | rto | exceptions | cancelled | all limit (query, integer) — Page size, 1–1000. cursor (query, string) — From the previous page's `nextCursor`. awb_number (query, string) — Exact AWB. order_no (query, string) — Your own reference. shipment_type (query, string) — Forward or reverse. — one of: 0 | 1 date_from (query, string) — Inclusive, `YYYY-MM-DD`. — pattern: YYYY-MM-DD date_to (query, string) — Inclusive, `YYYY-MM-DD`. — pattern: YYYY-MM-DD city (query, string) — Destination city, ≤100 characters. 200: {data:[shipment…], pagination:{mode:"cursor",limit,nextCursor}}. Each row carries channel, order_value and tracking_url. 400 bucket: unrecognised: A bucket outside the list above. 400 cursor: unrecognised shape: A cursor we did not issue. Example: curl "https://api.tekipost.com/v1/orders?bucket=transit&limit=50" \ -H "Authorization: Bearer $TOKEN" ### GET /v1/orders/{id} Shipment detail The full row for one order, plus channel and tracking_url. Parameters: id (path, integer, required) — The `order_id` from create. 200: The shipment object. 404 order not found: No such order on your account. ### GET /v1/orders/{id}/tracking Tracking scans Courier scans oldest-first. truncated tells you the list was capped at scan_limit. Parameters: id (path, integer, required) — The `order_id`. 200: {scans:[…], truncated, scan_limit, status_name, order_status_id}. 404 order not found: No such order on your account. ### GET /v1/orders/unshipped List unshipped orders Created but not yet booked, with the reason each one is stuck. Cursor-paginated and heavily filterable. Parameters: limit (query, integer) — Page size, 1–1000. cursor (query, string) — From `nextCursor`. error_type (query, string) — One of the values `/v1/orders/unshipped/error-types` returns. customer_order_no (query, string) — Your own reference. pickup_address_id (query, integer) — Warehouse id. — pattern: ^[1-9]\d*$ order_type (query, string) — Prepaid or COD. — one of: 0 | 1 amount_min (query, string) — Rupee amount. — pattern: ^\d{1,9}(\.\d{1,2})?$ amount_max (query, string) — Rupee amount. — pattern: ^\d{1,9}(\.\d{1,2})?$ 200: {data:[…], pagination}. Rows carry order_value, channel, confirmation and address. 400 order_type: must be 0 (prepaid) or 1 (COD): Anything else. 400 amount_min: must be a rupee amount: Not a plain decimal. ### GET /v1/orders/unshipped/counts Unshipped counts Forward answers {total, unshipped, failed, in_queue}; reverse answers {unshipped, failed}. Parameters: shipment_type (query, string) — Forward or reverse. — one of: 0 | 1 200: The counts object. 400 shipment_type: must be 0 (forward) or 1 (reverse): Anything else. ### PATCH /v1/orders/unshipped/{id} Edit an unshipped order Requires capability: orders.write Refused for: merchant_finance, merchant_support, merchant_staff Change buyer details on an order that has not shipped. The writable set is a closed allow-list; sender_id is stripped. Once booked, an order is no longer editable. Parameters: id (path, integer, required) — The `order_id`. 200: {order_id, status, changed:[field…]} — changed is what actually moved. 400 : The body carries field naming the offending key. 404 order not found: No such order on your account. 409 not_editable: Already booked or otherwise past the editable window. ### POST /v1/orders/{id}/hide Hide an order Requires capability: orders.write Soft-removes an order from your lists. Closes any open cancellation and reports what was refunded. Parameters: id (path, integer, required) — The `order_id`. 200: {order_id, order_no, hidden:true, closed, refunded}. 404 order not found: No such order on your account. ## Labels & manifests — The paperwork: labels, packing slips, and the handover manifest. ### GET /v1/documents/label/{orderId} Shipping label The label PDF, or the carrier's own file where they issue it. Fetching it stamps label_downloaded_at. Parameters: orderId (path, integer, required) — The `order_id`. 200: PDF bytes. 404: {"message":"not found"} — not yours, or no such order. 409 carrier_label_missing: The courier issues its own label and we have not captured it. ### POST /v1/documents/label Bulk labels One merged PDF, pages in the order you asked for. Maximum 200 orders. One missing label fails the whole batch rather than silently shipping a short PDF. Body: order_ids (array, required) — Positive integers, 1–200. 200: One merged PDF. 400: {"message":"max 200 orders per request"}. 409 carrier_label_missing: At least one label has not been captured. ### POST /v1/documents/manifest Manifest a batch Builds the handover manifest, one section per courier, and moves those packages to Ready-for-pickup. This is a write with side effects, not a report: it inserts manifest rows and fires shipment.status_changed. Body: order_ids (array, required) — Positive integers, 1–200. 200: PDF, with x-manifest-no and x-manifest-count headers. 409 not_ready_to_ship: Body lists order_ids and already_manifested_order_ids. 409 already_manifested: One or more AWBs are on a manifest already. ### GET /v1/documents/label/t/{token} Label by signed link Auth: signed-token (not a bearer token) Open — no Authorization header. The token is "{awb}.{hmac}" and is itself the credential, so a label link can be forwarded to a packer who has no login. Rate-limited at the edge. Served with Content-Disposition: attachment, X-Content-Type-Options: nosniff and a sandbox CSP. Treat the URL as a secret: anyone holding it can fetch that label. Parameters: token (path, string, required) — `{awb}.{hmac}`, as issued. 200: The label bytes. 404: Plain-text Not found for a bad token, an unknown AWB, or a courier-issued label we never captured. ## Warehouses — Your pickup addresses. A courier will not collect from one it does not know. ### GET /v1/warehouses List warehouses Every pickup address, with its courier-registration state and 30-day shipped count. Parameters: include (query, string) — CSV. `inactive` also returns retired rows. 200: {data:[{id, …address, active, retired, is_default, gstin, warehouse_code, warehouse_type, courier_registered, shipped_30d}]}. ### POST /v1/warehouses Create a warehouse Requires capability: warehouses.write Refused for: merchant_finance, merchant_support, merchant_staff The validation here is the courier's, not ours: Delhivery rejects a name under three characters and a sparse address, and a warehouse that passes here but fails there is one no courier will collect from. Set is_return_same truthy to mirror the forward address onto the return leg; otherwise every return_* field is required and validated the same way. Creating a warehouse also adopts any orders that arrived before you had one. Body: warehouse_name (string, required) — Unique to you. — 3–191 characters contact_person_name (string, required) — Who the courier asks for. contact_no (string, required) — Collection contact. — pattern: ^\d{10}$ address_line_1 (string, required) — Street address. city (string, required) — City. state (string, required) — State. pincode (string, required) — Pickup pincode. — pattern: ^\d{6}$ contact_email (string) — Optional, validated when present. gstin (string) — GSTIN for the state the goods are stocked in. — pattern: ^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][1-9A-Z]Z[0-9A-Z]$ warehouse_type (string) — Defaults to `fulfillment_pickup`. — one of: fulfillment_pickup | pickup | fulfillment is_return_same (boolean) — Mirror the forward address onto the return leg. 201: {data:{id, orders_repaired}} — orders_repaired is how many stranded orders were adopted. 400 validation_failed: details names every failure; return-leg messages are prefixed return_. ### DELETE /v1/warehouses/{id} Delete a warehouse Requires capability: warehouses.write A soft delete, and it refuses while orders still ship from the address. The 409 is the point of the endpoint: deleting a warehouse an open order points at strands that order at a collection point nothing resolves. Parameters: id (path, integer, required) — The warehouse id. 200: {data:{id, deleted:1}}. 404 not_found: Not yours, never existed, or already deleted — one answer for all three. 409 warehouse_in_use: Body carries count, unshipped, awaiting_pickup, and up to 20 order_ids so you can see exactly what is blocking. ## Failed deliveries (NDR) — Parcels the courier could not deliver, and what to tell them to do next. ### GET /v1/ndr List NDR shipments Failed-delivery parcels by tab, cursor-paginated, each with the notification we sent the buyer and how it landed. Parameters: tab (query, string, required) — Which slice of the queue. limit (query, integer) — Page size. cursor (query, string) — From `nextCursor`. awb_number (query, string) — Exact AWB. date_from (query, string) — Inclusive. — pattern: YYYY-MM-DD 200: {data:[row + order_value + notification], pagination}. 400 bad_tab: message lists the accepted tabs. 400 bad_date: Not YYYY-MM-DD, or not a real date — 2026-02-31 is refused. ### GET /v1/ndr/counts NDR counts One number per tab, so you can badge a queue without pulling every page of it. The keys are the same tab values GET /v1/ndr accepts, so you can drive both the badge and the link from one response. 200: {: n, …} — one key per tab. ### POST /v1/ndr/{id}/comment Add an NDR comment Refused for: merchant_finance, merchant_support, merchant_staff Adds a note to the NDR thread. Note merchant_support holds the ndr.act capability but is a read-only role, so the mount refuses it here — a known inconsistency, stated rather than hidden. Parameters: id (path, integer, required) — The order id. Body: comment (string, required) — The note. — 1–255 characters 201: The created comment row. 400 empty_comment: Blank. 400 comment_too_long: Over 255 characters. ## Lookups — Shared reference data. Nothing here is specific to your account. ### GET /v1/pincode/{pincode} Pincode lookup An unknown pincode is 200 with found:false, not a 404 — so you can call it on every keystroke of an address form without treating "not yet six digits" as an error. Parameters: pincode (path, string, required) — Six digits. — pattern: ^[0-9]{6}$ 200: {found:true, pincode, city, state} or {found:false, pincode, city:"", state:""}. 400 pincode must be 6 digits: Not six digits after whitespace is stripped. 502 lookup_failed: The lookup is unavailable. Retry. Example: curl "https://api.tekipost.com/v1/pincode/411001" \ -H "Authorization: Bearer $TOKEN"