Developer documentation
DTS Customer API v1
The DTS Customer API lets your own systems price courier jobs with Direct Transport Solutions, book them at the price you were shown, and track them through to delivery. It follows the same steps as Place the Booking on the DTS portal.
- Price a job Get a price for each service with
POST /quotes. - Book it Book the service you chose from the quote, at its price, with
POST /bookings. - Track it Read the status, progress and driver with
GET /bookings/{jobNumber}, or list a day's bookings withGET /bookings. - Get the paperwork Download the shipping label and tax invoice as PDFs, and read proof of delivery.
The API is for DTS business customers. It works over HTTPS with JSON, and each region has its own address and keys. Start by getting a key, then follow the booking flow guide. To check that a key works, call GET /account:
curl https://portal.directtransport.com.au/api/v1/account \
-H "Authorization: Bearer $DTS_API_KEY"Regions and base URLs
DTS has a portal in each of three regions. Each region has its own API address (base URL), its own keys and its own bookings. Use the base URL of the region your account books in.
| Region | Base URL | Time zone | region in GET /account |
|---|---|---|---|
| Sydney (NSW) | https://portal.directtransport.com.au/api/v1 | Australia/Sydney | sydney |
| Melbourne (VIC) | https://melbourne.directtransport.com.au/api/v1 | Australia/Melbourne | melbourne |
| Queensland (QLD) | https://queensland.directtransport.com.au/api/v1 | Australia/Brisbane | queensland |
- A key only works with the base URL of the region that issued it. Sent to another region's base URL, it's refused with
invalid_api_key(401) orwrong_region(403). - If you book in more than one region, you need a key for each region.
- A key sees only its own account's bookings in its region. Bookings of linked accounts aren't included.
- Paths in this documentation are relative to the base URL. In Sydney,
POST /quotesmeansPOST https://portal.directtransport.com.au/api/v1/quotes. - Examples use the Sydney base URL. Melbourne and Queensland work the same way.
Melbourne's time zone is named Australia/Melbourne, but its clock is the same as Sydney's. Queensland doesn't use daylight saving time. See dates and times.
Getting a key
DTS staff issue API keys; you can't create them yourself. Email IT@directtransport.com.au with your account's name and email address, the region, and whether you need a live key, a test key or both. Keys are only for DTS business accounts.
Live and test keys
| Live key | Test key | |
|---|---|---|
| Starts with | dts_live_ | dts_test_ |
| Prices | Real prices for your account | Real prices for your account |
| Bookings | Real jobs, handled by DTS like bookings made on the portal | Saved as test bookings with job numbers like TEST-DTS12345. No driver is sent and no email goes to DTS. |
| Progress | Updates as the job moves | Simulated: allocated after 5 minutes, picked up after 15, delivered after 30 |
| Bookings it can read | Your account's live bookings in the region, including bookings made on the portal | Your account's test bookings only |
| Daily limits | For example 20,000 requests, 2,000 price checks and 500 bookings a day | Lower: for example 5,000 requests, 200 price checks and 200 bookings a day |
X-DTS-Mode header | live | test |
A key is dts_live_ or dts_test_ followed by 40 letters and digits. For example (this isn't a real key):
dts_test_EXAMPLEexampleEXAMPLEexampleEXAMPLE01234Build and test with a test key first. See test mode and limits and usage. An account can have up to five keys that aren't revoked, so you can keep separate keys for separate systems, or move to a new key before the old one is revoked.
Keep keys secret
- Use keys only in server-side code. Never put a key in a website, browser code or a mobile app, where anyone can read it. The API doesn't accept calls from web pages on other sites.
- Keep keys out of source code and version control. Load them from an environment variable or a secrets manager.
- Never send a key by email, in a support request or in a URL. To tell DTS which key you mean, give its prefix (for example
dts_test_EXAMPL), shown byGET /account. - If a key may have been exposed, email IT@directtransport.com.au straight away. DTS will revoke it and issue a new one.
Authentication
Send your key with every request, in either of these headers:
| Header | Format |
|---|---|
Authorization | Authorization: Bearer <your key> (preferred) |
X-API-Key | X-API-Key: <your key> |
The word Bearer isn't case-sensitive. If a request carries both headers, the Bearer key is used.
Every request checks the key and the account it belongs to, so a change such as a revoked key applies straight away. After DTS allows a paused key again, allow up to 30 seconds before every request is accepted.
curl
curl https://portal.directtransport.com.au/api/v1/account \
-H "Authorization: Bearer $DTS_API_KEY"JavaScript
const response = await fetch("https://portal.directtransport.com.au/api/v1/account", {
headers: { Authorization: `Bearer ${process.env.DTS_API_KEY}` },
});Python
import os
import requests
response = requests.get(
"https://portal.directtransport.com.au/api/v1/account",
headers={"Authorization": f"Bearer {os.environ['DTS_API_KEY']}"},
timeout=30,
)Authentication errors
| Code | HTTP status | Meaning and what to do |
|---|---|---|
missing_api_key | 401 | No key was sent. Send it in Authorization: Bearer <your key> or X-API-Key. |
invalid_api_key | 401 | The key is mistyped, isn't a DTS key, or wasn't issued for this region. Check the key and the base URL. |
key_revoked | 401 | The key has been revoked. Ask DTS for a new one. |
key_suspended | 403 | The key is paused, usually because it went over a daily limit. Ask DTS to allow it again. |
account_not_allowed | 403 | The key's account can't use the API, for example because it's no longer a business account, it has been archived or its email address has changed. Contact DTS. |
wrong_region | 403 | The key belongs to another region. Use the base URL of the region that issued it. |
Requests and responses
Requests
- Use HTTPS and your region's base URL.
- Send request bodies as JSON, with
Content-Type: application/json. Other content types are refused withunsupported_media_type(415). A body that isn't valid JSON getsinvalid_request(400). - A request body can be up to 100 KB (102,400 bytes). A larger body gets
payload_too_large(413). - Field names are case-sensitive. Fields the API doesn't know are refused with
invalid_request, so a mistyped name is never silently ignored. The same goes for query parameters onGET /bookingsand the label and invoice endpoints. - Types are strict. Send numbers as JSON numbers (
12.5, not"12.5") and yes/no values astrueorfalse. - Spaces at the start and end of text such as addresses, names, phone numbers, references and instructions are removed.
- The JavaScript examples use
fetch, which is built into Node.js 18 and later; run them in an ES module or an async function. The Python examples use therequestspackage.
Responses
A successful response has the result in data. Each endpoint below describes its data.
{
"data": {},
"requestId": "req_0f8c2a5d9b7e4c1a8e3f6b2d4a9c7e10"
}An error response has an error object instead:
{
"error": {
"code": "invalid_request",
"message": "Some fields aren't valid.",
"details": [
{ "field": "items.0.weightKg", "message": "weightKg must be more than 0." },
{ "field": "pickup.suburb", "message": "pickup.suburb isn't a field this request takes." }
]
},
"requestId": "req_7d1e4b9a2c6f4e8d9a0b3c5e7f1a2b4c"
}codedoesn't change, so build your error handling on it. Every code is listed under errors.messageis written for people and may change. It usually says exactly what to fix, so log it and show it to your staff.detailsis only there for some errors. Forinvalid_requestit's a list offieldandmessagepairs.fieldis the path to the field, counting items from 0 (items.0.weightKgis the first item's weight), ornullwhen the problem isn't a single field, such as a body that isn't a JSON object. Each field the request doesn't take gets its own entry. Other errors' details are described with each error.- A response may not list every problem at once. Checks that compare fields (such as tailgate with HIAB) only run once no field is missing, of the wrong type, or outside its list of allowed values.
Response headers
| Header | Sent | Meaning |
|---|---|---|
X-Request-Id | Every API response | The request's id: req_ and 32 hexadecimal characters. JSON bodies also carry it as requestId. Quote it when you contact DTS. |
X-DTS-Mode | Once the key is accepted | live or test. |
X-RateLimit-Limit | Once the key is accepted | Requests allowed per minute. |
X-RateLimit-Remaining | Once the key is accepted | Requests left in the current minute. |
X-RateLimit-Reset | Once the key is accepted | Seconds until the current minute ends and the count starts again (1 to 60). |
Retry-After | 429 responses | Seconds to wait before trying again. |
Location | 201 from POST /bookings | The new booking's path, for example /api/v1/bookings/DTS12345. |
Idempotent-Replayed | Booking answers sent again | true when the answer repeats a booking already made from the same quote. See retrying a booking. |
Cache-Control | Every API response | no-store. Don't cache API answers. |
Content-Type | Every API response | application/json, or application/pdf for labels and invoices. |
Content-Disposition | Labels and invoices | For example inline; filename="DTS12345-label.pdf". |
Dates and times
- Ready dates and times, and the dates you list bookings by, are in the region's time zone:
Australia/Sydneyfor Sydney,Australia/Melbournefor Melbourne (the same clock as Sydney) andAustralia/Brisbanefor Queensland, which has no daylight saving time. Responses repeat the zone inreadyAt.timeZone. - Dates are
YYYY-MM-DD, for example2026-09-15. Times are 24-hourHH:mm, for example09:30or17:45. - Moments such as
createdAtand theprogresstimes are ISO 8601 in UTC, for example2026-09-14T06:05:12.412Z. - Daily limits count from midnight to midnight in the region's time zone.
The price object
Quotes and bookings show money in a price object. Amounts are Australian dollars, as numbers rounded to the cent.
| Field | Type | Meaning |
|---|---|---|
currency | string | Always AUD. |
base | number | The price of the job for this service. |
serviceCharge | number | Service charge. |
tailgate | number | Tailgate, when you asked for it; otherwise 0. |
hiab | number | HIAB, when you asked for it; otherwise 0. |
insurance | number | Freight insurance, when you asked for it; otherwise 0. |
waitTime | number | Waiting time charges. 0 when you book; added later if the driver records waiting time at pickup or delivery. |
tolls | number | Tolls on the route. |
totalExGst | number | Total before GST. |
gst | number | GST. Tolls don't have GST added. |
total | number | Total including GST. A booking from a quote is only made at its option's total. |
{
"currency": "AUD",
"base": 95,
"serviceCharge": 9.5,
"tailgate": 35,
"hiab": 0,
"insurance": 0,
"waitTime": 0,
"tolls": 4.2,
"totalExGst": 143.7,
"gst": 13.95,
"total": 157.65
}Booking flow guide
Booking through the API follows the same steps as Place the Booking on the portal: price the job, choose a service, book it at that price, then track it.
- Price the job
POST /quoteswith the addresses, the ready date and time, and the items. You get a price for each service, and aquoteId. - Choose a servicePick an option with
"bookable": true. The other options say why they can't be booked. - Book it
POST /bookingswith thequoteId, the service you chose, and the contact and references. - Track it
GET /bookings/{jobNumber}for status and progress. Download the label and invoice, and read proof of delivery.
Step 1: Price the job
Send the job to POST /quotes: the booking type, when the job will be ready, the pickup and delivery addresses, and either the items or a job code, plus tailgate, HIAB or insurance if you need them. Nothing is booked: the answer prices each service and gives a quoteId to book from.
This example is a Same Day job in Sydney, ready at 9:30 AM on Tuesday 15 September 2026: two pallets and six boxes, with a tailgate.
curl
curl https://portal.directtransport.com.au/api/v1/quotes \
-H "Authorization: Bearer $DTS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"bookingType": "same_day",
"readyAt": { "date": "2026-09-15", "time": "09:30" },
"pickup": { "address": "Unit 4, 10 Example Street, Silverwater NSW 2128" },
"delivery": { "address": "25 Sample Road, Botany NSW 2019" },
"items": [
{ "type": "Pallet", "quantity": 2, "weightKg": 180, "lengthCm": 120, "widthCm": 120, "heightCm": 110 },
{ "type": "Box", "quantity": 6, "weightKg": 12.5, "lengthCm": 40, "widthCm": 30, "heightCm": 30, "stackable": true }
],
"tailgate": true
}'JavaScript
const job = {
bookingType: "same_day",
readyAt: { date: "2026-09-15", time: "09:30" },
pickup: { address: "Unit 4, 10 Example Street, Silverwater NSW 2128" },
delivery: { address: "25 Sample Road, Botany NSW 2019" },
items: [
{ type: "Pallet", quantity: 2, weightKg: 180, lengthCm: 120, widthCm: 120, heightCm: 110 },
{ type: "Box", quantity: 6, weightKg: 12.5, lengthCm: 40, widthCm: 30, heightCm: 30, stackable: true },
],
tailgate: true,
};
const response = await fetch("https://portal.directtransport.com.au/api/v1/quotes", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.DTS_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(job),
});
const quote = await response.json();Python
import os
import requests
job = {
"bookingType": "same_day",
"readyAt": {"date": "2026-09-15", "time": "09:30"},
"pickup": {"address": "Unit 4, 10 Example Street, Silverwater NSW 2128"},
"delivery": {"address": "25 Sample Road, Botany NSW 2019"},
"items": [
{"type": "Pallet", "quantity": 2, "weightKg": 180, "lengthCm": 120, "widthCm": 120, "heightCm": 110},
{"type": "Box", "quantity": 6, "weightKg": 12.5, "lengthCm": 40, "widthCm": 30, "heightCm": 30, "stackable": True},
],
"tailgate": True,
}
response = requests.post(
"https://portal.directtransport.com.au/api/v1/quotes",
headers={"Authorization": f"Bearer {os.environ['DTS_API_KEY']}"},
json=job,
timeout=60,
)
quote = response.json()200 OK
{
"data": {
"quoteId": "qt_7c1e9a4b2d6f48a0b3c5d7e9f1a2b4c6",
"expiresAt": "2026-09-14T06:32:47.519Z",
"bookingType": "same_day",
"readyAt": { "date": "2026-09-15", "time": "09:30", "timeZone": "Australia/Sydney" },
"serviceArea": "metro",
"pickup": {
"address": "Unit 4/10 Example St, Silverwater NSW 2128, Australia",
"suburb": "Silverwater",
"state": "NSW",
"postcode": "2128",
"location": { "lat": -33.8352, "lng": 151.0473 },
"precision": "exact",
"insideMetroArea": true
},
"delivery": {
"address": "25 Sample Rd, Botany NSW 2019, Australia",
"suburb": "Botany",
"state": "NSW",
"postcode": "2019",
"location": { "lat": -33.9461, "lng": 151.1965 },
"precision": "exact",
"insideMetroArea": true
},
"distanceKm": 24.6,
"options": [
{
"service": "Standard",
"bookable": true,
"jobCode": "1T",
"vehicle": "1T",
"price": { "currency": "AUD", "base": 95, "serviceCharge": 9.5, "tailgate": 35, "hiab": 0, "insurance": 0, "waitTime": 0, "tolls": 4.2, "totalExGst": 143.7, "gst": 13.95, "total": 157.65 }
},
{
"service": "Express",
"bookable": true,
"jobCode": "1T",
"vehicle": "1T",
"price": { "currency": "AUD", "base": 125, "serviceCharge": 12.5, "tailgate": 35, "hiab": 0, "insurance": 0, "waitTime": 0, "tolls": 4.2, "totalExGst": 176.7, "gst": 17.25, "total": 193.95 }
},
{
"service": "Direct",
"bookable": true,
"jobCode": "1T",
"vehicle": "1T",
"price": { "currency": "AUD", "base": 160, "serviceCharge": 16, "tailgate": 35, "hiab": 0, "insurance": 0, "waitTime": 0, "tolls": 4.2, "totalExGst": 215.2, "gst": 21.1, "total": 236.3 }
},
{
"service": "After Hours",
"bookable": false,
"reason": "outside_service_hours",
"message": "After Hours jobs need a ready time before 7:00 AM or after 5:00 PM.",
"jobCode": "1T",
"vehicle": "1T",
"price": { "currency": "AUD", "base": 210, "serviceCharge": 21, "tailgate": 35, "hiab": 0, "insurance": 0, "waitTime": 0, "tolls": 4.2, "totalExGst": 270.2, "gst": 26.6, "total": 296.8 }
},
{
"service": "Weekend Deliveries",
"bookable": false,
"reason": "outside_service_hours",
"message": "Weekend Deliveries need a ready date on a Saturday or Sunday.",
"jobCode": "1T",
"vehicle": "1T",
"price": { "currency": "AUD", "base": 230, "serviceCharge": 23, "tailgate": 35, "hiab": 0, "insurance": 0, "waitTime": 0, "tolls": 4.2, "totalExGst": 292.2, "gst": 28.8, "total": 321 }
}
]
},
"requestId": "req_3b9e1f7c5a2d4e6f8a1b3c5d7e9f0a2b"
}Step 2: Choose a service
Each entry in options is one service.
- Only options with
"bookable": truecan be booked. In the example, that's Standard, Express and Direct. - An option with
"bookable": falsehas areasoncode and amessageyou can show. Here, After Hours and Weekend Deliveries can't be booked because 9:30 AM on a Tuesday is outside their hours. All reasons are listed under option reasons. - Keep the quote's
quoteIdand the chosen option'sservice: you book with them in step 3. The quote can be booked until itsexpiresAt(see how long a quote can be booked). - Check
pickup.precisionanddelivery.precision. If either address was only found approximately (approximate), a driver can't be sent to it, so the job can't be booked: every option that was priced has"bookable": falsewith the reasonaddress_not_precise, and its message names the address. The prices are still shown. Send a full street address, or add itslocation, and price the job again (see addresses).
For example, if the delivery address had been sent as just Botany NSW, the Standard option would come back like this:
{
"service": "Standard",
"bookable": false,
"reason": "address_not_precise",
"message": "The delivery address was found as \"Botany NSW 2019, Australia\", which isn't precise enough to send a driver to. Send a full street address, or its location as well.",
"jobCode": "1T",
"vehicle": "1T",
"price": { "currency": "AUD", "base": 92, "serviceCharge": 9.2, "tailgate": 35, "hiab": 0, "insurance": 0, "waitTime": 0, "tolls": 4.2, "totalExGst": 140.4, "gst": 13.62, "total": 154.02 }
}JavaScript
const option = quote.data.options.find((o) => o.bookable && o.service === "Standard");
if (!option) {
// Show why each service can't be booked, and let the person booking choose again.
for (const o of quote.data.options) console.log(o.service, o.bookable, o.reason ?? "", o.message ?? "");
throw new Error("Standard can't be booked for this job.");
}
console.log(`Standard costs $${option.price.total.toFixed(2)}. Book it by ${quote.data.expiresAt}.`);Python
options = quote["data"]["options"]
option = next((o for o in options if o["bookable"] and o["service"] == "Standard"), None)
if option is None:
# Show why each service can't be booked, and let the person booking choose again.
for o in options:
print(o["service"], o["bookable"], o.get("reason", ""), o.get("message", ""))
raise SystemExit("Standard can't be booked for this job.")
print(f"Standard costs ${option['price']['total']:.2f}. Book it by {quote['data']['expiresAt']}.")A quote can be booked until its expiresAt: 30 minutes after it was made, or 5 minutes after its ready time if that's sooner. The price is worked out again when you book. If it has changed by then, nothing is booked and the API tells you the new price (see step 3).
Step 3: Book the job
Send POST /bookings before the quote's expiresAt, with:
- the quote's
quoteId, and theserviceyou chose from its options; contact, the name of the person to contact about the job;- if you like, your own
internalReferenceandinternalReference2, and a company name, phone number, reference and instructions for thepickupand thedelivery.
The addresses, items, ready time, tailgate, HIAB and insurance come from the quote, so the job is booked as it was priced. If the request times out or the connection drops, send the same booking again: a quote books one job, so it can't be booked twice (see retrying a booking).
curl
curl https://portal.directtransport.com.au/api/v1/bookings \
-H "Authorization: Bearer $DTS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"quoteId": "qt_7c1e9a4b2d6f48a0b3c5d7e9f1a2b4c6",
"service": "Standard",
"contact": "Jamie Citizen",
"internalReference": "ORDER-5501",
"pickup": {
"companyName": "Acme Freight",
"phone": "02 5550 1234",
"reference": "PO-1001",
"instructions": "Loading dock at the rear."
},
"delivery": {
"companyName": "Example Retail",
"phone": "02 5550 5678",
"reference": "INV-2002",
"instructions": "Deliver to goods inwards."
}
}'JavaScript
const response = await fetch("https://portal.directtransport.com.au/api/v1/bookings", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.DTS_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
quoteId: quote.data.quoteId, // from step 1
service: option.service, // "Standard", chosen in step 2
contact: "Jamie Citizen",
internalReference: "ORDER-5501",
pickup: {
companyName: "Acme Freight",
phone: "02 5550 1234",
reference: "PO-1001",
instructions: "Loading dock at the rear.",
},
delivery: {
companyName: "Example Retail",
phone: "02 5550 5678",
reference: "INV-2002",
instructions: "Deliver to goods inwards.",
},
}),
});
const booking = await response.json();Python
booking_request = {
"quoteId": quote["data"]["quoteId"], # from step 1
"service": option["service"], # "Standard", chosen in step 2
"contact": "Jamie Citizen",
"internalReference": "ORDER-5501",
"pickup": {
"companyName": "Acme Freight",
"phone": "02 5550 1234",
"reference": "PO-1001",
"instructions": "Loading dock at the rear.",
},
"delivery": {
"companyName": "Example Retail",
"phone": "02 5550 5678",
"reference": "INV-2002",
"instructions": "Deliver to goods inwards.",
},
}
response = requests.post(
"https://portal.directtransport.com.au/api/v1/bookings",
headers={"Authorization": f"Bearer {os.environ['DTS_API_KEY']}"},
json=booking_request,
timeout=60,
)
booking = response.json()The booking is made. The answer is 201 Created, with the new booking's path in Location and the booking in data:
HTTP/1.1 201 Created
Content-Type: application/json
Location: /api/v1/bookings/DTS12345
X-Request-Id: req_9c2d7e4f1a6b4c8d8e2f5a7b9c1d3e5f
X-DTS-Mode: live
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 57
X-RateLimit-Reset: 48
Cache-Control: no-store{
"data": {
"jobNumber": "DTS12345",
"testMode": false,
"status": "pending",
"bookingType": "same_day",
"service": "Standard",
"readyAt": { "date": "2026-09-15", "time": "09:30", "timeZone": "Australia/Sydney" },
"createdAt": "2026-09-14T06:05:12.412Z",
"contact": "Jamie Citizen",
"internalReference": "ORDER-5501",
"internalReference2": "",
"pickup": {
"companyName": "Acme Freight",
"address": "Unit 4/10 Example St, Silverwater NSW 2128, Australia",
"suburb": "Silverwater",
"reference": "PO-1001",
"phone": "02 5550 1234",
"instructions": "Loading dock at the rear.",
"location": { "lat": -33.8352, "lng": 151.0473 }
},
"delivery": {
"companyName": "Example Retail",
"address": "25 Sample Rd, Botany NSW 2019, Australia",
"suburb": "Botany",
"reference": "INV-2002",
"phone": "02 5550 5678",
"instructions": "Deliver to goods inwards.",
"location": { "lat": -33.9461, "lng": 151.1965 }
},
"items": [
{ "type": "Pallet", "quantity": 2, "weightKg": 180, "lengthCm": 120, "widthCm": 120, "heightCm": 110, "stackable": false },
{ "type": "Box", "quantity": 6, "weightKg": 12.5, "lengthCm": 40, "widthCm": 30, "heightCm": 30, "stackable": true }
],
"jobCode": "1T",
"vehicle": "1T",
"tailgate": true,
"hiab": false,
"insurance": false,
"distanceKm": 24.6,
"price": { "currency": "AUD", "base": 95, "serviceCharge": 9.5, "tailgate": 35, "hiab": 0, "insurance": 0, "waitTime": 0, "tolls": 4.2, "totalExGst": 143.7, "gst": 13.95, "total": 157.65 }
},
"requestId": "req_9c2d7e4f1a6b4c8d8e2f5a7b9c1d3e5f"
}If the total is no longer the quote's price.total (to the cent), the API answers price_changed (409) with the new price in error.details.price, and nothing is booked. Get a new quote, show its total to the person booking or apply your own rule, then book from the new quote.
409 Conflict
{
"error": {
"code": "price_changed",
"message": "The price for this job has changed since the quote: it is now $161.05, not $157.65. Get a new quote to book at the new price.",
"details": {
"quotedTotal": 157.65,
"price": { "currency": "AUD", "base": 95, "serviceCharge": 9.5, "tailgate": 35, "hiab": 0, "insurance": 0, "waitTime": 0, "tolls": 7.6, "totalExGst": 147.1, "gst": 13.95, "total": 161.05 }
}
},
"requestId": "req_4e6a8c0b2d4f4a6c8e0b2d4f6a8c0e2b"
}A booking can also be refused because the quote has expired (quote_expired), an address was only found approximately (address_not_precise), the service can't be booked for the ready time (service_not_available), the job needs a custom quote or can't be priced online (cannot_price_online), or a field is missing, not valid or required by your account (invalid_request). Nothing is booked. See POST /bookings for every case, and retrying a booking for what to send next.
Step 4: Track the booking
GET /bookings/DTS12345returns the booking with itsstatus,progresstimes,driverand whether proof of delivery is available.GET /bookings?date=2026-09-15lists your bookings ready on a day. Usefromandtofor up to 31 days.GET /bookings/DTS12345/label?paper=A4andGET /bookings/DTS12345/invoicereturn the shipping label and the tax invoice as PDFs.GET /bookings/DTS12345/podreturns proof of delivery, if it's turned on for your account.
To follow a job, check it every few minutes, and stop once it's delivered, returned, futile or cancelled. When you follow many jobs, one GET /bookings?date=… page uses one request of your limits instead of one per job.
Full example
The whole flow in one script: price a job, choose Standard, book it from the quote, get a new quote if the price has changed, and read the booking back.
JavaScript
// book-a-job.mjs: price, choose, book and track a job. Node.js 18 or later.
const BASE_URL = "https://portal.directtransport.com.au/api/v1";
const API_KEY = process.env.DTS_API_KEY; // keep keys on your server
async function api(method, path, body) {
const response = await fetch(BASE_URL + path, {
method,
headers: {
Authorization: `Bearer ${API_KEY}`,
...(body ? { "Content-Type": "application/json" } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
return { status: response.status, payload: await response.json() };
}
const job = {
bookingType: "same_day",
readyAt: { date: "2026-09-15", time: "09:30" },
pickup: { address: "Unit 4, 10 Example Street, Silverwater NSW 2128" },
delivery: { address: "25 Sample Road, Botany NSW 2019" },
items: [{ type: "Pallet", quantity: 2, weightKg: 180, lengthCm: 120, widthCm: 120, heightCm: 110 }],
tailgate: true,
};
// 1. Price the job, and 2. choose a service that can be booked.
async function quoteStandard() {
const quote = await api("POST", "/quotes", job);
if (quote.status !== 200) throw new Error(`${quote.payload.error.code}: ${quote.payload.error.message}`);
const option = quote.payload.data.options.find((o) => o.bookable && o.service === "Standard");
if (!option) throw new Error("Standard can't be booked for this job.");
return { quoteId: quote.payload.data.quoteId, option };
}
// 3. Book it from the quote, which has the addresses, items and ready time.
// If a request times out, send the same booking again: a quote books one job, so it can't be booked twice.
function book(quoteId, service) {
return api("POST", "/bookings", {
quoteId,
service,
contact: "Jamie Citizen",
internalReference: "ORDER-5501",
pickup: { companyName: "Acme Freight", phone: "02 5550 1234" },
delivery: { companyName: "Example Retail", phone: "02 5550 5678" },
});
}
let { quoteId, option } = await quoteStandard();
let booking = await book(quoteId, option.service);
if (booking.status === 409 && booking.payload.error.code === "price_changed") {
// Nothing was booked. Get a new quote at the new price. This script books it; your system may need a person to confirm it.
({ quoteId, option } = await quoteStandard());
console.log(`The price is now $${option.price.total.toFixed(2)}.`);
booking = await book(quoteId, option.service);
}
if (booking.status !== 201) throw new Error(`${booking.payload.error.code}: ${booking.payload.error.message}`);
const { jobNumber } = booking.payload.data;
console.log(`Booked ${jobNumber}`);
// 4. Track it.
const detail = await api("GET", `/bookings/${jobNumber}`);
console.log(detail.payload.data.status, detail.payload.data.progress);Python
# book_a_job.py: price, choose, book and track a job. Needs the requests package.
import os
import requests
BASE_URL = "https://portal.directtransport.com.au/api/v1"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['DTS_API_KEY']}" # keep keys on your server
job = {
"bookingType": "same_day",
"readyAt": {"date": "2026-09-15", "time": "09:30"},
"pickup": {"address": "Unit 4, 10 Example Street, Silverwater NSW 2128"},
"delivery": {"address": "25 Sample Road, Botany NSW 2019"},
"items": [{"type": "Pallet", "quantity": 2, "weightKg": 180, "lengthCm": 120, "widthCm": 120, "heightCm": 110}],
"tailgate": True,
}
def fail(response):
error = response.json()["error"]
raise SystemExit(f"{error['code']}: {error['message']}")
# 1. Price the job, and 2. choose a service that can be booked.
def quote_standard():
quote = session.post(f"{BASE_URL}/quotes", json=job, timeout=60)
if quote.status_code != 200:
fail(quote)
data = quote.json()["data"]
option = next((o for o in data["options"] if o["bookable"] and o["service"] == "Standard"), None)
if option is None:
raise SystemExit("Standard can't be booked for this job.")
return data["quoteId"], option
# 3. Book it from the quote, which has the addresses, items and ready time.
# If a request times out, send the same booking again: a quote books one job, so it can't be booked twice.
def book(quote_id, service):
body = {
"quoteId": quote_id,
"service": service,
"contact": "Jamie Citizen",
"internalReference": "ORDER-5501",
"pickup": {"companyName": "Acme Freight", "phone": "02 5550 1234"},
"delivery": {"companyName": "Example Retail", "phone": "02 5550 5678"},
}
return session.post(f"{BASE_URL}/bookings", json=body, timeout=60)
quote_id, option = quote_standard()
booking = book(quote_id, option["service"])
if booking.status_code == 409 and booking.json()["error"]["code"] == "price_changed":
# Nothing was booked. Get a new quote at the new price. This script books it; your system may need a person to confirm it.
quote_id, option = quote_standard()
print(f"The price is now ${option['price']['total']:.2f}.")
booking = book(quote_id, option["service"])
if booking.status_code != 201:
fail(booking)
job_number = booking.json()["data"]["jobNumber"]
print(f"Booked {job_number}")
# 4. Track it.
detail = session.get(f"{BASE_URL}/bookings/{job_number}", timeout=30).json()["data"]
print(detail["status"], detail["progress"])Endpoints
All paths are relative to your region's base URL.
| Method | Path | What it does |
|---|---|---|
| GET | /account | Your key, account, limits and today's usage. |
| POST | /quotes | Prices a job for each service, and gives a quoteId to book it with. Nothing is booked. |
| POST | /bookings | Books the service you chose from a quote, at the quote's price. |
| GET | /bookings | Lists your bookings by ready date. |
| GET | /bookings/{jobNumber} | One booking, with its progress, driver and whether proof of delivery is available. |
| GET | /bookings/{jobNumber}/label | The shipping label as a PDF. |
| GET | /bookings/{jobNumber}/invoice | The tax invoice as a PDF. |
| GET | /bookings/{jobNumber}/pod | Proof of delivery, for accounts with it turned on. |
Errors any endpoint can return
As well as the errors listed with each endpoint, any request can get missing_api_key, invalid_api_key, key_revoked, key_suspended, account_not_allowed or wrong_region (see authentication errors), rate_limited or daily_limit_reached (see limits and usage), and internal_error or service_unavailable. Every code is in the errors table.
GET /account
Shows the key you called with, its account and region, the limits that apply to it and what it has used today. Use it to check your set-up and to keep an eye on your usage.
Example request
curl
curl https://portal.directtransport.com.au/api/v1/account \
-H "Authorization: Bearer $DTS_API_KEY"JavaScript
const response = await fetch("https://portal.directtransport.com.au/api/v1/account", {
headers: { Authorization: `Bearer ${process.env.DTS_API_KEY}` },
});
const { data } = await response.json();
console.log(data.key.mode, data.region, data.usageToday);Python
import os
import requests
response = requests.get(
"https://portal.directtransport.com.au/api/v1/account",
headers={"Authorization": f"Bearer {os.environ['DTS_API_KEY']}"},
timeout=30,
)
data = response.json()["data"]
print(data["key"]["mode"], data["region"], data["usageToday"])Example response
200 OK
{
"data": {
"key": {
"id": "key_EXAMPLEexample12",
"mode": "test",
"prefix": "dts_test_EXAMPL",
"label": "Test key",
"createdAt": "2026-09-14T00:12:45.318Z"
},
"account": { "email": "bookings@acmefreight.example", "name": "Acme Freight" },
"region": "sydney",
"limits": {
"requestsPerMinute": 60,
"requestsPerDay": 5000,
"priceChecksPerDay": 200,
"bookingsPerDay": 200,
"documentsPerDay": 500,
"googleCallsPerDay": 600,
"tollCallsPerDay": 200,
"databaseReadsPerDay": 250000,
"databaseWritesPerDay": 100000,
"rateLimitedPerDay": 1000
},
"usageToday": {
"requests": 42,
"priceChecks": 12,
"bookings": 3,
"documents": 4,
"googleCalls": 36,
"tollCalls": 5,
"databaseReads": 287,
"databaseWrites": 151,
"rateLimited": 0
}
},
"requestId": "req_1a3c5e7f9b2d4f6a8c0e1b3d5f7a9c2e"
}Response fields
| Field | Type | Meaning |
|---|---|---|
key.id | string | The key's id, key_ and 16 letters and digits. It isn't the key itself. |
key.mode | string | live or test. |
key.prefix | string | The start of the key: dts_live_ or dts_test_ and its first 6 characters. Use it to tell DTS which key you mean. |
key.label | string or null | The key's name, given by DTS. |
key.createdAt | string or null | When the key was created (ISO 8601, UTC). |
account.email | string | The email address of the account the key books for. |
account.name | string | The account's name. |
region | string | sydney, melbourne or queensland. |
limits | object | The limits for this key: requestsPerMinute and the daily limits. DTS can change them for your key. See limits and usage. |
usageToday | object | What the key has used so far today (in the region's time zone), counted as the daily limits count it, including this request. |
Errors
Only the errors any endpoint can return.
POST /quotes
Prices a job for each service its booking type offers, or for the services you ask for, and says whether each one can be booked for the ready time. Nothing is booked. The answer has a quoteId: book the service you choose with POST /bookings before the quote's expiresAt. The price is worked out again when you book.
Request body
items[].type means the type of each entry in items. In error details the same field is written items.0.type, items.1.type and so on.
| Field | Type | Required | Rules |
|---|---|---|---|
bookingType | string | Yes | same_day, next_day or three_four_day. See services by booking type. |
readyAt | object | No | When the job will be ready, in the region's time zone. Leave it out to price the job for now. See ready date and time. |
readyAt.date | string | Yes, in readyAt | YYYY-MM-DD. Today or later, and at most 90 days ahead. |
readyAt.time | string | Yes, in readyAt | HH:mm, 24-hour. Must not have passed. A time up to 5 minutes ago counts as now: the job is priced and checked at the current time. |
services | array of strings | No | 1 to 5 of Standard, Express, Direct, After Hours and Weekend Deliveries, each offered for the booking type. Leave it out to price every service the booking type offers. |
pickup | object | Yes | Where the job is picked up. |
pickup.address | string | Yes | 3 to 250 characters. A full street address in Australia. See addresses. |
pickup.location | object | No | The address's point, as lat (from -45 to -9) and lng (from 111 to 155). Used instead of the point found for the address. |
delivery | object | Yes | Where the job is delivered. |
delivery.address | string | Yes | As for pickup.address. |
delivery.location | object | No | As for pickup.location. |
items | array of objects | Yes, unless you send jobCode | 1 to 50 items. Send either items or jobCode, not both. |
items[].type | string | Yes | One of the item types, spelt exactly as listed. |
items[].quantity | integer | Yes | 1 to 500. |
items[].weightKg | number | Yes | Weight of one item in kilograms. More than 0, at most 50,000. |
items[].lengthCm | number | Yes | Length of one item in centimetres. More than 0, at most 3,000. |
items[].widthCm | number | Yes | Width of one item in centimetres. More than 0, at most 1,000. |
items[].heightCm | number | Yes | Height of one item in centimetres. More than 0, at most 1,000. |
items[].stackable | boolean | No | Default false. Only Ladder, Box and Crate items can be stackable. |
jobCode | string | Yes, unless you send items | Price by job code instead of items: Courier, HT, 1T, 2T, 4T, 6T, 8T, 10T, 12T, 14T or 16T. Only for accounts with job codes turned on. Long Distance is worked out from the addresses, so send the vehicle's code. See job codes. |
tailgate | boolean | No | Default false. Can't be true together with hiab. |
hiab | boolean | No | Default false. Can't be true together with tailgate. |
insurance | boolean | No | Default false. Freight insurance. |
How it works
- Checks the body, the ready date and time, and what your account can use: job codes, and whether tailgate, HIAB and insurance are available.
- Looks up both addresses in Australia and works out the driving distance.
- Works out whether the job is metro or regional and country (
serviceArea). Regional and country jobs are refused while they can't be booked online in the region. - Prices each service and checks whether it can be booked: both addresses must have been found precisely enough to send a driver to, and the ready time must suit the service (see service hours and regional cutoffs).
- Keeps the quote under a new
quoteId, for this API key only, untilexpiresAt.
Example request
Three stackable boxes, priced with a test key for Standard and Express only, with the delivery address's point sent as location. For a full example with every service, see step 1 of the booking flow.
curl
curl https://portal.directtransport.com.au/api/v1/quotes \
-H "Authorization: Bearer $DTS_TEST_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"bookingType": "same_day",
"readyAt": { "date": "2026-09-15", "time": "09:30" },
"services": ["Standard", "Express"],
"pickup": { "address": "Unit 4, 10 Example Street, Silverwater NSW 2128" },
"delivery": {
"address": "25 Sample Road, Botany NSW 2019",
"location": { "lat": -33.9461, "lng": 151.1965 }
},
"items": [
{ "type": "Box", "quantity": 3, "weightKg": 8, "lengthCm": 40, "widthCm": 30, "heightCm": 30, "stackable": true }
]
}'JavaScript
const response = await fetch("https://portal.directtransport.com.au/api/v1/quotes", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.DTS_TEST_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
bookingType: "same_day",
readyAt: { date: "2026-09-15", time: "09:30" },
services: ["Standard", "Express"],
pickup: { address: "Unit 4, 10 Example Street, Silverwater NSW 2128" },
delivery: {
address: "25 Sample Road, Botany NSW 2019",
location: { lat: -33.9461, lng: 151.1965 },
},
items: [{ type: "Box", quantity: 3, weightKg: 8, lengthCm: 40, widthCm: 30, heightCm: 30, stackable: true }],
}),
});
const quote = await response.json();
if (!response.ok) throw new Error(`${quote.error.code}: ${quote.error.message}`);
const bookable = quote.data.options.filter((option) => option.bookable);Python
import os
import requests
response = requests.post(
"https://portal.directtransport.com.au/api/v1/quotes",
headers={"Authorization": f"Bearer {os.environ['DTS_TEST_API_KEY']}"},
json={
"bookingType": "same_day",
"readyAt": {"date": "2026-09-15", "time": "09:30"},
"services": ["Standard", "Express"],
"pickup": {"address": "Unit 4, 10 Example Street, Silverwater NSW 2128"},
"delivery": {
"address": "25 Sample Road, Botany NSW 2019",
"location": {"lat": -33.9461, "lng": 151.1965},
},
"items": [{"type": "Box", "quantity": 3, "weightKg": 8, "lengthCm": 40, "widthCm": 30, "heightCm": 30, "stackable": True}],
},
timeout=60,
)
quote = response.json()
if not response.ok:
raise SystemExit(f"{quote['error']['code']}: {quote['error']['message']}")
bookable = [option for option in quote["data"]["options"] if option["bookable"]]Example response
200 OK
{
"data": {
"quoteId": "qt_2f8d4b6a0c1e43a7b9d5e7f1a3c5b7d9",
"expiresAt": "2026-09-14T06:44:03.268Z",
"bookingType": "same_day",
"readyAt": { "date": "2026-09-15", "time": "09:30", "timeZone": "Australia/Sydney" },
"serviceArea": "metro",
"pickup": {
"address": "Unit 4/10 Example St, Silverwater NSW 2128, Australia",
"suburb": "Silverwater",
"state": "NSW",
"postcode": "2128",
"location": { "lat": -33.8352, "lng": 151.0473 },
"precision": "exact",
"insideMetroArea": true
},
"delivery": {
"address": "25 Sample Rd, Botany NSW 2019, Australia",
"suburb": "Botany",
"state": "NSW",
"postcode": "2019",
"location": { "lat": -33.9461, "lng": 151.1965 },
"precision": "exact",
"insideMetroArea": true
},
"distanceKm": 24.6,
"options": [
{
"service": "Standard",
"bookable": true,
"jobCode": "HT",
"vehicle": "HT",
"price": { "currency": "AUD", "base": 38, "serviceCharge": 3.8, "tailgate": 0, "hiab": 0, "insurance": 0, "waitTime": 0, "tolls": 4.2, "totalExGst": 46, "gst": 4.18, "total": 50.18 }
},
{
"service": "Express",
"bookable": true,
"jobCode": "HT",
"vehicle": "HT",
"price": { "currency": "AUD", "base": 52, "serviceCharge": 5.2, "tailgate": 0, "hiab": 0, "insurance": 0, "waitTime": 0, "tolls": 4.2, "totalExGst": 61.4, "gst": 5.72, "total": 67.12 }
}
]
},
"requestId": "req_5d7f9b1c3e5a4c7e9b1d3f5a7c9e1b3d"
}Response fields
| Field | Type | Meaning |
|---|---|---|
quoteId | string | The quote's id: qt_ and 32 hexadecimal characters. Book the service you choose with it, using the same API key. |
expiresAt | string | When the quote can no longer be booked (ISO 8601, UTC). See how long a quote can be booked. |
bookingType | string | As sent. |
readyAt | object | The date, time and timeZone the job was priced and checked for. If you left readyAt out, or sent a time up to 5 minutes ago, this is the current date and time in the region. |
serviceArea | string | metro for a same_day job with both addresses inside the metro service area. Otherwise regional (regional and country), which includes every next_day and three_four_day job. |
pickup, delivery | object | Each address as it was found (the fields below). |
pickup.address | string | The address as found, for example 25 Sample Rd, Botany NSW 2019, Australia. |
pickup.suburb, pickup.state, pickup.postcode | string or null | Parts of the address as found. state is short, for example NSW. |
pickup.location | object | lat and lng used for pricing: the location you sent, or the point found for the address. |
pickup.precision | string | exact or approximate. If either address is approximate, the options can't be booked (reason address_not_precise). |
pickup.insideMetroArea | boolean | Whether the address is inside the metro service area. |
distanceKm | number | The driving distance in kilometres. |
options | array | One entry for each service priced, in the order of services, or Standard, Express, Direct, After Hours, Weekend Deliveries. |
options[].service | string | The service. |
options[].bookable | boolean | Whether this service can be booked for the ready time. Only true options can be booked. |
options[].reason | string | Only when bookable is false. See option reasons. |
options[].message | string | Only when bookable is false. Why, in words you can show. |
options[].contactEmail | string | Only for custom_quote_required and cannot_price_online: who to contact to arrange the job. |
options[].jobCode | string or null | The job code the job was priced as, for example 1T. |
options[].vehicle | string or null | The vehicle the job needs: Courier, HT, 1T, 2T, 4T, 6T, 8T, 10T, 12T, 14T or 16T. |
options[].price | object | The price object. jobCode, vehicle and price are left out when the reason is pricing_failed, custom_quote_required or cannot_price_online. |
An approximate address doesn't stop a job being priced, but a driver can't be sent to it, so none of the priced options can be booked. Send a full street address, or add location, and price the job again.
How long a quote can be booked
- A quote belongs to the API key that asked for it. Only that key can book it; with any other key,
POST /bookingsgetsquote_not_found(404). - It can be booked until
expiresAt: 30 minutes after it was made, or 5 minutes after its ready time if that's sooner. The 5 minutes allow for clocks that differ. - A quote without
readyAtis for now. It can be booked for the 30 minutes, and booking it books the job ready at the moment of booking. - After
expiresAt, a booking getsquote_expired(422), with a message that says why:A quote can be booked for 30 minutes, and this one is older. Get a new quote with POST /quotes.or, for example,The quote's ready time, 09:30 on 2026-09-15, has passed: it is 09:36 on 2026-09-15 in Australia/Sydney. Get a new quote with a ready time from now on. - A quote books one job (see retrying a booking). A quote made with a test key is booked as a test booking.
Option reasons
When an option has "bookable": false, its reason is one of these. Options that can't be booked because of an address or the timing still show their price.
reason | Meaning | Booking it anyway gets |
|---|---|---|
address_not_precise | The pickup or delivery address was only found approximately (for example just a suburb), so a driver can't be sent to it. Every option that was priced gets this reason and still shows its price; the message names the address. Send a full street address, or its location, and price the job again. | address_not_precise (422) |
outside_service_hours | Metro Same Day: the ready time (or day) is outside the service's hours. See service hours. | service_not_available (422) |
booking_cutoff_passed | The job is ready today and it's past today's booking cutoff: regional and country Same Day, or 3-4 Day. See regional cutoffs. | service_not_available (422) |
service_not_offered | After Hours and Weekend Deliveries aren't offered for regional and country jobs. | service_not_available (422) |
custom_quote_required | The load needs a custom quote, for example because it's outside standard vehicle limits. Contact contactEmail. | cannot_price_online (422) |
cannot_price_online | The job can't be priced online, for example a Next Day or 3-4 Day job within the metro area. Contact contactEmail. | cannot_price_online (422) |
pricing_failed | The service couldn't be priced just now. Get a new quote shortly. | service_not_available (422) |
{
"service": "Standard",
"bookable": false,
"reason": "custom_quote_required",
"message": "This load falls outside our standard vehicle limits. Our bookings team will review the job details and prepare the right vehicle and pricing for you.",
"contactEmail": "bookings@directtransport.com.au"
}Errors
| Code | HTTP status | When |
|---|---|---|
invalid_request | 400 | A field is missing or not valid, or the body isn't valid JSON. Also when readyAt isn't a real date and time, its date is in the past or more than 90 days ahead, or its time has passed (see ready date and time). |
unsupported_media_type | 415 | The body wasn't sent as application/json. |
payload_too_large | 413 | The body is over 100 KB. |
feature_not_enabled | 403 | You sent jobCode, but job codes aren't enabled for your account. Send items instead. |
service_not_available | 422 | Tailgate, HIAB or freight insurance isn't available. details names the field (tailgate, hiab or insurance). |
address_not_found | 422 | The pickup or delivery address couldn't be found, for example We couldn't find the pickup address. Check the street address, suburb and postcode. details names pickup.address or delivery.address. |
no_route | 422 | There's no driving route between the two addresses. |
regional_not_available | 422 | The job is regional or country, and those can't be booked online in this region at the moment. Contact DTS. |
daily_limit_reached | 403 | Pricing would take the key over its daily price checks, Google Maps calls or toll lookups. The key is paused. |
service_unavailable | 503 | Address lookups are unavailable at the moment. Try again shortly. |
POST /bookings
Books the service you chose from a quote, for your account, at the quote's price. Send the quoteId from POST /quotes, the service, and the booking's own details, such as the contact. The job is priced again, exactly as the quote was, and only booked if the service can be booked for the ready time and the total is still the quote's price.total.
Request body
| Field | Type | Required | Rules |
|---|---|---|---|
quoteId | string | Yes | The quoteId from POST /quotes: qt_ and 32 hexadecimal characters. The quote must have been made with the same API key, and not have expired (see how long a quote can be booked). |
service | string | Yes | One of the quote's options, such as Standard. Only an option with "bookable": true can be booked. |
contact | string | Yes | 1 to 120 characters. The name of the person to contact about the job. |
internalReference | string | No | Up to 120 characters. Your own reference, such as an order number. |
internalReference2 | string | No | Up to 120 characters. A second reference of your own. |
pickup | object | No | The booking's details for the pickup (the fields below). The address comes from the quote. |
pickup.companyName | string | No | Up to 120 characters. |
pickup.phone | string | No* | Up to 40 characters. |
pickup.reference | string | No* | Up to 200 characters. |
pickup.instructions | string | No* | Up to 500 characters. |
delivery | object | No | The booking's details for the delivery (the fields below). The address comes from the quote. |
delivery.companyName | string | No | Up to 120 characters. |
delivery.phone | string | No* | Up to 40 characters. |
delivery.reference | string | No* | Up to 200 characters. |
delivery.instructions | string | No* | Up to 500 characters. |
* Your account may require these. See account-required fields. Text fields you leave out are saved empty.
The job's details come from the quote, so a booking can't send them:
bookingTypereadyAtitemsjobCodetailgatehiabinsuranceservicespickup.addresspickup.locationdelivery.addressdelivery.location
A booking that sends any of these is refused with invalid_request (400), with a detail for each, such as readyAt comes from the quote. To change it, get a new quote. Any other field the request doesn't take gets a detail such as notes isn't a field this request takes.
How it works
- Checks the body, and the fields your account requires.
- Checks whether the quote has already been booked. The same booking sent again gets the first answer back, a different booking of a booked quote is refused, and nothing is booked twice (see retrying a booking).
- Finds the quote. It must have been made with the same API key (
quote_not_found), and not have expired (quote_expired). - Checks that the service is one of the quote's options, with a price.
- Prices the job again exactly as
POST /quotesdid, from the quote's addresses, items, ready time and extras, with the same checks on the ready date and time, your account and the addresses. A quote withoutreadyAtis booked ready at the time of booking. - Refuses the booking when an address isn't
exact(address_not_precise), the job needs a custom quote or can't be priced online (cannot_price_online), the service can't be booked for the ready time (service_not_available), or the total differs from the quote'sprice.totalby more than half a cent (price_changed). - Saves the booking under a new job number and answers
201. With a live key, the job goes to DTS like a booking made on the portal. A quote made with a test key is saved as a test booking.
Example request
This books the Standard option from the POST /quotes example, for $50.18. That quote was made with a test key, so the same test key books it.
curl
curl https://portal.directtransport.com.au/api/v1/bookings \
-H "Authorization: Bearer $DTS_TEST_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"quoteId": "qt_2f8d4b6a0c1e43a7b9d5e7f1a3c5b7d9",
"service": "Standard",
"contact": "Jamie Citizen",
"internalReference": "ORDER-5502",
"pickup": {
"companyName": "Acme Freight",
"phone": "02 5550 1234"
},
"delivery": {
"companyName": "Example Retail"
}
}'JavaScript
const response = await fetch("https://portal.directtransport.com.au/api/v1/bookings", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.DTS_TEST_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
quoteId: "qt_2f8d4b6a0c1e43a7b9d5e7f1a3c5b7d9", // data.quoteId from POST /quotes
service: "Standard",
contact: "Jamie Citizen",
internalReference: "ORDER-5502",
pickup: { companyName: "Acme Freight", phone: "02 5550 1234" },
delivery: { companyName: "Example Retail" },
}),
});
const result = await response.json();
if (response.status === 201) {
console.log("Booked", result.data.jobNumber);
} else {
console.error(result.error.code, result.error.message, result.error.details);
}Python
import os
import requests
response = requests.post(
"https://portal.directtransport.com.au/api/v1/bookings",
headers={"Authorization": f"Bearer {os.environ['DTS_TEST_API_KEY']}"},
json={
"quoteId": "qt_2f8d4b6a0c1e43a7b9d5e7f1a3c5b7d9", # data.quoteId from POST /quotes
"service": "Standard",
"contact": "Jamie Citizen",
"internalReference": "ORDER-5502",
"pickup": {"companyName": "Acme Freight", "phone": "02 5550 1234"},
"delivery": {"companyName": "Example Retail"},
},
timeout=60,
)
result = response.json()
if response.status_code == 201:
print("Booked", result["data"]["jobNumber"])
else:
print(result["error"]["code"], result["error"]["message"], result["error"].get("details"))Example response
201 Created with Location: /api/v1/bookings/TEST-DTS12345. The fields are described under the booking object.
{
"data": {
"jobNumber": "TEST-DTS12345",
"testMode": true,
"status": "pending",
"bookingType": "same_day",
"service": "Standard",
"readyAt": { "date": "2026-09-15", "time": "09:30", "timeZone": "Australia/Sydney" },
"createdAt": "2026-09-14T06:20:31.907Z",
"contact": "Jamie Citizen",
"internalReference": "ORDER-5502",
"internalReference2": "",
"pickup": {
"companyName": "Acme Freight",
"address": "Unit 4/10 Example St, Silverwater NSW 2128, Australia",
"suburb": "Silverwater",
"reference": "",
"phone": "02 5550 1234",
"instructions": "",
"location": { "lat": -33.8352, "lng": 151.0473 }
},
"delivery": {
"companyName": "Example Retail",
"address": "25 Sample Rd, Botany NSW 2019, Australia",
"suburb": "Botany",
"reference": "",
"phone": "",
"instructions": "",
"location": { "lat": -33.9461, "lng": 151.1965 }
},
"items": [
{ "type": "Box", "quantity": 3, "weightKg": 8, "lengthCm": 40, "widthCm": 30, "heightCm": 30, "stackable": true }
],
"jobCode": "HT",
"vehicle": "HT",
"tailgate": false,
"hiab": false,
"insurance": false,
"distanceKm": 24.6,
"price": { "currency": "AUD", "base": 38, "serviceCharge": 3.8, "tailgate": 0, "hiab": 0, "insurance": 0, "waitTime": 0, "tolls": 4.2, "totalExGst": 46, "gst": 4.18, "total": 50.18 }
},
"requestId": "req_6f8a0c2e4b6d4f8a0c2e4b6d8f0a2c4e"
}Errors
| Code | HTTP status | When |
|---|---|---|
invalid_request | 400 | A field is missing or not valid (for example no contact), a field comes from the quote, a field your account requires is blank, or the body isn't valid JSON. Also when service isn't one of the quote's options, with a message such as The quote has no Express option. Choose one of its options (Standard), or get a new quote. |
unsupported_media_type | 415 | The body wasn't sent as application/json. |
payload_too_large | 413 | The body is over 100 KB. |
quote_not_found | 404 | There's no quote with this quoteId for this API key: it's mistyped, or another key made the quote. |
quote_expired | 422 | The quote can't be booked any more: it's more than 30 minutes old, or its ready time has passed. The message says which. Get a new quote. |
quote_already_booked | 409 | A different booking already booked this quote. The message and details.jobNumber name the job. Get a new quote to book another job. |
request_in_progress | 409 | A booking for this quote is still being worked on. Wait a moment, then send it again. |
price_changed | 409 | The total worked out now isn't the quote's price.total. Nothing is booked. details.quotedTotal is the quote's total and details.price is the new price. Get a new quote to book at the new price. |
feature_not_enabled | 403 | The quote was priced by job code, and job codes are no longer enabled for your account. |
address_not_found | 422 | The pickup or delivery address couldn't be found when the job was priced again. |
address_not_precise | 422 | An address in the quote was only found approximately, so a driver can't be sent to it. details names pickup.address or delivery.address. Get a new quote with a full street address, or its location. |
no_route | 422 | There's no driving route between the two addresses. |
service_not_available | 422 | The service can't be booked for the ready time; details has an entry for service with the reason. Also when the quote has no price for the service, or tailgate, HIAB or insurance isn't available. |
cannot_price_online | 422 | The job needs a custom quote or can't be priced online. details.reason is custom_quote_required or cannot_price_online, and details.contactEmail says who to contact. |
regional_not_available | 422 | Regional and country jobs can't be booked online in this region at the moment. |
daily_limit_reached | 403 | The booking would take the key over a daily limit (bookings, price checks, Google Maps calls or toll lookups). The key is paused. |
service_unavailable | 503 | Address lookups or pricing are unavailable at the moment. Try again shortly by sending the same booking again. |
A field that comes from the quote:
{
"error": {
"code": "invalid_request",
"message": "Some fields aren't valid.",
"details": [
{ "field": "readyAt", "message": "readyAt comes from the quote. To change it, get a new quote." }
]
},
"requestId": "req_3a5c7e9b1d2f4a6c8e0b2d4f6a8c1e3b"
}A quote more than 30 minutes old:
{
"error": {
"code": "quote_expired",
"message": "A quote can be booked for 30 minutes, and this one is older. Get a new quote with POST /quotes."
},
"requestId": "req_6d8f0a2c4e1b4d7f9a3c5e7b9d1f3a5c"
}A service that can't be booked for the ready time, here a quote for now booked after 3:00 PM:
{
"error": {
"code": "service_not_available",
"message": "Standard jobs ready today need a ready time between 7:00 AM and 3:00 PM.",
"details": [
{
"field": "service",
"reason": "outside_service_hours",
"message": "Standard jobs ready today need a ready time between 7:00 AM and 3:00 PM."
}
]
},
"requestId": "req_2c4e6a8b0d2f4b6d8a0c2e4f6b8d0a1c"
}A job that needs a custom quote:
{
"error": {
"code": "cannot_price_online",
"message": "This load falls outside our standard vehicle limits. Our bookings team will review the job details and prepare the right vehicle and pricing for you.",
"details": {
"reason": "custom_quote_required",
"contactEmail": "bookings@directtransport.com.au"
}
},
"requestId": "req_8b0d2f4a6c8e4a0c2e4b6d8f0a2c4e6b"
}The booking object
POST /bookings, GET /bookings and GET /bookings/{jobNumber} show a booking with these fields. GET /bookings/{jobNumber} also adds progress, driver and proofOfDelivery.
| Field | Type | Meaning |
|---|---|---|
jobNumber | string | The job number, for example DTS12345, or TEST-DTS12345 for a test booking. |
testMode | boolean | true for test bookings. |
status | string | Where the job is up to. See statuses. |
bookingType | string | same_day, next_day or three_four_day. Bookings DTS made for you can have other types, such as interstate. |
service | string | The service booked. |
readyAt | object | date (YYYY-MM-DD), time (HH:mm) and timeZone. date or time can be null on some older bookings. |
createdAt | string or null | When the job was booked (ISO 8601, UTC). |
contact | string | The contact name. |
internalReference, internalReference2 | string | Your references. Empty ("") when not given. |
pickup, delivery | object | Each address with the fields below. |
pickup.companyName | string | Company name, or "". |
pickup.address | string | The address as found. |
pickup.suburb | string or null | The suburb. |
pickup.reference | string | Reference, or "". |
pickup.phone | string | Phone number, or "". |
pickup.instructions | string | Instructions, or "". |
pickup.location | object or null | lat and lng. |
items | array | Each item's type, quantity, weightKg, lengthCm, widthCm, heightCm and stackable. Empty for jobs booked by job code. |
jobCode | string or null | The job code the job was priced as. |
vehicle | string or null | The vehicle the job needs. |
tailgate, hiab, insurance | boolean | Whether each was booked. |
distanceKm | number or null | The driving distance in kilometres. |
price | object | The price object, as the booking is now. waitTime, and so total, can go up after booking if the driver records waiting time. |
Live keys see every booking your account has in the region, including bookings made on the portal. Those can have empty or null values where a booking doesn't have the information.
GET /bookings
Lists your account's bookings by ready date: one day with date, or up to 31 days with from and to. Bookings come newest ready date first, and in job-number order within a date. Test keys list test bookings. Bookings archived by DTS aren't listed.
Query parameters
| Parameter | Type | Required | Rules |
|---|---|---|---|
date | string | Either date, or from and to | A ready date, YYYY-MM-DD. Lists that day's bookings. Can't be sent with from or to. |
from | string | With to | The first ready date, YYYY-MM-DD. |
to | string | With from | The last ready date, YYYY-MM-DD. from must be on or before to, and together they can cover at most 31 days, both included. |
limit | integer | No | Bookings per page, 1 to 100. Default 25. |
cursor | string | No | nextCursor from the previous page. With a cursor you can leave out date, from and to; if you send them, they must be the same as for the first page. See pagination. |
Other query parameters are refused. The dates are ready dates in the region's time zone, not the days the jobs were booked: a job booked today for next Tuesday is listed under next Tuesday.
Example request
curl
curl "https://portal.directtransport.com.au/api/v1/bookings?date=2026-09-15&limit=2" \
-H "Authorization: Bearer $DTS_API_KEY"JavaScript
const params = new URLSearchParams({ date: "2026-09-15", limit: "2" });
const response = await fetch(`https://portal.directtransport.com.au/api/v1/bookings?${params}`, {
headers: { Authorization: `Bearer ${process.env.DTS_API_KEY}` },
});
const { data } = await response.json();
for (const booking of data.bookings) console.log(booking.jobNumber, booking.status);
console.log("Next page:", data.nextCursor);Python
import os
import requests
response = requests.get(
"https://portal.directtransport.com.au/api/v1/bookings",
headers={"Authorization": f"Bearer {os.environ['DTS_API_KEY']}"},
params={"date": "2026-09-15", "limit": 2},
timeout=30,
)
data = response.json()["data"]
for booking in data["bookings"]:
print(booking["jobNumber"], booking["status"])
print("Next page:", data["nextCursor"])Example response
200 OK
{
"data": {
"bookings": [
{
"jobNumber": "DTS12345",
"testMode": false,
"status": "allocated",
"bookingType": "same_day",
"service": "Standard",
"readyAt": { "date": "2026-09-15", "time": "09:30", "timeZone": "Australia/Sydney" },
"createdAt": "2026-09-14T06:05:12.412Z",
"contact": "Jamie Citizen",
"internalReference": "ORDER-5501",
"internalReference2": "",
"pickup": {
"companyName": "Acme Freight",
"address": "Unit 4/10 Example St, Silverwater NSW 2128, Australia",
"suburb": "Silverwater",
"reference": "PO-1001",
"phone": "02 5550 1234",
"instructions": "Loading dock at the rear.",
"location": { "lat": -33.8352, "lng": 151.0473 }
},
"delivery": {
"companyName": "Example Retail",
"address": "25 Sample Rd, Botany NSW 2019, Australia",
"suburb": "Botany",
"reference": "INV-2002",
"phone": "02 5550 5678",
"instructions": "Deliver to goods inwards.",
"location": { "lat": -33.9461, "lng": 151.1965 }
},
"items": [
{ "type": "Pallet", "quantity": 2, "weightKg": 180, "lengthCm": 120, "widthCm": 120, "heightCm": 110, "stackable": false },
{ "type": "Box", "quantity": 6, "weightKg": 12.5, "lengthCm": 40, "widthCm": 30, "heightCm": 30, "stackable": true }
],
"jobCode": "1T",
"vehicle": "1T",
"tailgate": true,
"hiab": false,
"insurance": false,
"distanceKm": 24.6,
"price": { "currency": "AUD", "base": 95, "serviceCharge": 9.5, "tailgate": 35, "hiab": 0, "insurance": 0, "waitTime": 0, "tolls": 4.2, "totalExGst": 143.7, "gst": 13.95, "total": 157.65 }
},
{
"jobNumber": "DTS23456",
"testMode": false,
"status": "pending",
"bookingType": "same_day",
"service": "Express",
"readyAt": { "date": "2026-09-15", "time": "13:00", "timeZone": "Australia/Sydney" },
"createdAt": "2026-09-15T01:47:09.120Z",
"contact": "Jamie Citizen",
"internalReference": "ORDER-5510",
"internalReference2": "",
"pickup": {
"companyName": "Acme Freight",
"address": "Unit 4/10 Example St, Silverwater NSW 2128, Australia",
"suburb": "Silverwater",
"reference": "",
"phone": "02 5550 1234",
"instructions": "",
"location": { "lat": -33.8352, "lng": 151.0473 }
},
"delivery": {
"companyName": "Sample Hardware",
"address": "3 Trial Ln, Parramatta NSW 2150, Australia",
"suburb": "Parramatta",
"reference": "",
"phone": "",
"instructions": "",
"location": { "lat": -33.815, "lng": 151.0011 }
},
"items": [
{ "type": "Box", "quantity": 2, "weightKg": 20, "lengthCm": 60, "widthCm": 40, "heightCm": 40, "stackable": false }
],
"jobCode": "HT",
"vehicle": "HT",
"tailgate": false,
"hiab": false,
"insurance": false,
"distanceKm": 9.8,
"price": { "currency": "AUD", "base": 45, "serviceCharge": 4.5, "tailgate": 0, "hiab": 0, "insurance": 0, "waitTime": 0, "tolls": 0, "totalExGst": 49.5, "gst": 4.95, "total": 54.45 }
}
],
"nextCursor": "eyJmcm9tIjoiMjAyNi0wOS0xNSIsInRvIjoiMjAyNi0wOS0xNSIsImRheSI6IjIwMjYtMDktMTUiLCJhZnRlciI6IkRUUzIzNDU2In0"
},
"requestId": "req_0d2f4b6c8e0a4c2e6b8d0f2a4c6e8b0d"
}Response fields
| Field | Type | Meaning |
|---|---|---|
bookings | array | Booking objects. For a booking's progress and driver, use GET /bookings/{jobNumber}. |
nextCursor | string or null | Send it as cursor to get the next page. null when there are no more pages. |
Every page except the last is full, and the last page can be empty. Keep asking while nextCursor isn't null.
Errors
| Code | HTTP status | When |
|---|---|---|
invalid_request | 400 | No dates were sent; date was sent with from or to; only one of from and to was sent; a date isn't a real YYYY-MM-DD date; from is after to or the range is over 31 days; limit isn't a whole number from 1 to 100; the cursor isn't valid or doesn't match the dates sent; or a query parameter isn't known. details names the parameter. |
GET /bookings/{jobNumber}
One of your account's bookings, with its progress, driver and whether proof of delivery is available.
Path parameters
| Parameter | Type | Required | Rules |
|---|---|---|---|
jobNumber | string | Yes | The job number exactly as the API returned it, for example DTS12345 or TEST-DTS12345. |
You get not_found (404) when the booking doesn't exist, belongs to another account, has been archived, or is a live booking asked for with a test key (or a test booking with a live key).
Example request
curl
curl https://portal.directtransport.com.au/api/v1/bookings/DTS12345 \
-H "Authorization: Bearer $DTS_API_KEY"JavaScript
const response = await fetch("https://portal.directtransport.com.au/api/v1/bookings/DTS12345", {
headers: { Authorization: `Bearer ${process.env.DTS_API_KEY}` },
});
const { data: booking } = await response.json();
console.log(booking.status, booking.progress.pickedUpAt, booking.driver?.name);Python
import os
import requests
response = requests.get(
"https://portal.directtransport.com.au/api/v1/bookings/DTS12345",
headers={"Authorization": f"Bearer {os.environ['DTS_API_KEY']}"},
timeout=30,
)
booking = response.json()["data"]
print(booking["status"], booking["progress"]["pickedUpAt"], (booking["driver"] or {}).get("name"))Example response
200 OK
{
"data": {
"jobNumber": "DTS12345",
"testMode": false,
"status": "picked_up",
"bookingType": "same_day",
"service": "Standard",
"readyAt": { "date": "2026-09-15", "time": "09:30", "timeZone": "Australia/Sydney" },
"createdAt": "2026-09-14T06:05:12.412Z",
"contact": "Jamie Citizen",
"internalReference": "ORDER-5501",
"internalReference2": "",
"pickup": {
"companyName": "Acme Freight",
"address": "Unit 4/10 Example St, Silverwater NSW 2128, Australia",
"suburb": "Silverwater",
"reference": "PO-1001",
"phone": "02 5550 1234",
"instructions": "Loading dock at the rear.",
"location": { "lat": -33.8352, "lng": 151.0473 }
},
"delivery": {
"companyName": "Example Retail",
"address": "25 Sample Rd, Botany NSW 2019, Australia",
"suburb": "Botany",
"reference": "INV-2002",
"phone": "02 5550 5678",
"instructions": "Deliver to goods inwards.",
"location": { "lat": -33.9461, "lng": 151.1965 }
},
"items": [
{ "type": "Pallet", "quantity": 2, "weightKg": 180, "lengthCm": 120, "widthCm": 120, "heightCm": 110, "stackable": false },
{ "type": "Box", "quantity": 6, "weightKg": 12.5, "lengthCm": 40, "widthCm": 30, "heightCm": 30, "stackable": true }
],
"jobCode": "1T",
"vehicle": "1T",
"tailgate": true,
"hiab": false,
"insurance": false,
"distanceKm": 24.6,
"price": { "currency": "AUD", "base": 95, "serviceCharge": 9.5, "tailgate": 35, "hiab": 0, "insurance": 0, "waitTime": 0, "tolls": 4.2, "totalExGst": 143.7, "gst": 13.95, "total": 157.65 },
"progress": {
"bookedAt": "2026-09-14T06:05:12.412Z",
"allocatedAt": "2026-09-14T22:41:03.000Z",
"driverAcceptedAt": "2026-09-14T22:43:27.000Z",
"pickedUpAt": "2026-09-14T23:36:50.000Z",
"deliveredAt": null,
"futileAt": null,
"returnedAt": null,
"cancelledAt": null
},
"driver": { "name": "Jordan Sample" },
"proofOfDelivery": { "available": false }
},
"requestId": "req_e1c3a5b7d9f14e3a5c7b9d1f3a5c7e9b"
}Response fields
All the fields of the booking object, and:
| Field | Type | Meaning |
|---|---|---|
progress | object | When the booking reached each step, as ISO 8601 times in UTC. A step that hasn't been reached, or was recorded without a time, is null. See statuses and progress. |
progress.bookedAt | string or null | When the job was booked (the same as createdAt). |
progress.allocatedAt | string or null | When it was allocated to a driver. |
progress.driverAcceptedAt | string or null | When the driver accepted it. |
progress.pickedUpAt | string or null | When it was picked up. |
progress.deliveredAt | string or null | When it was delivered. |
progress.futileAt | string or null | When it was marked futile. |
progress.returnedAt | string or null | When it was returned. |
progress.cancelledAt | string or null | When it was cancelled. |
driver | object or null | name: the driver's name, once a driver is allocated. null while the job is pending, or once it's cancelled. |
proofOfDelivery | object | available: true once there's proof of delivery (a receiver's name, a signature, photos or a POD PDF). Read it with GET /bookings/{jobNumber}/pod. |
Errors
| Code | HTTP status | When |
|---|---|---|
not_found | 404 | There's no booking with this job number that the key can see (message Booking not found.). |
GET /bookings/{jobNumber}/label
The booking's shipping label as a one-page PDF, drawn from the booking as it is now.
Query parameters
| Parameter | Type | Required | Rules |
|---|---|---|---|
paper | string | No | The label size: LABEL_4X6, LABEL_4X65, LABEL_4X675, LABEL_4X8, A6, A5 or A4, in capitals as shown. Default LABEL_4X65. See label sizes. |
Other query parameters are refused.
Example request
curl
curl "https://portal.directtransport.com.au/api/v1/bookings/DTS12345/label?paper=A4" \
-H "Authorization: Bearer $DTS_API_KEY" \
-o DTS12345-label.pdfJavaScript
import { writeFile } from "node:fs/promises";
const response = await fetch("https://portal.directtransport.com.au/api/v1/bookings/DTS12345/label?paper=A4", {
headers: { Authorization: `Bearer ${process.env.DTS_API_KEY}` },
});
if (!response.ok) {
const { error } = await response.json(); // errors are JSON
throw new Error(`${error.code}: ${error.message}`);
}
await writeFile("DTS12345-label.pdf", Buffer.from(await response.arrayBuffer()));Python
import os
import requests
response = requests.get(
"https://portal.directtransport.com.au/api/v1/bookings/DTS12345/label",
headers={"Authorization": f"Bearer {os.environ['DTS_API_KEY']}"},
params={"paper": "A4"},
timeout=60,
)
if response.status_code != 200:
error = response.json()["error"] # errors are JSON
raise SystemExit(f"{error['code']}: {error['message']}")
with open("DTS12345-label.pdf", "wb") as file:
file.write(response.content)Example response
The body is the PDF file. Errors are JSON, as for every endpoint, so check the status before saving the body.
HTTP/1.1 200 OK
Content-Type: application/pdf
Content-Disposition: inline; filename="DTS12345-label.pdf"
X-Request-Id: req_c3e5a7b9d1f34a5c7e9b1d3f5a7c9e1b
X-DTS-Mode: live
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 59
X-RateLimit-Reset: 22
Cache-Control: no-storeErrors
| Code | HTTP status | When |
|---|---|---|
invalid_request | 400 | paper isn't one of the sizes, or a query parameter isn't known. |
not_found | 404 | There's no booking with this job number that the key can see. |
daily_limit_reached | 403 | The label would take the key over its daily documents. The key is paused. |
GET /bookings/{jobNumber}/invoice
The booking's tax invoice as a PDF. It's drawn from the booking as it is now, so it includes changes made after booking, such as waiting time.
Example request
curl
curl https://portal.directtransport.com.au/api/v1/bookings/DTS12345/invoice \
-H "Authorization: Bearer $DTS_API_KEY" \
-o DTS12345-invoice.pdfJavaScript
import { writeFile } from "node:fs/promises";
const response = await fetch("https://portal.directtransport.com.au/api/v1/bookings/DTS12345/invoice", {
headers: { Authorization: `Bearer ${process.env.DTS_API_KEY}` },
});
if (!response.ok) {
const { error } = await response.json(); // errors are JSON
throw new Error(`${error.code}: ${error.message}`);
}
await writeFile("DTS12345-invoice.pdf", Buffer.from(await response.arrayBuffer()));Python
import os
import requests
response = requests.get(
"https://portal.directtransport.com.au/api/v1/bookings/DTS12345/invoice",
headers={"Authorization": f"Bearer {os.environ['DTS_API_KEY']}"},
timeout=60,
)
if response.status_code != 200:
error = response.json()["error"] # errors are JSON
raise SystemExit(f"{error['code']}: {error['message']}")
with open("DTS12345-invoice.pdf", "wb") as file:
file.write(response.content)Example response
HTTP/1.1 200 OK
Content-Type: application/pdf
Content-Disposition: inline; filename="DTS12345-invoice.pdf"
X-Request-Id: req_d4f6b8c0e2a44b6d8f0c2e4a6b8d0f2c
X-DTS-Mode: live
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 58
X-RateLimit-Reset: 21
Cache-Control: no-storeErrors
| Code | HTTP status | When |
|---|---|---|
invalid_request | 400 | A query parameter was sent. |
not_found | 404 | There's no booking with this job number that the key can see. |
daily_limit_reached | 403 | The invoice would take the key over its daily documents. The key is paused. |
GET /bookings/{jobNumber}/pod
A booking's proof of delivery: who received it and when, the signature, photos, documents from pickup, and the POD PDF once one is made. Only for accounts with proof of delivery turned on; ask DTS if you need it.
Example request
curl
curl https://portal.directtransport.com.au/api/v1/bookings/DTS12345/pod \
-H "Authorization: Bearer $DTS_API_KEY"JavaScript
const response = await fetch("https://portal.directtransport.com.au/api/v1/bookings/DTS12345/pod", {
headers: { Authorization: `Bearer ${process.env.DTS_API_KEY}` },
});
const { data: pod } = await response.json();
if (pod.available) console.log(pod.receiverName, pod.deliveredAt, pod.podPdfUrl);Python
import os
import requests
response = requests.get(
"https://portal.directtransport.com.au/api/v1/bookings/DTS12345/pod",
headers={"Authorization": f"Bearer {os.environ['DTS_API_KEY']}"},
timeout=30,
)
pod = response.json()["data"]
if pod["available"]:
print(pod["receiverName"], pod["deliveredAt"], pod["podPdfUrl"])Example response
200 OK. The links here are made up; real links point to the stored files.
{
"data": {
"jobNumber": "DTS12345",
"status": "delivered",
"available": true,
"deliveredAt": "2026-09-15T00:18:44.000Z",
"receiverName": "Sam Receiver",
"signatureUrl": "https://files.example.com/pod/DTS12345/signature.png",
"deliveryPhotos": [
"https://files.example.com/pod/DTS12345/delivery-1.jpg"
],
"pickupPhotos": [
"https://files.example.com/pod/DTS12345/pickup-1.jpg"
],
"pickupDocuments": [
{ "url": "https://files.example.com/pod/DTS12345/consignment-note.pdf", "fileName": "consignment-note.pdf" }
],
"podPdfUrl": "https://files.example.com/pod/DTS12345/pod.pdf",
"stops": []
},
"requestId": "req_b2d4f6a8c0e24b6d8f0a2c4e6b8d0f2a"
}Response fields
| Field | Type | Meaning |
|---|---|---|
jobNumber | string | The job number. |
status | string | The booking's status. |
available | boolean | Whether there's any proof of delivery yet. |
deliveredAt | string or null | When the job was delivered (ISO 8601, UTC). |
receiverName | string or null | The name of the person who received the delivery. |
signatureUrl | string or null | Link to the signature image. |
deliveryPhotos | array of strings | Links to photos taken at delivery. |
pickupPhotos | array of strings | Links to photos taken at pickup. |
pickupDocuments | array of objects | Documents from pickup, each with url and fileName (string or null). |
podPdfUrl | string or null | Link to the proof of delivery PDF, once one is made. |
stops | array of objects | For a job with several pickups or drops, each stop's own proof (see below). Empty for a job with one pickup and one drop. |
Every link is an https URL. If you need to keep the files, download them.
Jobs with several pickups or drops
The driver records proof at each stop, so each drop has its own receiver, signature and photos. For these jobs receiverName lists every drop's receiver, deliveryPhotos and pickupPhotos hold every stop's photos, deliveredAt is when the last drop was done, and signatureUrl is null: each drop's signature is in stops, in route order, pickups first.
"stops": [
{
"type": "pickup",
"name": "Pickup A",
"company": "Sender Co",
"address": "1 William St, Darlinghurst NSW 2010, Australia",
"completedAt": "2026-09-14T22:15:00.000Z",
"photos": ["https://files.example.com/pod/DTS12345/pickup-a-1.jpg"]
},
{
"type": "delivery",
"name": "Delivery A",
"company": "Receiver Co",
"address": "20 Macquarie St, Parramatta NSW 2150, Australia",
"completedAt": "2026-09-15T00:05:00.000Z",
"receiverName": "Sam Receiver",
"signatureUrl": "https://files.example.com/pod/DTS12345/delivery-a-signature.png",
"photos": ["https://files.example.com/pod/DTS12345/delivery-a-1.jpg"]
}
]| Field | Type | Meaning |
|---|---|---|
type | string | pickup or delivery. |
name | string | The stop's name on the job: Pickup A, Delivery A, Delivery B… |
company | string or null | The company at the stop. |
address | string or null | The stop's address. |
completedAt | string or null | When the driver finished the stop (ISO 8601, UTC); null until then. |
receiverName | string or null | Deliveries only: who received it. |
signatureUrl | string or null | Deliveries only: link to the receiver's signature. |
photos | array of strings | Links to the photos taken at the stop. |
Errors
| Code | HTTP status | When |
|---|---|---|
feature_not_enabled | 403 | Proof of delivery isn't enabled for your account (message Proof of delivery isn't enabled for this account.). |
not_found | 404 | There's no booking with this job number that the key can see. |
Items, services and timing rules
These rules apply when POST /quotes prices a job, and again when POST /bookings books it from the quote. They match Place the Booking on the portal. Times are in the region's time zone.
Item types and measurements
items[].type must be one of these, spelt exactly as shown (note Aluminum):
AluminumBagsBoxCoilConduitCrateDrumEnvelopeHosesLadderPailPalletPipesRackRollsSatchelSkidSteelTimberTubesTyres
| Field | Rule |
|---|---|
items | 1 to 50 items. |
items[].quantity | A whole number from 1 to 500. |
items[].weightKg | More than 0 and at most 50,000. The weight of one item, in kilograms. |
items[].lengthCm | More than 0 and at most 3,000. The length of one item, in centimetres. |
items[].widthCm | More than 0 and at most 1,000. The width of one item, in centimetres. |
items[].heightCm | More than 0 and at most 1,000. The height of one item, in centimetres. |
items[].stackable | true or false (default). Only Ladder, Box and Crate items can be stackable. |
Measurements are for one item, and quantity says how many there are. Weights and measurements can have decimals, such as 12.5.
Set stackable to true for items that can be stacked on top of each other, so the job can be priced with them stacked. Only Ladder, Box and Crate items can be stackable; on any other type, "stackable": true is refused with Only Ladder, Box, Crate items can be stacked.
Tailgate, HIAB and insurance
| Field | Asks for | Price shown in | When it isn't available |
|---|---|---|---|
tailgate | A tailgate | price.tailgate | Tailgate isn't available. |
hiab | A HIAB | price.hiab | HIAB isn't available. |
insurance | Freight insurance | price.insurance | Freight insurance isn't available. |
- Each one is
falseunless you sendtrue. tailgateandhiabcan't both betrue(Choose Tailgate or HIAB, not both.).- DTS can make any of them unavailable for a time. Asking for one that isn't available gets
service_not_available(422), withdetailsnaming the field. insuranceis for metro Same Day jobs. For regional and country jobs (Next Day, 3-4 Day, or Same Day with either address outside the metro area), asking for it getsservice_not_available(422) withFreight insurance isn't available for regional and country jobs.
Job codes
Accounts with job codes turned on can price by job code instead of listing items: send jobCode and leave items out.
- Job codes:
Courier,HT,1T,2T,4T,6T,8T,10T,12T,14Tand16T. - Long Distance is applied automatically from the addresses: send the vehicle's code. A
same_dayjob with either address outside the metro area is priced at that vehicle's long-distance rate, and itsjobCodein the answer shows it, for example1T-LDG. LDis still accepted, so integrations that send it keep working, but it names no vehicle, so every option comes back ascustom_quote_required. Send the vehicle's code instead.LDis only forsame_dayjobs (LD is only for same_day jobs.).- Send one of
itemsandjobCode. Sending neither getsSend items (what is being sent), or a jobCode.and sending both getsSend either items or a jobCode, not both. - If job codes aren't turned on for your account,
jobCodegetsfeature_not_enabled(403):Job codes aren't enabled for this account. Send items instead.Ask DTS if you need them. - A booking made by job code has an empty
itemslist.
Services by booking type
bookingType | Services offered |
|---|---|
same_day | Standard, Express, Direct, After Hours, Weekend Deliveries |
next_day | Standard |
three_four_day | Standard |
Asking for a service the booking type doesn't offer gets invalid_request, for example Express isn't offered for next_day jobs. Offered: Standard. A booking's service must be one of its quote's options, or it gets invalid_request, for example The quote has no Express option. Choose one of its options (Standard), or get a new quote.
A same_day job is metro when both addresses are inside the metro service area, and regional and country otherwise. Every next_day and three_four_day job counts as regional and country. Quotes show this in serviceArea and in each address's insideMetroArea. Next Day and 3-4 Day jobs within the metro area can't be priced online (cannot_price_online). If regional and country jobs can't be booked online in the region at the moment, quotes and bookings for them get regional_not_available (422).
Service hours (metro Same Day)
A metro Same Day job must be ready within its service's hours:
| Service | Ready today | Ready on a later day |
|---|---|---|
Standard | 7:00 AM to 3:00 PM | No later than 3:00 PM |
Express | 7:00 AM to 4:00 PM | No later than 4:00 PM |
Direct | 6:00 AM to 5:00 PM | 6:00 AM to 5:00 PM |
After Hours | Before 7:00 AM or after 5:00 PM | Before 7:00 AM or after 5:00 PM |
Weekend Deliveries | A Saturday or Sunday, any time | A Saturday or Sunday, any time |
The times shown are included, so a Standard job ready at 3:00 PM can be booked. After Hours is the other way round: a ready time of exactly 7:00 AM or 5:00 PM isn't After Hours. A ready time up to 5 minutes ago is checked as the current time (see ready date and time).
Outside these hours the option's reason is outside_service_hours, with a message such as Standard jobs ready today need a ready time between 7:00 AM and 3:00 PM. or Express jobs need a ready time no later than 4:00 PM. These hours are only for metro Same Day jobs; regional and country jobs follow the cutoffs below.
Regional and country cutoffs
Regional and country jobs have no ready-time hours, but a job that's ready today has to be booked by a cutoff. The cutoff is checked against the time you quote or book.
| Job | Ready today: book by | Ready on a later day |
|---|---|---|
same_day Standard | 12:00 PM | No cutoff |
same_day Express | 2:00 PM | No cutoff |
same_day Direct | 5:00 PM | No cutoff |
same_day After Hours, Weekend Deliveries | Not offered (service_not_offered) | Not offered |
next_day Standard | No cutoff | No cutoff |
three_four_day Standard | 5:00 PM | No cutoff |
After a cutoff the option's reason is booking_cutoff_passed, with a message such as Regional and country Standard jobs ready today must be booked by 12:00 PM. or 3-4 Day jobs ready today must be booked by 5:00 PM.
Ready date and time
readyAthas adate(YYYY-MM-DD) and atime(HH:mm, 24-hour) in the region's time zone. You send it toPOST /quotes, and a booking takes it from the quote.- A quote can leave it out: the job is then priced for now, the quote's
readyAtshows the current date and time in the region, and booking the quote books the job ready at the moment of booking. - It must be a real date and time (
readyAt isn't a real date and time.). - The date must be today or later (
The ready date is in the past.) and at most 90 days ahead (The ready date can be at most 90 days ahead.). - The time must not have passed. A time up to 5 minutes ago is accepted, to allow for clocks that differ, and counts as now: the quote's
readyAtshows the current time, service hours and cutoffs are checked at the current time, and a booking is saved with it. For example, a metro Standard job for 15:00 today, sent at 15:03, is checked at 15:03. That's after Standard's hours, so it can't be booked. - A time more than 5 minutes ago is refused with a message such as
The ready time has passed: it is 10:42 on 2026-09-15 in Australia/Sydney. Send a ready time from now on. - These are
invalid_request(400) errors, withdetailsnamingreadyAt,readyAt.dateorreadyAt.time.
Booking a quote made without readyAt checks service hours and cutoffs again at the time of booking. For example, a metro Standard job quoted for now at 2:55 PM and booked at 3:05 PM gets service_not_available, because a Standard job ready today must be ready by 3:00 PM.
Addresses
- Send a full street address in Australia, as you would type it on the portal: number, street, suburb, state and postcode. Addresses are only looked up in Australia.
- Responses give each address as it was found, for example
25 Sample Rd, Botany NSW 2019, Australia. precisionisexactwhen the address was found precisely enough to send a driver to (such as a street address, building or business), andapproximatewhen it was only found roughly, such as just a street or a suburb.- A job can only be booked when both addresses are
exact. If either isapproximate, a quote still prices the job, but every option that was priced has"bookable": falsewith the reasonaddress_not_precise, and a booking is refused with theaddress_not_preciseerror (422). The message names the address, for example:The delivery address was found as "Botany NSW 2019, Australia", which isn't precise enough to send a driver to. Send a full street address, or its location as well. - If you know the exact point, send it as
locationwithlatandlngwhen you price the job. The address text is still looked up and must be found; the point you send is then used for pricing and saved with a booking made from the quote, and the address counts asexact. - An address that can't be found gets
address_not_found(422), with a message such asWe couldn't find the pickup address. Check the street address, suburb and postcode.Correct the address and price the job again.detailsnames the field, for example{ "field": "pickup.address", "message": "Address not found." }. - If there's no driving route between the addresses, the answer is
no_route(422).
A booking with an address that was only found approximately:
{
"error": {
"code": "address_not_precise",
"message": "The delivery address was found as \"Botany NSW 2019, Australia\", which isn't precise enough to send a driver to. Send a full street address, or its location as well.",
"details": [
{ "field": "delivery.address", "message": "Not precise enough to send a driver to." }
]
},
"requestId": "req_9e1b3d5f7a9c4e1b3d5f7a9c1e3b5d7f"
}Account-required fields
DTS can set your account to require some booking details, as the portal does. POST /bookings then refuses a booking that leaves a required field out or blank, with invalid_request (400). Quotes don't check these fields.
| Field | Named in the message as |
|---|---|
pickup.reference | a pickup reference |
pickup.phone | a pickup phone number |
pickup.instructions | pickup instructions |
delivery.reference | a delivery reference |
delivery.phone | a delivery phone number |
delivery.instructions | delivery instructions |
{
"error": {
"code": "invalid_request",
"message": "This account requires a pickup reference, a delivery phone number.",
"details": [
{ "field": "pickup.reference", "message": "Required for this account." },
{ "field": "delivery.phone", "message": "Required for this account." }
]
},
"requestId": "req_f5a7c9e1b3d54f7a9c1e3b5d7f9a1c3e"
}The API doesn't say which fields your account requires. Ask DTS, or check the booking form on the portal.
Statuses and progress
status | Meaning |
|---|---|
pending | Booked, and not yet allocated to a driver. |
allocated | Allocated to a driver. |
picked_up | Picked up. |
delivered | Delivered. |
returned | Returned. |
futile | Marked futile: the job couldn't be completed as booked. |
cancelled | Cancelled. |
- A job usually goes
pending,allocated,picked_up,delivered. - A job taken off its driver goes back to
pending, and itsallocatedAtanddriverAcceptedAtgo back tonull. - Some older bookings carry other status values, written in lower case with underscores. Treat a status you don't recognise as not finished.
GET /bookings/{jobNumber} shows when each step happened in progress: bookedAt, allocatedAt, driverAcceptedAt, pickedUpAt, deliveredAt, futileAt, returnedAt and cancelledAt, as ISO 8601 times in UTC. A step that hasn't happened, or was recorded without a time, is null, so decide where a job is up to from status, not from progress.
Check jobs that aren't finished every few minutes, and use GET /bookings?date=… to check many jobs in one request. Test bookings move on by themselves; see test mode.
Proof of delivery
- Proof of delivery is turned on for each account by DTS. Email IT@directtransport.com.au if you need it. Without it,
GET /bookings/{jobNumber}/podgetsfeature_not_enabled(403). proofOfDelivery.availableinGET /bookings/{jobNumber}shows whether there's proof of delivery yet, whether or not it's turned on for your account.- Proof of delivery is available once there's a receiver's name, a signature, delivery photos or a POD PDF. These can arrive at different times, so if you need a particular one, such as
podPdfUrl, check again later. - The response has the receiver's name, when it was delivered, and links to the signature, photos from pickup and delivery, documents from pickup and the POD PDF.
- For a test booking, proof of delivery becomes available when its simulated delivery happens, with
receiverNameTest Receiverand no signature, photos, documents or PDF.
Documents
Every booking the key can see, including test bookings, has two PDFs:
- the shipping label:
GET /bookings/{jobNumber}/label?paper=…, one page; - the tax invoice:
GET /bookings/{jobNumber}/invoice.
Label sizes
paper | Size |
|---|---|
LABEL_4X6 | 4.00" x 6.00" (100 mm x 150 mm) |
LABEL_4X65 | 4.00" x 6.50" (102 mm x 165 mm). The default. |
LABEL_4X675 | 4.00" x 6.75" (102 mm x 172 mm) |
LABEL_4X8 | 4.00" x 8.00" (100 mm x 200 mm) |
A6 | A6 (105 mm x 148 mm) |
A5 | A5 (148 mm x 210 mm) |
A4 | A4 (210 mm x 297 mm) |
The label's text is made smaller to fit the page. If a booking has more items than fit even then, the label lists as many as fit and ends the list with "+N more items".
- Both documents are drawn from the booking as it is now, so download them again after a change, such as waiting time added to the invoice.
- They come back with
Content-Type: application/pdfandContent-Disposition: inline, with a file name such asDTS12345-label.pdforDTS12345-invoice.pdf. - Each PDF counts as one document towards
documentsPerDay: 2,000 a day for live keys and 500 for test keys.
Test mode
Test keys (dts_test_) let you build and try your integration with real prices, without booking real jobs. Use the same base URL as for live keys; only the key changes when you go live.
- Quotes are priced exactly as with a live key, with real address lookups and your account's prices.
- A quote made with a test key is booked as a test booking, with that key.
POST /bookingschecks everything a live booking checks, then saves it: its job number isTEST-followed by a DTS number (for exampleTEST-DTS12345) andtestModeistrue. No driver is sent and no email goes to DTS. - Test keys only see test bookings, and live keys only see live bookings. A job number from the other mode gets
not_found, and a quote can only be booked with the key that made it. - Labels and invoices can be downloaded for test bookings. Proof of delivery is simulated (it still needs proof of delivery turned on for your account).
- Validation, errors, quotes, retrying a booking and the per-minute limit work as they do for live keys. Daily limits are lower; see daily limits.
Simulated progress
A test booking moves on by itself, counted from when it was booked (createdAt). GET /bookings and GET /bookings/{jobNumber} show the simulated status.
| Time after booking | status | progress times set | Also |
|---|---|---|---|
| 0 minutes | pending | bookedAt | driver is null |
| 5 minutes | allocated | allocatedAt, driverAcceptedAt | driver is { "name": "Test Driver" } |
| 15 minutes | picked_up | pickedUpAt | |
| 30 minutes | delivered | deliveredAt | proofOfDelivery.available is true, and the POD has receiverName Test Receiver |
futileAt, returnedAt and cancelledAt stay null. These are the progress fields of a test booking 20 minutes after it was booked:
{
"jobNumber": "TEST-DTS12345",
"testMode": true,
"status": "picked_up",
"createdAt": "2026-09-14T06:20:31.907Z",
"progress": {
"bookedAt": "2026-09-14T06:20:31.907Z",
"allocatedAt": "2026-09-14T06:25:31.907Z",
"driverAcceptedAt": "2026-09-14T06:25:31.907Z",
"pickedUpAt": "2026-09-14T06:35:31.907Z",
"deliveredAt": null,
"futileAt": null,
"returnedAt": null,
"cancelledAt": null
},
"driver": { "name": "Test Driver" },
"proofOfDelivery": { "available": false }
}Nobody will collect or deliver them. Don't give TEST- job numbers, or their labels, to anyone expecting a delivery.
Limits and usage
Each key has a per-minute limit and daily limits. GET /account shows the limits for your key in limits and what it has used today in usageToday. If you need more, email IT@directtransport.com.au: DTS can raise limits for your key.
Per minute
- A key can make 60 requests a minute (
requestsPerMinute), live or test. The count starts again at the start of each minute. - Once the key is accepted, every response has
X-RateLimit-Limit,X-RateLimit-RemainingandX-RateLimit-Reset(the seconds until the minute ends). - A request over the limit gets
rate_limited(429) with aRetry-Afterheader: the number of seconds until the next minute. Wait that long before trying again. - Requests that are turned away still count towards that minute, so retrying straight away doesn't help. They also count towards
rateLimitedPerDay: more than 1,000 in a day pauses the key.
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 17
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 17
X-Request-Id: req_a9c1e3b5d7f94a1c3e5b7d9f1a3c5e7b
X-DTS-Mode: live
Cache-Control: no-store{
"error": {
"code": "rate_limited",
"message": "Too many requests this minute. Wait until the time in Retry-After, then try again."
},
"requestId": "req_a9c1e3b5d7f94a1c3e5b7d9f1a3c5e7b"
}Daily limits
Days run from midnight to midnight in the region's time zone. The names are the fields in limits; the matching counts in usageToday drop PerDay (for example priceChecks).
| Limit | What counts | Live key | Test key |
|---|---|---|---|
requestsPerMinute | Requests in one minute (see above). | 60 | 60 |
requestsPerDay | Requests, apart from those turned away with 429. | 20,000 | 5,000 |
priceChecksPerDay | Price checks: each POST /quotes or POST /bookings that gets as far as looking up the addresses. | 2,000 | 200 |
bookingsPerDay | Bookings made. | 500 | 200 |
documentsPerDay | Labels and invoices. | 2,000 | 500 |
googleCallsPerDay | Google Maps calls: 3 for each price check (two address lookups and the driving distance). | 6,000 | 600 |
tollCallsPerDay | Toll lookups made while pricing. | 2,000 | 200 |
databaseReadsPerDay | Records read to answer your requests: about 4 for a simple request, and more for lists. | 250,000 | 250,000 |
databaseWritesPerDay | Records written to answer your requests: about 3 or 4 for a request. | 100,000 | 100,000 |
rateLimitedPerDay | Requests turned away with 429. | 1,000 | 1,000 |
When a daily limit is reached
- A request that would take any daily count over its limit is refused with
daily_limit_reached(403), and the key is paused.detailssays which count and its limit. - A count can also go over while a request is being answered, for example records read for a long list. That request still gets its answer, and the key is paused straight after.
- Work that uses price checks, Google Maps calls, toll lookups, bookings or documents is checked before it starts, so the refused request doesn't use them.
- While the key is paused, every request with it, including tracking and documents, gets
key_suspended(403). - A paused key stays paused, even after midnight, until DTS allows it again. Email IT@directtransport.com.au with the key's prefix.
- Once DTS allows the key again, what it used earlier that day no longer counts towards that day's limits.
{
"error": {
"code": "daily_limit_reached",
"message": "This request would go over a daily usage limit, so the key has been paused. Contact IT@directtransport.com.au.",
"details": { "counter": "priceChecks", "limit": 2000 }
},
"requestId": "req_c7e9b1d3f5a74c9e1b3d5f7a9c1e3b5d"
}Staying within your limits
- After a 429, wait for
Retry-After. Spread large batches of work out over time. - Don't price the same job again and again: each quote uses a price check and 3 Google Maps calls, and so does each booking.
- Check unfinished jobs every few minutes, not every few seconds, and check many at once with
GET /bookings?date=…. - Download labels and invoices when you need them, not on every check.
- Keep an eye on
usageTodayinGET /account.
Retrying a booking
A quote books one job. If a POST /bookings request times out or the connection drops, send the same booking again: you get the first answer back, and the job can't be booked twice.
- The same booking means the same
quoteId,service,contact, references, and pickup and delivery details. The order of the fields doesn't matter, spaces at the start and end of text are removed, and a text field left out is the same as an empty one. - Once a booking is made, sending the same booking again gets it back for 24 hours, even after its quote has expired.
- To book another job, get a new quote.
| You send | What happens |
|---|---|
| The same booking again, after the quote was booked with it | You get the booking again (201), with Idempotent-Replayed: true. Nothing new is booked. The answer has its own X-Request-Id and requestId. |
| A different booking of a quote that was already booked, such as another service or contact | quote_already_booked (409). The message and details.jobNumber name the job the quote was booked as. Get a new quote to book another job. |
| A booking of a quote while another booking of it is still being worked on | request_in_progress (409). Wait a moment, then send it again. If that request never finishes, the quote can be booked again after 10 minutes. If it did make a booking, the same booking gets that booking back, and a different one gets quote_already_booked. |
| A booking of a quote after a booking of it was refused or failed, so nothing was booked | The request is worked on as if it were new. A refusal or a server error doesn't use the quote up, so send the corrected booking with the same quoteId while the quote can still be booked. |
A booking sent again after it was made gets the same answer, with Idempotent-Replayed:
HTTP/1.1 201 Created
Content-Type: application/json
Location: /api/v1/bookings/DTS12345
Idempotent-Replayed: true
X-Request-Id: req_b5d7f9a1c3e24b6d8f0a3c5e7b9d2f4a
X-DTS-Mode: live
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 55
X-RateLimit-Reset: 31
Cache-Control: no-storeA different booking of a quote that was already booked:
{
"error": {
"code": "quote_already_booked",
"message": "This quote was already booked as DTS12345. Get a new quote to book another job.",
"details": { "jobNumber": "DTS12345" }
},
"requestId": "req_8e0a2c4f6b3d4e8a0c2f4b6d8e1a3c5f"
}Which answers are kept
- Kept and repeated for 24 hours: only a booking that was made (201).
- Not kept: every refusal and error, including
quote_not_found(404),price_changed(409),quote_expired,service_not_available,address_not_preciseandcannot_price_online(422),daily_limit_reached(403) and server errors (500 and 503). Nothing was booked.
For example, after service_not_available, book another of the quote's bookable options with the same quoteId. After price_changed or quote_expired, get a new quote and book from it.
Pagination
GET /bookings returns bookings a page at a time.
- Ask for the first page with
date, orfromandto, andlimitif you want a page size other than 25 (up to 100). - If
nextCursorisn'tnull, ask for the next page withcursorset to it. You can leave outdate,fromandto, or send the same ones again;limitcan change. - Stop when
nextCursorisnull. The last page can be empty.
- Use cursors as they are; don't build or change them. A cursor that has been changed gets
invalid_request:cursor isn't valid. Use nextCursor from the previous page. - A list isn't a snapshot. Bookings made or changed while you page through can be missed or can show up.
JavaScript
async function listBookings(from, to) {
const bookings = [];
let cursor = null;
do {
const params = new URLSearchParams({ from, to, limit: "100" });
if (cursor) params.set("cursor", cursor);
const response = await fetch(`https://portal.directtransport.com.au/api/v1/bookings?${params}`, {
headers: { Authorization: `Bearer ${process.env.DTS_API_KEY}` },
});
const body = await response.json();
if (!response.ok) throw new Error(`${body.error.code}: ${body.error.message}`);
bookings.push(...body.data.bookings);
cursor = body.data.nextCursor;
} while (cursor);
return bookings;
}
const september = await listBookings("2026-09-01", "2026-09-30");Python
import os
import requests
def list_bookings(date_from, date_to):
bookings, cursor = [], None
while True:
params = {"from": date_from, "to": date_to, "limit": 100}
if cursor:
params["cursor"] = cursor
response = requests.get(
"https://portal.directtransport.com.au/api/v1/bookings",
headers={"Authorization": f"Bearer {os.environ['DTS_API_KEY']}"},
params=params,
timeout=30,
)
body = response.json()
if response.status_code != 200:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
bookings.extend(body["data"]["bookings"])
cursor = body["data"]["nextCursor"]
if not cursor:
return bookings
september = list_bookings("2026-09-01", "2026-09-30")Errors
Every error has the same format. Build your handling on code; message is for people and can change.
| Code | HTTP status | Meaning and when it happens |
|---|---|---|
missing_api_key | 401 | No API key was sent. Send Authorization: Bearer <your key> or X-API-Key. |
invalid_api_key | 401 | The key isn't valid: it's mistyped, isn't a DTS key, or wasn't issued for this region. |
key_revoked | 401 | The key has been revoked. Ask DTS for a new one. |
key_suspended | 403 | The key is paused, for example after going over a daily limit. Ask DTS to allow it again. |
account_not_allowed | 403 | The key's account can't use the API, for example because it's no longer a business account or it has been archived. |
wrong_region | 403 | The key belongs to another region. Use the base URL of the region that issued it. |
rate_limited | 429 | Too many requests this minute. Wait for the seconds in Retry-After, then try again. |
daily_limit_reached | 403 | The request would go over a daily limit, so it was refused and the key was paused. details has the counter and its limit. See limits. |
invalid_request | 400 | The request isn't valid: a field or query parameter, the JSON, a field your account requires, a field a booking takes from its quote, or the ready date and time. details lists the fields and problems. |
unsupported_media_type | 415 | The body wasn't sent as JSON with Content-Type: application/json. |
payload_too_large | 413 | The request body is over 100 KB. |
feature_not_enabled | 403 | Something isn't turned on for your account: job codes (jobCode) or proof of delivery. |
not_found | 404 | The booking doesn't exist or the key can't see it. A path that isn't part of the API also answers not_found (404). |
method_not_allowed | 405 | The path doesn't accept this HTTP method, for example DELETE /bookings. The Allow header lists the methods it does accept. |
address_not_found | 422 | The pickup or delivery address couldn't be found. Check the street address, suburb and postcode. details names the field. |
no_route | 422 | There's no driving route between the pickup and delivery addresses. |
service_not_available | 422 | Tailgate, HIAB or insurance isn't available, or (when booking) the service can't be booked for the ready time or has no price in the quote. details names the field, and for a service gives the reason. |
regional_not_available | 422 | Regional and country jobs can't be booked online in this region at the moment. |
address_not_precise | 422 | When booking: an address in the quote was only found approximately, so a driver can't be sent to it. Get a new quote with a full street address, or its location as well. Quotes show the same problem as the option reason address_not_precise. |
price_changed | 409 | When booking: the total worked out now isn't the quote's price.total. Nothing was booked. details has the quotedTotal and the new price. Get a new quote. |
quote_not_found | 404 | When booking: there's no quote with this quoteId for the API key. It's mistyped, or another key made the quote. |
quote_expired | 422 | When booking: the quote can't be booked any more, because it's more than 30 minutes old or its ready time has passed. Get a new quote. |
quote_already_booked | 409 | When booking: a different booking already booked this quote. details.jobNumber names the job. Get a new quote to book another job. |
request_in_progress | 409 | A booking for this quote is still being worked on. Wait a moment, then send it again. |
cannot_price_online | 422 | When booking: the job needs a custom quote or can't be priced online. details has the reason and a contactEmail. |
internal_error | 500 | Something went wrong on DTS's side. Try again, and if it keeps happening, contact DTS with the requestId. |
service_unavailable | 503 | The API, address lookups or pricing are unavailable for the moment. Try again shortly. |
When to retry
| Answer | Retry? |
|---|---|
rate_limited (429) | Yes, after the seconds in Retry-After. |
request_in_progress (409) | Yes, after a few seconds: send the same booking again. |
internal_error (500), service_unavailable (503), timeouts and network errors | Yes, a little later, waiting longer each time. For a booking, send the same booking again: if it was made, you get it back instead of a second booking. |
price_changed (409), quote_expired (422) | Not as it was. Get a new quote, confirm its price, then book from it. |
quote_already_booked (409) | No. The quote is booked, as details.jobNumber. To book another job, get a new quote. |
| Other errors (400, 401, 403, 404, 413, 415, 422) | Not until you've fixed the request, or DTS has fixed the key or account. A booking can then be sent again with the same quoteId, until the quote expires. |
Changelog
| Version | Date | Changes |
|---|---|---|
| v1 | 14 September 2026 | First release: GET /account, POST /quotes, which prices a job and gives a quoteId, POST /bookings, which books a service from a quote by its quoteId, GET /bookings, GET /bookings/{jobNumber}, and the label, invoice and proof of delivery endpoints. |
Support
Email IT@directtransport.com.au to:
- get a live or test key;
- raise your limits;
- have a paused key allowed again;
- revoke a key that may have been exposed;
- turn on proof of delivery or job codes for your account;
- ask anything about the API.
For custom quotes, contact the DTS bookings team at bookings@directtransport.com.au, the contactEmail the API gives.
When you write about a request, include:
- the region;
- the key's prefix, such as
dts_live_Ab12Cd(never the whole key); - the
requestId(orX-Request-Id), the endpoint and the time, and for a booking, itsquoteId; - the error
codeandmessage.