Skip to main content
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.

Handling async jobs | Route4Green