DevelopersCustomer API v1

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.

Start with the booking flow Browse the endpoints

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.

RegionBase URLTime zoneregion in GET /account
Sydney (NSW)https://portal.directtransport.com.au/api/v1Australia/Sydneysydney
Melbourne (VIC)https://melbourne.directtransport.com.au/api/v1Australia/Melbournemelbourne
Queensland (QLD)https://queensland.directtransport.com.au/api/v1Australia/Brisbanequeensland
  • 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) or wrong_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 /quotes means POST 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 keyTest key
Starts withdts_live_dts_test_
PricesReal prices for your accountReal prices for your account
BookingsReal jobs, handled by DTS like bookings made on the portalSaved as test bookings with job numbers like TEST-DTS12345. No driver is sent and no email goes to DTS.
ProgressUpdates as the job movesSimulated: allocated after 5 minutes, picked up after 15, delivered after 30
Bookings it can readYour account's live bookings in the region, including bookings made on the portalYour account's test bookings only
Daily limitsFor example 20,000 requests, 2,000 price checks and 500 bookings a dayLower: for example 5,000 requests, 200 price checks and 200 bookings a day
X-DTS-Mode headerlivetest

A key is dts_live_ or dts_test_ followed by 40 letters and digits. For example (this isn't a real key):

dts_test_EXAMPLEexampleEXAMPLEexampleEXAMPLE01234

Build 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

Anyone who has your key can book jobs on your account.
  • 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 by GET /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:

HeaderFormat
AuthorizationAuthorization: Bearer <your key> (preferred)
X-API-KeyX-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

CodeHTTP statusMeaning and what to do
missing_api_key401No key was sent. Send it in Authorization: Bearer <your key> or X-API-Key.
invalid_api_key401The key is mistyped, isn't a DTS key, or wasn't issued for this region. Check the key and the base URL.
key_revoked401The key has been revoked. Ask DTS for a new one.
key_suspended403The key is paused, usually because it went over a daily limit. Ask DTS to allow it again.
account_not_allowed403The 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_region403The 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 with unsupported_media_type (415). A body that isn't valid JSON gets invalid_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 on GET /bookings and the label and invoice endpoints.
  • Types are strict. Send numbers as JSON numbers (12.5, not "12.5") and yes/no values as true or false.
  • 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 the requests package.

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"
}
  • code doesn't change, so build your error handling on it. Every code is listed under errors.
  • message is written for people and may change. It usually says exactly what to fix, so log it and show it to your staff.
  • details is only there for some errors. For invalid_request it's a list of field and message pairs. field is the path to the field, counting items from 0 (items.0.weightKg is the first item's weight), or null when 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

HeaderSentMeaning
X-Request-IdEvery API responseThe request's id: req_ and 32 hexadecimal characters. JSON bodies also carry it as requestId. Quote it when you contact DTS.
X-DTS-ModeOnce the key is acceptedlive or test.
X-RateLimit-LimitOnce the key is acceptedRequests allowed per minute.
X-RateLimit-RemainingOnce the key is acceptedRequests left in the current minute.
X-RateLimit-ResetOnce the key is acceptedSeconds until the current minute ends and the count starts again (1 to 60).
Retry-After429 responsesSeconds to wait before trying again.
Location201 from POST /bookingsThe new booking's path, for example /api/v1/bookings/DTS12345.
Idempotent-ReplayedReplayed booking answerstrue when the answer repeats a booking already made with the same Idempotency-Key. See idempotency.
Cache-ControlEvery API responseno-store. Don't cache API answers.
Content-TypeEvery API responseapplication/json, or application/pdf for labels and invoices.
Content-DispositionLabels and invoicesFor 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/Sydney for Sydney, Australia/Melbourne for Melbourne (the same clock as Sydney) and Australia/Brisbane for Queensland, which has no daylight saving time. Responses repeat the zone in readyAt.timeZone.
  • Dates are YYYY-MM-DD, for example 2026-09-15. Times are 24-hour HH:mm, for example 09:30 or 17:45.
  • Moments such as createdAt and the progress times are ISO 8601 in UTC, for example 2026-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.

FieldTypeMeaning
currencystringAlways AUD.
basenumberThe price of the job for this service.
serviceChargenumberService charge.
tailgatenumberTailgate, when you asked for it; otherwise 0.
hiabnumberHIAB, when you asked for it; otherwise 0.
insurancenumberFreight insurance, when you asked for it; otherwise 0.
waitTimenumberWaiting time charges. 0 when you book; added later if the driver records waiting time at pickup or delivery.
tollsnumberTolls on the route.
totalExGstnumberTotal before GST.
gstnumberGST. Tolls don't have GST added.
totalnumberTotal 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.

  1. Price the jobPOST /quotes with the addresses, the ready date and time, and the items. You get a price for each service.
  2. Choose a servicePick an option with "bookable": true. The other options say why they can't be booked.
  3. Book itPOST /bookings with the same job, the service, contact and references, an Idempotency-Key and expectedTotal.
  4. Track itGET /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": true can be booked. In the example, that's Standard, Express and Direct.
  • An option with "bookable": false has a reason code and a message you 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 as expectedTotal when you book.
  • Check pickup.precision and delivery.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": false with the reason address_not_precise, and its message names the address. The prices are still shown. Send a full street address, or add its location, 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.65

Python

options = quote["data"]["options"]
option = next((o for o in options if o["bookable"] and o["service"] == "Standard"), None)
if option is None:
    # Show why each service can't be booked, and let the person booking choose again.
    for o in options:
        print(o["service"], o["bookable"], o.get("reason", ""), o.get("message", ""))
    raise SystemExit("Standard can't be booked for this job.")
expected_total = option["price"]["total"]  # 157.65
A quote isn't held

The price is worked out again when you book. If it has changed by then, nothing is booked and the API tells you the new price (see step 3).

Step 3: Book the job

Send POST /bookings with:

  • the same job details you priced: bookingType, readyAt, the pickup and delivery addresses (and location, if you sent it), items or jobCode, and tailgate, hiab and insurance;
  • the service you 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 own internalReference and internalReference2;
  • expectedTotal, the chosen option's price.total (required);
  • an Idempotency-Key header 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"
}
The price is always worked out again when you book

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

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.

MethodPathWhat it does
GET/accountYour key, account, limits and today's usage.
POST/quotesPrices a job for each service. Nothing is booked.
POST/bookingsBooks a job at the price you were shown.
GET/bookingsLists your bookings by ready date.
GET/bookings/{jobNumber}One booking, with its progress, driver and whether proof of delivery is available.
GET/bookings/{jobNumber}/labelThe shipping label as a PDF.
GET/bookings/{jobNumber}/invoiceThe tax invoice as a PDF.
GET/bookings/{jobNumber}/podProof 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.

URL
https://portal.directtransport.com.au/api/v1/account
Parameters
None
Success
200

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

FieldTypeMeaning
key.idstringThe key's id, key_ and 16 letters and digits. It isn't the key itself.
key.modestringlive or test.
key.prefixstringThe start of the key: dts_live_ or dts_test_ and its first 6 characters. Use it to tell DTS which key you mean.
key.labelstring or nullThe key's name, given by DTS.
key.createdAtstring or nullWhen the key was created (ISO 8601, UTC).
account.emailstringThe email address of the account the key books for.
account.namestringThe account's name.
regionstringsydney, melbourne or queensland.
limitsobjectThe limits for this key: requestsPerMinute and the daily limits. DTS can change them for your key. See limits and usage.
usageTodayobjectWhat 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.

URL
https://portal.directtransport.com.au/api/v1/quotes
Headers
Authorization, Content-Type: application/json
Success
200
Uses
1 price check and 3 Google Maps calls however many services are priced, plus toll lookups (see limits and usage)

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.

FieldTypeRequiredRules
bookingTypestringYessame_day, next_day or three_four_day. See services by booking type.
readyAtobjectNoWhen 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.datestringYes, in readyAtYYYY-MM-DD. Today or later, and at most 90 days ahead.
readyAt.timestringYes, in readyAtHH: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.
servicesarray of stringsNo1 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.
pickupobjectYesWhere the job is picked up.
pickup.addressstringYes3 to 250 characters. A full street address in Australia. See addresses.
pickup.locationobjectNoThe address's point, as lat (from -45 to -9) and lng (from 111 to 155). Used instead of the point found for the address.
deliveryobjectYesWhere the job is delivered.
delivery.addressstringYesAs for pickup.address.
delivery.locationobjectNoAs for pickup.location.
itemsarray of objectsYes, unless you send jobCode1 to 50 items. Send either items or jobCode, not both.
items[].typestringYesOne of the item types, spelt exactly as listed.
items[].quantityintegerYes1 to 500.
items[].weightKgnumberYesWeight of one item in kilograms. More than 0, at most 50,000.
items[].lengthCmnumberYesLength of one item in centimetres. More than 0, at most 3,000.
items[].widthCmnumberYesWidth of one item in centimetres. More than 0, at most 1,000.
items[].heightCmnumberYesHeight of one item in centimetres. More than 0, at most 1,000.
items[].stackablebooleanNoDefault false. Only Ladder, Box and Crate items can be stackable.
jobCodestringYes, unless you send itemsPrice 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.
tailgatebooleanNoDefault false. Can't be true together with hiab.
hiabbooleanNoDefault false. Can't be true together with tailgate.
insurancebooleanNoDefault false. Freight insurance.

How it works

  1. Checks the body, the ready date and time, and what your account can use: job codes, and whether tailgate, HIAB and insurance are available.
  2. Looks up both addresses in Australia and works out the driving distance.
  3. 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.
  4. 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

FieldTypeMeaning
bookingTypestringAs sent.
readyAtobjectThe 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.
serviceAreastringmetro 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, deliveryobjectEach address as it was found (the fields below).
pickup.addressstringThe address as found, for example 25 Sample Rd, Botany NSW 2019, Australia.
pickup.suburb, pickup.state, pickup.postcodestring or nullParts of the address as found. state is short, for example NSW.
pickup.locationobjectlat and lng used for pricing: the location you sent, or the point found for the address.
pickup.precisionstringexact or approximate. If either address is approximate, the options can't be booked (reason address_not_precise).
pickup.insideMetroAreabooleanWhether the address is inside the metro service area.
distanceKmnumberThe driving distance in kilometres.
optionsarrayOne entry for each service priced, in the order of services, or Standard, Express, Direct, After Hours, Weekend Deliveries.
options[].servicestringThe service.
options[].bookablebooleanWhether this service can be booked for the ready time. Only true options can be booked.
options[].reasonstringOnly when bookable is false. See option reasons.
options[].messagestringOnly when bookable is false. Why, in words you can show.
options[].contactEmailstringOnly for custom_quote_required and cannot_price_online: who to contact to arrange the job.
options[].jobCodestring or nullThe job code the job was priced as, for example 1T.
options[].vehiclestring or nullThe vehicle the job needs: Courier, HT, 1T, 2T, 4T, 6T, 8T, 10T, 12T, 14T or 16T.
options[].priceobjectThe 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.

reasonMeaningBooking it anyway gets
address_not_preciseThe 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_hoursMetro Same Day: the ready time (or day) is outside the service's hours. See service hours.service_not_available (422)
booking_cutoff_passedThe 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_offeredAfter Hours and Weekend Deliveries aren't offered for regional and country jobs.service_not_available (422)
custom_quote_requiredThe load needs a custom quote, for example because it's outside standard vehicle limits. Contact contactEmail.cannot_price_online (422)
cannot_price_onlineThe 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_failedThe 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

CodeHTTP statusWhen
invalid_request400A 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_type415The body wasn't sent as application/json.
payload_too_large413The body is over 100 KB.
feature_not_enabled403You sent jobCode, but job codes aren't enabled for your account. Send items instead.
service_not_available422Tailgate, HIAB or freight insurance isn't available. details names the field (tailgate, hiab or insurance).
address_not_found422The 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_route422There's no driving route between the two addresses.
regional_not_available422The job is regional or country, and those can't be booked online in this region at the moment. Contact DTS.
daily_limit_reached403Pricing would take the key over its daily price checks, Google Maps calls or toll lookups. The key is paused.
service_unavailable503Address 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.

URL
https://portal.directtransport.com.au/api/v1/bookings
Headers
Authorization, Content-Type: application/json, Idempotency-Key (required)
Success
201, with the booking's path in Location
Uses
1 booking, 1 price check and 3 Google Maps calls, plus toll lookups

Headers

HeaderRequiredRules
Idempotency-KeyYes1 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

FieldTypeRequiredRules
bookingTypestringYessame_day, next_day or three_four_day.
readyAtobjectYesWhen the job will be ready, in the region's time zone. See ready date and time.
readyAt.datestringYesYYYY-MM-DD. Today or later, and at most 90 days ahead.
readyAt.timestringYesHH: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.
servicestringYesA 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.
pickupobjectYesWhere the job is picked up.
pickup.addressstringYes3 to 250 characters. The address you priced.
pickup.locationobjectNolat (from -45 to -9) and lng (from 111 to 155). Send it if you sent it when pricing.
pickup.companyNamestringNoUp to 120 characters.
pickup.phonestringNo*Up to 40 characters.
pickup.referencestringNo*Up to 200 characters.
pickup.instructionsstringNo*Up to 500 characters.
deliveryobjectYesWhere the job is delivered.
delivery.addressstringYes3 to 250 characters. The address you priced.
delivery.locationobjectNoAs for pickup.location.
delivery.companyNamestringNoUp to 120 characters.
delivery.phonestringNo*Up to 40 characters.
delivery.referencestringNo*Up to 200 characters.
delivery.instructionsstringNo*Up to 500 characters.
itemsarray of objectsYes, unless you send jobCode1 to 50 items, with the same fields and rules as POST /quotes.
jobCodestringYes, unless you send itemsAs for POST /quotes.
tailgatebooleanNoDefault false. Can't be true together with hiab.
hiabbooleanNoDefault false. Can't be true together with tailgate.
insurancebooleanNoDefault false. Freight insurance.
contactstringYes1 to 120 characters. The name of the person to contact about the job.
internalReferencestringNoUp to 120 characters. Your own reference, such as an order number.
internalReference2stringNoUp to 120 characters. A second reference of your own.
expectedTotalnumberYes0 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

  1. Checks the body, and the fields your account requires.
  2. Checks the Idempotency-Key. If a booking was already made with it, a retry gets that booking back, and nothing is booked twice.
  3. Prices the chosen service exactly as POST /quotes does, with the same checks on the ready date and time, your account and the addresses.
  4. 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't expectedTotal (price_changed).
  5. 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

CodeHTTP statusWhen
invalid_request400A 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_required400There's no Idempotency-Key header.
unsupported_media_type415The body wasn't sent as application/json.
payload_too_large413The body is over 100 KB.
feature_not_enabled403You sent jobCode, but job codes aren't enabled for your account.
price_changed409The 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_progress409The same request with this Idempotency-Key is still being worked on. Wait a moment, then retry.
idempotency_key_reused422This 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_found422The pickup or delivery address couldn't be found. Check the street address, suburb and postcode.
address_not_precise422An address was only found approximately, so a driver can't be sent to it. details names pickup.address or delivery.address.
no_route422There's no driving route between the two addresses.
service_not_available422The 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_online422The 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_available422Regional and country jobs can't be booked online in this region at the moment.
daily_limit_reached403The booking would take the key over a daily limit (bookings, price checks, Google Maps calls or toll lookups). The key is paused.
service_unavailable503Address 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.

FieldTypeMeaning
jobNumberstringThe job number, for example DTS12345, or TEST-DTS12345 for a test booking.
testModebooleantrue for test bookings.
statusstringWhere the job is up to. See statuses.
bookingTypestringsame_day, next_day or three_four_day. Bookings DTS made for you can have other types, such as interstate.
servicestringThe service booked.
readyAtobjectdate (YYYY-MM-DD), time (HH:mm) and timeZone. date or time can be null on some older bookings.
createdAtstring or nullWhen the job was booked (ISO 8601, UTC).
contactstringThe contact name.
internalReference, internalReference2stringYour references. Empty ("") when not given.
pickup, deliveryobjectEach address with the fields below.
pickup.companyNamestringCompany name, or "".
pickup.addressstringThe address as found.
pickup.suburbstring or nullThe suburb.
pickup.referencestringReference, or "".
pickup.phonestringPhone number, or "".
pickup.instructionsstringInstructions, or "".
pickup.locationobject or nulllat and lng.
itemsarrayEach item's type, quantity, weightKg, lengthCm, widthCm, heightCm and stackable. Empty for jobs booked by job code.
jobCodestring or nullThe job code the job was priced as.
vehiclestring or nullThe vehicle the job needs.
tailgate, hiab, insurancebooleanWhether each was booked.
distanceKmnumber or nullThe driving distance in kilometres.
priceobjectThe 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.

URL
https://portal.directtransport.com.au/api/v1/bookings?date=2026-09-15
Success
200

Query parameters

ParameterTypeRequiredRules
datestringEither date, or from and toA ready date, YYYY-MM-DD. Lists that day's bookings. Can't be sent with from or to.
fromstringWith toThe first ready date, YYYY-MM-DD.
tostringWith fromThe last ready date, YYYY-MM-DD. from must be on or before to, and together they can cover at most 31 days, both included.
limitintegerNoBookings per page, 1 to 100. Default 25.
cursorstringNonextCursor 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

FieldTypeMeaning
bookingsarrayBooking objects. For a booking's progress and driver, use GET /bookings/{jobNumber}.
nextCursorstring or nullSend 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

CodeHTTP statusWhen
invalid_request400No 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.

URL
https://portal.directtransport.com.au/api/v1/bookings/DTS12345
Success
200

Path parameters

ParameterTypeRequiredRules
jobNumberstringYesThe 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:

FieldTypeMeaning
progressobjectWhen 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.bookedAtstring or nullWhen the job was booked (the same as createdAt).
progress.allocatedAtstring or nullWhen it was allocated to a driver.
progress.driverAcceptedAtstring or nullWhen the driver accepted it.
progress.pickedUpAtstring or nullWhen it was picked up.
progress.deliveredAtstring or nullWhen it was delivered.
progress.futileAtstring or nullWhen it was marked futile.
progress.returnedAtstring or nullWhen it was returned.
progress.cancelledAtstring or nullWhen it was cancelled.
driverobject or nullname: the driver's name, once a driver is allocated. null while the job is pending, or once it's cancelled.
proofOfDeliveryobjectavailable: 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

CodeHTTP statusWhen
not_found404There'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.

URL
https://portal.directtransport.com.au/api/v1/bookings/DTS12345/label?paper=A4
Success
200, with Content-Type: application/pdf
Uses
1 document

Query parameters

ParameterTypeRequiredRules
paperstringNoThe 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.pdf

JavaScript

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-store

Errors

CodeHTTP statusWhen
invalid_request400paper isn't one of the sizes, or a query parameter isn't known.
not_found404There's no booking with this job number that the key can see.
daily_limit_reached403The 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.

URL
https://portal.directtransport.com.au/api/v1/bookings/DTS12345/invoice
Parameters
None. Any query parameter is refused.
Success
200, with Content-Type: application/pdf
Uses
1 document

Example request

curl

curl https://portal.directtransport.com.au/api/v1/bookings/DTS12345/invoice \
  -H "Authorization: Bearer $DTS_API_KEY" \
  -o DTS12345-invoice.pdf

JavaScript

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-store

Errors

CodeHTTP statusWhen
invalid_request400A query parameter was sent.
not_found404There's no booking with this job number that the key can see.
daily_limit_reached403The 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.

URL
https://portal.directtransport.com.au/api/v1/bookings/DTS12345/pod
Success
200

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

FieldTypeMeaning
jobNumberstringThe job number.
statusstringThe booking's status.
availablebooleanWhether there's any proof of delivery yet.
deliveredAtstring or nullWhen the job was delivered (ISO 8601, UTC).
receiverNamestring or nullThe name of the person who received the delivery.
signatureUrlstring or nullLink to the signature image.
deliveryPhotosarray of stringsLinks to photos taken at delivery.
pickupPhotosarray of stringsLinks to photos taken at pickup.
pickupDocumentsarray of objectsDocuments from pickup, each with url and fileName (string or null).
podPdfUrlstring or nullLink 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

CodeHTTP statusWhen
feature_not_enabled403Proof of delivery isn't enabled for your account (message Proof of delivery isn't enabled for this account.).
not_found404There'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):

  • Aluminum
  • Bags
  • Box
  • Coil
  • Conduit
  • Crate
  • Drum
  • Envelope
  • Hoses
  • Ladder
  • Pail
  • Pallet
  • Pipes
  • Rack
  • Rolls
  • Satchel
  • Skid
  • Steel
  • Timber
  • Tubes
  • Tyres
FieldRule
items1 to 50 items.
items[].quantityA whole number from 1 to 500.
items[].weightKgMore than 0 and at most 50,000. The weight of one item, in kilograms.
items[].lengthCmMore than 0 and at most 3,000. The length of one item, in centimetres.
items[].widthCmMore than 0 and at most 1,000. The width of one item, in centimetres.
items[].heightCmMore than 0 and at most 1,000. The height of one item, in centimetres.
items[].stackabletrue 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

FieldAsks forPrice shown inWhen it isn't available
tailgateA tailgateprice.tailgateTailgate isn't available.
hiabA HIABprice.hiabHIAB isn't available.
insuranceFreight insuranceprice.insuranceFreight insurance isn't available.
  • Each one is false unless you send true.
  • tailgate and hiab can't both be true (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), with details naming the field.
  • insurance is 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 gets service_not_available (422) with Freight 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, 14T and 16T.
  • LD is only for same_day jobs (LD is only for same_day jobs.).
  • Send one of items and jobCode. Sending neither gets Send items (what is being sent), or a jobCode. and sending both gets Send either items or a jobCode, not both.
  • If job codes aren't turned on for your account, jobCode gets feature_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 items list.

Services by booking type

bookingTypeServices offered
same_dayStandard, Express, Direct, After Hours, Weekend Deliveries
next_dayStandard
three_four_dayStandard

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:

ServiceReady todayReady on a later day
Standard7:00 AM to 3:00 PMNo later than 3:00 PM
Express7:00 AM to 4:00 PMNo later than 4:00 PM
Direct6:00 AM to 5:00 PM6:00 AM to 5:00 PM
After HoursBefore 7:00 AM or after 5:00 PMBefore 7:00 AM or after 5:00 PM
Weekend DeliveriesA Saturday or Sunday, any timeA 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.

JobReady today: book byReady on a later day
same_day Standard12:00 PMNo cutoff
same_day Express2:00 PMNo cutoff
same_day Direct5:00 PMNo cutoff
same_day After Hours, Weekend DeliveriesNot offered (service_not_offered)Not offered
next_day StandardNo cutoffNo cutoff
three_four_day Standard5:00 PMNo 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

  • readyAt has a date (YYYY-MM-DD) and a time (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's readyAt shows 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 readyAt shows 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, with details naming readyAt, readyAt.date or readyAt.time.
Pricing for now, booking later

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.
  • precision is exact when the address was found precisely enough to send a driver to (such as a street address, building or business), and approximate when 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 is approximate, a quote still prices the job, but every option that was priced has "bookable": false with the reason address_not_precise, and a booking is refused with the address_not_precise error (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 location with lat and lng. 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 as exact.
  • An address that can't be found gets address_not_found (422), with a message such as We couldn't find the pickup address. Check the street address, suburb and postcode. Correct the address and send the request again. details names 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.

FieldNamed in the message as
pickup.referencea pickup reference
pickup.phonea pickup phone number
pickup.instructionspickup instructions
delivery.referencea delivery reference
delivery.phonea delivery phone number
delivery.instructionsdelivery 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

statusMeaning
pendingBooked, and not yet allocated to a driver.
allocatedAllocated to a driver.
picked_upPicked up.
deliveredDelivered.
returnedReturned.
futileMarked futile: the job couldn't be completed as booked.
cancelledCancelled.
  • A job usually goes pending, allocated, picked_up, delivered.
  • A job taken off its driver goes back to pending, and its allocatedAt and driverAcceptedAt go back to null.
  • 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}/pod gets feature_not_enabled (403).
  • proofOfDelivery.available in GET /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 receiverName Test Receiver and no signature, photos, documents or PDF.

Documents

Every booking the key can see, including test bookings, has two PDFs:

Label sizes

paperSize
LABEL_4X64.00" x 6.00" (100 mm x 150 mm)
LABEL_4X654.00" x 6.50" (102 mm x 165 mm). The default.
LABEL_4X6754.00" x 6.75" (102 mm x 172 mm)
LABEL_4X84.00" x 8.00" (100 mm x 200 mm)
A6A6 (105 mm x 148 mm)
A5A5 (148 mm x 210 mm)
A4A4 (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/pdf and Content-Disposition: inline, with a file name such as DTS12345-label.pdf or DTS12345-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 /bookings checks everything a live booking checks, then saves a test booking: its job number is TEST- followed by a DTS number (for example TEST-DTS12345) and testMode is true. 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 bookingstatusprogress times setAlso
0 minutespendingbookedAtdriver is null
5 minutesallocatedallocatedAt, driverAcceptedAtdriver is { "name": "Test Driver" }
15 minutespicked_uppickedUpAt
30 minutesdelivereddeliveredAtproofOfDelivery.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 }
}
Test bookings aren't real jobs

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-Remaining and X-RateLimit-Reset (the seconds until the minute ends).
  • A request over the limit gets rate_limited (429) with a Retry-After header: 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).

LimitWhat countsLive keyTest key
requestsPerMinuteRequests in one minute (see above).6060
requestsPerDayRequests, apart from those turned away with 429.20,0005,000
priceChecksPerDayPrice checks: each POST /quotes or POST /bookings that gets as far as looking up the addresses.2,000200
bookingsPerDayBookings made.500200
documentsPerDayLabels and invoices.2,000500
googleCallsPerDayGoogle Maps calls: 3 for each price check (two address lookups and the driving distance).6,000600
tollCallsPerDayToll lookups made while pricing.2,000200
databaseReadsPerDayRecords read to answer your requests: about 4 for a simple request, and more for lists.250,000250,000
databaseWritesPerDayRecords written to answer your requests: about 3 or 4 for a request.100,000100,000
rateLimitedPerDayRequests turned away with 429.1,0001,000

When a daily limit is reached

Going over a daily limit pauses the key
  • A request that would take any daily count over its limit is refused with daily_limit_reached (403), and the key is paused. details says 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 usageToday in GET /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 sendWhat happens
The same Idempotency-Key and the same request, after a booking was made with itYou 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 itidempotency_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 bookedThe 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 onrequest_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 onidempotency_key_reused (422).
No Idempotency-Keyidempotency_key_required (400).
A value that isn't 1 to 255 visible charactersinvalid_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_found and cannot_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.
After a refusal, send the corrected booking with the same key

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.

  1. Ask for the first page with date, or from and to, and limit if you want a page size other than 25 (up to 100).
  2. If nextCursor isn't null, ask for the next page with cursor set to it. You can leave out date, from and to, or send the same ones again; limit can change.
  3. Stop when nextCursor is null. 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.

CodeHTTP statusMeaning and when it happens
missing_api_key401No API key was sent. Send Authorization: Bearer <your key> or X-API-Key.
invalid_api_key401The key isn't valid: it's mistyped, isn't a DTS key, or wasn't issued for this region.
key_revoked401The key has been revoked. Ask DTS for a new one.
key_suspended403The key is paused, for example after going over a daily limit. Ask DTS to allow it again.
account_not_allowed403The key's account can't use the API, for example because it's no longer a business account or it has been archived.
wrong_region403The key belongs to another region. Use the base URL of the region that issued it.
rate_limited429Too many requests this minute. Wait for the seconds in Retry-After, then try again.
daily_limit_reached403The 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_request400The 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_type415The body wasn't sent as JSON with Content-Type: application/json.
payload_too_large413The request body is over 100 KB.
idempotency_key_required400POST /bookings was sent without an Idempotency-Key header.
feature_not_enabled403Something isn't turned on for your account: job codes (jobCode) or proof of delivery.
not_found404The 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_allowed405The path doesn't accept this HTTP method, for example DELETE /bookings. This answer can come without a JSON body.
address_not_found422The pickup or delivery address couldn't be found. Check the street address, suburb and postcode. details names the field.
no_route422There's no driving route between the pickup and delivery addresses.
service_not_available422Tailgate, 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_available422Regional and country jobs can't be booked online in this region at the moment.
address_not_precise422When 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_changed409When booking: the total worked out now isn't expectedTotal. Nothing was booked. details has your expectedTotal and the new price.
idempotency_key_reused422This 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_progress409The same request with this Idempotency-Key is still being worked on. Wait a moment, then retry.
cannot_price_online422When booking: the job needs a custom quote or can't be priced online. details has the reason and a contactEmail.
internal_error500Something went wrong on DTS's side. Try again, and if it keeps happening, contact DTS with the requestId.
service_unavailable503The API, address lookups or pricing are unavailable for the moment. Try again shortly.

When to retry

AnswerRetry?
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 errorsYes, 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

VersionDateChanges
v114 September 2026First 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 (or X-Request-Id), the endpoint and the time;
  • the error code and message.