Quick start
Four calls, in this order. Each one has its own section with the full payload and the rules that apply — this page is the map, not the detail.
-
Sign in
Exchange the account we issued you for a token, then send that token on every other call. Any catalog endpoint is a cheap way to confirm it works: no body, changes nothing. Authentication →
-
Load the reference data
Service codes, country codes and postal codes are not free text. Fetch them, cache them, use the returned values verbatim. Catalog endpoints →
-
Create a booking
You get back a waybill number. That number is permanent and cannot be reissued, so read Avoiding duplicates before you go live. The booking lands at Draft. Create a booking →
-
Confirm it
A draft is not a live shipment — nobody is dispatched until you confirm, and this is what sends the confirmation email. Confirm a booking →
The staging environment writes to a test database. Create as many bookings as you need while you build — you do not have to clean them up.
Authentication
Every request carries a bearer token. There is no API key: you sign in with a FastLink account and send the token you get back.
Getting a token
POST /api/v1/booking/auth/login with the account and password we issued you —
send just the email address we gave you — nothing before it, nothing after it. Anything else is rejected before we even try it. It returns
an accessToken good for one hour, and a refreshToken to renew it
without sending the password again through POST /api/v1/booking/auth/refresh.
curl -X POST "https://booking-gateway-api.fastlink.dev/api/v1/booking/auth/login" \
-H "Content-Type: application/json" \
-d '{ "userName": "YOUR_ACCOUNT", "password": "YOUR_PASSWORD" }'
Then send it on every other call:
Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6…
What the login gives you besides the token
The response carries customers: the customer codes that account may book for, with
their names — ready to put in front of a user, or to check against your own records at startup.
It is a convenience, not the permission itself; every booking call re-checks.
Keep it on your server — never in a browser bundle, a mobile app, or a public repository. Store the refresh token the same way. Tell us immediately if either is exposed.
Do not log in on every request. One login per process, then refresh — repeated failed logins lock the account, and a lockout stops your integration, not just that call.
| Status | Meaning | What to do |
|---|---|---|
| 401 | Token missing, malformed or expired | Check the header name and the value. A token lasts one hour — renew with /auth/refresh rather than logging in again on every call. |
| 403 | Token is valid but your client is not allowed on this route | Contact us; the route has to be enabled for your client. |
Your account is scoped to a set of customer codes. You can only create and read bookings for those codes — see Common mistakes for how that surfaces.
The customers you may act on are the ones your account is assigned to in our internal system with the booking role. Being assigned to a customer for billing or sales does not let you book for it.
Two consequences worth planning for. Adding or removing a customer takes effect immediately,
with no change on your side and no redeploy on ours — so a code that worked yesterday can
start returning 40, and a new one can start working without us telling you.
And a booking for a code outside your scope answers exactly like one that does not exist, so
never read "not found" as proof the waybill is unknown to us.
Response envelope
HTTP 200 does not mean the request succeeded. Business outcomes are carried in the
code field of the body, not in the HTTP status. Checking only the status code
is the single most common integration bug we see.
Every response has the same shape:
{
"traceId": "00-dcf0cd16…-9588d2d7…-01", // give us this when reporting a problem
"correlationId": null,
"data": { … } | null, // payload on success, null on failure
"code": 0, // 0 = success, anything else = failure
"subCode": null,
"message": "Success", // human-readable, English
"errors": [] // per-field messages on validation failure
}
Click a code to see what it means and what to do about it.
Reading a response correctly
const res = await fetch(url, { method: 'POST', headers, body });
const body = await res.json();
if (res.status === 401 || res.status === 403) throw new Error('Auth problem');
if (body.code === 0) return body.data; // success
if (body.code === 12) return body.data; // duplicate — same booking returned, treat as success
if (body.code === 21) throw new Error(body.errors.map(e => e.message).join('; '));
throw new Error(`${body.code}: ${body.message}`); // 11, 20, 40, …
Store traceId against your own order record. When you report a problem we can
find the exact request in our logs from that value alone, which turns a day of
back-and-forth into a few minutes.
Booking lifecycle
Creating a booking takes two calls. create returns a booking at
1 — Draft with its tracking number already assigned, and
confirm moves it to 10 — Booked. Nobody is dispatched to collect
the shipment until you confirm, so a draft left behind is not a live shipment.
The split exists so you can create the booking, show the tracking number to your customer, and only commit once they are ready. If you have no such step, call the two back to back.
| Status | Meaning | Update | Cancel | |
|---|---|---|---|---|
| 1 | Draft — created but not confirmed; no collection scheduled | Yes | Yes | No |
| 10 | Booked — confirmed, collection scheduled | No | Yes | Yes |
| 15 | Picked up — the shipment is physically with us | No | No | Yes |
| 20 | Processing | No | No | Yes |
| 100 | Cancelled | No | No | No |
Note the asymmetry at 10: you can still cancel a confirmed booking, but you cannot amend one. Confirming hands the details to our operation, so a change after that point would not reach the people already scheduled to collect. Cancel it and create a new booking instead — the tracking number changes, so use the new one from then on.
Once a shipment reaches 15 it has been physically handled, so amending or cancelling it needs a human process — contact your account manager rather than the API.
Catalog endpoints
Four read-only lists. Cache them on your side and refresh about once a day; they change rarely, and calling them before every booking only slows you down.
Valid values for the service field. Do not hard-code these —
they differ per environment. Codes you will typically see: EOCS,
ARAMEX, EDHL, EUPS, FEDEX,
FBM, KHA.
Feeds deliveryAddress.countryCode.
Returns code (postal code) and name (state or province). Feeds
deliveryAddress.zipCode and deliveryAddress.zoneName — both
required. A postalCode shorter than 3 characters returns an empty list.
Feeds invoice.currencyCode. Only needed if you type in an invoice.
{
"data": [
{ "code": "EDHL", "name": "DHL" },
{ "code": "FEDEX", "name": "FedEx" }
],
"code": 0,
"message": "Success"
}
Create a booking
Creates the shipment and returns its waybill number. Written in one transaction — either the booking, all package lines, both addresses and the invoice are stored, or nothing is.
{
"custCode": "0320XXXX",
"service": "EDHL",
"pkgType": "SPX",
"partnerRefNo": "SHP-2026-00123",
"estimatePickTime": "2026-12-31T14:00:00",
"taxCode": "0101234567",
"termOfTrade": "DDU",
"paidDutyBy": "PP",
"hasCustomsDeclaration": true,
"customsDeclarationType": "HNC_OPENS",
"customsDeclarationNumber": "103456789012",
"note": "Fragile, call before pickup",
"pickupAddress": {
"senderName": "ACME TRADING",
"senderNameEng": "ACME TRADING CO LTD",
"contactPerson": "Jane Doe",
"mobile": "0901234567",
"addressDetail": "12 Tran Duy Hung, Ha Noi"
},
"deliveryAddress": {
"consigneeName": "XYZ CORPORATION",
"contactPerson": "John Smith",
"mobile": "+1 213 555 0100",
"email": "john@xyz.com",
"addressDetail": "1234 S Main St",
"countryCode": "US",
"zoneName": "CALIFORNIA",
"zipCode": "90001"
},
"details": [
{
"noPieces": 2,
"originTotalWeight": 5,
"contentType": "ELECTRON",
"contentDetail": "Electronic components",
"vwLength": 40,
"vwWidth": 30,
"vwHeight": 20
}
],
"invoice": {
"invoiceType": "COMMERCIAL",
"invoiceNumber": "INV-2026-0001",
"invoiceDate": "2026-08-01T00:00:00",
"currencyCode": "USD",
"sender": "ACME TRADING",
"recipient": "XYZ CORPORATION",
"details": [
{ "quantity": 2, "descriptionContent": "Sensor module", "unitValue": 10, "unitWeight": 0.5, "countryOfOrigin": "VN", "hsCode": "8543", "unitCode": "PCE" },
{ "quantity": 3, "descriptionContent": "Cable", "unitValue": 5, "unitWeight": 0.2, "countryOfOrigin": "VN" }
]
}
}
{
"traceId": "00-dcf0cd1656bfb138646039fd25653311-9588d2d7b1ac1362-01",
"data": {
"hawbNo": "893600001782644", // ← your waybill number
"status": 10,
"service": "EDHL",
"pkgType": "SPX",
"custCode": "0320XXXX",
"custName": "ACME TRADING",
"noPieces": 5, // computed by us
"weight": 10.0, // computed by us
"isInvoice": true,
"invoiceDbFiles": "",
"createdTime": "2026-08-07T07:53:07",
"details": [ … ],
"pickupAddress": { … },
"deliveryAddress": { … }
},
"code": 0,
"message": "Success"
}
Field reference
Booking
| Field | Notes | |
|---|---|---|
| custCode | Required | Your customer code with us. Must be one of the codes your account is authorised for — the login response lists them. |
| service | Required | From /catalog/services. Decides how the waybill number is issued, so it cannot be changed later. |
| pkgType | Required | DOX documents · SPX goods. SPX requires an invoice. |
| estimatePickTime | Required | Must be in the future. ISO 8601, local time. |
| taxCode | Required | Stored exactly as you send it — we do not substitute the value on the customer record. |
| details | Required | At least one package line. |
| partnerRefNo | Optional | Your own order reference. Strongly recommended — see Avoiding duplicates. |
| termOfTrade | Optional | e.g. DDU, DDP. |
| paidDutyBy | Optional | Who pays duty. |
| note | Optional | Free text for our operations team. |
| hasCustomsDeclaration | Optional | Set to true when a customs declaration is opened for the shipment. |
| customsDeclarationType | Optional | Required when hasCustomsDeclaration is true and pkgType is SPX. Who opens the declaration: CUSTOMER_OPENS or HNC_OPENS. Any other value is accepted but shows up verbatim on the confirmation email instead of a readable label. |
| customsDeclarationNumber | Optional | The declaration number, once you have it. |
| batchCode | Optional | If you group shipments into batches. |
| hawbNo | We fill | Issued by us. Never send it. |
| custName, companyCode | We fill | Read from your customer record. |
| noPieces, weight | We fill | Summed from details. Anything you send is ignored. |
Address — pickupAddress and deliveryAddress
| Field | Pickup | Delivery | Notes |
|---|---|---|---|
| senderName | Required | — | Legal sender name. |
| consigneeName | — | Required | Receiving company. |
| contactPerson | Required | Required | |
| mobile | Required | Required | |
| addressDetail | Required | Required | Street address. |
| countryCode | — | Required | From /catalog/countries. |
| zoneName | — | Required | State or province, from /catalog/postal-codes. |
| zipCode | — | Required | Countries without postal codes: send a single space. |
| Optional | Optional | Required when service is ARAMEX — Aramex rejects shipments without a consignee email. | |
| senderNameEng | Optional | — | Used on printed documents. |
| taxId | Optional | Optional | Consignee tax / EIN / BIN number. |
Read-backs contain two fields you never sent: addressFull and
countryName. We derive them — the delivery one by joining street, state, postal
code and country name, the pickup one from the street alone — because our operations screens
and the printed label read those columns. Send them yourself only if you want a specific
wording; whatever you send is kept as-is.
Package line — details[]
| Field | Notes | |
|---|---|---|
| noPieces | Required | Greater than 0. |
| originTotalWeight | Required | Actual weight in kg, greater than 0. |
| contentType | Required | Except when service is FBM. One of LICENSE · GARMENT · MECHANICAL · ELECTRON · PLASTIC · FOOD · OTHER — the same seven our own booking screen offers. Anything else is stored as sent and reaches our operators as an unknown code. |
| vwLength, vwWidth, vwHeight | Optional | Dimensions in cm. Send them and we compute volumetric weight — see below. |
| totalVwWeight | We fill | Computed from the dimensions unless you send your own value. |
| seq | Optional | Line order. Left empty, we number them in the order you sent. |
| contentDetail, note | Optional | Free text. |
Volumetric weight
Send vwLength, vwWidth and vwHeight in centimetres and
leave totalVwWeight empty. We work it out and write it back on the booking, so you
can read it from /express/get rather than computing it yourself.
Billing uses the greater of actual and volumetric weight. Sending dimensions therefore changes the price — send them when they apply, omit them when they do not.
Invoices and attachments
Goods shipments (pkgType = "SPX") need a commercial invoice. Supply it in
exactly one of two ways.
invoice and invoiceFiles are mutually exclusive and sending both
is rejected. Our operations screen hides the attachment panel on bookings that carry a
typed-in invoice, so a file sent alongside one would be invisible to the staff handling
your shipment.
Option A — type the invoice in
Best when you already hold the commercial data. We derive the totals so you do not have to:
- Leave
subTotalempty → we computequantity × unitValue - Leave
totalWeightempty → we computequantity × unitWeight - Leave
totalInvoiceAmountempty → we sum the lines
Four invoice header fields come from details[] — the package lines you already
sent — not from the invoice lines. Leave them out and they arrive filled:
| Field | Derived from |
|---|---|
| totalNetWeight | Sum of details[].originTotalWeight |
| totalGrossWeight | Sum of the volumetric weights we computed for each line |
| totalShipmentPieces | Sum of details[].noPieces |
| carrier | The booking's own service |
currencyCode defaults to USD. Send any of these five explicitly and
we store exactly what you sent — the derivation only fills blanks.
Read it back later with POST /api/v1/booking/express/get-invoice.
Fields that end up on paper
A typed-in invoice also produces a printed invoice PDF attached to the confirmation email, and a few fields that look cosmetic in the payload become visible boxes on it. Leave them out and the box prints empty — nothing errors, so it is worth filling them in:
| Field | Where it prints |
|---|---|
| details[].descriptionContentVn | The Material column, one row per line |
| commoditySize | The Dimension box |
| importer | The Importer box — left out, it prints Same as Consignee |
| deliveryAddress.taxId | Not on the invoice, but on the OCS consignment note under the consignee name |
Option B — attach a file
Send the document as base64 inside the same create call. No separate upload step.
"invoiceFiles": [
{ "fileName": "INV-2026-0001.pdf", "fileBase64": "JVBERi0xLjQKMSAwIG9iago…" }
]
| Rule | Value |
|---|---|
| Accepted types | .pdf .jpg .jpeg .png |
| Maximum size | 5 MB per file |
| Number of files | No fixed limit; send what the shipment needs |
| Result | Recorded in invoiceDbFiles as a JSON array — see Read a booking |
Files are uploaded before the waybill number is issued, so a rejected file never costs you a number — you can fix it and retry cleanly.
Avoiding duplicates
A waybill number, once issued, cannot be withdrawn or reused. That makes retries dangerous by default:
1. You call create
2. We create the booking and issue waybill …600
3. The response is lost — timeout, dropped connection, deploy
4. You do not know it worked, so you retry
5. Without protection: a second booking, waybill …611
→ one shipment, two waybills, two labels, double billing
Send partnerRefNo — your own order reference — and step 5 changes: we recognise
the reference, skip creation entirely, and return the original booking with
code: 12. Call it ten times and you still get one shipment.
Cancelled bookings are ignored by this check. Once you cancel a booking, the
same partnerRefNo is free again — re-book it and you get a fresh waybill number
rather than the dead booking back. That is deliberate: a cancelled order being re-placed under
the same reference is normal, and you should not have to invent a new one.
It carries the same data as the original response, waybill number included.
Nothing was created, nothing is wrong — your retry simply found the earlier booking.
The reference is compared within one customer code. Two shipments for different customers may carry the same string without interfering, so you can keep numbering per customer if that suits you. Leave it empty and no protection applies — the choice is yours, but we recommend always sending it.
One consequence worth knowing if more than one of our partners books for the same customer code: they share that reference space. The same string sent by either of you resolves to the same booking, which is usually what you want — one order, one waybill — but it does mean the reference is not private to you.
Confirm a booking
Moves the booking from 1 — Draft to 10 — Booked and sends the
confirmation email. Until you call this, no collection is scheduled.
{
"hawbNo": "893600001782644",
"emails": ["ops@yourcompany.com", "customer@example.com"]
}
Who receives the email
emails is your list. We add the address on the customer record and the staff
assigned to that customer, so the message usually reaches more people than you listed. That is
expected — those are the people who act on the booking.
Every address in emails must be a plain, well-formed address. Send one that is
not — a name, a placeholder, two addresses in one string — and the call is rejected with
HTTP 400 before anything changes; the booking stays at Draft and you can
retry. We validate here rather than let the mail server refuse later, because one malformed
address makes the whole message fail: you, the customer and the assigned staff would all
receive nothing while the booking had already moved to Booked.
Addresses we add ourselves are treated differently. If the customer record or a staff profile holds something unusable, we drop that one address and still send to everyone else — one stale record should not silence a booking confirmation.
If the list ends up empty — you sent none and the customer record has no address — the call
returns code: 20 and the booking stays at Draft. A booking nobody
was told about is worse than one you can retry.
What the email contains
A summary of the booking — customer, service, pickup time, package lines, and both addresses — followed by a link to the waybill label PDF under File đính kèm. That label is the sheet to print and attach to the parcel.
Which files you get depends on the shipment. Standard express services get the waybill label.
EOCS bookings get an OCS consignment note instead of it, not in addition.
And any booking with a typed-in invoice gets a commercial invoice PDF on top of whichever
label applies — so a booking can carry two files.
One exception: FBM bookings do not carry the Amazon label, because that template
is not present on our reporting service. A booking is still valid without any of these files —
you can always request a reprint.
Calling it twice
Only a booking at status 1 can be confirmed. A second call returns
code: 20 and changes nothing, so a retry after a network timeout is safe.
The email is queued, not sent inline. A successful response means the booking is confirmed and the message accepted for delivery — delivery itself happens moments later. If mail delivery fails, the booking still stays confirmed; we would rather you have a live shipment than lose it to a mail server.
Look up a booking
Header, package lines ordered by seq, and both addresses.
{ "hawbNo": "893600001782644" }
Two fields worth knowing:
isInvoice— whether a typed-in invoice exists. Iftrue, fetch it from/get-invoice.invoiceDbFiles— the attached invoice files, as a JSON array encoded in a string. Parse it if you need the file names back; it is stored exactly as our internal screens read it.
[
{
"Category": "fin_trinh_gia_send_mail",
"OriginalName": "INV-2026-0001.pdf",
"Code": "0320XXXX",
"Filename": "5eed8b033afa4216835e6733041e1514.pdf",
"Ext": ".pdf"
}
]
Two details that trip people up. The field names inside are PascalCase, unlike
every other field in this API — that shape belongs to the storage service, not to us, and we pass
it through unchanged. And Code is the customer code, not a file id;
Filename is what identifies the stored file.
Bookings created before we moved file storage still hold the older format in this field — a
plain comma-separated list of ids. Check whether the value starts with [ before
parsing it as JSON.
Several at once
The same payload as /get, for a list of waybill numbers — use it to reconcile a
batch instead of firing one request per booking. 50 numbers per call.
{ "hawbNos": ["893600001782644", "893600001782651"] }
Unknown numbers are skipped, not reported. A waybill that does not exist — or
belongs to a customer code you are not registered for — is simply absent from the response.
That is the same reasoning as /get returning not found for both cases:
saying which of the two it was would let anyone probe for other partners' waybill numbers.
Compare the returned list against what you sent to see what is missing.
Results keep the order you sent, with duplicates removed.
The commercial invoice and its line items. Only for bookings where the invoice was typed
in; bookings carrying attached files return code: 11.
Update a booking
Only while the booking is still at status 1 — Draft. Once you confirm it, cancel and create again.
What you cannot change
These fields are absent from the update payload by design:
| Field | Why |
|---|---|
| hawbNo | It identifies the booking; it is not an editable value. |
| custCode | Changing it would move the shipment to a different customer and bypass your access scope. |
| service | The waybill number was issued according to this service. Changing it would leave the number inconsistent with the shipment. |
| partnerRefNo | It is the duplicate-protection key. Moving it would make a later retry fail to recognise the booking. |
How the parts behave
| Part | Behaviour |
|---|---|
| Addresses | The same two address rows are kept, but every field is overwritten — this is not a merge. A field you leave out is cleared, not preserved. Send the whole address block exactly as you did on create, including values that did not change. The one most often forgotten is senderNameEng: lose it and the printed label falls back to the customer name on file, while the “sender company” line of the confirmation email goes blank. |
details | Replaced wholesale. Whatever you send is exactly what remains — send the complete list, not just the changes. |
invoice | Replaced wholesale. Omit it to remove the invoice entirely. |
invoiceFiles | Omit to keep the current files; send [] to remove them all. |
All of it runs in one transaction: either every part changes, or none does.
Cancel a booking
Only while the booking is at status 1 or 10.
{
"hawbNo": "893600001782644",
"note": "Customer changed their mind"
}
note is required — our operations team reads it to understand why the shipment
disappeared. It is appended to the existing note rather than replacing it,
so whatever you wrote at creation survives.
Calling it twice returns code: 20 with a message saying the booking is already
cancelled, so you can tell a duplicate call apart from a genuine state error.
Cancellation email
Cancelling a confirmed booking sends a cancellation notice carrying your
note as the reason. It goes to exactly the people who received the confirmation
— the addresses you passed to confirm, plus the customer record and the assigned
staff. Nobody who was told the shipment exists is left thinking it still does.
Cancelling a booking still at Draft sends nothing, because no confirmation
was ever sent. As with confirm, a mail failure never undoes the cancellation.
Print the shipping note
Only once the booking is confirmed — status 10, 15 or 20.
Returns the shipping note — the air waybill that travels with the box — as a base64 PDF. It is built from the same template as the copy attached to your confirmation email, from the booking as it stands right now. Details that changed since that email, and the booking date stamp once a shipment moves on through our operation, can therefore differ between the two. The waybill number never does, and that is what identifies the shipment.
{ "hawbNo": "893600001782644" }
{
"code": 0,
"data": {
"fileName": "AWB_893600001782644.pdf",
"contentType": "application/pdf",
"fileBase64": "JVBERi0xLjQKMSAwIG9iago…"
}
}
Decode fileBase64 and you have the file. Nothing is stored on our side: the PDF is
rebuilt from the booking on every call, so it always shows the current data and you can call it
as often as you need — a jammed printer or a torn label costs you one more request, not a
support ticket.
Why a draft has none
A draft is not a live shipment, so it has no shipping note. Printing one would put a label on a box that nobody has been scheduled to collect, and that label would be indistinguishable from a real one. Confirm the booking first. A cancelled booking has no shipping note either, for the same reason in reverse: the label would still work if it reached a warehouse.
Which template we use follows the service on the booking, and you do not choose it — sending a different one would produce a document our operation does not accept.
Common mistakes
Every one of these has cost an integrator real time. They are listed most-frequent first.
1 · Checking only the HTTP status
A failed lookup returns HTTP 200 with code: 11. Treating 200 as
success means silently processing an empty result. Always branch on code.
2 · Expecting “not found” and “not allowed” to differ
A booking that exists but belongs to a customer code you are not authorised for returns the same answer as one that does not exist. This is deliberate: distinguishing them would let anyone probe for other partners' waybill numbers.
The check runs on every call and looks only at the customer code on the booking —
it does not care who created it. So a booking you created yourself becomes unreachable the
moment your access to that customer code is withdrawn, and it comes back as
code: 11 like any unknown waybill.
That means code: 11 on a waybill you are sure exists has two possible causes,
and the response will not tell you which: the number is wrong, or your access to that
customer changed. Check the customer code first — if other bookings for the same customer
also stopped resolving, it is access, not the number. Then contact us.
3 · Sending both an invoice and an invoice file
Rejected — see Invoices and attachments. Pick one.
4 · Retrying without partnerRefNo
Duplicate shipments and duplicate billing. See Avoiding duplicates.
5 · Sending only the changed package lines to update
details is replaced wholesale, not merged. Sending one line leaves the booking
with exactly one line.
6 · Hard-coding service or country codes
They differ between staging and production. Read them from the catalog endpoints.
7 · Omitting zipCode for countries that have no postal code
The field is required. Send a single space rather than omitting it.
8 · Forgetting the consignee email on ARAMEX
Aramex rejects shipments without one, so we reject them at the door rather than letting the shipment fail later.
Go-live checklist
- Production account requested, and
/auth/loginconfirmed working against it - Account name stored exactly as we issued it, character for character
- Token cached for its hour and renewed with
/auth/refresh, not one login per request - Response handling branches on
code, not on HTTP status code: 12handled as success, not as an errorpartnerRefNosent on everycreate, unique per order on your side- Every
createfollowed by aconfirm— and an alert on your side for drafts left unconfirmed traceIdstored against your order record- Catalog data cached and refreshed on a schedule
- Retry policy in place for network failures — safe now that duplicates are handled
- Password and refresh token held server-side only, never in client code or version control
- A person or alias on your side we can contact about shipment problems