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/.
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
Submit the file
POST /api/v1/imports/jobsas multipart form data with a singlefilepart (CSV or XLSX, up to 5 MB, up to 750 rows). You get back202 Accepted, aLocationheader pointing at the status URL, and aRetry-Afterheader advising the first poll delay. - 2
Poll the run
GET /api/v1/imports/runs/{transactionId}untilstateis terminal. Use the run id from the202body (theidfield) as{transactionId}. Send the previous response'sETagback asIf-None-Matchto get a cheap304 Not Modifiedwhile nothing has changed. - 3
Handle the first push
The first time Offload sees a column shape it has not mapped for your organization, the run staysQUEUEDand carries anextActiondeep 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
Read the results
GET /api/v1/imports/runs/{transactionId}/results/{bucket}where{bucket}issucceeded,failed,warnings, orskipped. The page comes back in thedataarray; follownextPageUrl(or pass thecursorquery parameter) to walk the rest. - 5
Branch on the code, not the message
Each failed row carries one or more stable wire codes undererrors[].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.
| State | Terminal? | Meaning |
|---|---|---|
QUEUED | No | Created, not yet processing, OR staged for in-app review (see the first push). Keep polling. |
RUNNING | No | Processing in progress. Keep polling. |
SUCCEEDED | Yes | Every row landed cleanly, and at least one record was created or updated. |
PARTIAL | Yes | Some rows succeeded, some failed or warned. Walk the failed bucket to see why. |
SKIPPED | Yes | Terminal, but no row changed state. Every row was a no-op duplicate. Lets you tell a quiet push from a busy one. |
FAILED | Yes | The run could not produce per-row results (envelope or parse error, a stuck-run reaper, or staging expiry). Read error.code on the run. |
CANCELLED | Yes | An 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 carriescreatedRecordIds.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 awarnings[]array.failed: validation rejected the row. Each row carries anerrors[]array, each entry with a stablecode.skipped: the row was a no-op duplicate (same natural keys, same content as a prior import). Each row carries areason.
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.
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[].code | What happened | What to do |
|---|---|---|
LOCATION_NAME_ADDRESS_MISMATCH | The 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_MISS | The 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_AMBIGUOUS | Your 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. |
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.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/5xxwith an RFC 7807application/problem+jsonbody and thecodeat the top level. IncludesMISSING_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 instate: FAILEDwith the code onerror.code. IncludesEMPTY_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
failedorwarningsbucket undererrors[].code/warnings[].code. IncludesTYPE_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.
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
- API reference: the interactive Scalar reference with every endpoint and field, plus the error code table.
- Authentication: token format, rotation, and leak handling.
- Quickstart: confirm your token is live in one request.