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": "pickup.suburb", "message": "pickup.suburb isn't a field this request takes." }
    ]
  },
  "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 a body that isn't a JSON object. Each field the request doesn't take gets its own entry. Other errors' details are described with each error.
  • A response may not list every problem at once. Checks that compare fields (such as tailgate with HIAB) only run once no field is missing, of the wrong type, or outside its list of allowed values.

Response headers

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-ReplayedBooking answers sent againtrue when the answer repeats a booking already made from the same quote. See retrying a booking.
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. A booking from a quote is only made at its option's total.
{
  "currency": "AUD",
  "base": 95,
  "serviceCharge": 9.5,
  "tailgate": 35,
  "hiab": 0,
  "insurance": 0,
  "waitTime": 0,
  "tolls": 4.2,
  "totalExGst": 143.7,
  "gst": 13.95,
  "total": 157.65
}

Booking flow guide

Booking through the API follows the same steps as Place the Booking on the portal: price the job, choose a service, book it at that price, then track it.

  1. Price the jobPOST /quotes with the addresses, the ready date and time, and the items. You get a price for each service, and a quoteId.
  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 quoteId, the service you chose, and the contact and references.
  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: the answer prices each service and gives a quoteId to book from.

This example is a Same Day job in Sydney, ready at 9:30 AM on Tuesday 15 September 2026: two pallets and six boxes, with a tailgate.

curl

curl https://portal.directtransport.com.au/api/v1/quotes \
  -H "Authorization: Bearer $DTS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "bookingType": "same_day",
    "readyAt": { "date": "2026-09-15", "time": "09:30" },
    "pickup": { "address": "Unit 4, 10 Example Street, Silverwater NSW 2128" },
    "delivery": { "address": "25 Sample Road, Botany NSW 2019" },
    "items": [
      { "type": "Pallet", "quantity": 2, "weightKg": 180, "lengthCm": 120, "widthCm": 120, "heightCm": 110 },
      { "type": "Box", "quantity": 6, "weightKg": 12.5, "lengthCm": 40, "widthCm": 30, "heightCm": 30, "stackable": true }
    ],
    "tailgate": true
  }'

JavaScript

const job = {
  bookingType: "same_day",
  readyAt: { date: "2026-09-15", time: "09:30" },
  pickup: { address: "Unit 4, 10 Example Street, Silverwater NSW 2128" },
  delivery: { address: "25 Sample Road, Botany NSW 2019" },
  items: [
    { type: "Pallet", quantity: 2, weightKg: 180, lengthCm: 120, widthCm: 120, heightCm: 110 },
    { type: "Box", quantity: 6, weightKg: 12.5, lengthCm: 40, widthCm: 30, heightCm: 30, stackable: true },
  ],
  tailgate: true,
};

const response = await fetch("https://portal.directtransport.com.au/api/v1/quotes", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.DTS_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify(job),
});
const quote = await response.json();

Python

import os
import requests

job = {
    "bookingType": "same_day",
    "readyAt": {"date": "2026-09-15", "time": "09:30"},
    "pickup": {"address": "Unit 4, 10 Example Street, Silverwater NSW 2128"},
    "delivery": {"address": "25 Sample Road, Botany NSW 2019"},
    "items": [
        {"type": "Pallet", "quantity": 2, "weightKg": 180, "lengthCm": 120, "widthCm": 120, "heightCm": 110},
        {"type": "Box", "quantity": 6, "weightKg": 12.5, "lengthCm": 40, "widthCm": 30, "heightCm": 30, "stackable": True},
    ],
    "tailgate": True,
}

response = requests.post(
    "https://portal.directtransport.com.au/api/v1/quotes",
    headers={"Authorization": f"Bearer {os.environ['DTS_API_KEY']}"},
    json=job,
    timeout=60,
)
quote = response.json()

200 OK

{
  "data": {
    "quoteId": "qt_7c1e9a4b2d6f48a0b3c5d7e9f1a2b4c6",
    "expiresAt": "2026-09-14T06:32:47.519Z",
    "bookingType": "same_day",
    "readyAt": { "date": "2026-09-15", "time": "09:30", "timeZone": "Australia/Sydney" },
    "serviceArea": "metro",
    "pickup": {
      "address": "Unit 4/10 Example St, Silverwater NSW 2128, Australia",
      "suburb": "Silverwater",
      "state": "NSW",
      "postcode": "2128",
      "location": { "lat": -33.8352, "lng": 151.0473 },
      "precision": "exact",
      "insideMetroArea": true
    },
    "delivery": {
      "address": "25 Sample Rd, Botany NSW 2019, Australia",
      "suburb": "Botany",
      "state": "NSW",
      "postcode": "2019",
      "location": { "lat": -33.9461, "lng": 151.1965 },
      "precision": "exact",
      "insideMetroArea": true
    },
    "distanceKm": 24.6,
    "options": [
      {
        "service": "Standard",
        "bookable": true,
        "jobCode": "1T",
        "vehicle": "1T",
        "price": { "currency": "AUD", "base": 95, "serviceCharge": 9.5, "tailgate": 35, "hiab": 0, "insurance": 0, "waitTime": 0, "tolls": 4.2, "totalExGst": 143.7, "gst": 13.95, "total": 157.65 }
      },
      {
        "service": "Express",
        "bookable": true,
        "jobCode": "1T",
        "vehicle": "1T",
        "price": { "currency": "AUD", "base": 125, "serviceCharge": 12.5, "tailgate": 35, "hiab": 0, "insurance": 0, "waitTime": 0, "tolls": 4.2, "totalExGst": 176.7, "gst": 17.25, "total": 193.95 }
      },
      {
        "service": "Direct",
        "bookable": true,
        "jobCode": "1T",
        "vehicle": "1T",
        "price": { "currency": "AUD", "base": 160, "serviceCharge": 16, "tailgate": 35, "hiab": 0, "insurance": 0, "waitTime": 0, "tolls": 4.2, "totalExGst": 215.2, "gst": 21.1, "total": 236.3 }
      },
      {
        "service": "After Hours",
        "bookable": false,
        "reason": "outside_service_hours",
        "message": "After Hours jobs need a ready time before 7:00 AM or after 5:00 PM.",
        "jobCode": "1T",
        "vehicle": "1T",
        "price": { "currency": "AUD", "base": 210, "serviceCharge": 21, "tailgate": 35, "hiab": 0, "insurance": 0, "waitTime": 0, "tolls": 4.2, "totalExGst": 270.2, "gst": 26.6, "total": 296.8 }
      },
      {
        "service": "Weekend Deliveries",
        "bookable": false,
        "reason": "outside_service_hours",
        "message": "Weekend Deliveries need a ready date on a Saturday or Sunday.",
        "jobCode": "1T",
        "vehicle": "1T",
        "price": { "currency": "AUD", "base": 230, "serviceCharge": 23, "tailgate": 35, "hiab": 0, "insurance": 0, "waitTime": 0, "tolls": 4.2, "totalExGst": 292.2, "gst": 28.8, "total": 321 }
      }
    ]
  },
  "requestId": "req_3b9e1f7c5a2d4e6f8a1b3c5d7e9f0a2b"
}

Step 2: Choose a service

Each entry in options is one service.

  • Only options with "bookable": 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 quote's quoteId and the chosen option's service: you book with them in step 3. The quote can be booked until its expiresAt (see how long a quote can be booked).
  • 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.");
}
console.log(`Standard costs $${option.price.total.toFixed(2)}. Book it by ${quote.data.expiresAt}.`);

Python

options = quote["data"]["options"]
option = next((o for o in options if o["bookable"] and o["service"] == "Standard"), None)
if option is None:
    # Show why each service can't be booked, and let the person booking choose again.
    for o in options:
        print(o["service"], o["bookable"], o.get("reason", ""), o.get("message", ""))
    raise SystemExit("Standard can't be booked for this job.")
print(f"Standard costs ${option['price']['total']:.2f}. Book it by {quote['data']['expiresAt']}.")
Book within 30 minutes

A quote can be booked until its expiresAt: 30 minutes after it was made, or 5 minutes after its ready time if that's sooner. The price is worked out again when you book. If it has changed by then, nothing is booked and the API tells you the new price (see step 3).

Step 3: Book the job

Send POST /bookings before the quote's expiresAt, with:

  • the quote's quoteId, and the service you chose from its options;
  • contact, the name of the person to contact about the job;
  • if you like, your own internalReference and internalReference2, and a company name, phone number, reference and instructions for the pickup and the delivery.

The addresses, items, ready time, tailgate, HIAB and insurance come from the quote, so the job is booked as it was priced. If the request times out or the connection drops, send the same booking again: a quote books one job, so it can't be booked twice (see retrying a booking).

curl

curl https://portal.directtransport.com.au/api/v1/bookings \
  -H "Authorization: Bearer $DTS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "quoteId": "qt_7c1e9a4b2d6f48a0b3c5d7e9f1a2b4c6",
    "service": "Standard",
    "contact": "Jamie Citizen",
    "internalReference": "ORDER-5501",
    "pickup": {
      "companyName": "Acme Freight",
      "phone": "02 5550 1234",
      "reference": "PO-1001",
      "instructions": "Loading dock at the rear."
    },
    "delivery": {
      "companyName": "Example Retail",
      "phone": "02 5550 5678",
      "reference": "INV-2002",
      "instructions": "Deliver to goods inwards."
    }
  }'

JavaScript

const response = await fetch("https://portal.directtransport.com.au/api/v1/bookings", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.DTS_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    quoteId: quote.data.quoteId, // from step 1
    service: option.service, // "Standard", chosen in step 2
    contact: "Jamie Citizen",
    internalReference: "ORDER-5501",
    pickup: {
      companyName: "Acme Freight",
      phone: "02 5550 1234",
      reference: "PO-1001",
      instructions: "Loading dock at the rear.",
    },
    delivery: {
      companyName: "Example Retail",
      phone: "02 5550 5678",
      reference: "INV-2002",
      instructions: "Deliver to goods inwards.",
    },
  }),
});
const booking = await response.json();

Python

booking_request = {
    "quoteId": quote["data"]["quoteId"],  # from step 1
    "service": option["service"],  # "Standard", chosen in step 2
    "contact": "Jamie Citizen",
    "internalReference": "ORDER-5501",
    "pickup": {
        "companyName": "Acme Freight",
        "phone": "02 5550 1234",
        "reference": "PO-1001",
        "instructions": "Loading dock at the rear.",
    },
    "delivery": {
        "companyName": "Example Retail",
        "phone": "02 5550 5678",
        "reference": "INV-2002",
        "instructions": "Deliver to goods inwards.",
    },
}

response = requests.post(
    "https://portal.directtransport.com.au/api/v1/bookings",
    headers={"Authorization": f"Bearer {os.environ['DTS_API_KEY']}"},
    json=booking_request,
    timeout=60,
)
booking = response.json()

The booking is made. The answer is 201 Created, with the new booking's path in Location and the booking in data:

HTTP/1.1 201 Created
Content-Type: application/json
Location: /api/v1/bookings/DTS12345
X-Request-Id: req_9c2d7e4f1a6b4c8d8e2f5a7b9c1d3e5f
X-DTS-Mode: live
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 57
X-RateLimit-Reset: 48
Cache-Control: no-store
{
  "data": {
    "jobNumber": "DTS12345",
    "testMode": false,
    "status": "pending",
    "bookingType": "same_day",
    "service": "Standard",
    "readyAt": { "date": "2026-09-15", "time": "09:30", "timeZone": "Australia/Sydney" },
    "createdAt": "2026-09-14T06:05:12.412Z",
    "contact": "Jamie Citizen",
    "internalReference": "ORDER-5501",
    "internalReference2": "",
    "pickup": {
      "companyName": "Acme Freight",
      "address": "Unit 4/10 Example St, Silverwater NSW 2128, Australia",
      "suburb": "Silverwater",
      "reference": "PO-1001",
      "phone": "02 5550 1234",
      "instructions": "Loading dock at the rear.",
      "location": { "lat": -33.8352, "lng": 151.0473 }
    },
    "delivery": {
      "companyName": "Example Retail",
      "address": "25 Sample Rd, Botany NSW 2019, Australia",
      "suburb": "Botany",
      "reference": "INV-2002",
      "phone": "02 5550 5678",
      "instructions": "Deliver to goods inwards.",
      "location": { "lat": -33.9461, "lng": 151.1965 }
    },
    "items": [
      { "type": "Pallet", "quantity": 2, "weightKg": 180, "lengthCm": 120, "widthCm": 120, "heightCm": 110, "stackable": false },
      { "type": "Box", "quantity": 6, "weightKg": 12.5, "lengthCm": 40, "widthCm": 30, "heightCm": 30, "stackable": true }
    ],
    "jobCode": "1T",
    "vehicle": "1T",
    "tailgate": true,
    "hiab": false,
    "insurance": false,
    "distanceKm": 24.6,
    "price": { "currency": "AUD", "base": 95, "serviceCharge": 9.5, "tailgate": 35, "hiab": 0, "insurance": 0, "waitTime": 0, "tolls": 4.2, "totalExGst": 143.7, "gst": 13.95, "total": 157.65 }
  },
  "requestId": "req_9c2d7e4f1a6b4c8d8e2f5a7b9c1d3e5f"
}
The price is always worked out again when you book

If the total is no longer the quote's price.total (to the cent), the API answers price_changed (409) with the new price in error.details.price, and nothing is booked. Get a new quote, show its total to the person booking or apply your own rule, then book from the new quote.

409 Conflict

{
  "error": {
    "code": "price_changed",
    "message": "The price for this job has changed since the quote: it is now $161.05, not $157.65. Get a new quote to book at the new price.",
    "details": {
      "quotedTotal": 157.65,
      "price": { "currency": "AUD", "base": 95, "serviceCharge": 9.5, "tailgate": 35, "hiab": 0, "insurance": 0, "waitTime": 0, "tolls": 7.6, "totalExGst": 147.1, "gst": 13.95, "total": 161.05 }
    }
  },
  "requestId": "req_4e6a8c0b2d4f4a6c8e0b2d4f6a8c0e2b"
}

A booking can also be refused because the quote has expired (quote_expired), an address was only found approximately (address_not_precise), the service can't be booked for the ready time (service_not_available), the job needs a custom quote or can't be priced online (cannot_price_online), or a field is missing, not valid or required by your account (invalid_request). Nothing is booked. See POST /bookings for every case, and retrying a booking for what to send next.

Step 4: Track the booking

To follow a job, check it every few minutes, and stop once it's delivered, returned, futile or cancelled. When you follow many jobs, one GET /bookings?date=… page uses one request of your limits instead of one per job.

Full example

The whole flow in one script: price a job, choose Standard, book it from the quote, get a new quote if the price has changed, and read the booking back.

JavaScript

// book-a-job.mjs: price, choose, book and track a job. Node.js 18 or later.
const BASE_URL = "https://portal.directtransport.com.au/api/v1";
const API_KEY = process.env.DTS_API_KEY; // keep keys on your server

async function api(method, path, body) {
  const response = await fetch(BASE_URL + path, {
    method,
    headers: {
      Authorization: `Bearer ${API_KEY}`,
      ...(body ? { "Content-Type": "application/json" } : {}),
    },
    body: body ? JSON.stringify(body) : undefined,
  });
  return { status: response.status, payload: await response.json() };
}

const job = {
  bookingType: "same_day",
  readyAt: { date: "2026-09-15", time: "09:30" },
  pickup: { address: "Unit 4, 10 Example Street, Silverwater NSW 2128" },
  delivery: { address: "25 Sample Road, Botany NSW 2019" },
  items: [{ type: "Pallet", quantity: 2, weightKg: 180, lengthCm: 120, widthCm: 120, heightCm: 110 }],
  tailgate: true,
};

// 1. Price the job, and 2. choose a service that can be booked.
async function quoteStandard() {
  const quote = await api("POST", "/quotes", job);
  if (quote.status !== 200) throw new Error(`${quote.payload.error.code}: ${quote.payload.error.message}`);
  const option = quote.payload.data.options.find((o) => o.bookable && o.service === "Standard");
  if (!option) throw new Error("Standard can't be booked for this job.");
  return { quoteId: quote.payload.data.quoteId, option };
}

// 3. Book it from the quote, which has the addresses, items and ready time.
// If a request times out, send the same booking again: a quote books one job, so it can't be booked twice.
function book(quoteId, service) {
  return api("POST", "/bookings", {
    quoteId,
    service,
    contact: "Jamie Citizen",
    internalReference: "ORDER-5501",
    pickup: { companyName: "Acme Freight", phone: "02 5550 1234" },
    delivery: { companyName: "Example Retail", phone: "02 5550 5678" },
  });
}

let { quoteId, option } = await quoteStandard();
let booking = await book(quoteId, option.service);
if (booking.status === 409 && booking.payload.error.code === "price_changed") {
  // Nothing was booked. Get a new quote at the new price. This script books it; your system may need a person to confirm it.
  ({ quoteId, option } = await quoteStandard());
  console.log(`The price is now $${option.price.total.toFixed(2)}.`);
  booking = await book(quoteId, option.service);
}
if (booking.status !== 201) throw new Error(`${booking.payload.error.code}: ${booking.payload.error.message}`);

const { jobNumber } = booking.payload.data;
console.log(`Booked ${jobNumber}`);

// 4. Track it.
const detail = await api("GET", `/bookings/${jobNumber}`);
console.log(detail.payload.data.status, detail.payload.data.progress);

Python

# book_a_job.py: price, choose, book and track a job. Needs the requests package.
import os

import requests

BASE_URL = "https://portal.directtransport.com.au/api/v1"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['DTS_API_KEY']}"  # keep keys on your server

job = {
    "bookingType": "same_day",
    "readyAt": {"date": "2026-09-15", "time": "09:30"},
    "pickup": {"address": "Unit 4, 10 Example Street, Silverwater NSW 2128"},
    "delivery": {"address": "25 Sample Road, Botany NSW 2019"},
    "items": [{"type": "Pallet", "quantity": 2, "weightKg": 180, "lengthCm": 120, "widthCm": 120, "heightCm": 110}],
    "tailgate": True,
}


def fail(response):
    error = response.json()["error"]
    raise SystemExit(f"{error['code']}: {error['message']}")


# 1. Price the job, and 2. choose a service that can be booked.
def quote_standard():
    quote = session.post(f"{BASE_URL}/quotes", json=job, timeout=60)
    if quote.status_code != 200:
        fail(quote)
    data = quote.json()["data"]
    option = next((o for o in data["options"] if o["bookable"] and o["service"] == "Standard"), None)
    if option is None:
        raise SystemExit("Standard can't be booked for this job.")
    return data["quoteId"], option


# 3. Book it from the quote, which has the addresses, items and ready time.
# If a request times out, send the same booking again: a quote books one job, so it can't be booked twice.
def book(quote_id, service):
    body = {
        "quoteId": quote_id,
        "service": service,
        "contact": "Jamie Citizen",
        "internalReference": "ORDER-5501",
        "pickup": {"companyName": "Acme Freight", "phone": "02 5550 1234"},
        "delivery": {"companyName": "Example Retail", "phone": "02 5550 5678"},
    }
    return session.post(f"{BASE_URL}/bookings", json=body, timeout=60)


quote_id, option = quote_standard()
booking = book(quote_id, option["service"])
if booking.status_code == 409 and booking.json()["error"]["code"] == "price_changed":
    # Nothing was booked. Get a new quote at the new price. This script books it; your system may need a person to confirm it.
    quote_id, option = quote_standard()
    print(f"The price is now ${option['price']['total']:.2f}.")
    booking = book(quote_id, option["service"])
if booking.status_code != 201:
    fail(booking)

job_number = booking.json()["data"]["jobNumber"]
print(f"Booked {job_number}")

# 4. Track it.
detail = session.get(f"{BASE_URL}/bookings/{job_number}", timeout=30).json()["data"]
print(detail["status"], detail["progress"])

Endpoints

All paths are relative to your region's base URL.

MethodPathWhat it does
GET/accountYour key, account, limits and today's usage.
POST/quotesPrices a job for each service, and gives a quoteId to book it with. Nothing is booked.
POST/bookingsBooks the service you chose from a quote, at the quote's price.
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. The answer has a quoteId: book the service you choose with POST /bookings before the quote's expiresAt. The price is worked out again when you book.

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: Courier, HT, 1T, 2T, 4T, 6T, 8T, 10T, 12T, 14T or 16T. Only for accounts with job codes turned on. Long Distance is worked out from the addresses, so send the vehicle's code. See job codes.
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).
  5. Keeps the quote under a new quoteId, for this API key only, until expiresAt.

Example request

Three stackable boxes, priced with a test key for Standard and Express only, with the delivery address's point sent as location. For a full example with every service, see step 1 of the booking flow.

curl

curl https://portal.directtransport.com.au/api/v1/quotes \
  -H "Authorization: Bearer $DTS_TEST_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "bookingType": "same_day",
    "readyAt": { "date": "2026-09-15", "time": "09:30" },
    "services": ["Standard", "Express"],
    "pickup": { "address": "Unit 4, 10 Example Street, Silverwater NSW 2128" },
    "delivery": {
      "address": "25 Sample Road, Botany NSW 2019",
      "location": { "lat": -33.9461, "lng": 151.1965 }
    },
    "items": [
      { "type": "Box", "quantity": 3, "weightKg": 8, "lengthCm": 40, "widthCm": 30, "heightCm": 30, "stackable": true }
    ]
  }'

JavaScript

const response = await fetch("https://portal.directtransport.com.au/api/v1/quotes", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.DTS_TEST_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    bookingType: "same_day",
    readyAt: { date: "2026-09-15", time: "09:30" },
    services: ["Standard", "Express"],
    pickup: { address: "Unit 4, 10 Example Street, Silverwater NSW 2128" },
    delivery: {
      address: "25 Sample Road, Botany NSW 2019",
      location: { lat: -33.9461, lng: 151.1965 },
    },
    items: [{ type: "Box", quantity: 3, weightKg: 8, lengthCm: 40, widthCm: 30, heightCm: 30, stackable: true }],
  }),
});
const quote = await response.json();
if (!response.ok) throw new Error(`${quote.error.code}: ${quote.error.message}`);
const bookable = quote.data.options.filter((option) => option.bookable);

Python

import os
import requests

response = requests.post(
    "https://portal.directtransport.com.au/api/v1/quotes",
    headers={"Authorization": f"Bearer {os.environ['DTS_TEST_API_KEY']}"},
    json={
        "bookingType": "same_day",
        "readyAt": {"date": "2026-09-15", "time": "09:30"},
        "services": ["Standard", "Express"],
        "pickup": {"address": "Unit 4, 10 Example Street, Silverwater NSW 2128"},
        "delivery": {
            "address": "25 Sample Road, Botany NSW 2019",
            "location": {"lat": -33.9461, "lng": 151.1965},
        },
        "items": [{"type": "Box", "quantity": 3, "weightKg": 8, "lengthCm": 40, "widthCm": 30, "heightCm": 30, "stackable": True}],
    },
    timeout=60,
)
quote = response.json()
if not response.ok:
    raise SystemExit(f"{quote['error']['code']}: {quote['error']['message']}")
bookable = [option for option in quote["data"]["options"] if option["bookable"]]

Example response

200 OK

{
  "data": {
    "quoteId": "qt_2f8d4b6a0c1e43a7b9d5e7f1a3c5b7d9",
    "expiresAt": "2026-09-14T06:44:03.268Z",
    "bookingType": "same_day",
    "readyAt": { "date": "2026-09-15", "time": "09:30", "timeZone": "Australia/Sydney" },
    "serviceArea": "metro",
    "pickup": {
      "address": "Unit 4/10 Example St, Silverwater NSW 2128, Australia",
      "suburb": "Silverwater",
      "state": "NSW",
      "postcode": "2128",
      "location": { "lat": -33.8352, "lng": 151.0473 },
      "precision": "exact",
      "insideMetroArea": true
    },
    "delivery": {
      "address": "25 Sample Rd, Botany NSW 2019, Australia",
      "suburb": "Botany",
      "state": "NSW",
      "postcode": "2019",
      "location": { "lat": -33.9461, "lng": 151.1965 },
      "precision": "exact",
      "insideMetroArea": true
    },
    "distanceKm": 24.6,
    "options": [
      {
        "service": "Standard",
        "bookable": true,
        "jobCode": "HT",
        "vehicle": "HT",
        "price": { "currency": "AUD", "base": 38, "serviceCharge": 3.8, "tailgate": 0, "hiab": 0, "insurance": 0, "waitTime": 0, "tolls": 4.2, "totalExGst": 46, "gst": 4.18, "total": 50.18 }
      },
      {
        "service": "Express",
        "bookable": true,
        "jobCode": "HT",
        "vehicle": "HT",
        "price": { "currency": "AUD", "base": 52, "serviceCharge": 5.2, "tailgate": 0, "hiab": 0, "insurance": 0, "waitTime": 0, "tolls": 4.2, "totalExGst": 61.4, "gst": 5.72, "total": 67.12 }
      }
    ]
  },
  "requestId": "req_5d7f9b1c3e5a4c7e9b1d3f5a7c9e1b3d"
}

Response fields

FieldTypeMeaning
quoteIdstringThe quote's id: qt_ and 32 hexadecimal characters. Book the service you choose with it, using the same API key.
expiresAtstringWhen the quote can no longer be booked (ISO 8601, UTC). See how long a quote can be booked.
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.

How long a quote can be booked

  • A quote belongs to the API key that asked for it. Only that key can book it; with any other key, POST /bookings gets quote_not_found (404).
  • It can be booked until expiresAt: 30 minutes after it was made, or 5 minutes after its ready time if that's sooner. The 5 minutes allow for clocks that differ.
  • A quote without readyAt is for now. It can be booked for the 30 minutes, and booking it books the job ready at the moment of booking.
  • After expiresAt, a booking gets quote_expired (422), with a message that says why: A quote can be booked for 30 minutes, and this one is older. Get a new quote with POST /quotes. or, for example, The quote's ready time, 09:30 on 2026-09-15, has passed: it is 09:36 on 2026-09-15 in Australia/Sydney. Get a new quote with a ready time from now on.
  • A quote books one job (see retrying a booking). A quote made with a test key is booked as a test booking.

Option reasons

When an option has "bookable": false, its reason is one of these. Options that can't be booked because of an address or the timing still show their price.

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. Get a new quote shortly.service_not_available (422)
{
  "service": "Standard",
  "bookable": false,
  "reason": "custom_quote_required",
  "message": "This load falls outside our standard vehicle limits. Our bookings team will review the job details and prepare the right vehicle and pricing for you.",
  "contactEmail": "bookings@directtransport.com.au"
}

Errors

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 the service you chose from a quote, for your account, at the quote's price. Send the quoteId from POST /quotes, the service, and the booking's own details, such as the contact. The job is priced again, exactly as the quote was, and only booked if the service can be booked for the ready time and the total is still the quote's price.total.

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

Request body

FieldTypeRequiredRules
quoteIdstringYesThe quoteId from POST /quotes: qt_ and 32 hexadecimal characters. The quote must have been made with the same API key, and not have expired (see how long a quote can be booked).
servicestringYesOne of the quote's options, such as Standard. Only an option with "bookable": true can be booked.
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.
pickupobjectNoThe booking's details for the pickup (the fields below). The address comes from the quote.
pickup.companyNamestringNoUp to 120 characters.
pickup.phonestringNo*Up to 40 characters.
pickup.referencestringNo*Up to 200 characters.
pickup.instructionsstringNo*Up to 500 characters.
deliveryobjectNoThe booking's details for the delivery (the fields below). The address comes from the quote.
delivery.companyNamestringNoUp to 120 characters.
delivery.phonestringNo*Up to 40 characters.
delivery.referencestringNo*Up to 200 characters.
delivery.instructionsstringNo*Up to 500 characters.

* Your account may require these. See account-required fields. Text fields you leave out are saved empty.

The job's details come from the quote, so a booking can't send them:

  • bookingType
  • readyAt
  • items
  • jobCode
  • tailgate
  • hiab
  • insurance
  • services
  • pickup.address
  • pickup.location
  • delivery.address
  • delivery.location

A booking that sends any of these is refused with invalid_request (400), with a detail for each, such as readyAt comes from the quote. To change it, get a new quote. Any other field the request doesn't take gets a detail such as notes isn't a field this request takes.

How it works

  1. Checks the body, and the fields your account requires.
  2. Checks whether the quote has already been booked. The same booking sent again gets the first answer back, a different booking of a booked quote is refused, and nothing is booked twice (see retrying a booking).
  3. Finds the quote. It must have been made with the same API key (quote_not_found), and not have expired (quote_expired).
  4. Checks that the service is one of the quote's options, with a price.
  5. Prices the job again exactly as POST /quotes did, from the quote's addresses, items, ready time and extras, with the same checks on the ready date and time, your account and the addresses. A quote without readyAt is booked ready at the time of booking.
  6. Refuses the booking when an address isn't exact (address_not_precise), the job needs a custom quote or can't be priced online (cannot_price_online), the service can't be booked for the ready time (service_not_available), or the total differs from the quote's price.total by more than half a cent (price_changed).
  7. Saves the booking under a new job number and answers 201. With a live key, the job goes to DTS like a booking made on the portal. A quote made with a test key is saved as a test booking.

Example request

This books the Standard option from the POST /quotes example, for $50.18. That quote was made with a test key, so the same test key books it.

curl

curl https://portal.directtransport.com.au/api/v1/bookings \
  -H "Authorization: Bearer $DTS_TEST_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "quoteId": "qt_2f8d4b6a0c1e43a7b9d5e7f1a3c5b7d9",
    "service": "Standard",
    "contact": "Jamie Citizen",
    "internalReference": "ORDER-5502",
    "pickup": {
      "companyName": "Acme Freight",
      "phone": "02 5550 1234"
    },
    "delivery": {
      "companyName": "Example Retail"
    }
  }'

JavaScript

const response = await fetch("https://portal.directtransport.com.au/api/v1/bookings", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.DTS_TEST_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    quoteId: "qt_2f8d4b6a0c1e43a7b9d5e7f1a3c5b7d9", // data.quoteId from POST /quotes
    service: "Standard",
    contact: "Jamie Citizen",
    internalReference: "ORDER-5502",
    pickup: { companyName: "Acme Freight", phone: "02 5550 1234" },
    delivery: { companyName: "Example Retail" },
  }),
});
const result = await response.json();
if (response.status === 201) {
  console.log("Booked", result.data.jobNumber);
} else {
  console.error(result.error.code, result.error.message, result.error.details);
}

Python

import os
import requests

response = requests.post(
    "https://portal.directtransport.com.au/api/v1/bookings",
    headers={"Authorization": f"Bearer {os.environ['DTS_TEST_API_KEY']}"},
    json={
        "quoteId": "qt_2f8d4b6a0c1e43a7b9d5e7f1a3c5b7d9",  # data.quoteId from POST /quotes
        "service": "Standard",
        "contact": "Jamie Citizen",
        "internalReference": "ORDER-5502",
        "pickup": {"companyName": "Acme Freight", "phone": "02 5550 1234"},
        "delivery": {"companyName": "Example Retail"},
    },
    timeout=60,
)
result = response.json()
if response.status_code == 201:
    print("Booked", result["data"]["jobNumber"])
else:
    print(result["error"]["code"], result["error"]["message"], result["error"].get("details"))

Example response

201 Created with Location: /api/v1/bookings/TEST-DTS12345. The fields are described under the booking object.

{
  "data": {
    "jobNumber": "TEST-DTS12345",
    "testMode": true,
    "status": "pending",
    "bookingType": "same_day",
    "service": "Standard",
    "readyAt": { "date": "2026-09-15", "time": "09:30", "timeZone": "Australia/Sydney" },
    "createdAt": "2026-09-14T06:20:31.907Z",
    "contact": "Jamie Citizen",
    "internalReference": "ORDER-5502",
    "internalReference2": "",
    "pickup": {
      "companyName": "Acme Freight",
      "address": "Unit 4/10 Example St, Silverwater NSW 2128, Australia",
      "suburb": "Silverwater",
      "reference": "",
      "phone": "02 5550 1234",
      "instructions": "",
      "location": { "lat": -33.8352, "lng": 151.0473 }
    },
    "delivery": {
      "companyName": "Example Retail",
      "address": "25 Sample Rd, Botany NSW 2019, Australia",
      "suburb": "Botany",
      "reference": "",
      "phone": "",
      "instructions": "",
      "location": { "lat": -33.9461, "lng": 151.1965 }
    },
    "items": [
      { "type": "Box", "quantity": 3, "weightKg": 8, "lengthCm": 40, "widthCm": 30, "heightCm": 30, "stackable": true }
    ],
    "jobCode": "HT",
    "vehicle": "HT",
    "tailgate": false,
    "hiab": false,
    "insurance": false,
    "distanceKm": 24.6,
    "price": { "currency": "AUD", "base": 38, "serviceCharge": 3.8, "tailgate": 0, "hiab": 0, "insurance": 0, "waitTime": 0, "tolls": 4.2, "totalExGst": 46, "gst": 4.18, "total": 50.18 }
  },
  "requestId": "req_6f8a0c2e4b6d4f8a0c2e4b6d8f0a2c4e"
}

Errors

CodeHTTP statusWhen
invalid_request400A field is missing or not valid (for example no contact), a field comes from the quote, a field your account requires is blank, or the body isn't valid JSON. Also when service isn't one of the quote's options, with a message such as The quote has no Express option. Choose one of its options (Standard), or get a new quote.
unsupported_media_type415The body wasn't sent as application/json.
payload_too_large413The body is over 100 KB.
quote_not_found404There's no quote with this quoteId for this API key: it's mistyped, or another key made the quote.
quote_expired422The quote can't be booked any more: it's more than 30 minutes old, or its ready time has passed. The message says which. Get a new quote.
quote_already_booked409A different booking already booked this quote. The message and details.jobNumber name the job. Get a new quote to book another job.
request_in_progress409A booking for this quote is still being worked on. Wait a moment, then send it again.
price_changed409The total worked out now isn't the quote's price.total. Nothing is booked. details.quotedTotal is the quote's total and details.price is the new price. Get a new quote to book at the new price.
feature_not_enabled403The quote was priced by job code, and job codes are no longer enabled for your account.
address_not_found422The pickup or delivery address couldn't be found when the job was priced again.
address_not_precise422An address in the quote was only found approximately, so a driver can't be sent to it. details names pickup.address or delivery.address. Get a new quote with a full street address, or its location.
no_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 the quote has no price for the service, or 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. Try again shortly by sending the same booking again.

A field that comes from the quote:

{
  "error": {
    "code": "invalid_request",
    "message": "Some fields aren't valid.",
    "details": [
      { "field": "readyAt", "message": "readyAt comes from the quote. To change it, get a new quote." }
    ]
  },
  "requestId": "req_3a5c7e9b1d2f4a6c8e0b2d4f6a8c1e3b"
}

A quote more than 30 minutes old:

{
  "error": {
    "code": "quote_expired",
    "message": "A quote can be booked for 30 minutes, and this one is older. Get a new quote with POST /quotes."
  },
  "requestId": "req_6d8f0a2c4e1b4d7f9a3c5e7b9d1f3a5c"
}

A service that can't be booked for the ready time, here a quote for now booked after 3:00 PM:

{
  "error": {
    "code": "service_not_available",
    "message": "Standard jobs ready today need a ready time between 7:00 AM and 3:00 PM.",
    "details": [
      {
        "field": "service",
        "reason": "outside_service_hours",
        "message": "Standard jobs ready today need a ready time between 7:00 AM and 3:00 PM."
      }
    ]
  },
  "requestId": "req_2c4e6a8b0d2f4b6d8a0c2e4f6b8d0a1c"
}

A job that needs a custom quote:

{
  "error": {
    "code": "cannot_price_online",
    "message": "This load falls outside our standard vehicle limits. Our bookings team will review the job details and prepare the right vehicle and pricing for you.",
    "details": {
      "reason": "custom_quote_required",
      "contactEmail": "bookings@directtransport.com.au"
    }
  },
  "requestId": "req_8b0d2f4a6c8e4a0c2e4b6d8f0a2c4e6b"
}

The booking object

POST /bookings, GET /bookings and GET /bookings/{jobNumber} show a booking with these fields. GET /bookings/{jobNumber} also adds progress, driver and proofOfDelivery.

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",
    "stops": []
  },
  "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.
stopsarray of objectsFor a job with several pickups or drops, each stop's own proof (see below). Empty for a job with one pickup and one drop.

Every link is an https URL. If you need to keep the files, download them.

Jobs with several pickups or drops

The driver records proof at each stop, so each drop has its own receiver, signature and photos. For these jobs receiverName lists every drop's receiver, deliveryPhotos and pickupPhotos hold every stop's photos, deliveredAt is when the last drop was done, and signatureUrl is null: each drop's signature is in stops, in route order, pickups first.

"stops": [
  {
    "type": "pickup",
    "name": "Pickup A",
    "company": "Sender Co",
    "address": "1 William St, Darlinghurst NSW 2010, Australia",
    "completedAt": "2026-09-14T22:15:00.000Z",
    "photos": ["https://files.example.com/pod/DTS12345/pickup-a-1.jpg"]
  },
  {
    "type": "delivery",
    "name": "Delivery A",
    "company": "Receiver Co",
    "address": "20 Macquarie St, Parramatta NSW 2150, Australia",
    "completedAt": "2026-09-15T00:05:00.000Z",
    "receiverName": "Sam Receiver",
    "signatureUrl": "https://files.example.com/pod/DTS12345/delivery-a-signature.png",
    "photos": ["https://files.example.com/pod/DTS12345/delivery-a-1.jpg"]
  }
]
FieldTypeMeaning
typestringpickup or delivery.
namestringThe stop's name on the job: Pickup A, Delivery A, Delivery B…
companystring or nullThe company at the stop.
addressstring or nullThe stop's address.
completedAtstring or nullWhen the driver finished the stop (ISO 8601, UTC); null until then.
receiverNamestring or nullDeliveries only: who received it.
signatureUrlstring or nullDeliveries only: link to the receiver's signature.
photosarray of stringsLinks to the photos taken at the stop.

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 when POST /quotes prices a job, and again when POST /bookings books it from the quote. They match Place the Booking on the portal. Times are in the region's time zone.

Item types and measurements

items[].type must be one of these, spelt exactly as shown (note Aluminum):

  • 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: Courier, HT, 1T, 2T, 4T, 6T, 8T, 10T, 12T, 14T and 16T.
  • Long Distance is applied automatically from the addresses: send the vehicle's code. A same_day job with either address outside the metro area is priced at that vehicle's long-distance rate, and its jobCode in the answer shows it, for example 1T-LDG.
  • LD is still accepted, so integrations that send it keep working, but it names no vehicle, so every option comes back as custom_quote_required. Send the vehicle's code instead. 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 booking's service must be one of its quote's options, or it gets invalid_request, for example The quote has no Express option. Choose one of its options (Standard), or get a new quote.

A same_day job is metro when both addresses are inside the metro service area, and regional and country otherwise. Every next_day and three_four_day job counts as regional and country. Quotes show this in serviceArea and in each address's insideMetroArea. Next Day and 3-4 Day jobs within the metro area can't be priced online (cannot_price_online). If regional and country jobs can't be booked online in the region at the moment, quotes and bookings for them get regional_not_available (422).

Service hours (metro Same Day)

A metro Same Day job must be ready within its service's hours:

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. You send it to POST /quotes, and a booking takes it from the quote.
  • A quote can leave it out: the job is then priced for now, the quote's readyAt shows the current date and time in the region, and booking the quote books the job ready at the moment of booking.
  • It must be a real date and time (readyAt isn't a real date and time.).
  • The date must be today or later (The ready date is in the past.) and at most 90 days ahead (The ready date can be at most 90 days ahead.).
  • The time must not have passed. A time up to 5 minutes ago is accepted, to allow for clocks that differ, and counts as now: the quote's 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.
A quote for now is booked for the moment you book

Booking a quote made without readyAt checks service hours and cutoffs again at the time of booking. For example, a metro Standard job quoted for now at 2:55 PM and booked at 3:05 PM gets service_not_available, because a Standard job ready today must be ready by 3:00 PM.

Addresses

  • Send a full street address in Australia, as you would type it on the portal: number, street, suburb, state and postcode. Addresses are only looked up in Australia.
  • Responses give each address as it was found, for example 25 Sample Rd, Botany NSW 2019, Australia.
  • 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 when you price the job. The address text is still looked up and must be found; the point you send is then used for pricing and saved with a booking made from the quote, and the address counts 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 price the job 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.
  • A quote made with a test key is booked as a test booking, with that key. POST /bookings checks everything a live booking checks, then saves it: 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, and a quote can only be booked with the key that made it.
  • Labels and invoices can be downloaded for test bookings. Proof of delivery is simulated (it still needs proof of delivery turned on for your account).
  • Validation, errors, quotes, retrying a booking and the per-minute limit work as they do for live keys. Daily limits are lower; see daily limits.

Simulated progress

A test booking moves on by itself, counted from when it was booked (createdAt). GET /bookings and GET /bookings/{jobNumber} show the simulated status.

Time after 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.

Retrying a booking

A quote books one job. If a POST /bookings request times out or the connection drops, send the same booking again: you get the first answer back, and the job can't be booked twice.

  • The same booking means the same quoteId, service, contact, references, and pickup and delivery details. The order of the fields doesn't matter, spaces at the start and end of text are removed, and a text field left out is the same as an empty one.
  • Once a booking is made, sending the same booking again gets it back for 24 hours, even after its quote has expired.
  • To book another job, get a new quote.
You sendWhat happens
The same booking again, after the quote was booked with itYou get the booking again (201), with Idempotent-Replayed: true. Nothing new is booked. The answer has its own X-Request-Id and requestId.
A different booking of a quote that was already booked, such as another service or contactquote_already_booked (409). The message and details.jobNumber name the job the quote was booked as. Get a new quote to book another job.
A booking of a quote while another booking of it is still being worked onrequest_in_progress (409). Wait a moment, then send it again. If that request never finishes, the quote can be booked again after 10 minutes. If it did make a booking, the same booking gets that booking back, and a different one gets quote_already_booked.
A booking of a quote after a booking of it was refused or failed, so nothing was bookedThe request is worked on as if it were new. A refusal or a server error doesn't use the quote up, so send the corrected booking with the same quoteId while the quote can still be booked.

A booking sent again after it was made gets the same answer, with Idempotent-Replayed:

HTTP/1.1 201 Created
Content-Type: application/json
Location: /api/v1/bookings/DTS12345
Idempotent-Replayed: true
X-Request-Id: req_b5d7f9a1c3e24b6d8f0a3c5e7b9d2f4a
X-DTS-Mode: live
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 55
X-RateLimit-Reset: 31
Cache-Control: no-store

A different booking of a quote that was already booked:

{
  "error": {
    "code": "quote_already_booked",
    "message": "This quote was already booked as DTS12345. Get a new quote to book another job.",
    "details": { "jobNumber": "DTS12345" }
  },
  "requestId": "req_8e0a2c4f6b3d4e8a0c2f4b6d8e1a3c5f"
}

Which answers are kept

  • Kept and repeated for 24 hours: only a booking that was made (201).
  • Not kept: every refusal and error, including quote_not_found (404), price_changed (409), quote_expired, service_not_available, address_not_precise and cannot_price_online (422), daily_limit_reached (403) and server errors (500 and 503). Nothing was booked.
After a refusal, put the booking right and send it again

For example, after service_not_available, book another of the quote's bookable options with the same quoteId. After price_changed or quote_expired, get a new quote and book from it.

Pagination

GET /bookings returns bookings a page at a time.

  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, a field your account requires, a field a booking takes from its quote, or the ready date and time. details lists the fields and problems.
unsupported_media_type415The body wasn't sent as JSON with Content-Type: application/json.
payload_too_large413The request body is over 100 KB.
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 not_found (404).
method_not_allowed405The path doesn't accept this HTTP method, for example DELETE /bookings. The Allow header lists the methods it does accept.
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 or has no price in the quote. 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 in the quote was only found approximately, so a driver can't be sent to it. Get a new quote with a full street address, or its location as well. Quotes show the same problem as the option reason address_not_precise.
price_changed409When booking: the total worked out now isn't the quote's price.total. Nothing was booked. details has the quotedTotal and the new price. Get a new quote.
quote_not_found404When booking: there's no quote with this quoteId for the API key. It's mistyped, or another key made the quote.
quote_expired422When booking: the quote can't be booked any more, because it's more than 30 minutes old or its ready time has passed. Get a new quote.
quote_already_booked409When booking: a different booking already booked this quote. details.jobNumber names the job. Get a new quote to book another job.
request_in_progress409A booking for this quote is still being worked on. Wait a moment, then send it again.
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: send the same booking again.
internal_error (500), service_unavailable (503), timeouts and network errorsYes, a little later, waiting longer each time. For a booking, send the same booking again: if it was made, you get it back instead of a second booking.
price_changed (409), quote_expired (422)Not as it was. Get a new quote, confirm its price, then book from it.
quote_already_booked (409)No. The quote is booked, as details.jobNumber. To book another job, get a new quote.
Other errors (400, 401, 403, 404, 413, 415, 422)Not until you've fixed the request, or DTS has fixed the key or account. A booking can then be sent again with the same quoteId, until the quote expires.

Changelog

VersionDateChanges
v114 September 2026First release: GET /account, POST /quotes, which prices a job and gives a quoteId, POST /bookings, which books a service from a quote by its quoteId, GET /bookings, GET /bookings/{jobNumber}, and the label, invoice and proof of delivery endpoints.

Support

Email IT@directtransport.com.au to:

  • get a live or test key;
  • raise your limits;
  • have a paused key allowed again;
  • revoke a key that may have been exposed;
  • turn on proof of delivery or job codes for your account;
  • ask anything about the API.

For custom quotes, contact the DTS bookings team at bookings@directtransport.com.au, the contactEmail the API gives.

When you write about a request, include:

  • the region;
  • the key's prefix, such as dts_live_Ab12Cd (never the whole key);
  • the requestId (or X-Request-Id), the endpoint and the time, and for a booking, its quoteId;
  • the error code and message.