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, at that 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": "expectedTotal", "message": "Send expectedTotal: the price.total of the service you chose from POST /quotes." }
]
},
"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 an unknown field at the top level of the body. 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 | Replayed booking answers | true when the answer repeats a booking already made with the same Idempotency-Key. See idempotency. |
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. When you book, send this as expectedTotal. |
{
"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. - Choose a servicePick an option with
"bookable": true. The other options say why they can't be booked. - Book it
POST /bookingswith the same job, the service, contact and references, anIdempotency-KeyandexpectedTotal. - 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.
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": {
"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 chosen option's
price.total. You send it asexpectedTotalwhen you book. - 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.");
}
const expectedTotal = option.price.total; // 157.65Python
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.")
expected_total = option["price"]["total"] # 157.65The 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 with:
- the same job details you priced:
bookingType,readyAt, the pickup and delivery addresses (andlocation, if you sent it),itemsorjobCode, andtailgate,hiabandinsurance; - the
serviceyou chose; contact, the name of the person to contact about the job, and if you like, a company name, phone number, reference and instructions for each address, and your owninternalReferenceandinternalReference2;expectedTotal, the chosen option'sprice.total(required);- an
Idempotency-Keyheader with a new unique value, such as a UUID, so that retrying the request can't book the job twice.
curl
curl https://portal.directtransport.com.au/api/v1/bookings \
-H "Authorization: Bearer $DTS_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"bookingType": "same_day",
"readyAt": { "date": "2026-09-15", "time": "09:30" },
"service": "Standard",
"pickup": {
"address": "Unit 4, 10 Example Street, Silverwater NSW 2128",
"companyName": "Acme Freight",
"phone": "02 5550 1234",
"reference": "PO-1001",
"instructions": "Loading dock at the rear."
},
"delivery": {
"address": "25 Sample Road, Botany NSW 2019",
"companyName": "Example Retail",
"phone": "02 5550 5678",
"reference": "INV-2002",
"instructions": "Deliver to goods inwards."
},
"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,
"contact": "Jamie Citizen",
"internalReference": "ORDER-5501",
"expectedTotal": 157.65
}'JavaScript
import { randomUUID } from "node:crypto";
// One key for this booking: send it with every attempt until the booking is made.
const idempotencyKey = randomUUID();
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",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify({
...job, // the same job details you priced in step 1
service: "Standard",
pickup: {
...job.pickup,
companyName: "Acme Freight",
phone: "02 5550 1234",
reference: "PO-1001",
instructions: "Loading dock at the rear.",
},
delivery: {
...job.delivery,
companyName: "Example Retail",
phone: "02 5550 5678",
reference: "INV-2002",
instructions: "Deliver to goods inwards.",
},
contact: "Jamie Citizen",
internalReference: "ORDER-5501",
expectedTotal: option.price.total, // 157.65
}),
});
const booking = await response.json();Python
import uuid
# One key for this booking: send it with every attempt until the booking is made.
idempotency_key = str(uuid.uuid4())
booking_request = {
**job, # the same job details you priced in step 1
"service": "Standard",
"pickup": {
**job["pickup"],
"companyName": "Acme Freight",
"phone": "02 5550 1234",
"reference": "PO-1001",
"instructions": "Loading dock at the rear.",
},
"delivery": {
**job["delivery"],
"companyName": "Example Retail",
"phone": "02 5550 5678",
"reference": "INV-2002",
"instructions": "Deliver to goods inwards.",
},
"contact": "Jamie Citizen",
"internalReference": "ORDER-5501",
"expectedTotal": option["price"]["total"], # 157.65
}
response = requests.post(
"https://portal.directtransport.com.au/api/v1/bookings",
headers={
"Authorization": f"Bearer {os.environ['DTS_API_KEY']}",
"Idempotency-Key": idempotency_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 price is no longer expectedTotal (to the cent), the API answers price_changed (409) with the new price in error.details.price, and nothing is booked. Show the new total to the person booking, or apply your own rule, then send the same booking again with expectedTotal set to the new price.total. You can send the same Idempotency-Key: a refused request doesn't use it up.
409 Conflict
{
"error": {
"code": "price_changed",
"message": "The price for this job is now $161.05. Send that as expectedTotal to book at this price.",
"details": {
"expectedTotal": 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 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). See POST /bookings for every case.
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, handle a changed price, and read the booking back.
JavaScript
// book-a-job.mjs: price, choose, book and track a job. Node.js 18 or later.
import { randomUUID } from "node:crypto";
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, headers = {} } = {}) {
const response = await fetch(BASE_URL + path, {
method,
headers: {
Authorization: `Bearer ${API_KEY}`,
...(body ? { "Content-Type": "application/json" } : {}),
...headers,
},
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.
const quote = await api("POST", "/quotes", { body: job });
if (quote.status !== 200) throw new Error(`${quote.payload.error.code}: ${quote.payload.error.message}`);
// 2. Choose a service that can be booked.
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.");
// 3. Book it at the price shown.
// One Idempotency-Key for this booking. A retry after a timeout can't book twice, and a refusal
// such as price_changed doesn't use the key up, so the same key is sent again with the new price.
const idempotencyKey = randomUUID();
function book(expectedTotal) {
return api("POST", "/bookings", {
headers: { "Idempotency-Key": idempotencyKey },
body: {
...job,
service: option.service,
pickup: { ...job.pickup, companyName: "Acme Freight", phone: "02 5550 1234" },
delivery: { ...job.delivery, companyName: "Example Retail", phone: "02 5550 5678" },
contact: "Jamie Citizen",
internalReference: "ORDER-5501",
expectedTotal,
},
});
}
let booking = await book(option.price.total);
if (booking.status === 409 && booking.payload.error.code === "price_changed") {
const newTotal = booking.payload.error.details.price.total;
// Nothing was booked. This script accepts the new total; your system may need a person to confirm it.
console.log(`The price is now $${newTotal.toFixed(2)}.`);
booking = await book(newTotal);
}
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 uuid
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.
quote = session.post(f"{BASE_URL}/quotes", json=job, timeout=60)
if quote.status_code != 200:
fail(quote)
# 2. Choose a service that can be booked.
options = quote.json()["data"]["options"]
option = next((o for o in options if o["bookable"] and o["service"] == "Standard"), None)
if option is None:
raise SystemExit("Standard can't be booked for this job.")
# 3. Book it at the price shown.
# One Idempotency-Key for this booking. A retry after a timeout can't book twice, and a refusal
# such as price_changed doesn't use the key up, so the same key is sent again with the new price.
idempotency_key = str(uuid.uuid4())
def book(expected_total):
body = {
**job,
"service": option["service"],
"pickup": {**job["pickup"], "companyName": "Acme Freight", "phone": "02 5550 1234"},
"delivery": {**job["delivery"], "companyName": "Example Retail", "phone": "02 5550 5678"},
"contact": "Jamie Citizen",
"internalReference": "ORDER-5501",
"expectedTotal": expected_total,
}
headers = {"Idempotency-Key": idempotency_key}
return session.post(f"{BASE_URL}/bookings", json=body, headers=headers, timeout=60)
booking = book(option["price"]["total"])
if booking.status_code == 409 and booking.json()["error"]["code"] == "price_changed":
new_total = booking.json()["error"]["details"]["price"]["total"]
# Nothing was booked. This script accepts the new total; your system may need a person to confirm it.
print(f"The price is now ${new_total:.2f}.")
booking = book(new_total)
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. Nothing is booked. |
| POST | /bookings | Books a job at the price you were shown. |
| 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, and a quote isn't held: 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: LD, Courier, HT, 1T, 2T, 4T, 6T, 8T, 10T, 12T, 14T or 16T. Only for accounts with job codes turned on. LD is only for same_day. 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).
Example request
Three stackable boxes, priced 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_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_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_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": {
"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 |
|---|---|---|
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.
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. Try again shortly. | service_unavailable (503) |
{
"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 a job for your account at the price POST /quotes showed you. The job is priced again, exactly as a quote is, and only booked if the service can be booked for the ready time and the total is still expectedTotal.
Headers
| Header | Required | Rules |
|---|---|---|
Idempotency-Key | Yes | 1 to 255 visible characters with no spaces, such as a UUID. Use a new value for each new booking, and the same value for every attempt at that booking until it's made. See idempotency. |
Request body
| Field | Type | Required | Rules |
|---|---|---|---|
bookingType | string | Yes | same_day, next_day or three_four_day. |
readyAt | object | Yes | When the job will be ready, in the region's time zone. See ready date and time. |
readyAt.date | string | Yes | YYYY-MM-DD. Today or later, and at most 90 days ahead. |
readyAt.time | string | Yes | HH:mm, 24-hour. Must not have passed. A time up to 5 minutes ago counts as now: the job is priced, checked and saved at the current time. |
service | string | Yes | A service offered for the booking type: Standard, Express, Direct, After Hours or Weekend Deliveries for same_day; Standard for next_day and three_four_day. |
pickup | object | Yes | Where the job is picked up. |
pickup.address | string | Yes | 3 to 250 characters. The address you priced. |
pickup.location | object | No | lat (from -45 to -9) and lng (from 111 to 155). Send it if you sent it when pricing. |
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 | Yes | Where the job is delivered. |
delivery.address | string | Yes | 3 to 250 characters. The address you priced. |
delivery.location | object | No | As for pickup.location. |
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. |
items | array of objects | Yes, unless you send jobCode | 1 to 50 items, with the same fields and rules as POST /quotes. |
jobCode | string | Yes, unless you send items | As for POST /quotes. |
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. |
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. |
expectedTotal | number | Yes | 0 or more. The price.total of the option you chose from POST /quotes. If the total worked out now is different (by more than half a cent), nothing is booked and you get price_changed (409) with the new price. |
* Your account may require these. See account-required fields.
How it works
- Checks the body, and the fields your account requires.
- Checks the
Idempotency-Key. If a booking was already made with it, a retry gets that booking back, and nothing is booked twice. - Prices the chosen service exactly as
POST /quotesdoes, with the same checks on the ready date and time, your account and the addresses. - 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 isn'texpectedTotal(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. With a test key, it's saved as a test booking.
Example request
This uses a test key to book the Standard option from the POST /quotes example, for $50.18.
curl
curl https://portal.directtransport.com.au/api/v1/bookings \
-H "Authorization: Bearer $DTS_TEST_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 0b8f3c52-6d1e-4a7b-9c2d-5e8f1a3b7c64" \
-d '{
"bookingType": "same_day",
"readyAt": { "date": "2026-09-15", "time": "09:30" },
"service": "Standard",
"pickup": {
"address": "Unit 4, 10 Example Street, Silverwater NSW 2128",
"companyName": "Acme Freight",
"phone": "02 5550 1234"
},
"delivery": {
"address": "25 Sample Road, Botany NSW 2019",
"location": { "lat": -33.9461, "lng": 151.1965 },
"companyName": "Example Retail"
},
"items": [
{ "type": "Box", "quantity": 3, "weightKg": 8, "lengthCm": 40, "widthCm": 30, "heightCm": 30, "stackable": true }
],
"contact": "Jamie Citizen",
"internalReference": "ORDER-5502",
"expectedTotal": 50.18
}'JavaScript
import { randomUUID } from "node:crypto";
const idempotencyKey = randomUUID(); // use it for every attempt at this booking until it's made
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",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify({
bookingType: "same_day",
readyAt: { date: "2026-09-15", time: "09:30" },
service: "Standard",
pickup: {
address: "Unit 4, 10 Example Street, Silverwater NSW 2128",
companyName: "Acme Freight",
phone: "02 5550 1234",
},
delivery: {
address: "25 Sample Road, Botany NSW 2019",
location: { lat: -33.9461, lng: 151.1965 },
companyName: "Example Retail",
},
items: [{ type: "Box", quantity: 3, weightKg: 8, lengthCm: 40, widthCm: 30, heightCm: 30, stackable: true }],
contact: "Jamie Citizen",
internalReference: "ORDER-5502",
expectedTotal: 50.18,
}),
});
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 uuid
import requests
idempotency_key = str(uuid.uuid4()) # use it for every attempt at this booking until it's made
response = requests.post(
"https://portal.directtransport.com.au/api/v1/bookings",
headers={
"Authorization": f"Bearer {os.environ['DTS_TEST_API_KEY']}",
"Idempotency-Key": idempotency_key,
},
json={
"bookingType": "same_day",
"readyAt": {"date": "2026-09-15", "time": "09:30"},
"service": "Standard",
"pickup": {
"address": "Unit 4, 10 Example Street, Silverwater NSW 2128",
"companyName": "Acme Freight",
"phone": "02 5550 1234",
},
"delivery": {
"address": "25 Sample Road, Botany NSW 2019",
"location": {"lat": -33.9461, "lng": 151.1965},
"companyName": "Example Retail",
},
"items": [{"type": "Box", "quantity": 3, "weightKg": 8, "lengthCm": 40, "widthCm": 30, "heightCm": 30, "stackable": True}],
"contact": "Jamie Citizen",
"internalReference": "ORDER-5502",
"expectedTotal": 50.18,
},
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 expectedTotal), a field your account requires is blank, the Idempotency-Key isn't 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. |
idempotency_key_required | 400 | There's no Idempotency-Key header. |
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. |
price_changed | 409 | The total isn't expectedTotal. Nothing is booked. details.expectedTotal is what you sent and details.price is the new price. To book at the new price, send the same request with the new total. The same Idempotency-Key can be used. |
request_in_progress | 409 | The same request with this Idempotency-Key is still being worked on. Wait a moment, then retry. |
idempotency_key_reused | 422 | This Idempotency-Key already made a booking from a different request, or a different request with it is still being worked on. Use a new key for a new booking. |
address_not_found | 422 | The pickup or delivery address couldn't be found. Check the street address, suburb and postcode. |
address_not_precise | 422 | An address was only found approximately, so a driver can't be sent to it. details names pickup.address or delivery.address. |
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 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. Retry shortly with the same Idempotency-Key. |
A service that can't be booked for the ready time:
{
"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"
},
"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. |
Every link is an https URL. If you need to keep the files, download them.
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 to both POST /quotes and POST /bookings, and 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:
LD,Courier,HT,1T,2T,4T,6T,8T,10T,12T,14Tand16T. 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 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.- Bookings must send it (
Send readyAt: the date and time the job will be ready.). Quotes can leave it out: the job is then priced for now, and the quote'sreadyAtshows the current date and time in the region. - 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.
If you price a job without readyAt and book it more than 5 minutes later, send the current time as readyAt when you book. The quote's time will have passed by then.
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
locationwithlatandlng. The address text is still looked up and must be found; the point you send is then used for pricing and saved with the booking, 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 send the request 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.
POST /bookingschecks everything a live booking checks, then saves a test booking: 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. - 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, idempotency 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.
Idempotency
POST /bookings needs an Idempotency-Key header. It makes retrying safe: if a request times out or the connection drops, send it again with the same key, and the job can't be booked twice.
- The value is 1 to 255 visible characters with no spaces. A UUID, such as
0b8f3c52-6d1e-4a7b-9c2d-5e8f1a3b7c64, is ideal. - Use a new value for each new booking, and send the same value with every attempt at that booking until it's made: a retry after a timeout or network error, or the booking sent again after a refusal.
- Once a booking is made, its value is remembered for 24 hours, separately for each API key.
| You send | What happens |
|---|---|
The same Idempotency-Key and the same request, after a booking was made with it | You get the booking again (201), with Idempotent-Replayed: true. Nothing new is booked. The replay has its own X-Request-Id and requestId. |
The same Idempotency-Key with a different request, after a booking was made with it | idempotency_key_reused (422). Use a new key for a new booking. |
The same Idempotency-Key after a request with 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 key up, so send the corrected request with the same key. |
The same Idempotency-Key and the same request while the first is still being worked on | request_in_progress (409). Wait a moment, then retry. If the first request never finishes, the key can be used again after 10 minutes. If that request did make a booking, you get that booking back instead of a second one. |
The same Idempotency-Key with a different request while the first is still being worked on | idempotency_key_reused (422). |
No Idempotency-Key | idempotency_key_required (400). |
| A value that isn't 1 to 255 visible characters | invalid_request (400): The Idempotency-Key header must be 1 to 255 visible characters, with no spaces. |
Which answers are kept
- Kept and repeated for 24 hours: only a booking that was made (201).
- Not kept: every refusal and error, including
price_changed(409),service_not_available,address_not_precise,address_not_foundandcannot_price_online(422), a ready time that has passed (400),daily_limit_reached(403) and server errors (500 and 503). Nothing was booked, and the key can be used again straight away.
For example, after price_changed, send the same request with expectedTotal set to the new price.total and the same Idempotency-Key, and the job is booked. Use a new key only for a new booking.
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, the Idempotency-Key, a field your account requires, 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. |
idempotency_key_required | 400 | POST /bookings was sent without an Idempotency-Key header. |
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 404, possibly without a JSON body. |
method_not_allowed | 405 | The path doesn't accept this HTTP method, for example DELETE /bookings. This answer can come without a JSON body. |
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. 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 was only found approximately, so a driver can't be sent to it. Send 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 expectedTotal. Nothing was booked. details has your expectedTotal and the new price. |
idempotency_key_reused | 422 | This Idempotency-Key already made a booking from a different request, or a different request with it is still being worked on. Use a new key for a new booking. |
request_in_progress | 409 | The same request with this Idempotency-Key is still being worked on. Wait a moment, then retry. |
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, with the same Idempotency-Key. |
internal_error (500), service_unavailable (503), timeouts and network errors | Yes, a little later, waiting longer each time. For bookings, use the same Idempotency-Key. |
price_changed (409) | Not as it was. Confirm the new price, then send the booking again with the new expectedTotal. The same Idempotency-Key can be used. |
| 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 Idempotency-Key, unless the answer was idempotency_key_reused. |
Changelog
| Version | Date | Changes |
|---|---|---|
| v1 | 14 September 2026 | First release: GET /account, POST /quotes, POST /bookings, 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; - the error
codeandmessage.