Docs menu
Handling async jobs
Six states, four of them terminal, and when to stop.
The SDK is not released. What follows uses plain HTTP.
A submit returns 202, which means accepted rather than finished. You follow the job by polling for its status or by receiving a webhook, and you stop as soon as it reaches one of the four terminal states.
import { randomUUID } from "node:crypto";
const BASE = "https://<your-host>/api/v1/partners/addons/route-optimization";
const headers = { "api-key": process.env.ROUTE4GREEN_API_KEY! };
// A submit answers 202 Accepted: the job is recorded, not finished.
// Generate the idempotency key once and reuse it for every retry of THIS
// submit, so a network timeout cannot create a second job.
const idempotencyKey = randomUUID();
async function submitJob(body: unknown) {
const response = await fetch(`${BASE}/jobs`, {
method: "POST",
headers: { ...headers, "Idempotency-Key": idempotencyKey, "content-type": "application/json" },
body: JSON.stringify(body),
});
const envelope = await response.json();
if (!envelope.success && String(envelope.error_code) === "IDEMPOTENCY_CONFLICT") {
// You reused the key with DIFFERENT content. The replay contract is:
// same key plus identical payload returns the original job; same key
// plus a different payload is refused with this 409. Do not retry the
// same call in a loop. Mint a new key for what is genuinely a new job.
throw new Error("Idempotency-Key was reused with a different payload");
}
if (!envelope.success) throw new Error(String(envelope.error_code));
// A replayed submit lands here too, returning the ORIGINAL job.
return envelope.data.id as string;
}
// The four terminal states, taken from the reference. QUEUED and
// PROCESSING are the only non-terminal ones.
const TERMINAL = new Set(["SUCCEEDED","PARTIAL","FAILED","CANCELLED"]);
async function waitForJob(jobId: string, deadlineMs = 30 * 60 * 1000) {
const startedAt = Date.now();
while (Date.now() - startedAt < deadlineMs) {
const response = await fetch(`${BASE}/jobs/${jobId}`, { headers });
const envelope = await response.json();
if (!envelope.success) throw new Error(String(envelope.error_code));
const status: string = envelope.data.status;
if (TERMINAL.has(status)) return status;
await new Promise((resolve) => setTimeout(resolve, 5_000));
}
throw new Error("Job did not reach a terminal state before the deadline");
}
// The minimal valid body: one vehicle, one order, every required field
// filled in. See the POST /jobs reference for the full field list.
const body = {
external_reference: "your-own-id",
optimization_options: {
delivery_mode: "1",
kind_of_plan: "1",
tour_mode: "1;0;0;0;0;0",
},
vehicles: [
{
external_vehicle_id: "veh-01",
resource_id: 1,
from_address: "118 Tay Son, Dong Da, Ha Noi",
from_lat: 21.0122,
from_lon: 105.8252,
from_working_time: "2026-09-04T07:00:00+07:00",
to_working_time: "2026-09-04T18:00:00+07:00",
handling_unit_ids: [1],
height_of_vehicle: 180,
length_of_vehicle: 420,
width_of_vehicle: 190,
vehicle_tonnage: 1500,
},
],
orders: [
{
external_order_id: "ord-01",
transport_mode: "lastmile",
handling_unit_id: 1,
service_id: 1,
pickup_address: "118 Tay Son, Dong Da, Ha Noi",
pickup_lat: 21.0122,
pickup_lon: 105.8252,
pickup_date_time: "2026-09-04T08:00:00+07:00",
pickup_date_time_to: "2026-09-04T09:00:00+07:00",
drop_address: "72 Nguyen Trai, Thanh Xuan, Ha Noi",
drop_lat: 20.9971,
drop_lon: 105.8032,
drop_date_time: "2026-09-04T09:30:00+07:00",
drop_date_time_to: "2026-09-04T11:00:00+07:00",
rlength: 40,
rwidth: 30,
rheight: 30,
rweight: 5,
count_of_parcels: 1,
},
],
};
const jobId = await submitJob(body);
const status = await waitForJob(jobId);
// Only SUCCEEDED and PARTIAL have a result to collect. FAILED and
// CANCELLED are terminal with nothing to fetch.
if (status === "SUCCEEDED" || status === "PARTIAL") {
const result = await fetch(`${BASE}/jobs/${jobId}/result`, { headers });
const envelope = await result.json();
if (envelope.success) use(envelope.data);
}The full status table and a bounded polling example are in the route optimisation integration section.