# ProShip API > REST API for ProShip (proship.co.th), the Thai order and shipping system where sellers ship on their own carrier accounts at their own rates. With a seller's API key an agent can create orders, print labels, read and update order status, check duplicates, parse Thai addresses and receive webhooks. This file is written for LLMs and coding agents and needs a ProShip API key: the seller gets it in the ProShip app, or, if the human has no account yet, an agent can create the account through the register API and receive the key in the response (see Create an account). ## Read this first - Every write is real. There is no sandbox or test mode: creating an order books a real parcel with a real carrier on the seller's account. Never create an order "to test" the integration. - Ask the human to confirm before you: register an account, create a shop, create an order or a batch, update an order, change a status, cancel, change the label address, generate a top-up QR, or change the webhook URL. Show them what you are about to send (sender shop, recipient, address, carrier, weight, COD amount) and wait for a yes. The endpoint index below marks these calls. - Never auto-retry a create (`POST /orders/v1/orders` or `POST /batch/v1/orders-batch`). There is no idempotency key, so a timeout or 5xx on a create means "unknown outcome": reconcile first (see Recover from a timed-out create). - A create that ProShip rejects with a 4xx because of the address is different: nothing was booked, so fix the address and send it again, and keep going until it succeeds (see Create order). Only the address changes between attempts; recipient, carrier, weight and COD stay as the human confirmed them. - Read calls (GET) may be retried once after 1 second on a timeout, 429 or 5xx. Keep calls sequential; rate limits are not published. - Registering an account is a real write too: it creates a real production account that belongs to the human. Do it only when the human says they have no ProShip account, with values the human gave you (see Create an account). It books no parcel. - The API key is a long-lived token with full access to the seller's account. Keep it in an environment variable (for example `PROSHIP_API_KEY`); never print it, log it or put it in a URL. - Use only what is written in this file. If an endpoint, field or parameter is not here, do not guess it; tell the human it is not documented. ## Create an account (if the human has none) Use this only when the human says they have no ProShip account. Before registering, ask as a separate question whether the human or their shop already has a ProShip account (under any email or any staff login). A request to "sign up" or "create an account" is not that answer. If they have one, do not register: they copy the key from the app as described under Basics. Never register a second account for the same human; one account per shop owner. You need to be able to send HTTP requests yourself (a shell or an HTTP tool). If you cannot, stop: never put the human's password or key into a third-party request-tester website. Tell the human you cannot create the account from here and point them to the ProShip team at https://www.proship.co.th/contact/. The account belongs to the human. They log in to https://app.proship.co.th with the email and password they give you, and password reset and notices go to that email and phone. So ask the human, in plain words, and use only what they answer. Never invent, synthesize or guess any of these values: - name for the account (`username`; the shop or business name works) - their real email - their real mobile number - a password they choose themselves. Tell them to choose one they use nowhere else, because it passes through this conversation. Lowercase the email before sending it (a web login with a mixed-case email has been seen to fail otherwise). Show the human the name, email and mobile number you are about to send (not the password) and get an explicit yes to "create the account" before you call. Agreeing on the values is not that yes. The request is `POST https://api.proship.me/auth/v1/auth/register` with this JSON body: ```json { "username": "", "phoneNo": "", "email": "", "password": "", "passwordConfirmation": "" } ``` A 200 response is `{"token": "", "user_id": ""}`. From a shell, send it so that the key never reaches the screen and no file ever holds the password. Run the first block as ONE command (many agent shells start fresh on every call, so variables set in one call are gone in the next). Put the human's four values in place of the placeholders; if a value contains a single quote, write that quote as `'\''`. ```sh umask 077 PS_USERNAME='' PS_PHONE='' PS_EMAIL='' PS_PASSWORD='' \ python3 -c 'import os,json; pw=os.environ["PS_PASSWORD"]; print(json.dumps({"username":os.environ["PS_USERNAME"],"phoneNo":os.environ["PS_PHONE"],"email":os.environ["PS_EMAIL"].lower(),"password":pw,"passwordConfirmation":pw}))' \ | curl -sS --max-time 30 -o register-response.json -w '%{http_code}\n' -X POST https://api.proship.me/auth/v1/auth/register -H 'Content-Type: application/json' --data-binary @- ``` It prints only the HTTP status. Only if that is `200`, move the key into a `.env` file without printing it: ```sh umask 077; python3 -c 'import json; print("PROSHIP_API_KEY=" + json.load(open("register-response.json"))["token"])' >> .env && rm -f register-response.json ``` Before that, if the directory is a git repository, make sure the file is ignored: `git check-ignore -q .env || echo .env >> .gitignore`. Never commit it. Start every later shell command with `set -a; . ./.env; set +a` so `$PROSHIP_API_KEY` is set. If the status is not `200`, or curl exits with an error (a timeout prints `000`), do not run the second block; see the rules below. After a 200, never print the response file: it holds the key. After a 4xx it holds only the error text, which you may show. - No `Authorization` header is needed for this call. On production the response comes back at once, with no OTP or email verification step. - `token` is the API key for every other call in this file. Use it as `PROSHIP_API_KEY` (see Read this first); never print it or log it. Keep it in the git-ignored `.env` file as above, or wherever the human tells you to. If the key is ever lost, the human copies it again from the ProShip app at การตั้งค่า > การเชื่อมต่อ API; never register again to get a new one. Do not print the password back either, and do not store it. - Never auto-retry a register. On a timeout or a 5xx the outcome is unknown: the account may exist. Do not send it again; ask the human to try logging in at https://app.proship.co.th with that email and password, and only if that fails, ask the human what to do. If the response is a 4xx, show the error text as it is (see Errors) and ask the human; the field rules are not documented here, so do not guess a fix. - Registering creates no order. Every order is still a real parcel: keep asking the human before each create. What comes next: 1. Read `GET /core/v1/shops`. A create needs a `shopId`. Whether a freshly registered account already has a shop is not documented here: if the array is empty, ask the human for the shop's name, phone number and sender address (address, subDistrict, district, province, zipcode), then, after they confirm, create it: ```json { "name": "", "phoneNo": "", "address": { "address": "", "subDistrict": "", "district": "", "province": "", "zipcode": <5-digit zipcode, as a number>, "phone": "", "nameAddress": "", "state": 2 }, "shippingMethods": ["thaipost0"], "defaultWeight": null, "paymentMethods": [], "onechat": { "enabled": false, "statusList": [] } } ``` Write that body to `shop-body.json` with a JSON serializer (never by pasting the human's text into a shell string: an apostrophe or a quote in a shop name or address would break it), then: ```sh set -a; . ./.env; set +a curl -sS --max-time 30 -w '\n%{http_code}\n' -X POST https://api.proship.me/core/v1/shops -H "Authorization: Bearer $PROSHIP_API_KEY" -H 'Content-Type: application/json' --data-binary @shop-body.json # -> {"message":"Success"} and 200 (no id in the reply) ``` The reply carries no id: call `GET /core/v1/shops?responseSize=all` and use the shop only if exactly one has that name; if none or more than one does, ask the human. Everything in angle brackets comes from the human; send `phone`, `nameAddress`, `state`, `defaultWeight`, `paymentMethods` and `onechat` exactly as shown. Never auto-retry this create: on a timeout or 5xx, read the list first, and if no shop with that name appears, ask the human before sending it again. Fill the address the same way as in Parse a Thai address. 2. A new account can ship non-COD orders with `thaipost0` straight away. A Thailand Post COD order needs a COD wallet, which the human sets up in the ProShip app, and every other carrier must be connected by the human in the app; see Carriers. ## Order IDs: the rule that breaks most integrations A full order id looks like `order-|`. The short id is everything before `|`. | Where | Id form | |---|---| | Get, Update, Update status, Cancel, Shop for label (id in the URL path) | SHORT: `order-xxxx` | | Print labels (`orders` array in the body) | FULL: `order-xxxx|1727591940677` | | Print batch | the batch id returned by Create batch | - A short id sent to Print labels fails with "Orders not eligible for printing labels". - Create normally returns the full id. Store it. If the returned id has no `|`, call Get order with it and build the full id as `${id}|${createdAt}`. - List orders, Get order and webhooks return the short `id` plus `createdAt`. Build the full id as `${id}|${createdAt}`. - If a full id ever goes in a URL path, encode `|` as `%7C`. ```js const shortId = (id) => String(id).split("|")[0]; const fullId = (o) => String(o.id).includes("|") ? o.id : `${o.id}|${o.createdAt}`; ``` ## Endpoint index All paths are relative to `https://api.proship.me`. "Confirm" means: ask the human before calling. Details for each row are under Endpoints. | Action | Method and path | Order id | Kind | Confirm | |---|---|---|---|---| | Register an account | `POST /auth/v1/auth/register` | none | write, never auto-retry, no key needed | YES | | Shops | `GET /core/v1/shops` | none | read | no | | Create shop | `POST /core/v1/shops` | none | write, never auto-retry | YES | | Allowed carriers | `GET /core/v1/settings/shippingMethods` | none | read | no | | Parse a Thai address | `GET /utils/v1/parse-address?text=` | none | read | no | | Check duplicate | `POST /orders/v1/orders/check-duplicate` | none | lookup | no | | Create order | `POST /orders/v1/orders` | returns full | write, never auto-retry | YES | | Create batch | `POST /batch/v1/orders-batch` | returns batch id | write, never auto-retry | YES | | Get order (track) | `GET /orders/v1/orders/{id}` | short | read | no | | List orders | `GET /orders/v1/orders-v2` | none | read | no | | Update order | `PUT /orders/v1/orders/{id}` | short | write | YES | | Update status | `PUT /orders/v1/orders/{id}/status` | short | write | YES | | Cancel order | `DELETE /orders/v1/orders/{id}` | short | write | YES | | Shop for label | `POST /utils/v1/orders/{id}/update-shop-for-label` | short | write | YES | | Print labels | `POST /print/v1/print-label` | full, in body | returns a PDF | no | | Print batch | `POST /batch/v1/print-batch` | batch id, in body | returns a PDF | no | | Get webhook | `GET /core/v1/settings/webhook` | none | read | no | | Set webhook | `PUT /core/v1/settings/webhook` | none | write | YES | | J&T wallet top-up QR | `POST /wallet/v1/gen-qr` | none | write | YES | ## Basics - Base URL: `https://api.proship.me` (one production environment). - Auth: header `Authorization: Bearer $PROSHIP_API_KEY` on every call except Register. If the human already has a ProShip account, they get the key in the ProShip app at การตั้งค่า > การเชื่อมต่อ API (https://app.proship.co.th). If they have no account, create one first (see Create an account); the response is the key. - Send `Content-Type: application/json` with every body. - Responses are JSON, but the response `Content-Type` header can say `text/plain`. Parse the body as JSON regardless of that header. - Units: weight in grams (integer), money in THB, timestamps in epoch milliseconds. Exception: raw carrier fields inside webhooks keep the carrier's units (see Webhooks). - `shopId` (required on create) is the sender shop; its address goes on the label. Get it from `GET /core/v1/shops`. - `user` (required on create) is the account's username. The API key is a JWT: base64url-decode its middle part and read the `username` claim (some tokens call it `user`): ```js // Node 16+. Reads the username without printing the key. const claims = JSON.parse(Buffer.from(process.env.PROSHIP_API_KEY.split(".")[1], "base64url").toString("utf8")); const user = claims.username ?? claims.user; ``` ### Errors Any non-2xx status is an error. The body comes in one of three shapes, so handle all of them: - An object with `message`, for example `{"message": "Unauthorized"}` (HTTP 401: the key is missing or wrong) or `{"message": "Not found"}` (HTTP 404: Get order with an id that does not exist). - Sometimes a JSON array of strings, or a plain string, instead of an object. Validation errors arrive this way with HTTP 400 or 422. - HTTP 401 on a key that used to work means the key is no longer valid: stop and ask the human for a new one. ```js const errorText = (body) => Array.isArray(body) ? body.join("; ") : typeof body === "string" ? body : (body && body.message) || JSON.stringify(body); ``` Show the error text to the human as it is. A timeout or 5xx on a create is not a failure, it is an unknown outcome (see Recover from a timed-out create). If an update, status change or cancel times out, read the order with Get order before deciding whether to send it again. ## Quickstart (curl) ```sh export PROSHIP_API_KEY # step 0 reads it from the environment H='Authorization: Bearer '"$PROSHIP_API_KEY" API=https://api.proship.me # 0. Username for the "user" field (read from the key; prints only the username) python3 -c 'import os,json,base64; p=os.environ["PROSHIP_API_KEY"].split(".")[1]; c=json.loads(base64.urlsafe_b64decode(p+"="*(-len(p)%4))); print(c.get("username") or c.get("user"))' # 1. Pick the sender shop (use its "id" as shopId) curl -s "$API/core/v1/shops" -H "$H" # 2. Create an order (ask the human first; this books a real parcel). # Carrier and COD amount come from the human, not from this example. curl -s -X POST "$API/orders/v1/orders" -H "$H" -H 'Content-Type: application/json' -d '{ "user": "", "shopId": "shop-xxxx", "shippingMethod": "thaipost", "weight": 500, "customer": { "name": "สมชาย ใจดี", "phoneNo": "0812345678", "address": { "address": "99/1 ซอยสุขุมวิท 101", "subDistrict": "บางจาก", "district": "พระโขนง", "province": "กรุงเทพมหานคร", "zipcode": 10260 } }, "codAmount": 0, "remarks": "INV-1001" }' # -> {"message":"Success","id":"order-xxxx|1727591940677"} # 3. Read status and tracking number (SHORT id) curl -s "$API/orders/v1/orders/order-xxxx" -H "$H" # 4. Print the label (FULL id) and save the PDF curl -s -X POST "$API/print/v1/print-label" -H "$H" -H 'Content-Type: application/json' \ -d '{"orders":["order-xxxx|1727591940677"],"size":"normal","printer":"proship","base64":true}' \ | jq -r '.base64 // .data' | base64 -d > label.pdf # 5. Cancel (SHORT id; ask the human first) curl -s -X DELETE "$API/orders/v1/orders/order-xxxx" -H "$H" ``` ## Carriers (`shippingMethod`) | Code | Carrier | |---|---| | `thaipost` | Thailand Post (ไปรษณีย์ไทย) | | `thaipost0` | Thailand Post EPS | | `kerry` | Kerry Express (KEX) | | `flash` | Flash Express | | `shopee` | SPX Express (Shopee Express); needs extra fields, see Create order | | `jt` | J&T Express | | `dhl` | DHL | `thaipost0` needs no carrier connection: it runs on ProShip's own Thailand Post account, and a create on a brand-new account with nothing connected was accepted. Every other carrier must first be connected by the seller in the ProShip app. `GET /core/v1/settings/shippingMethods` is not proof of connection: an account with no carrier connected still gets the whole list. So ask the human which carrier to use. If a create fails with a carrier error, ask the human to connect that carrier in the app. Use only the codes in this table: ignore `eps`, `scg` and `proship19` if you see them. Switching carrier is usually just a different `shippingMethod` value. Carrier notes: - Thailand Post COD (`thaipost`, `thaipost0`): per ProShip's manual the shop needs a COD wallet, and the ProShip app refuses such an order without one. Before a Thailand Post COD create, call Shops with `?responseSize=all`; if `details.walletId` is missing or empty, tell the human instead of creating. - `thaipost` and `shopee`: the ProShip app refuses a weight above 20,000 g. - `jt`: prepaid from the J&T wallet (see J&T wallet top-up QR), so a create can fail when the wallet is empty. J&T has been seen answering HTTP 500 to a create with a street-text address; a 500 is still "unknown outcome", so reconcile, then tell the human rather than trying another payload. ## Order status codes | Code | Meaning | Thai label in the app | |---|---|---| | -2 | Blacklisted recipient | ระงับการใช้งาน | | -1 | Draft | ฉบับร่าง | | 1 | Pending, waiting for carrier pickup | รอรับพัสดุ | | 2 | To ship (docs: TO_SHIP) | จัดส่งแล้ว | | 3 | Picked up by carrier (docs: SHIPPED) | ขนส่งรับพัสดุแล้ว | | 4 | Delivered | จัดส่งสำเร็จ | | 5 | Cancelled by seller | ยกเลิกโดยผู้ขาย | | 6 | Out for delivery (docs say ERROR; live data uses 6 for out for delivery) | กำลังนำส่ง | | 8 | Returned to sender | ตีกลับผู้ส่ง | | 34 | Recipient not answering the phone | ลูกค้าไม่รับโทรศัพท์ | | 41 | Delivery problem | มีปัญหาการจัดส่ง | Final states: 4, 5, 8. Tell the seller about: 8, 34, 41. Ignore codes not in this table. ## Endpoints ### Shops `GET /core/v1/shops` lists the account's shops as an array. Each item has `id` (use as `shopId`) and `details`. - By default `details` is trimmed to `name`, `defaultWeight` and `shippingMethods`: `[{"id": "shop-xxxx", "details": {"name": "My Shop", "defaultWeight": 1000, "shippingMethods": []}}]`. - Add `?responseSize=all` to also get `details.phoneNo` and `details.address` (`{address, subDistrict, district, province, zipcode}`), `details.walletId` when a COD wallet is set, plus `createdAt`, `updatedAt` and `createdBy` on the item. If the account has more than one shop, ask the human which one sends the parcel. `details.shippingMethods` is the list of carriers switched on for that shop; the ProShip app offers only those. It does not choose the carrier for you: the human does. If their carrier is not in the list, say so before creating. One `thaipost0` order was accepted with the list at `[]`; nothing is known for other carriers. See Carriers. ### Allowed carriers `GET /core/v1/settings/shippingMethods` returns an array of carrier codes the account may use, for example `["thaipost","kerry","flash","jt","thaipost0","eps","dhl","scg","shopee"]`. It is not proof that any of them is connected; see Carriers. ### Parse a Thai address `GET /utils/v1/parse-address?text=` ```json { "inputText": "99/1 ซอยสุขุมวิท 101 บางจาก พระโขนง กรุงเทพมหานคร 10260", "nameAddress": "99/1 ซอยสุขุมวิท 101", "subdistrictName": "บางจาก", "subdistrictMatched": true, "districtName": "พระโขนง", "districtMatched": true, "provinceName": "กรุงเทพมหานคร", "provinceMatched": true, "zipcode": "10260", "zipcodeMatched": true, "phone": "" } ``` - Use the result only when all four `...Matched` booleans are true and `subdistrictName`, `districtName`, `provinceName` and `zipcode` are all non-empty; otherwise ask the human. `zipcodeMatched` is true with `"zipcode": ""` when the text has no zipcode. Text it cannot parse still returns HTTP 200, with the booleans false. - Map `subdistrictName`, `districtName`, `provinceName`, `zipcode` to `subDistrict`, `district`, `province`, `zipcode` in the create body. - `districtName` and `provinceName` repeat what was typed, not the official name: `กทม` comes back as `กทม`, `กรุงเทพฯ` as `กรุงเทพ`, and `เมือง` (for เมืองปทุมธานี) as `เมือง`, all with the matched flag true. Fixing this is your job, not ProShip's: before you create, rewrite abbreviations, nicknames and misspellings to the official full names (กรุงเทพมหานคร, เมืองปทุมธานี), check that sub-district, district, province and zipcode belong together, then parse the corrected text again and confirm all four flags are true and the names come back exactly as you will send them. Show the human the corrected address in the confirmation. If two readings are possible, or the zipcode does not fit the district, ask the human instead of choosing. - `nameAddress` is the text left over after the matched parts are removed. It is usually the house number and street, but it can also contain the recipient's name or stray characters when those were in the input. Check it against the original text before using it as `address`. - `phone` is a phone number found in the text, or `""`. - A missing or empty `text` returns HTTP 400 `{"message": "Invalid address"}`. ### Check duplicate `POST /orders/v1/orders/check-duplicate` with body `{"phoneNo": "0812345678", "zipcode": "10260"}`. Optional `hours` (number or numeric string): how far back to look; the ProShip app sends 24, 48 or 72. Response: `{"duplicate": false, "order": null}`, or, when an earlier order matches (real response, values faked): ```json { "duplicate": true, "order": { "trackingNo": "EX000000000TH", "orderCode": "O-xxxx", "status": 1, "shippingMethod": "thaipost", "customerName": "สมชาย ใจดี", "phoneNo": "0812345678", "address": "99/1 ซอยสุขุมวิท 101", "zipcode": "10260", "remarks": "INV-1001", "codAmount": 0, "codPaid": false, "hasError": false, "printed": false, "printCount": 0, "totalSalesPrice": 0, "discountAmount": 0, "createdAt": 1727591940677, "updatedAt": 1727591940677, "products": null, "shopId": "shop-xxxx", "shopName": "My Shop", "user": "" } } ``` The `order` has no `id`, so you cannot get, cancel or print the earlier order from this response; to act on it, find it with List orders by its `trackingNo`. Call Check duplicate before creating, to catch double orders. If `duplicate` is true, show the earlier order (tracking number, status, `remarks`, `createdAt`) to the human and let them decide. ### Create order `POST /orders/v1/orders`: one order per request. Confirm with the human first. Never auto-retry. Required: - `user` (string): username from the key (see Basics). - `shopId` (string): from Shops. - `shippingMethod` (string): a carrier code from Carriers. - `weight` (integer): grams. - `customer` (object): `name`, `phoneNo` (digits only, e.g. `"0812345678"`), `address` = `{address, subDistrict, district, province, zipcode}`. `address` is house number and street. On `thaipost0` an empty `address` was rejected with HTTP 400. If the human gave no house number or street, ask for it; never invent one. The other parts must be real Thai names (use Parse a Thai address). `zipcode` may be a number or a string. Bangkok uses the same fields: แขวง goes in `subDistrict` and เขต in `district`, without the words แขวง or เขต. Optional `salesChannel`: free-text tag such as `"facebook"` or `"web"`. - Limits the ProShip app's order form enforces; stay inside them, and if a value does not fit, ask the human: `phoneNo` starts with 0 and is 10 digits for a mobile (06x, 08x, 09x) or 9 for a landline (convert `+66...` to `0...`, strip spaces and dashes); `zipcode` is 5 digits; `remarks` is at most 500 characters. Always send a `weight` above 0. Optional: - `codAmount` (number, THB): the cash the recipient pays the courier on delivery; the whole amount, nothing is added to it. Plain number, no commas or currency sign. Not COD: send 0 or leave it out. If the human gives a COD amount, send exactly that. Do not add shipping to it. - `remarks` (string): always send it. Printed on the label. Put a unique reference here (for example your invoice number); it is how you find the order again after a timeout. - `products` (object): send `{}` or leave it out. - `productItems` (array): `[{"sku": "SE976B-RED", "qty": 2, "sortIndex": 0}]`. SKUs must exist in the seller's ProShip products; `sortIndex` sets the print order on the label. - `totalSalesPrice`, `discountAmount`, `shippingCostCharged` (numbers; the last is shipping charged to the buyer): record what the sale was made of. The ProShip app's form prefills COD as `totalSalesPrice - discountAmount + shippingCostCharged` (never below 0). Use that sum only when the human gives you the parts and no total, and show them the result. After a COD create, read the order and check `details.codAmount` equals the confirmed amount; if it differs, tell the human at once. - `salesPerson` (object): `{"name": "agent-name", "value": "sales-agent-id"}`. - `shopForLabel` (object): a different sender name and address to print on the label only; same shape as the `shop` object in Shop for label. - `blacklist` (boolean): marks the order as blacklisted; it starts at status -2. - `refNo` (string): your reference. If you leave it out, ProShip generates one (`REF-...`). - SPX (`shopee`) also requires `custRef` (string, your reference), `express_insured_value` (number) and `parcel_item_name` (string). Response: `{"message": "Success", "id": "order-xxxx|1727591940677", "trackingNo": "EX000000000TH"}`. Store the `id`. If `trackingNo` is not in this response, read it with Get order. If the create is rejected with a 4xx and the error text is about the address (sub-district, district, province or zipcode), do not stop and do not hand it back to the human yet. Correct the address as described in Parse a Thai address, parse the corrected text again, and send the create again. Repeat until it succeeds. Rules for this loop: - Retry only on a 4xx whose message is about the address. A timeout or 5xx is never retried this way: go to Recover from a timed-out create. - The loop covers sub-district, district, province and zipcode only. A missing or rejected house number or street goes to the human. - Change only the address fields. Use the same `remarks` on every attempt. - Each attempt must send a different address from the ones already rejected. When you have no new correction to try, or two readings of the address are equally likely, ask the human. - Tell the human afterwards which address was finally accepted. ### Create batch (several parcels to one recipient) `POST /batch/v1/orders-batch`: same body as Create order plus `box_quantity` (number of identical orders to create). Confirm with the human first. Never auto-retry. Response: `{"message": "Success", "id": "order-xxxx", "fail": 0}`; `id` is the batch id, used only by Print batch. ### Get order (track) `GET /orders/v1/orders/{short id}` Returns `status`, `trackingNo`, `shippingMethod`, `hasError`, `createdAt`, `updatedAt` (and usually `id`), plus `details` with the full order: `customer`, `shop`, `weight`, `codAmount`, `remarks`, `orderCode`, `invoiceNo`, `manifestNo`, `products`, and `histories` (`[{"user", "timestamp", "status"}]`). Real response for a new order (status 1), with the values replaced by fake ones. Other orders can carry more fields or fewer. Never build an Update order body from this example; start from the order's own response. ```json { "id": "order-xxxx", "createdAt": 1727591940677, "updatedAt": 1727591940677, "createdBy": "user-xxxx", "status": 1, "trackingNo": "EX000000000TH", "shippingMethod": "thaipost", "hasError": false, "nc": true, "details": { "user": "", "shopId": "shop-xxxx", "shippingMethod": "thaipost", "status": 1, "trackingNo": "EX000000000TH", "hasError": false, "weight": 500, "actualWeight": "N/A", "codAmount": 0, "remarks": "INV-1001", "orderCode": "O-xxxx", "invoiceNo": "xxxx", "refNo": "REF-xxxx", "customer": { "name": "สมชาย ใจดี", "phoneNo": "0812345678", "showSocialLinkOnLabel": false, "address": { "address": "99/1 ซอยสุขุมวิท 101", "subDistrict": "บางจาก", "district": "พระโขนง", "province": "กรุงเทพมหานคร", "zipcode": "10260", "raw": "99/1 ซอยสุขุมวิท 101\nพระโขนง\nกรุงเทพมหานคร\nบางจาก\n10260" } }, "shop": { "name": "My Shop", "phoneNo": "0980000000", "defaultWeight": 1000, "shippingMethods": [], "paymentMethods": [], "lineUrl": null, "onechat": { "enabled": false, "statusList": [] }, "address": { "address": "...", "subDistrict": "บางกะดี", "district": "เมืองปทุมธานี", "province": "ปทุมธานี", "zipcode": "12000" } }, "histories": [ { "user": "", "timestamp": 1727591940677, "status": 1 } ] } } ``` - Fields not set on the order may be missing: treat a missing `remarks` as empty and a missing `codAmount` as 0. - Read `status` and `trackingNo` from the top level; if they are not there, read them from `details`. - An id that does not exist returns HTTP 404 `{"message": "Not found"}`. - This is how you track a parcel: there is no tracking-timeline endpoint. ### List orders `GET /orders/v1/orders-v2?status=1&onlyError=false` - `status` (optional): a status code, including `-1` (drafts) and `-2`; leave it out for all statuses. - `onlyError` (optional): `true` returns only orders flagged with an error (`hasError`). Send it together with `status`: on an empty test account `onlyError=true` alone returned HTTP 500. - `perPage`: not enforced; even `perPage=1` can return hundreds of rows. - `ExclusiveStartKey`: the cursor. Send `encodeURIComponent(JSON.stringify(LastEvaluatedKey))` from the previous response, with the same `status` and `onlyError`. A `LastEvaluatedKey` that is `null` or missing means it was the last page. Dedupe rows by `id`. Response: ```json { "orders": [ { "id": "order-xxxx", "createdAt": 1727591376673, "status": 1, "trackingNo": "EX000000000TH", "shippingMethod": "thaipost", "orderCode": "O-xxxx", "custName": "...", "custPhone": "0812345678", "shopId": "shop-xxxx", "shopName": "...", "codAmount": 0 } ], "orderCounts": { "0": 120, "1": 30, "4": 70 }, "LastEvaluatedKey": { "id": "order-xxxx", "createdBy": "user-xxxx", "createdAt": 1665732307684 } } ``` - List rows have no `remarks` or address; use Get order for those. - Rows have been seen newest first, but no order is promised: do not stop paging early on the assumption that the rest is older. - `orderCounts` is keyed by status code and is the same whatever `status` you filter on; key `0` is the total. Other keys (such as 7, 81 or 99-102) and `null` values may appear; ignore keys that are not in the status table. - An account with no orders returns `"orders": []` and `"LastEvaluatedKey": null`. ### Update order `PUT /orders/v1/orders/{short id}`. Confirm with the human first. Get the order first, change what you need inside `details`, and send the `details` object itself as the body (not wrapped in `{"details": ...}`): `{"remarks": "...", "customer": {...}, "shop": {...}, "weight": 500, ...}`. ProShip calls the carrier's API first and saves only if the carrier accepts. Response: `{"message": "Updated"}`. Not every order can be edited. ProShip's manual describes the app's edit screen as follows; the API has not been tested against it, so treat it as the limit: - `kerry`, `shopee`, `dhl`: no edits. Do not send an update; tell the human the order must be cancelled and created again, and do each of those only on their yes. - `thaipost0`: name, address and phone can change; the COD amount cannot. - `flash`, `jt`: only weight, sale price, discount and product list can change; not name, address, phone or COD. - `thaipost`: name, address, phone, remarks, COD, weight and prices can change. - After an edit on `thaipost`, `flash` or `jt` the tracking number stays the same and the label must be printed again. If an update is rejected, tell the human; never cancel and re-create on your own. ### Update status `PUT /orders/v1/orders/{short id}/status` with body `{"status": 3}`. Confirm with the human first. Response: `{"message": "Updated"}`. This changes the status in ProShip only. Use it only when the human asks for a specific status, and never to cancel (use Cancel order). ### Cancel order `DELETE /orders/v1/orders/{short id}`. Confirm with the human first. Response: `{"message": "Cancelled"}`; status becomes 5. Get order can show the old status for a second or two after a cancel. To confirm, read the order again after 1, 2, 4 and 8 seconds; if it is still not 5, tell the human. Do not send the DELETE again. ### Shop for label `POST /utils/v1/orders/{short id}/update-shop-for-label`: the carrier gets the shop's real address, the label shows this one. Confirm with the human first. ```json { "shop": { "name": "My Shop", "phoneNo": "0980000000", "address": { "address": "...", "subDistrict": "บางกะดี", "district": "เมืองปทุมธานี", "province": "ปทุมธานี", "zipcode": "12000" } } } ``` Response: `{"message": "Updated"}`. If the short id is rejected, retry with the full id. ### Print labels `POST /print/v1/print-label` ```json { "orders": ["order-xxxx|1727591940677"], "size": "normal", "printer": "proship", "base64": true } ``` - `orders`: FULL ids. Several ids give one PDF. - `size`: `normal`, or `a4` (4 labels per A4 page). - `printer`: `default`, `proship`, `proship_v3p`, `tsc`, `tsc_wp`, `x100`, `100x75`, `paperang`. Use `proship` and `normal` unless the human says otherwise. - If the human names a paper, not a printer (pairs from the ProShip app): A4 = `a4` + `default`; 100x150 mm = `normal` + `proship_v3p`; 100x100 mm = `normal` + `x100`; 75x100 mm = `normal` + `tsc` (`tsc_wp` without product list, `100x75` landscape); Paperang = `normal` + `paperang`. (`proship` is the older 100x150 mm layout.) - The ProShip app sends this endpoint fewer than 50 orders per call; do the same and split larger jobs. - `base64`: always send `true`. The response is `{"base64": ""}`; some responses use `data` instead, so read `base64`, else `data`. Base64-decode the value and save it as a `.pdf` file. Before you say it worked, check the decoded bytes start with `%PDF`; an error body decodes to garbage, not a label. ### Print batch `POST /batch/v1/print-batch`: same body as Print labels, but `orders` holds exactly one batch id from Create batch; it prints every order in that batch. ### Webhook settings - `GET /core/v1/settings/webhook` returns the current webhook. When no webhook is set it returns HTTP 200 with an empty body, so check for an empty body before parsing JSON. - `PUT /core/v1/settings/webhook` with `{"url": "https://example.com/proship-webhook", "headers": "{\"X-Webhook-Secret\":\"\"}"}` sets it (response `{"message": "Success"}`). Call Get webhook first and show the human the current URL; this call replaces it. Confirm with the human first: every order goes to this URL. `headers` is a JSON string (not an object) of headers ProShip adds to every webhook call; use it for a shared secret you check on receipt. ### J&T wallet top-up QR `POST /wallet/v1/gen-qr` with `{"balance": "100"}` (THB). J&T Express only. Returns a PromptPay QR; the wallet balance updates by itself after payment. Confirm with the human first. ```json { "status": "success", "data": { "orderNo": "000000000000", "referenceNo": "000000000000", "total": 100, "orderdatetime": "2026-10-04 09:49:06", "expiredate": "2026-10-04 09:59:06", "image": "data:image/png;base64,..." } } ``` `orderdatetime` and `expiredate` are `YYYY-MM-DD HH:mm:ss`; the QR stops working at `expiredate`. `image` is a data URI you can show directly. ## Webhooks ProShip POSTs JSON `{"type": "...", "data": {...}}` to the seller's webhook URL. Answer 200 quickly. Webhooks are not signed: check the secret header you set in Webhook settings and reject calls without it. | `type` | When | `data` | |---|---|---| | `ORDER_STATUS_CHANGE_MANUAL` | status changed in ProShip | the full order, same shape as Get order | | `ORDER_STATUS_CHANGE_SHIPPER_WEBHOOK` | status changed by the carrier | the full order, same shape as Get order | | `WEIGHT_UPDATE` | the carrier weighed the parcel | `{"orderId", "createdAt", "data": {...}}`; the inner `data` has the carrier's raw fields (`weight`, `length`, `width`, `height`, `price`, `pno` = carrier tracking number) | | `PRICE_UPDATE` | the carrier's final price | same wrapper; raw fields include `freightPrice`, `codFee`, `codAmount`, `remoteAreaFee`, `pno` | - Accept both spellings of the last two: `WEIGHT_UPDATE` and `WEIGHT UPDATE`, `PRICE_UPDATE` and `PRICE UPDATE`. - Raw carrier fields are strings in the carrier's own units: in the Flash examples money appears to be in satang (`"14800"` = 148 THB) and times are epoch seconds. Check them against the order before using them as THB. - To print from a status webhook, build the full id as `${data.id}|${data.createdAt}`. ## Recover from a timed-out create Before every create, note the current time in epoch ms and put a unique `remarks` in the body. Without both, this procedure cannot work. 1. Do not retry yet. 2. `GET /orders/v1/orders-v2` with no `status`; keep rows whose `createdAt` is after your attempt minus 2 minutes and whose `custPhone` ends with the same last 9 digits as your recipient, and skip status 5. 3. `GET /orders/v1/orders/{short id}` on each candidate and compare `details.remarks` with the unique `remarks` you sent. 4. Found it: the order exists, so do not create it again; use `${id}|${createdAt}` as the full id. Not found: tell the human and retry only if they agree. 5. Create batch: the same steps can match several orders. Tell the human how many you found against the `box_quantity` you sent, and never send the batch again without their yes. The batch id is not recoverable this way, and printing those orders one by one is not documented, so ask the human. ## Typical flows - Ship one parcel: Shops -> Parse a Thai address (if the address is free text) -> Check duplicate -> confirm with the human -> Create order (keep the full id) -> Get order for `trackingNo` -> Print labels with the full id -> save the PDF. - Track: Get order gives `status` and `trackingNo`. For many orders, List orders by `status` with the cursor, or receive webhooks instead of polling. - Cancel: confirm with the human -> Cancel order with the short id. - Print labels for a date range: List orders has no date filter. Page through it, keep rows whose `createdAt` falls in the range (ask the human which timezone they mean), skip status 5, build full ids as `${id}|${createdAt}`, and send them to Print labels. ## Not available through the API Rates or price quotes, a tracking timeline, connecting carriers and carrier credentials, products and stock, reports, the COD wallet balance, and the Facebook and chat features (bot, #เปิดบิล, broadcast). Those are done in the ProShip app. If the human asks for one of these, say so; do not look for an endpoint. ## Links - ProShip app (log in, find the API key if you already have an account, connect carriers): https://app.proship.co.th - Create an account through the API: see Create an account in this file. - Developers page: https://www.proship.co.th/developers/ - Manual (Thai): https://www.proship.co.th/manual/ - Site overview for LLMs: https://www.proship.co.th/llms.txt