offloaddocs
Type
Reference
For
Driver
Coordinator
Admin
Developer
On
Mobile app
Tablet
Desktop
API

Bulk import guide

Push a CSV or XLSX file of loads into Offload from your scheduler, ERP, or broker system, with no browser in the loop. The flow is asynchronous: submit a file, get a run id, poll until the run is terminal, then read the per-row results. This page is the end-to-end walkthrough; for exact request and response schemas see the API reference.

Before you start

You need a Public API token (osk_live_…). Mint one from Settings → API Keys in the Offload app (you need organization admin access). See Authentication for the full token guide. Send it as a Bearer token on every request:

Authorization: Bearer osk_live_…

The base URL is https://api.letsoffload.com. Every endpoint is mounted under /api/v1/.

NoteThe only import kind available today is jobs. Each row becomes one Load attached to a Project (the Project is created on the fly when the jobCode is new). Posting to any other kind (for example /api/v1/imports/loads) returns 400 INVALID_KIND. More kinds will land as additive endpoints.

The flow

  1. 1

    Submit the file

    POST /api/v1/imports/jobs as multipart form data with a single file part (CSV or XLSX, up to 5 MB, up to 750 rows). You get back 202 Accepted, a Location header pointing at the status URL, and a Retry-After header advising the first poll delay.
  2. 2

    Poll the run

    GET /api/v1/imports/runs/{transactionId} until state is terminal. Use the run id from the 202 body (the id field) as {transactionId}. Send the previous response's ETag back as If-None-Match to get a cheap 304 Not Modified while nothing has changed.
  3. 3

    Handle the first push

    The first time Offload sees a column shape it has not mapped for your organization, the run stays QUEUED and carries a nextAction deep link. Someone at your company confirms the mapping once in the app, and the run stays open for 72 hours while they do. Every later push of the same shape commits unattended.
  4. 4

    Read the results

    GET /api/v1/imports/runs/{transactionId}/results/{bucket} where {bucket} is succeeded, failed, warnings, or skipped. The page comes back in the data array; follow nextPageUrl (or pass the cursor query parameter) to walk the rest.
  5. 5

    Branch on the code, not the message

    Each failed row carries one or more stable wire codes under errors[].code. Branch your integration on the code; messages may be reworded. Codes and their fixes live in the error reference.

Run states

A run moves through up to two non-terminal states and ends in one of five terminal states. Your poll loop stops as soon as state is terminal.

StateTerminal?Meaning
QUEUEDNoCreated, not yet processing, OR staged for in-app review (see the first push). Keep polling.
RUNNINGNoProcessing in progress. Keep polling.
SUCCEEDEDYesEvery row landed cleanly, and at least one record was created or updated.
PARTIALYesSome rows succeeded, some failed or warned. Walk the failed bucket to see why.
SKIPPEDYesTerminal, but no row changed state. Every row was a no-op duplicate. Lets you tell a quiet push from a busy one.
FAILEDYesThe run could not produce per-row results (envelope or parse error, a stuck-run reaper, or staging expiry). Read error.code on the run.
CANCELLEDYesAn Offload operator aborted the run.

There is no COMPLETED state. A clean success is SUCCEEDED.

Result buckets

After a run terminates, each row lands in exactly one of four buckets (a committed row that needed normalization appears in both succeeded and warnings):

  • succeeded: the Load was created or updated. Each row carries createdRecordIds.
  • warnings: the Load committed, but something was normalized or resolved to something worth flagging (unit conversion, an address that would not geocode, a location name that resolved to an existing record). Each row carries a warnings[] array.
  • failed: validation rejected the row. Each row carries an errors[] array, each entry with a stable code.
  • skipped: the row was a no-op duplicate (same natural keys, same content as a prior import). Each row carries a reason.

Every per-row result also echoes a source object with your own natural keys (for jobs: loadNo, jobCode, and projectName when present), so you can find the row in your file without counting from the top.

Submit a file (curl)

curl -i -X POST "$OFFLOAD_API_BASE/api/v1/imports/jobs" \
  -H "Authorization: Bearer $OFFLOAD_API_TOKEN" \
  -F "file=@loads.csv;type=text/csv"
# -> HTTP/1.1 202 Accepted
#    Location: /api/v1/imports/runs/8c4f2e10-3b7d-4f9e-9a1c-7e8b0d4f2a91
#    Retry-After: 5
#    { "id": "8c4f2e10-3b7d-4f9e-9a1c-7e8b0d4f2a91", "object": "import_run",
#      "kind": "jobs", "state": "QUEUED", ... }

The id in the body is the run id. Use it to build the poll URL (or just follow the Location header). XLSX works too: send ;type=application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.

TipSet the Offload-Dry-Run: true header to run the full pipeline (parse, map, validate, produce per-row outputs) without writing anything. The response carries dryRun: true and the same body shape as a live run. Dry-run preset-misses do not emit a nextAction (saving the mapping would itself be a side effect).

Poll, then read the failed rows (Python)

This polls with exponential backoff (5 s, then double to a 30 s ceiling), short-circuits on If-None-Match, stops on the first terminal state or a nextAction, and walks the failed bucket page by page.

import os
import time
 
import requests
 
BASE = os.environ["OFFLOAD_API_BASE"]
TOKEN = os.environ["OFFLOAD_API_TOKEN"]
HEADERS = {"Authorization": f"Bearer {TOKEN}"}
 
TERMINAL = {"SUCCEEDED", "PARTIAL", "SKIPPED", "FAILED", "CANCELLED"}
INITIAL_DELAY_S = 5
MAX_DELAY_S = 30
 
 
def submit(csv_path):
    with open(csv_path, "rb") as fp:
        resp = requests.post(
            f"{BASE}/api/v1/imports/jobs",
            headers=HEADERS,
            files={"file": (csv_path, fp, "text/csv")},
        )
    resp.raise_for_status()  # 202
    return f"{BASE}{resp.headers['Location']}"
 
 
def poll_until_terminal(status_url):
    delay, etag = INITIAL_DELAY_S, None
    while True:
        headers = dict(HEADERS)
        if etag:
            headers["If-None-Match"] = etag
        resp = requests.get(status_url, headers=headers)
        if resp.status_code != 304:
            resp.raise_for_status()
            etag = resp.headers.get("ETag")
            run = resp.json()
            # A QUEUED run with nextAction needs a one-time in-app mapping.
            if run.get("nextAction") or run["state"] in TERMINAL:
                return run
        time.sleep(delay)
        delay = min(delay * 2, MAX_DELAY_S)
 
 
def read_bucket(run_id, bucket):
    url = f"{BASE}/api/v1/imports/runs/{run_id}/results/{bucket}"
    while url:
        page = requests.get(url, headers=HEADERS)
        page.raise_for_status()
        body = page.json()
        for row in body["data"]:
            yield row
        nxt = body["nextPageUrl"]
        url = f"{BASE}{nxt}" if nxt else None
 
 
run = poll_until_terminal(submit("loads.csv"))
 
if run.get("nextAction"):
    print("Confirm the mapping in-app:", run["nextAction"]["url"])
elif run["state"] in ("PARTIAL", "FAILED"):
    for row in read_bucket(run["id"], "failed"):
        codes = [e["code"] for e in row["errors"]]
        print(row["rowIndex"], row["source"], codes)

Submit and poll (Node)

Same loop in Node 18+ (uses the built-in fetch, FormData, and Blob, so no dependencies).

import { readFile } from 'node:fs/promises';
 
const BASE = process.env.OFFLOAD_API_BASE;
const TOKEN = process.env.OFFLOAD_API_TOKEN;
const AUTH = { Authorization: `Bearer ${TOKEN}` };
 
const TERMINAL = new Set(['SUCCEEDED', 'PARTIAL', 'SKIPPED', 'FAILED', 'CANCELLED']);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
 
async function submit(path) {
  const form = new FormData();
  const bytes = await readFile(path);
  form.append('file', new Blob([bytes], { type: 'text/csv' }), 'loads.csv');
  const res = await fetch(`${BASE}/api/v1/imports/jobs`, {
    method: 'POST',
    headers: AUTH,
    body: form,
  });
  if (res.status !== 202) throw new Error(`submit failed: ${res.status}`);
  return `${BASE}${res.headers.get('Location')}`;
}
 
async function pollUntilTerminal(statusUrl) {
  let delayMs = 5000;
  let etag;
  for (;;) {
    const headers = { ...AUTH };
    if (etag) headers['If-None-Match'] = etag;
    const res = await fetch(statusUrl, { headers });
    if (res.status !== 304) {
      if (!res.ok) throw new Error(`poll failed: ${res.status}`);
      etag = res.headers.get('ETag');
      const run = await res.json();
      // A QUEUED run with nextAction needs a one-time in-app mapping.
      if (run.nextAction || TERMINAL.has(run.state)) return run;
    }
    await sleep(delayMs);
    delayMs = Math.min(delayMs * 2, 30000);
  }
}
 
async function* readBucket(runId, bucket) {
  let url = `${BASE}/api/v1/imports/runs/${runId}/results/${bucket}`;
  while (url) {
    const res = await fetch(url, { headers: AUTH });
    if (!res.ok) throw new Error(`results failed: ${res.status}`);
    const body = await res.json();
    yield* body.data;
    url = body.nextPageUrl ? `${BASE}${body.nextPageUrl}` : null;
  }
}
 
const run = await pollUntilTerminal(await submit('loads.csv'));
 
if (run.nextAction) {
  console.log('Confirm the mapping in-app:', run.nextAction.url);
} else if (run.state === 'PARTIAL' || run.state === 'FAILED') {
  for await (const row of readBucket(run.id, 'failed')) {
    const codes = row.errors.map((e) => e.code);
    console.log(row.rowIndex, row.source, codes);
  }
}

The first push

The very first push of a new column shape does not commit. Offload's mapper proposes how your headers line up against the target schema, and the run is staged for a one-time confirmation. The response stays QUEUED with a nextAction deep link:

{
  "id": "8c4f2e10-3b7d-4f9e-9a1c-7e8b0d4f2a91",
  "object": "import_run",
  "kind": "jobs",
  "state": "QUEUED",
  "nextAction": {
    "type": "review_in_app",
    "url": "https://app.letsoffload.com/imports/jobs/8c4f2e10-3b7d-4f9e-9a1c-7e8b0d4f2a91/mapping",
    "expiresAt": "2026-05-22T18:00:00Z"
  }
}

Someone at your company opens that URL (Auth0 SSO), confirms the column mapping, and commits.

By default we email everyone with the Org Admin role. An admin can narrow that to a specific list of addresses under Settings → Communication settings → Bulk import mapping approvals; the addresses do not have to belong to Offload users, so a shared inbox works.

From that point on, every push of the same column shape runs unattended: clean rows commit, rejected rows land in failed, normalized rows flag into warnings. If nobody confirms before nextAction.expiresAt (72 hours from when the run was created), it lands FAILED with error.code: STAGING_EXPIRED so your poll loop terminates instead of waiting forever. Re-pushing the same file while a run is still waiting returns IMPORT_IN_PROGRESS; confirm or wait out the window first.

Naming a location

Every address group in a jobs file (project pickup, project dropoff, load pickup, load dropoff) accepts one optional extra column: the location's name, the nickname a dispatcher would give it by hand, like Heavy Long Beach.

Map it and the address stops being anonymous. Offload looks for a saved location of that name in your organization and reuses it; if there is no match, it creates one under that name and it becomes findable by typing the name into address search. Push the same file again tomorrow and the same yard is reused rather than duplicated.

Matching ignores letter case and whitespace at either end — any kind of whitespace, including tabs, newlines and the non-breaking space a spreadsheet or a web paste leaves behind. Nothing else is ignored. heavy long beach and Heavy Long Beach are the same location; Heavy Long Beach Yard is a different one.

Two things worth knowing before you map it:

  • An import never renames or re-addresses a location you already have. If the name matches but the street in your file is different, Offload attaches the existing location, leaves its address exactly as it was, and warns on that row. Nothing is overwritten from a file.
  • Leaving the name column unmapped changes nothing. Addresses behave exactly as they do without it.

Three per-row warnings come from this. All of them still commit the row.

warnings[].codeWhat happenedWhat to do
LOCATION_NAME_ADDRESS_MISMATCHThe name matched exactly one saved location, but the row's street, city or ZIP is different. The saved location was used and its address left alone.Nothing, unless the address really did change. Then open the load and update it.
LOCATION_NAME_LOOKUP_MISSThe name matched nothing and the row carried no street address (a city, state and ZIP with no street does not count), so the load committed without a location.Add address columns to the file and push again, or set it on the load.
LOCATION_NAME_AMBIGUOUSYour organization has more than one saved location with that name. The oldest was used, and it will be used every time. When the address also disagrees, both facts arrive under this one code — ambiguity wins.Clean up the duplicates so the name means one place.
NoteAll three codes above are specific to location names, so you can branch on the code alone. They are deliberately not the older LOOKUP_MISS, which this endpoint already uses for two unrelated cases: a row whose address has a city, state and ZIP but no street, and a customer, branch or project manager name that matched nothing. These three are not yet listed in the generated error reference, so the docUrl on these particular warnings does not resolve yet — branch on the code, which is stable.
Heads upTwo imports running at the same time can both create a location for the same new name, because the check and the write are not a single atomic step. Sequential pushes are safe; this only shows up if you run two imports concurrently, or import while someone saves the same address by hand.

Error handling

Errors fire in one of three modes. Always branch on the wire code, never on the human-readable message.

  • Envelope errors (sync). The request never created a run. You get a 4xx / 5xx with an RFC 7807 application/problem+json body and the code at the top level. Includes MISSING_FILE, MALFORMED_MULTIPART, EMPTY_BODY, UNSUPPORTED_MIME, TOO_LARGE, INVALID_KIND, IMPORT_IN_PROGRESS, IDEMPOTENCY_KEY_RESERVED, UNAUTHORIZED.
  • Parse-level errors (async). A run was created, then the parser surfaced a problem after the 202. The run lands in state: FAILED with the code on error.code. Includes EMPTY_FILE, TOO_MANY_ROWS, TOO_MANY_HEADERS, MACRO_REJECTED, MALFORMED_CSV, MALFORMED_XLSX, RUN_TIMED_OUT, STAGING_EXPIRED.
  • Per-row errors (async). A row failed validation or needed normalization. Surfaces in the failed or warnings bucket under errors[].code / warnings[].code. Includes TYPE_MISMATCH, UNIQUE_VIOLATION, MISSING_REQUIRED, UNIT_NORMALIZED (a warning, not a failure).

The codes above are the common ones; the full, authoritative list lives in the API reference.

For transient errors (5xx, 429 RATE_LIMIT_EXCEEDED) retry with jittered exponential backoff. For deterministic 4xx errors, do not retry. Fix the upstream and push again. Every code and its fix is in the error reference.

Heads upA second push of the same file while the first run is still QUEUED or RUNNING returns 409 IMPORT_IN_PROGRESS with an existingRunUrl. Poll that URL instead of re-uploading. The fingerprint is over the file contents plus the kind, so two pushes of an identical file collide on purpose.
What about the Idempotency-Key header?

Idempotency-Key is reserved in the v1 contract but not implemented. Sending any non-empty value returns 400 IDEMPOTENCY_KEY_RESERVED. You do not need it: Offload's per-row diff already prevents duplicate writes when you retry the same file. Leave the header off in v1.

The run never leaves QUEUED or RUNNING

A run stuck in QUEUED with a nextAction is waiting on a one-time in-app mapping confirmation (see the first push). If there is no nextAction and the run sits past about 30 minutes, the stuck-run reaper transitions it to FAILED with error.code: RUN_TIMED_OUT, so your poll loop always terminates. Keep polling at the 30-second ceiling until you see a terminal state.

Next

Was this helpful?