Skip to main content
Docs menu

Integrating from a TMS, ERP or WMS

Map whatever system holds your data onto the one route optimisation job body.

The SDK is not released. What follows uses plain HTTP.

Whatever system holds your data, there is one way in: the route optimisation job body. A TMS, an ERP and a WMS differ only in which of the three inputs, vehicles, orders and the addresses behind them, they already hold, and what they call it. A TMS usually already has orders with time windows and a vehicle list. An ERP usually has sales or delivery orders and customer addresses, but rarely a vehicle list, and its addresses need geocoding before they reach the job body. A WMS usually has picked and packed shipments with a true measured weight and a dock ready time, but no vehicle list and no delivery window of its own.

Three systems, one job body

From a TMS

A transport management system usually already holds orders with time windows and a vehicle list, so most of the job body’s vehicles and orders fields map directly. The usual gap is the vehicle’s physical dimensions: height_of_vehicle, length_of_vehicle and width_of_vehicle. Most TMS systems track payload capacity by weight, not by the three dimensions the job body requires, so keep a one-time fleet reference for those alongside the vehicle list rather than expecting them from every job.

From an ERP

An ERP usually holds sales or delivery orders and customer addresses, but not a vehicle list, since fleet data usually lives with dispatch instead. Its addresses are text, not coordinates, so geocode them into pickup_lat, pickup_lon, drop_lat and drop_lon before they reach the job body, keeping the original text for pickup_address and drop_address too, since the field is still required alongside the coordinates. An ERP’s order lines are also usually itemised by product rather than by drop: aggregate the weight and package count of every line going to the same address into one order item before submitting, since rweight and count_of_parcels each describe a single order, not a single line.

From a WMS

A warehouse management system usually holds picked and packed shipments with a true measured weight and a dock ready time, which map directly onto rweight, count_of_parcels and pickup_date_time. What it does not usually hold is a vehicle list or a delivery window: those live with the carrier or the TMS on the receiving end, so a WMS only integration needs vehicles, drop_date_time and drop_date_time_to supplied from wherever that planning already happens, rather than invented at the warehouse.

Field mapping: job body to the Pilot data template

The middle column is the Pilot data template’s own column name, on the orders, vehicles or sites sheet. A field with no workbook column is a genuine gap: something your integration supplies from elsewhere, not something to force out of the template.

Job body fieldWorkbook columnNote
external_vehicle_idvehicles.external_idReuse the workbook id directly.
from_address, from_lat, from_lonvehicles.depot_codeLook the depot up in the sites sheet by depot_code and take its address, lat and lon.
from_working_time, to_working_timevehicles.shift_availabilityParse the shift window into two ISO 8601 timestamps; the workbook stores it as one value.
vehicle_tonnagevehicles.capacity_kgDirect, already in kilograms.
height_of_vehicle, length_of_vehicle, width_of_vehiclenoneNo workbook column. The template records payload capacity, not physical dimensions; keep a one-time fleet reference for these three.
handling_unit_idsnoneNo workbook column. An internal identifier your integration assigns, not customer data.
external_order_idorders.external_idReuse the workbook id directly.
pickup_address, pickup_lat, pickup_lonorders.site_code (pickup row)Look the site up in the sites sheet by site_code and take its address, lat and lon.
drop_address, drop_lat, drop_lonorders.site_code (drop row)Same lookup, for the row where direction marks a drop.
pickup_date_time, pickup_date_time_toorders.ready_time, orders.due_time (pickup row)Direct, once parsed to ISO 8601.
drop_date_time, drop_date_time_toorders.ready_time, orders.due_time (drop row)Direct, once parsed to ISO 8601.
rweightorders.weight_kgDirect, already in kilograms.
count_of_parcelsorders.package_countDirect.
rlength, rwidth, rheightorders.volume_m3No direct column. The template records one volume figure, not three dimensions; submit a fixed dimension set per package type if you track one.
transport_modenoneNo workbook column. Set once per integration rather than per row; a single delivery workbook is usually lastmile.
handling_unit_id, service_id, resource_idnoneNo workbook column. Internal identifiers your integration assigns.
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! };

// Shaped like the Pilot data template: one row per site, one row per
// vehicle, one row per order leg (direction "pickup" or "drop").
type Site = { site_code: string; address: string; lat: number; lon: number };
type Vehicle = {
  external_id: string;
  depot_code: string;
  capacity_kg: number;
  // Parsed once from the sheet's single shift_availability value.
  shiftFrom: string;
  shiftTo: string;
};
type OrderLeg = {
  external_id: string;
  direction: "pickup" | "drop";
  site_code: string;
  weight_kg: number;
  package_count: number;
  ready_time: string;
  due_time: string;
};

function buildJobBody(sites: Site[], vehicles: Vehicle[], orderLegs: OrderLeg[]) {
  const siteByCode = new Map(sites.map((site) => [site.site_code, site]));

  const jobVehicles = vehicles.map((vehicle) => {
    const depot = siteByCode.get(vehicle.depot_code)!;
    return {
      external_vehicle_id: vehicle.external_id,
      resource_id: 1, // assigned by your integration, not in the workbook
      from_address: depot.address,
      from_lat: depot.lat,
      from_lon: depot.lon,
      from_working_time: vehicle.shiftFrom,
      to_working_time: vehicle.shiftTo,
      handling_unit_ids: [1], // assigned by your integration, not in the workbook
      // No workbook column for physical dimensions: keep a one-time fleet
      // reference alongside the vehicle list rather than pulling these per job.
      height_of_vehicle: 180,
      length_of_vehicle: 420,
      width_of_vehicle: 190,
      vehicle_tonnage: vehicle.capacity_kg,
    };
  });

  // Pair each order's pickup and drop rows into one job order item.
  const legsByExternalId = new Map<string, OrderLeg[]>();
  for (const leg of orderLegs) {
    legsByExternalId.set(leg.external_id, [...(legsByExternalId.get(leg.external_id) ?? []), leg]);
  }

  const jobOrders = [...legsByExternalId.entries()].map(([externalId, legs]) => {
    const pickup = legs.find((leg) => leg.direction === "pickup")!;
    const drop = legs.find((leg) => leg.direction === "drop")!;
    const pickupSite = siteByCode.get(pickup.site_code)!;
    const dropSite = siteByCode.get(drop.site_code)!;

    return {
      external_order_id: externalId,
      // No workbook column: set once per integration, not per row.
      transport_mode: "lastmile" as const,
      handling_unit_id: 1, // assigned by your integration, not in the workbook
      service_id: 1, // assigned by your integration, not in the workbook
      pickup_address: pickupSite.address,
      pickup_lat: pickupSite.lat,
      pickup_lon: pickupSite.lon,
      pickup_date_time: pickup.ready_time,
      pickup_date_time_to: pickup.due_time,
      drop_address: dropSite.address,
      drop_lat: dropSite.lat,
      drop_lon: dropSite.lon,
      drop_date_time: drop.ready_time,
      drop_date_time_to: drop.due_time,
      // No workbook column for per-package dimensions: the template only
      // carries a total weight, so length/width/height need their own source.
      rlength: 40,
      rwidth: 30,
      rheight: 30,
      rweight: pickup.weight_kg,
      count_of_parcels: pickup.package_count,
    };
  });

  return {
    external_reference: `cutoff-${new Date().toISOString().slice(0, 10)}`,
    optimization_options: {
      delivery_mode: "1",
      kind_of_plan: "1",
      tour_mode: "1;0;0;0;0;0",
    },
    vehicles: jobVehicles,
    orders: jobOrders,
  };
}

// Submit once per cut-off, with a fresh Idempotency-Key for this submit.
async function submitFromWorkbook(sites: Site[], vehicles: Vehicle[], orderLegs: OrderLeg[]) {
  const body = buildJobBody(sites, vehicles, orderLegs);
  const idempotencyKey = randomUUID();

  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) throw new Error(String(envelope.error_code));

  return envelope.data.id as string;
}

The pattern that repeats

Submit one job per cut-off, using external_reference for your own job id and the Idempotency-Key header for safe retries; both replay branches are already covered in the handling async jobs guide. Follow the submitted job with a poll or a webhook, covered in the verifying webhooks guide, then write the result back to your source system keyed on the same external ids you submitted. Treat unassigned_orders explicitly: a job with unassigned items is still a completed job, so surface those items back to the source system rather than dropping them silently.

Limits still apply

The request limits and the last mile scope stated in the route optimisation reference apply to a TMS, ERP or WMS integration the same way they apply to any other. Nothing about the source system relaxes them.

The same column names used in the table below are the Pilot data template’s own vocabulary, so a customer who has already filled in the workbook is reading a table they recognise. Request access to the template from the resources page once you know which columns you can already fill.

Integrating from a TMS, ERP or WMS | Route4Green