# Routal API Developers — Full Reference > Complete documentation for the Routal API. Routal is a B2B SaaS for route optimization and last-mile delivery. The API is REST/JSON, authenticated with a `private_key` query parameter against `https://api.routal.com`. Source: https://developers.routal.com --- # Delete a plan URL: https://developers.routal.com/docs/plan/deleteV2PlanId Deletes the plan together with all its routes and stops. --- # Retrieve a plan URL: https://developers.routal.com/docs/plan/getV2PlanId Returns the full plan, including its routes, stops, vehicles and metadata. --- # List routes of a plan URL: https://developers.routal.com/docs/plan/getV2PlanIdRoutes >- --- # List stops of a plan URL: https://developers.routal.com/docs/plan/getV2PlanIdStops >- --- # List plans URL: https://developers.routal.com/docs/plan/getV2Plans >- --- # Create a plan URL: https://developers.routal.com/docs/plan/postV2Plan Creates a new plan inside the given project. --- # Optimize a plan URL: https://developers.routal.com/docs/plan/postV2PlanIdOptimize Triggers the Routal optimizer for this plan. --- # Update a plan URL: https://developers.routal.com/docs/plan/putV2PlanId >- --- # Delete a route URL: https://developers.routal.com/docs/route/deleteV2RouteId Deletes the route from its plan. --- # Retrieve a route URL: https://developers.routal.com/docs/route/getV2RouteId >- --- # Dispatch a route to its driver URL: https://developers.routal.com/docs/route/postV2RouteIdDispatch >- --- # Optimize the stop sequence of a single route URL: https://developers.routal.com/docs/route/postV2RouteIdOptimize >- --- # Create multiple routes URL: https://developers.routal.com/docs/route/postV2Routes Creates multiple routes in a plan in a single request. --- # Update a route URL: https://developers.routal.com/docs/route/putV2RouteId >- --- # Reorder the stops of a route URL: https://developers.routal.com/docs/route/putV2RouteIdStops Sets the ordered list of stop IDs for this route. --- # Retrieve a stop URL: https://developers.routal.com/docs/stop/getV2StopStop_id >- --- # Move stops between plans URL: https://developers.routal.com/docs/stop/postV2StopMove Moves one or more stops to a different plan. --- # Create multiple stops URL: https://developers.routal.com/docs/stop/postV2Stops Bulk-creates stops with already known coordinates. --- # Create stops with automatic geocoding URL: https://developers.routal.com/docs/stop/postV2StopsGeocode Bulk-creates stops from free-text addresses. --- # Search stops with rich filters URL: https://developers.routal.com/docs/stop/postV2StopsSearch >- --- # Update a stop URL: https://developers.routal.com/docs/stop/putV2StopStop_id Updates a stop. --- # Delete multiple stops URL: https://developers.routal.com/docs/stop/putV2StopsDelete Deletes the given stops in a single request. --- # Delete a task URL: https://developers.routal.com/docs/task/deleteV2TaskTask_id Removes a task from a stop. --- # Create a task on a stop URL: https://developers.routal.com/docs/task/postV2Task Attaches a new task to an existing stop. --- # Update a task URL: https://developers.routal.com/docs/task/putV2TaskTask_id >- --- # Retrieve a vehicle URL: https://developers.routal.com/docs/vehicle/getV2VehicleVehicle_id >- --- # List and search vehicles URL: https://developers.routal.com/docs/vehicle/getV2Vehicles Returns a paginated, sortable list of the vehicles available in a project. --- # Create multiple vehicles (v3) URL: https://developers.routal.com/docs/vehicle/postV3Vehicles Bulk-creates vehicles in a project. --- # Update a vehicle URL: https://developers.routal.com/docs/vehicle/putV2VehicleVehicle_id >- --- # Authentication URL: https://developers.routal.com/docs/authentication How API keys work in Routal — where to issue them, how to deactivate them, and how to keep them safe. The Routal API uses **API keys** to authenticate every request. Keys are scoped to a single organization and grant the same permissions as the user who created them. ## How keys are used Pass your key as the `private_key` query string parameter on every request: ```bash curl -G 'https://api.routal.com/v2/vehicles' \ --data-urlencode 'private_key=YOUR_KEY' ``` For methods that accept a body (POST, PUT), keep `private_key` in the query string and put the payload in the body: ```bash curl -X POST 'https://api.routal.com/v2/plan?private_key=YOUR_KEY&project_id=...' \ -H 'Content-Type: application/json' \ -d '{"label": "Hello"}' ``` The API **does not** accept keys in the `Authorization` header today. ## Key format API keys are random alphanumeric strings prefixed with `api_key_` (e.g. `api_key_xY7…32 chars`). Treat the entire string as the secret — there is no separate "secret part" to extract. ## Issuing and managing keys Create, label, deactivate, and delete keys from the planner dashboard: [planner.routal.com/h/settings/developers/api-keys](https://planner.routal.com/h/settings/developers/api-keys) Each key has: - A **label** you choose (e.g. `staging-integration`, `analytics-readonly`) — purely for your own bookkeeping. - An **active** flag — when set to `false` the key is rejected on auth. Useful to temporarily revoke a key without deleting it. - A **created at** timestamp. There is **no `last_used_at` timestamp today** — you can't tell from the dashboard when (or whether) a specific key was last used. You can have multiple keys active at the same time per organization, which makes [rotation](#rotation) painless. ## Security - **Treat the key like a password.** Anyone with the key can read and mutate every plan, route, stop, and vehicle in your organization (across projects the creating user has access to). - **Never commit keys to a repository.** Use environment variables (`process.env.ROUTAL_API_KEY`) or a secret manager (1Password, Doppler, AWS Secrets Manager, Vault). - **Never put a key in a URL that ends up in logs.** Most server logs and HTTP intermediaries capture full URLs, including query strings. Where possible, log the path only and redact the query string before persisting it. This is the biggest practical risk of query-string auth. - **Browser-side code is a no-go.** A key shipped to a browser is a public key. If you need to call the API from a browser, proxy through your own backend. ## Rotation Rotate keys on a regular cadence (every 90 days is a reasonable default) and immediately if you suspect exposure. The recommended pattern: 1. Issue a new key in the dashboard. Keep the existing one **active**. 2. Deploy your services with the new key. 3. Once you're confident nothing is still using the old key (give it a deploy window plus a margin), set the old key's `active` flag to `false` from the dashboard. If anything was still calling it, you'll see auth failures — you can re-activate it instantly. 4. After another window passes without re-activations, delete the old key permanently. This zero-downtime swap works because Routal supports multiple active keys simultaneously per organization. ## Permissions and scoping API keys today are **organization-scoped**: they can read and mutate every resource the creating user has access to. There is no per-key scoping (read-only vs. read-write, project-level scoping, IP allowlist, etc.). If your integration only needs read access, isolate it behind your own service layer. Granular scopes are on the roadmap. If you have a concrete use case, [contact us](mailto:developers@routal.com). ## What's next - [Quickstart](/docs/quickstart) — make your first authenticated call. - [Errors](/docs/errors) — what happens when a key is missing, malformed, or deactivated. (Spoiler: every API-key auth failure surfaces as `highway.apiKey.error.not_found` today.) --- # Build with AI URL: https://developers.routal.com/docs/build-with-ai Wire Routal into your AI coding assistant — Cursor, Claude Code, ChatGPT, Gemini, Copilot — so it generates correct Routal code on the first try. In 2026 most developers write API code with an AI assistant open in the next tab. Routal ships three machine-readable assets that drop into any assistant and prime it for correct code generation. This page is the setup guide. ## The three assets | Asset | What it is | |---|---| | [`/llms.txt`](/llms.txt) | Concise index for LLM crawlers — title + summary + URL for every doc page and every endpoint. | | [`/llms-full.txt`](/llms-full.txt) | Full Markdown dump of every doc page + every endpoint, one file (~150 KB) — sized for an LLM context window. | | [`/openapi.json`](/openapi.json) | OpenAPI 3.0 spec — for codegen tools (`openapi-typescript`, `openapi-python-client`, `oapi-codegen`, etc.). | Most readers want `/llms-full.txt`. It is the authoritative dump and stays in sync with the docs automatically. --- ## Two ways to give your assistant the context ### Zero setup — use the "Open in AI" button Every [recipe page](/docs/recipes) has an **Open in AI assistant** button. Click → pick ChatGPT, Claude, Copilot, or Gemini → the assistant opens with the recipe context, the `llms.txt` URL, and a parameterised prompt already loaded. No config files, no rules, no setup. Use this path when you are exploring scenarios or writing Routal code occasionally. ### Project rules — wire it into your IDE once For an active integration project, drop the Routal context into your assistant's rules file. The model picks it up automatically on every session. #### Cursor Create `.cursor/rules/routal.mdc` in your repo root: ```mdc --- description: Routal API context alwaysApply: true --- When writing code that calls api.routal.com, treat https://developers.routal.com/llms-full.txt as the authoritative reference for endpoints, payload shapes, error codes, and gotchas. Do not invent endpoints — if it is not in that file or in https://developers.routal.com/openapi.json, it does not exist. Hard rules: - Auth is `?private_key=...` query parameter — NOT `Authorization: Bearer`. - Branch error handling on `message_id`, not `message` text. - Use bulk endpoints (POST /v2/stops, POST /v3/vehicles) instead of looping single creates. - React to webhooks instead of polling. ``` #### Claude Code Add to `CLAUDE.md` in your project root: ```markdown ## Routal API integration Authoritative API reference: https://developers.routal.com/llms-full.txt When writing code that calls api.routal.com: - Auth is `?private_key=...` query parameter, never an `Authorization` header. - Branch on `error.message_id`, not `error.message`. - Prefer bulk endpoints (POST /v2/stops, POST /v3/vehicles) over loops. - Subscribe to webhooks rather than polling. Do not invent endpoints. If it is not in `/llms-full.txt` or `/openapi.json`, it does not exist. ``` #### Windsurf, GitHub Copilot, or other assistants Most assistants accept a URL as a knowledge source via their rules / instructions file. Point them at `https://developers.routal.com/llms-full.txt`. If your tool doesn't accept URLs, copy the file contents in directly. --- ## Verify the assistant has the context After wiring, ask your assistant a control question. A correctly primed assistant produces something like the snippet below; an unprimed one will try `Authorization: Bearer`, hallucinate an endpoint, or forget `project_id`. **Prompt:** > Write a curl call that creates a Routal plan for tomorrow. **Expected answer:** ```bash curl -X POST 'https://api.routal.com/v2/plan?private_key=YOUR_KEY&project_id=YOUR_PROJECT_ID' \ -H 'Content-Type: application/json' \ -d '{ "label": "Tomorrow", "execution_date": "2026-05-22" }' ``` Three signals the assistant has the context: - `private_key` lives in the **query string**, not in `Authorization`. - `project_id` is required as a query parameter. - Base URL is `api.routal.com`, not `developers.routal.com`. If the answer looks wrong, paste `/llms-full.txt` into the conversation as a fallback and ask the assistant to re-read it. --- ## The 10 facts every assistant needs If you are writing a system prompt manually, these are the load-bearing rules. They are already inside `/llms-full.txt` — this is just the human-readable summary so you can sanity-check the assistant's output. 1. **Auth is `private_key` as a query parameter.** Never `Authorization: Bearer`. 2. **`external_id` is your idempotency lever.** No `Idempotency-Key` header today. Pattern: `POST /v2/stops/search` on `external_id` → if found, reuse; else create. 3. **Branch on `message_id`.** Errors are `{ message, message_id: "highway..error." }`. The text may change; the ID is stable. 4. **Use bulk endpoints.** `POST /v2/stops` and `POST /v3/vehicles` accept arrays. Looping single creates burns the 2,000 req/min budget. 5. **Subscribe to webhooks, don't poll.** Webhooks fire on state change. Polling wastes the rate budget and lags real events. 6. **`POST /v2/plan/{id}/optimize` is not retry-safe.** A second call may move stops if the first completed. If one is running you get `highway.optimization.error.sync_optimization_already_progress` — wait, don't retry. 7. **Dispatch sends an email; it does NOT change route status.** Route flips to `in_transit` when the driver opens the magic link, not when you call `POST /v2/route/{id}/dispatch`. 8. **Webhooks have no signature today.** Auth them with a URL token (`/webhooks/routal?token=...`). Dedup on `(event_id, data.id, data.updated_at)`. Webhooks auto-disable after 50 consecutive failures. 9. **Pagination is offset-based** — `{ total, limit, offset, pages, page, docs }`. Always pin a `sort` to avoid duplicates under concurrent writes. 10. **Deletes cascade and don't check status.** `DELETE /v2/plan/{id}` succeeds even when in-progress. The one hard guard is `is_locked: true` on a route. --- ## The Routal mental model ``` Project (long-lived workspace; vehicles, custom fields, integrations live here) └── Plan (one execution window — typically a day or a shift) ├── Stops (deliveries / pickups / services) ├── Vehicles (assigned to the plan, pulled from the project's fleet) └── Routes (ordered list of stops per vehicle, created by the optimizer) ``` Status enums you will see in responses and webhooks: - **Plan**: `draft` · `planning` · `in_progress` · `finished` - **Route**: `not_started` · `in_transit` · `finished` - **Stop**: `pending` · `incomplete` · `completed` · `canceled` Routal computes transitions automatically. Clients never call an endpoint to advance status. The route flips to `in_transit` when the driver opens the magic link; the plan flips to `finished` when every route is `finished`. --- ## What's NOT on this page - **The full API context block.** Lives at [`/llms-full.txt`](/llms-full.txt) — auto-generated from every doc page so it never drifts. Point your assistant at the URL rather than maintaining a separate copy. - **Code samples in TypeScript / Python / cURL.** Every endpoint page in the [API Reference](/docs) has them, and every [recipe](/docs/recipes) walks the full integration in three languages. - **A "how Routal works" overview.** Browse [planner.routal.com](https://planner.routal.com) for the product side, or read [Resource lifecycle](/docs/lifecycle) for the API-side state machine. --- # Changelog URL: https://developers.routal.com/docs/changelog Notable changes to the Routal API and the developer portal. Subscribe to the RSS feed to track every release. This is the running log of changes to the Routal API. Subscribe to the [RSS feed](/changelog/rss.xml) to track every entry without coming back here. The format is loosely [Keep a Changelog](https://keepachangelog.com/). Entries are dated, grouped by type, and link to the relevant docs or endpoints. Breaking changes never ship on the current major — see [Versioning](#versioning) below. --- ## Versioning Routal uses **path-based versioning**: the major version is the first segment after the host (`/v2/`, `/v3/`). The version you call is the contract you get — never silently upgraded under your feet. ### Active versions | Version | Status | Notes | |---|---|---| | `v2` | Stable — covers the full API surface used in production today. | Default for every endpoint in the [API Reference](/docs) unless otherwise noted. | | `v3` | Selective — a small set of endpoints that re-shape v2 ergonomics (bulk creation, mostly). | Track the entries below for additions. | #### What lives on `v3` today | `v3` endpoint | Use instead of | |---|---| | `POST /v3/vehicles` (create many vehicles in one call) | Multiple calls to `POST /v2/vehicle` | Everything else is on `v2`. `v2` is not deprecated. ### What counts as a breaking change These changes only ship in a new major version: - Removing or renaming a field in a response. - Tightening a constraint (e.g. an optional field becomes required). - Removing or renaming an enum value. - Removing an endpoint. - Changing an HTTP status code for an existing success path. - Changing the shape or signing scheme of webhook payloads. These are **not** breaking and can ship at any time on the current major: - Adding a new optional request field. - Adding a new field to a response. - Adding a new endpoint. - Adding a new enum value (handle unknowns defensively — see [Forward compatibility](#forward-compatibility)). - Adding a new webhook event type. - Changing the human-readable `message` text of an error (use `message_id` for branching — see [Errors](/docs/errors)). ### Forward compatibility Even on the same major version, your code should: 1. **Ignore unknown response fields.** Deserializers should not reject payloads with extra keys. 2. **Handle unknown enum values defensively.** A webhook can carry `type: "stop.something_new"`; a stop status field can return a value you don't know yet. Treat unknown as "ignore" or "route to a dead-letter queue", not "throw". 3. **Dedup webhook deliveries** using `(event_id, data.id, data.updated_at)` — the envelope doesn't carry a unique delivery ID today ([Idempotency](/docs/idempotency#pattern-3--webhook-handlers)). 4. **Don't hard-code response order** unless the endpoint guarantees it. Stops in a route are ordered by `chain_position`; other arrays are not guaranteed to keep their order between requests. ### Deprecation — current status Routal does not yet publish a formal deprecation timeline (notice period, sunset SLA, automatic `410 Gone` responses). When an endpoint or field is going away we will: - Announce it in the changelog entries below before the change ships. - Mark it `deprecated: true` in the [OpenAPI spec](/openapi.json). - Email customers whose tenants have called the deprecated surface recently. If your integration depends on a behaviour that isn't covered by the changelog or the OpenAPI spec, [contact us](mailto:developers@routal.com) — we'd rather extend the contract than have you build around undocumented quirks. ### OpenAPI spec — the source of truth The machine-readable spec covers both versions: - [`/openapi.json`](/openapi.json) — OpenAPI 3.0 - [`/llms.txt`](/llms.txt) — index for LLM crawlers - [`/llms-full.txt`](/llms-full.txt) — full markdown dump If you generate clients, point your codegen at this URL. The Routal backend also serves the same spec at `https://api.routal.com/swagger.json` (Swagger 2.0); the OpenAPI 3.0 version above is the recommended one for modern tooling. --- ## 2026-05-21 — Documentation accuracy pass **Changed** - [Authentication](/docs/authentication): aligned the documented API-key fields and format with what the API actually returns (`label`, `active`, `created_at`, keys prefixed `api_key_`). The single auth error code is `highway.apiKey.error.not_found`. - [Errors](/docs/errors): the machine-code table is now grouped by domain and lists only codes the API actually returns today. A handful of codes that had been documented without ever shipping are gone. - [Rate limits](/docs/rate-limits): updated to the real cap — **2,000 requests per minute per credential**, global. The previous per-method breakdown and `Retry-After` header claim were not accurate to what the platform exposes. - [Webhooks](/docs/webhooks): rewritten against the real delivery behaviour — envelope (`{ created_at, project_id, event_id, meta, data }`), event catalog (8 events with `routal..2..` naming), and failure handling (5 → owner email, 50 → auto-disable). The previous mention of HMAC signing, `X-Routal-*` headers, and a retry-schedule table was aspirational; none of that is implemented today. - [Quickstart](/docs/quickstart): corrected example responses against the real `VehicleData` / `PlanResponseData` schemas. `project_id` is required for both `GET /v2/vehicles` and `POST /v2/plan`. **Added** - ``, ``, `` — components that pull values straight from the OpenAPI spec at build time, so enums and defaults can never drift silently from `/openapi.json`. - A build-time drift check that fails CI if any prose claim no longer matches the spec — 27 claims tracked at launch. --- ## 2026-05-21 — Developer portal launch **Added** - `developers.routal.com` goes live with a curated API reference auto-generated from the OpenAPI spec. - Dedicated pages for [Authentication](/docs/authentication), [Errors](/docs/errors), [Rate limits](/docs/rate-limits), and [Webhooks](/docs/webhooks). - New conceptual content: [Resource lifecycle](/docs/lifecycle), [Pagination](/docs/pagination), [Idempotency](/docs/idempotency), and a [Versioning](#versioning) policy in this changelog. - Machine-readable assets for LLM crawlers and codegen: [`/openapi.json`](/openapi.json), [`/llms.txt`](/llms.txt), [`/llms-full.txt`](/llms-full.txt). **Changed** - The recommended OpenAPI surface is now `https://developers.routal.com/openapi.json` (OpenAPI 3.0). The backend still serves Swagger 2.0 at `https://api.routal.com/swagger.json`, but tooling should consume the converted 3.0 version. --- ## Subscribe - **RSS** — [/changelog/rss.xml](/changelog/rss.xml) - **Email** — drop a line at [developers@routal.com](mailto:developers@routal.com) to be added to the developer announcements list. - **Status incidents** — [routal.statuspage.io](https://routal.statuspage.io/) (separate from this changelog). ## Reporting a regression If a recent release broke your integration, email [developers@routal.com](mailto:developers@routal.com) with the endpoint, the request, and the response. Tag it `[regression]` in the subject line — those go to the on-call queue. --- # Errors URL: https://developers.routal.com/docs/errors HTTP status codes, the error envelope, and the machine codes Routal returns. The Routal API uses standard HTTP status codes. Every non-2xx response body is JSON with the same shape. ## Error envelope Every error response uses the same schema, named `ApiError` in the [OpenAPI spec](/openapi.json): Example body: ```json { "message": "Domain not found", "message_id": "highway.domain.error.not_found" } ``` - `message` is human-readable, suitable for log lines but not for end-user display. Wording can change for clarity across releases. - `message_id` is the stable, dotted machine code. Use it for branching logic in your integration. `message_id` follows the pattern `highway..error.`. `highway` is Routal's stable namespace for machine-readable error codes — branch on the full string, not on substrings. ## HTTP status codes | Status | Meaning | What to do | |---|---|---| | `200 OK` | Request succeeded. | Process the response body. | | `201 Created` | Resource created. | Read the new resource's `id` from the response body. | | `204 No Content` | Success with no body (typical for DELETE). | Treat as success. | | `400 Bad Request` | Payload validation failed — missing field, wrong type, unknown enum value, invalid ID format. | Inspect `message_id`. Fix the request. Do **not** retry the same payload — it will fail again. | | `401 Unauthorized` | Missing or invalid `private_key`. | Verify the key. See [Authentication](/docs/authentication). | | `403 Forbidden` | Authenticated, but the credential does not have permission for this resource or action. | Check project membership and the key's organization. | | `404 Not Found` | Resource ID does not exist (or has been deleted). | Confirm the ID. Do not retry. | | `409 Conflict` | The request collides with current resource state (e.g. deleting a locked route). | Read current state, then decide. | | `429 Too Many Requests` | You've hit a [rate limit](/docs/rate-limits). | Back off and retry. | | `5xx Server Error` | Something on our side went wrong. | Retry with exponential backoff. Check [status.routal.com](https://routal.statuspage.io/). If persistent, email support with the `message_id`. | ## Common machine codes Routal emits dozens of `message_id` values across many domains. Below are the most common ones an integration tends to hit. Codes you don't see here exist — when in doubt, log the `message_id` you got back and contact support. ### Authentication | `message_id` | Meaning | |---|---| | `highway.apiKey.error.not_found` | The `private_key` does not match any active API key in the tenant. | There is no separate `missing_key` / `invalid_key` / `revoked_key` taxonomy — every API-key auth failure surfaces as `highway.apiKey.error.not_found`. ### Resource not found | `message_id` | Meaning | |---|---| | `highway.plan.error.not_found` | No plan with the given `id`. | | `highway.route.error.not_found` | No route with the given `id`. | | `highway.stop.error.not_found` | No stop with the given `stop_id`. | | `highway.task.error.not_found` | No task with the given `task_id`. | | `highway.vehicle.error.not_found` | No vehicle with the given `vehicle_id`. | ### Validation | `message_id` | Meaning | |---|---| | `highway.validation.error.invalid_id` | A required ID field was not a valid 24-character hex string. | | `highway.stop.error.custom_fields_invalid` | A stop's `custom_fields` did not match the project's custom-field definitions. | | `highway.route.error.custom_fields_invalid` | Same, for a route. | | `highway.vehicle.error.custom_fields_invalid` | Same, for a vehicle. | | `highway.geocoding.error.wrong_lat_lng` | A location's `lat` / `lng` is outside the valid range. | Some payload validation errors (missing required field, wrong type) surface as a plain 400 with a different body shape — no `message_id`, just a `statusCode` and a `message` describing the offending field: ```json { "statusCode": 400, "error": "Bad Request", "message": "child \"label\" fails because [...]" } ``` Treat `statusCode === 400 && !body.message_id` as the sentinel for this case. Read `message` to figure out which field is wrong, fix the request, and don't retry until you've fixed it. ### Permissions | `message_id` | Meaning | |---|---| | `highway.plan.error.user_not_allowed` | Authenticated, but the user/key lacks permission on the target plan (or its project). | | `highway.organization.error.user_not_allowed` | The user is not part of the target organization. | | `highway.user.error.user_not_allowed` | The action is not permitted for this user. | ### Resource state | `message_id` | Meaning | |---|---| | `highway.route.error.locked` | Cannot delete or modify a locked route (`is_locked: true`). | | `highway.route.error.not_in_transit` | The action requires the route to be `in_transit`. | | `highway.stop.error.move_not_pending_stops` | Cannot move stops that are no longer `pending`. | ### Optimization | `message_id` | Meaning | |---|---| | `highway.optimization.error.sync_optimization_already_progress` | An optimization is already running for this plan. Wait for it to finish before issuing another. | | `highway.optimization.error.no_result_found` | The optimizer ran but couldn't produce a feasible solution. | | `highway.optimization.error.too_much_requests` | The optimizer backend rate-limited the request. | ## Recovery patterns **Retry only on 429 and 5xx.** Other 4xx codes will fail the same way on retry — fix the request first. **Use exponential backoff with jitter** when retrying: ```js async function withRetry(fn, attempt = 0) { try { return await fn(); } catch (err) { if (attempt >= 5 || !isRetryable(err)) throw err; const baseMs = 500 * 2 ** attempt; // 500, 1000, 2000, 4000, 8000 const jitterMs = Math.random() * 500; await new Promise((r) => setTimeout(r, baseMs + jitterMs)); return withRetry(fn, attempt + 1); } } const isRetryable = (err) => err.status === 429 || (err.status >= 500 && err.status < 600); ``` **Log the `message_id`, not just the `message`.** The `message` text may change for clarity; the `message_id` is stable across releases. ## Reporting issues If you hit a 5xx that persists, or a `message_id` you can't make sense of, email [developers@routal.com](mailto:developers@routal.com) and include: - The endpoint (method + path) - The full response body (`message` and `message_id`) - An approximate timestamp (UTC) That's enough to trace the request server-side. --- # Idempotency URL: https://developers.routal.com/docs/idempotency How to make your integration safe to retry — using external_id and webhook deduplication. Networks fail. Functions get retried. Your client should run twice and produce one result. This page is the playbook for the Routal API. ## What Routal offers today - **No `Idempotency-Key` header.** Routal does not yet accept the Stripe-style `Idempotency-Key` header. Sending one is a no-op. - **`external_id` is the closest thing.** Stops, plans, and vehicles all accept an `external_id` field that you control. Routal stores it as `Custom user external identifier` and you can search by it. The patterns below assume you have a stable identifier on **your** side — an order number, a job ID, a UUID generated before the call. If you generate the ID inside the same function that calls Routal, retries produce a new ID and the dedup falls apart. ## Pattern 1 — Stop creation: dedup by `external_id` The most common retry scenario: you're importing orders and your worker crashes mid-batch. Stop search is a **POST** with a `predicates` body, not a GET: ```js async function findStopByExternalId(externalId, projectId, privateKey) { const res = await fetch( `https://api.routal.com/v2/stops/search?private_key=${privateKey}&project_id=${projectId}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ limit: 1, predicates: [ { field: 'external_id', operator: 'eq', value: externalId, type: 'string' }, ], }), }, ); const body = await res.json(); return body.docs[0]; // undefined if not found } async function ensureStop(order, planId, projectId, privateKey) { const existing = await findStopByExternalId(order.id, projectId, privateKey); if (existing) return existing; const res = await fetch( `https://api.routal.com/v2/stops?private_key=${privateKey}&plan_id=${planId}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify([ { external_id: order.id, label: order.customer, location: order.location, duration: order.serviceMinutes * 60, }, ]), }, ); return (await res.json()).docs[0]; } ``` Each `predicate` is `{ field, operator, value, type }`. Operator names follow the search semantics — use `eq` for exact match. ## Pattern 2 — Mutations: read-then-write `PUT /v2/stop/{stop_id}` and `PUT /v2/route/{id}` overwrite the fields you send. They are inherently idempotent: applying the same `PUT` twice produces the same state. For partial updates that depend on current state (e.g. "add a task to the existing list"), read first: ```js const stop = await getStop(stopId); await putStop(stopId, { ...stop, tasks: [...stop.tasks, newTask] }); ``` A retry of this block reads the (now updated) state and the second write is a no-op. ## Pattern 3 — Webhook handlers Webhooks may be delivered more than once and **the envelope does not carry a unique delivery ID today**. `event_id` in the payload is actually the event *type* (e.g. `routal.planner.2.plan.updated`), not a per-delivery identifier. Practical patterns: 1. **Dedup using fields inside `data`.** Most payloads include the resource `id` plus a timestamp like `updated_at`. Use `(event_id, data.id, data.updated_at)` as your dedup key. 2. **Make handlers naturally idempotent.** UPSERT on `(project_id, resource_id, event_id)` instead of INSERT. Mark a stop "completed" via PUT, not "log this report and increment a counter". See [Webhooks → Idempotency](/docs/webhooks#idempotency) for the full discussion. ## What is **not** safe to retry blindly - **`POST /v2/plan/{id}/optimize`** — re-runs the solver and may reshuffle stops between routes. Routal rejects a second optimize while one is already running (`highway.optimization.error.sync_optimization_already_progress`), but if the first finished and you retry without checking, you'll burn quota and possibly reassign work. Wait for `plan.optimized` before retrying. - **`POST /v2/route/{id}/dispatch`** — re-sends the driver magic-link email. Dedup on your side before calling. - **`POST /v2/stop/move`** — moving stops in terminal states (`completed`, `canceled`) is allowed by the API but harms delivery history. The endpoint description warns against it; treat it as not-safe-to-retry for any stop past `pending`. ## What's coming A real `Idempotency-Key` request header — same semantics as Stripe — is on the roadmap. Until it ships, the patterns above are the supported playbook. --- # Welcome URL: https://developers.routal.com/docs The Routal API automates route planning, dispatching, and proof of delivery — all through a single REST/JSON interface. The Routal API is a REST/JSON interface served from `https://api.routal.com`. Every request is authenticated with a `private_key` query parameter, every response is JSON, and every endpoint is documented and versioned. ## Start here - [Quickstart](/docs/quickstart) — go from zero to a successful API call in under five minutes. - [Build with AI](/docs/build-with-ai) — a pasteable context block to make Claude / Cursor / Copilot produce correct Routal code on the first try. - [Recipes](/docs/recipes) — end-to-end, runnable walkthroughs for the most common integration scenarios (TS + Python + cURL). - [Authentication](/docs/authentication) — how API keys work, where to issue them, and how to rotate them. - [Errors](/docs/errors) — every HTTP status, every machine code, and what to do about each one. - [Rate limits](/docs/rate-limits) — quotas, headers, and recommended backoff. - [Webhooks](/docs/webhooks) — subscribe to plan, route, and stop lifecycle events. ## The resource model Four resources cover the core domain: - **Plans** — group stops, routes, and vehicles for a delivery window. Create, optimize, and dispatch plans programmatically. - **Routes** — ordered sequence of stops served by a single vehicle. Retrieve, mutate, and dispatch routes. - **Stops** — individual delivery or service tasks. Create, update, move between routes, and geocode addresses. - **Vehicles** — fleet resources that execute routes. Manage capacity, skills, and shifts. Explore each in the **API Reference** in the sidebar. ## Conventions - All timestamps are ISO 8601 (`YYYY-MM-DDTHH:mm:ss.sssZ`) in UTC. - Coordinates use `{ lat, lng }` as decimal degrees in WGS 84. - IDs are 24-character hex strings. - Pagination uses `?offset=N&limit=M`. Responses include `total`. ## Status & support - Operational status: [routal.statuspage.io](https://routal.statuspage.io/) - Security and compliance: [trust.routal.com](https://trust.routal.com/) - Developer articles: [Routal support — developers collection](https://support.routal.com/en/collections/2821410-developers) --- # Resource lifecycle URL: https://developers.routal.com/docs/lifecycle The states a Plan, Route, and Stop move through, and which transitions are automatic. Plans, Routes, and Stops each have a small status field. This page documents the values, the transitions Routal performs for you, and the few hard guards you'll hit if you try to bypass them. Status values come from the OpenAPI spec at build time. If the backend adds or removes a status, this page updates on the next deploy. Behavioural notes (what triggers a transition) are written here against the backend code — if you find a discrepancy, [tell us](mailto:developers@routal.com), that's a doc bug. ## Plan A **Plan** groups stops, routes, and vehicles for an execution window. ### Automatic transitions Plan status is recomputed by Routal, not set directly by your client: - A plan in `draft` or `planning` transitions to `in_progress` when **any** of its routes becomes `in_transit`. - A plan in `in_progress` transitions to `finished` when **all** of its routes become `finished`. - `finished` is terminal — no further status changes. These transitions happen inside Routal as routes change state. You don't call an endpoint to advance the status. ### What does **not** change the plan status - **Creating a plan** — starts in `planning` (or `draft` if you pass that explicitly). - **`POST /v2/plan/{id}/optimize`** — runs the solver and may reassign stops between routes; **status is unchanged**. When the plan already has `in_transit` routes, optimize uses a "live" path that respects work in progress. - **`POST /v2/route/{id}/dispatch`** — sends the route's magic-link email to the driver. Does not change the route or plan status; the driver opening the link is what eventually flips the route to `in_transit`. ### Concurrent optimizations If you call `optimize` while another optimization for the same plan is still running, Routal rejects with `highway.optimization.error.sync_optimization_already_progress`. Wait for the first to finish (subscribe to `plan.optimized` or poll the plan) before re-running. ### Deleting a plan `DELETE /v2/plan/{id}` checks permissions only — Routal does **not** block delete based on plan status. Stops and routes inside the plan are soft-deleted in cascade. If your integration cares about not deleting in-progress work, gate it on your side. ## Route A **Route** is the ordered sequence of stops assigned to one vehicle. ### Automatic transitions - Created in `not_started`. - Moves to `in_transit` when the driver opens the magic link / opens the route in the driver app. - Moves to `finished` when all of its stops are in a terminal status (`completed`, `incomplete`, or `canceled`). ### Hard guards on routes - **`DELETE /v2/route/{id}`** is rejected with `highway.route.error.locked` if the route has `is_locked: true`. Status is **not** part of the check — a locked `not_started` route is rejected, and an unlocked `in_transit` route is **not**. Lock routes you don't want deleted. ## Stop A **Stop** is one delivery, pickup, or service task. `incomplete` and `canceled` are both terminal but mean different things: `incomplete` = the stop ended without a successful delivery (typically: route finished with attempts but no terminal report), `canceled` = explicitly cancelled. ### Attempts and reports A stop is **not** "one shot, one outcome". The driver can record multiple **attempts** on the same stop before it reaches a terminal state. Each attempt is a `Report` with one of three `type` values: | `report.type` | Effect on the stop | |---|---| | `service_report_attempted` | Non-terminal. Increments the stop's `report_attempts` counter. Status stays `pending`. The driver can try again. | | `service_report_completed` | Terminal. Status moves to `completed`. Further reports on this stop are rejected with `highway.stop.error.report_already_exists`. | | `service_report_canceled` | Terminal. Status moves to `canceled`. Same rejection on subsequent reports. | The `report_attempts: number` counter is exposed in the stop response payload (`StopData` schema) so you can show "3rd attempt" in your UI or trigger your own retry policies without re-scanning the report list. A stop with `status: "pending"` and `report_attempts: 2` is a legitimate, in-progress state — not an error. ### Hard guards on stops - **One terminal report per stop.** Once a `service_report_completed` or `service_report_canceled` report exists, `POST /v2/report` (any report type) is rejected with `highway.stop.error.report_already_exists`. To "redo" the outcome you'd need to delete the report on Routal's side first. - **Moving a stop** (`POST /v2/stop/move`) resets its status to `pending` regardless of prior reports. The endpoint description carries an explicit warning: a stop in Routal models a single delivery slot — moving an executed stop to a different plan rewrites history and breaks audit trails. **For a retry on a different day, create a new stop in the new plan** instead of moving the old one. Use move only for stops that have not yet been executed. ## Reacting to status changes Only a subset of Routal's domain events is currently delivered to webhooks. The eight events relevant to this lifecycle: | Resource | Webhook `event_id` | |---|---| | Plan | `routal.planner.2.plan.created`, `routal.planner.2.plan.updated`, `routal.planner.2.plan.deleted` | | Route | `routal.drivers.2.route.started`, `routal.drivers.2.route.finished` | | Stop | `routal.planner.2.stop.created`, `routal.planner.2.stop.deleted`, `routal.planner.2.stop.reported` | Notable gaps: there is no webhook event for `route.created`, `route.updated`, or `route.deleted`; no `plan.optimized` or `plan.dispatched`; no separate `stop.completed` or `stop.failed` (use `stop.reported` and branch on the payload). See [Webhooks](/docs/webhooks) for the envelope shape, failure handling, and the (current) absence of signature verification. ## Reading the current state If you missed a webhook (network blip, deploy window) read the authoritative state directly: ```bash curl -G 'https://api.routal.com/v2/plan/{id}' \ --data-urlencode 'private_key=YOUR_KEY' ``` The plan response carries its own `status`. Use `GET /v2/plan/{id}/routes` for nested routes and their statuses. --- # Pagination URL: https://developers.routal.com/docs/pagination How list endpoints page their results — offset, limit, and the response envelope. Every list endpoint in the Routal API returns the same paginated envelope. This page documents the shape and the per-endpoint defaults. Defaults and parameter lists below are pulled live from the OpenAPI spec at build time. If the backend changes a default, this page updates on the next deploy. ## Response envelope ```json { "total": 1000, "limit": 50, "offset": 0, "pages": 20, "page": 1, "docs": [ /* items */ ] } ``` | Field | Type | Description | |---|---|---| | `total` | integer | Total items matching the filter, ignoring `limit` / `offset`. | | `limit` | integer | Echo of the requested limit (or default). | | `offset` | integer | Echo of the requested offset (or default). | | `pages` | integer | Total pages at the current `limit` (`ceil(total / limit)`). | | `page` | integer | Current page, 1-indexed (`floor(offset / limit) + 1`). | | `docs` | array | The page of items. | The shape is the same across `/v2/plans`, `/v2/vehicles`, and `/v2/stops/search`. ## Per-endpoint defaults Defaults are not uniform — each endpoint declares its own. The tables below come straight from the spec. ### `GET /v2/plans` `sort` follows the format `field:asc|desc` (e.g. `created_at:desc`). ```bash curl -G 'https://api.routal.com/v2/plans' \ --data-urlencode 'private_key=YOUR_KEY' \ --data-urlencode 'limit=50' ``` ### `GET /v2/vehicles` ### `POST /v2/stops/search` Pagination here lives in the **request body**, not the query string: Query string carries only `project_id` (and your `private_key`): ## Iterating all items ```js async function* iteratePlans(privateKey) { const limit = 50; let offset = 0; while (true) { const url = new URL('https://api.routal.com/v2/plans'); url.searchParams.set('private_key', privateKey); url.searchParams.set('limit', String(limit)); url.searchParams.set('offset', String(offset)); const res = await fetch(url); if (!res.ok) throw new Error(`Failed: ${res.status}`); const body = await res.json(); for (const plan of body.docs) yield plan; offset += limit; if (offset >= body.total) return; } } ``` For stop search, swap the GET for a POST with the body shape above and read `body.docs` the same way. ## Recommendations - **Cache `total` only at the start of a job.** It can shift while you paginate if other users mutate resources. - **Pin a sort order.** Without `sort` (or `sort_by` + `sort_direction` for stops/search) the same record can appear on two pages or be skipped if rows are inserted mid-pagination. - **Don't deep-paginate to find one record.** If you know the `id` or `external_id`, fetch it directly (`GET /v2/plan/{id}`) or filter through `POST /v2/stops/search` with a predicate. ## Empty pages When `offset >= total`, the response is `docs: []` with `total` echoed accurately. Treat that as the end of the iteration. ## What's not supported - **Cursor-based pagination** (`?after=…`) — not available. Use `offset` + a stable `sort`. - **Server-Sent Events / streaming list** — not available. --- # Quickstart URL: https://developers.routal.com/docs/quickstart Make your first authenticated call to the Routal API in under five minutes. This guide walks you from "no account" to a successful API call. It takes about five minutes. ## 1. Get an API key API keys live in the planner dashboard. Open your Routal account at [planner.routal.com](https://planner.routal.com) and go to **Settings → Developers → API Keys**, or jump straight to: [planner.routal.com/h/settings/developers/api-keys](https://planner.routal.com/h/settings/developers/api-keys) Click **Create key**, give it a descriptive label (e.g. `staging-integration`), and copy the value. The key starts with `api_key_`. Treat it like a password — it grants the same access as the user who created it. If you don't have a Routal account yet, [start a trial](https://routal.com). ## 2. Make your first request A good first endpoint is `GET /v2/vehicles` — it returns the vehicles in your fleet for a given project. `project_id` is required. ```bash curl -G 'https://api.routal.com/v2/vehicles' \ --data-urlencode 'private_key=YOUR_KEY' \ --data-urlencode 'project_id=YOUR_PROJECT_ID' ``` Replace `YOUR_KEY` with the value you copied above, and `YOUR_PROJECT_ID` with the 24-char hex ID of the project (visible in the URL of your project in the dashboard). ### Expected response The response uses the standard [paginated envelope](/docs/pagination). The fields below come from the real `VehicleData` schema in the spec. ```json { "total": 4, "limit": 20, "offset": 0, "pages": 1, "page": 1, "docs": [ { "id": "4f75d991ac359f8c4c79d762", "organization_id": "4f75d991ac359f8c4c79d762", "project_id": "4f75d991ac359f8c4c79d762", "external_id": "the_external_id", "label": "Van 01", "plate": "2596KHO", "vehicle_model": "Transit", "brand": "Ford", "color": "#089747", "enabled": true, "default_provides": ["frozen"], "created_at": "2026-05-21T10:00:00.000Z" } ] } ``` A vehicle has many more fields (capacity defaults, time windows, etc.) — see the [API Reference → Vehicle](/docs) page for the full schema. ### If it didn't work | Status | Likely cause | |---|---| | `401 Unauthorized` | Missing or invalid `private_key`. Every API-key auth failure surfaces as `highway.apiKey.error.not_found`. | | `400 Bad Request` | Missing `project_id` (it's required) or a typo in a parameter name. | | `404 Not Found` | Wrong URL. The base is `https://api.routal.com`, with no trailing slash. | See [Errors](/docs/errors) for the full machine-code reference. ## 3. Create your first plan A **Plan** groups stops, vehicles, and routes for a delivery window. `POST /v2/plan` creates one. `project_id` is required as a query parameter. ```bash curl -X POST 'https://api.routal.com/v2/plan?private_key=YOUR_KEY&project_id=YOUR_PROJECT_ID' \ -H 'Content-Type: application/json' \ -d '{ "label": "Hello Routal", "execution_date": "2026-05-22" }' ``` The response carries the plan's `id` plus aggregate counts. From there you can add stops, attach routes, and call `POST /v2/plan/{id}/optimize` to compute optimal assignments. ```json { "id": "4f75d991ac359f8c4c79d762", "organization_id": "4f75d991ac359f8c4c79d762", "project_id": "4f75d991ac359f8c4c79d762", "label": "Hello Routal", "status": "planning", "execution_date": "2026-05-22", "completed_stops": 0, "canceled_stops": 0, "pending_stops": 0, "incomplete_stops": 0, "total_stops": 0, "total_routes": 0, "created_at": "2026-05-21T10:05:00.000Z" } ``` ## What next? - Walk through a complete scenario in the [Recipes](/docs/recipes) section — each opens with a TL;DR card so you can self-identify in seconds. - Browse the [API Reference](/docs) for every endpoint, parameter, and schema. - Read [Authentication](/docs/authentication) — key rotation, security best practices, what API keys can and can't do today. - Subscribe to [Webhooks](/docs/webhooks) so your systems react to plan and stop lifecycle events in real time. - Understand the [Lifecycle](/docs/lifecycle) of plans, routes, and stops before building state-aware logic. - Working with an AI coding assistant? Drop the [LLM context block](/docs/build-with-ai) into your editor's rules file. Have feedback? Email [developers@routal.com](mailto:developers@routal.com) or reach us on the chat in the planner dashboard. --- # Rate limits URL: https://developers.routal.com/docs/rate-limits Routal's request quota, what happens when you exceed it, and recommended client patterns. The Routal API rate-limits requests **per authenticated credential** (API key or user session). One credential, one budget — sharing a key across many services means they all draw from the same pool. ## Current limit **2,000 requests per minute** per credential, applied as a rolling 60-second window. This is the platform-wide default that covers the `/v2/*` and `/v3/*` REST surface documented here. A handful of compute-heavy operations have lower per-route caps on top — geocoding being the most common one. When you cross one of those, you also get `429`; the patterns below cover both cases. ## What happens when you exceed the limit You receive a `429 Too Many Requests` response. A few things to know: - There is **no `Retry-After` header** today. - There is **no `highway.*` `message_id`** on rate-limit errors. The body for a 429 won't match the [standard error envelope](/docs/errors), so don't try to branch on `message_id` for this case. If you need to differentiate a rate-limit `429` from any other 4xx, branch on the status code alone. ## Recommended client patterns Until `Retry-After` is exposed, back off using exponential delays: ```js async function callWithBackoff(fn, attempt = 0) { try { return await fn(); } catch (err) { if (err.status !== 429 || attempt >= 5) throw err; const baseMs = 1000 * 2 ** attempt; // 1s, 2s, 4s, 8s, 16s const jitterMs = Math.random() * 500; await new Promise((r) => setTimeout(r, baseMs + jitterMs)); return callWithBackoff(fn, attempt + 1); } } ``` ## Staying under the limit A few patterns keep you under the quota without trying: - **Use bulk endpoints where they exist.** `POST /v2/stops` accepts an array — one call for 100 stops is much cheaper than 100 calls of one. Same for `POST /v3/vehicles`. - **Use pagination, not full scans.** Most read endpoints accept `?limit=&offset=` (see [Pagination](/docs/pagination)). Raise `limit` to the endpoint's documented max for sync jobs. - **Don't poll for state changes.** If you're calling `GET /v2/plan/{id}` in a loop waiting for status to change, replace it with a [webhook](/docs/webhooks) subscription. - **Cache reference data.** Vehicles, projects, custom-field definitions and other rarely-changing entities can live in your cache for the duration of a planning cycle. ## Concurrency The per-minute quota is independent of concurrency. You can fire multiple requests in parallel as long as the total in the rolling window stays under 2,000. Beyond ~10 concurrent connections we typically don't see meaningful throughput gains — the bottleneck shifts to your own infrastructure. ## Need more? If your integration consistently hits the cap on real workloads, email [developers@routal.com](mailto:developers@routal.com). Per-credential limits can be raised when the use case warrants it; bulk operations are usually cheaper for both of us than tight polling loops. --- # Capacitated distribution URL: https://developers.routal.com/docs/recipes/capacitated-distribution When weight, volume, cold chain, or specialist equipment are non-negotiable. The optimizer respects every constraint you declare; the cost of not declaring them is a truck that gets to the third stop, runs out of room, and has to come back to the depot. This recipe walks the constraint matrix and the operational patterns for cold chain, oversized loads, and mixed fleets. ## Who this is for Your operation moves **physical goods with hard physical constraints**: - A wholesaler distributing **palletized goods** to retailers — every truck has a finite load capacity in weight and pallet positions. - A frozen / refrigerated foods distributor maintaining the **cold chain** end-to-end — only specific vehicles carry refrigeration. - A beverage / bottled water / gas distributor where **volume is the binding constraint** rather than weight — a van full of empty 18L containers weighs little but the truck is "full". - A specialty chemicals or hazardous goods distributor — only **licensed vehicles** can carry certain SKUs. - A pharmacy or vaccine distribution chain — **temperature-controlled** and **chain-of-custody** are both binding. What makes you different from a generic last-mile operation: the wrong truck showing up at the wrong stop is not "inefficient", it's "broken". A van without refrigeration cannot serve a frozen-foods stop; a 3.5-ton van cannot serve a stop that needs an 8-ton truck; a driver without ADR can't carry hazardous goods. This recipe walks through the **constraint matrix** and the operational patterns that make the optimizer respect every declared constraint — including what to do when "no feasible solution" fires at midnight and the nightly batch can't ship. ## The constraint matrix Three families of constraint, each with a different shape: | Family | What it constrains | Stop fields | Vehicle fields | |---|---|---|---| | **Capacity** | How much one vehicle can carry. | `weight` (kg), `volume` (m³) | `default_max_weight`, `default_max_volume`, `default_max_services` | | **Skills** | What kinds of goods a vehicle can carry. | `requires: ["frozen", "adr", "oversized"]` | `default_provides: ["frozen", ...]` | | **Geometry** | When and from where the vehicle operates. | `time_windows`, `location` | `default_time_window`, `default_start_location`, `default_end_location`, `default_max_distance`, `default_max_time` | The optimizer treats all three as **hard constraints**. A vehicle that doesn't satisfy all three for a candidate stop will not be assigned that stop, even if relaxing one constraint would produce a better overall plan. ### Capacity — the most common constraint Set on every stop: ```jsonc { "external_id": "ORDER-9001", "label": "Acme Restaurant", "weight": 32, // kilograms — sum of all items in this order "volume": 0.4 // cubic meters — sum of all items in this order } ``` Set on every vehicle (becomes the default on every route it executes): ```jsonc { "external_id": "TRUCK-007", "label": "Truck 007 — 8-ton", "default_max_weight": 8000, // 8 tonnes "default_max_volume": 35, // 35 m³ "default_max_services": 25 // optional cap on stops/day for driver fatigue } ``` The optimizer sums `weight`/`volume` of all stops on a route and won't exceed the vehicle's max. If your operation is dominated by **volume** (bottled beverages, packaging, empty containers) — set `default_max_volume`. If it's dominated by **weight** (industrial supplies, beverages cases, hardware) — set `default_max_weight`. Most operations set both, even if one is rarely the binding one. `default_max_services` caps stops/day per driver. Useful for residential delivery operations where 25 stops is a long day regardless of weight; less useful for B2B where it's typically 8–12. ### Skills — `requires` / `provides` When the constraint isn't "how much" but "what kind", use skill matching: ```jsonc // stop { "external_id": "ORDER-9001", "requires": ["frozen"] } // vehicle { "external_id": "TRUCK-007", "default_provides": ["frozen", "refrigerated", "ambient"] } ``` A vehicle that **provides** a superset of what the stop **requires** is eligible. Empty `requires` means "any vehicle is fine" — the default. Skill examples that show up in real operations: | Skill string (suggested) | What it means | |---|---| | `frozen` | Vehicle has freezer compartment (≤ -18°C). | | `refrigerated` | Vehicle has chilled compartment (0 to 4°C). | | `ambient` | Vehicle is room temperature (default for most). | | `adr-class-3` | Driver and vehicle licensed for flammable liquids (ADR class 3). | | `oversized` | Vehicle is large enough for oversized items (mattresses, large appliances). | | `tail-lift` | Vehicle has a tail lift for unloading heavy items without a forklift. | | `crew-2` | Two-person crew (some installations or heavy unloadings require it). | | `forklift-pickup` | Stop has a forklift on site — vehicle can be a regular truck. | Be explicit. `requires: ["frozen"]` matches `provides: ["frozen", "ambient"]` because freezer trucks usually carry ambient too. But a `frozen` stop will not match a vehicle that only `provides: ["refrigerated"]` — chilled and frozen are different chambers. ## Quickstart The Quickstart below shows the full capacitated-distribution stop and vehicle shapes — weight, volume, skill requirements, narrow service durations. The remainder of the daily-plan job (ensure plan → bulk-add → optimize → dispatch) is the same as [nightly batch B2B](/docs/recipes/nightly-batch-b2b). **cURL:** ```bash # (1) Onboard the fleet — bulk-create vehicles with default_max_* + provides. curl -X POST "https://api.routal.com/v3/vehicles?private_key=YOUR_KEY&project_id=PROJECT_ID" \ -H 'Content-Type: application/json' \ -d '[ { "external_id": "TRUCK-FROZEN-001", "label": "Truck 001 — Frozen 8t", "default_provides": ["frozen", "refrigerated", "ambient"], "default_max_weight": 8000, "default_max_volume": 35, "default_max_services": 30, "default_time_window": [21600, 64800] }, { "external_id": "TRUCK-AMBIENT-002", "label": "Truck 002 — Ambient 3.5t", "default_provides": ["ambient"], "default_max_weight": 3500, "default_max_volume": 18, "default_max_services": 35, "default_time_window": [21600, 64800] } ]' # (2) Bulk-add stops with weight, volume, requires. Stops requiring "frozen" # can only land on the frozen truck. curl -X POST "https://api.routal.com/v2/stops?private_key=YOUR_KEY&plan_id=PLAN_ID&project_id=PROJECT_ID" \ -H 'Content-Type: application/json' \ -d '[ { "external_id": "ORDER-9001", "label": "FrozenFoods Restaurant — A", "location": { "lat": 41.20, "lng": 2.05 }, "duration": 600, "time_windows": [[28800, 43200]], "weight": 120, "volume": 0.8, "requires": ["frozen"] }, { "external_id": "ORDER-9002", "label": "Beta Hardware Store", "location": { "lat": 41.18, "lng": 2.07 }, "duration": 480, "time_windows": [[28800, 50400]], "weight": 240, "volume": 1.4, "requires": [] } ]' # (3) Optimize. Returns no_result_found if there's no feasible assignment. curl -X POST "https://api.routal.com/v2/plan/PLAN_ID/optimize?private_key=YOUR_KEY" ``` **TypeScript:** ```ts import createClient from 'openapi-fetch'; import type { paths } from './routal'; const routal = createClient({ baseUrl: 'https://api.routal.com' }); const ROUTAL_API_KEY = process.env.ROUTAL_API_KEY!; const PROJECT_ID = process.env.ROUTAL_PROJECT_ID!; type CapacitatedOrder = { id: string; customer: string; lat: number; lng: number; serviceMinutes: number; windowFromSec: number; windowToSec: number; weightKg: number; // explicit — never omit on a capacitated plan volumeM3: number; // explicit — never omit on a capacitated plan requires: string[]; // ['frozen'], ['adr-class-3'], etc. }; type CapacitatedVehicle = { id: string; label: string; provides: string[]; // superset of what stops can require maxWeightKg: number; maxVolumeM3: number; maxServices?: number; startSec: number; // working window start, seconds from midnight endSec: number; }; /** Onboard the fleet once — typically run during initial integration setup. */ export async function syncCapacitatedFleet(vehicles: CapacitatedVehicle[]): Promise { const { error } = await routal.POST('/v3/vehicles', { params: { query: { private_key: ROUTAL_API_KEY, project_id: PROJECT_ID } }, body: vehicles.map((v) => ({ external_id: v.id, label: v.label, default_provides: v.provides, default_max_weight: v.maxWeightKg, default_max_volume: v.maxVolumeM3, default_max_services: v.maxServices, default_time_window: [v.startSec, v.endSec], })) as never, }); if (error) throw error; } /** Bulk-add capacitated stops to a plan. */ export async function bulkAddCapacitatedStops( planId: string, orders: CapacitatedOrder[], ): Promise { if (orders.length === 0) return; const { error } = await routal.POST('/v2/stops', { params: { query: { private_key: ROUTAL_API_KEY, plan_id: planId, project_id: PROJECT_ID } }, body: orders.map((o) => ({ external_id: o.id, label: o.customer, location: { lat: o.lat, lng: o.lng }, duration: o.serviceMinutes * 60, time_windows: [[o.windowFromSec, o.windowToSec]], weight: o.weightKg, // always present, even if 0 volume: o.volumeM3, // always present, even if 0 requires: o.requires, })) as never, }); if (error) throw error; } /** Diagnostic — call before optimize to detect obviously infeasible plans. */ export async function preflightCapacityCheck(planId: string): Promise<{ totalWeight: number; totalVolume: number; fleetWeight: number; fleetVolume: number; feasible: boolean; unmatchedSkills: string[]; }> { const { data: stops } = await routal.GET('/v2/plan/{id}/stops', { params: { path: { id: planId }, query: { private_key: ROUTAL_API_KEY } }, }); const { data: vehicles } = await routal.GET('/v2/vehicles', { params: { query: { private_key: ROUTAL_API_KEY, project_id: PROJECT_ID, limit: 500 } }, }); const stopList = (stops as Array<{ weight?: number; volume?: number; requires?: string[] }>) ?? []; const vehList = (vehicles?.docs ?? []) as Array<{ enabled?: boolean; default_max_weight?: number; default_max_volume?: number; default_provides?: string[]; }>; const totalWeight = stopList.reduce((s, x) => s + (x.weight ?? 0), 0); const totalVolume = stopList.reduce((s, x) => s + (x.volume ?? 0), 0); const activeVeh = vehList.filter((v) => v.enabled !== false); const fleetWeight = activeVeh.reduce((s, v) => s + (v.default_max_weight ?? 0), 0); const fleetVolume = activeVeh.reduce((s, v) => s + (v.default_max_volume ?? 0), 0); const provided = new Set(activeVeh.flatMap((v) => v.default_provides ?? [])); const unmatchedSkills = Array.from( new Set( stopList.flatMap((s) => s.requires ?? []).filter((skill) => !provided.has(skill)), ), ); return { totalWeight, totalVolume, fleetWeight, fleetVolume, unmatchedSkills, feasible: totalWeight <= fleetWeight && totalVolume <= fleetVolume && unmatchedSkills.length === 0, }; } ``` **Python:** ```python import os import requests ROUTAL_API_KEY = os.environ["ROUTAL_API_KEY"] PROJECT_ID = os.environ["ROUTAL_PROJECT_ID"] BASE = "https://api.routal.com" def sync_capacitated_fleet(vehicles: list[dict]) -> None: payload = [ { "external_id": v["id"], "label": v["label"], "default_provides": v["provides"], "default_max_weight": v["max_weight_kg"], "default_max_volume": v["max_volume_m3"], "default_max_services": v.get("max_services"), "default_time_window": [v["start_sec"], v["end_sec"]], } for v in vehicles ] resp = requests.post( f"{BASE}/v3/vehicles", params={"private_key": ROUTAL_API_KEY, "project_id": PROJECT_ID}, json=payload, timeout=60, ) resp.raise_for_status() def bulk_add_capacitated_stops(plan_id: str, orders: list[dict]) -> None: if not orders: return payload = [ { "external_id": o["id"], "label": o["customer"], "location": {"lat": o["lat"], "lng": o["lng"]}, "duration": o["service_minutes"] * 60, "time_windows": [[o["window_from_sec"], o["window_to_sec"]]], "weight": o["weight_kg"], "volume": o["volume_m3"], "requires": o["requires"], } for o in orders ] resp = requests.post( f"{BASE}/v2/stops", params={"private_key": ROUTAL_API_KEY, "plan_id": plan_id, "project_id": PROJECT_ID}, json=payload, timeout=60, ) resp.raise_for_status() def preflight_capacity_check(plan_id: str) -> dict: """ Diagnostic — call before optimize to detect obviously infeasible plans. Returns aggregate stop demand vs aggregate fleet supply, plus any required skills that no vehicle provides. """ stops = requests.get( f"{BASE}/v2/plan/{plan_id}/stops", params={"private_key": ROUTAL_API_KEY}, timeout=30, ).json() vehicles = requests.get( f"{BASE}/v2/vehicles", params={"private_key": ROUTAL_API_KEY, "project_id": PROJECT_ID, "limit": 500}, timeout=30, ).json().get("docs", []) total_weight = sum(s.get("weight") or 0 for s in stops) total_volume = sum(s.get("volume") or 0 for s in stops) active = [v for v in vehicles if v.get("enabled") is not False] fleet_weight = sum(v.get("default_max_weight") or 0 for v in active) fleet_volume = sum(v.get("default_max_volume") or 0 for v in active) provided = {p for v in active for p in (v.get("default_provides") or [])} required = {r for s in stops for r in (s.get("requires") or [])} unmatched = sorted(required - provided) return { "total_weight": total_weight, "total_volume": total_volume, "fleet_weight": fleet_weight, "fleet_volume": fleet_volume, "unmatched_skills": unmatched, "feasible": ( total_weight <= fleet_weight and total_volume <= fleet_volume and not unmatched ), } ``` ## Production hardening ### Always declare weight and volume, even when "small" The most common production bug in capacitated operations is treating `weight` and `volume` as optional. A stop without `weight` defaults to 0 — and 0 fits anywhere, so the optimizer is free to put 50 of them on the smallest van. On execution day the driver runs out of room and turns around. Make the import job **fail closed**: if the source order doesn't carry weight and volume, raise an error rather than push the stop with zeros. ```ts if (order.weightKg == null || order.volumeM3 == null) { throw new Error( `order ${order.id} missing weight/volume — refuse to push to a capacitated plan`, ); } ``` Same logic for `requires`: a frozen-foods order pushed without `requires: ["frozen"]` will land on an ambient truck and arrive thawed. ### Pre-flight before optimize `POST /v2/plan/{id}/optimize` returns `highway.optimization.error.no_result_found` when constraints can't be satisfied — but it doesn't tell you **which** constraint failed. Run an aggregate-supply-vs-aggregate-demand check on your side before calling optimize: ```ts const check = await preflightCapacityCheck(planId); if (!check.feasible) { await alertSupervisor({ plan_id: planId, overage_weight_kg: Math.max(0, check.totalWeight - check.fleetWeight), overage_volume_m3: Math.max(0, check.totalVolume - check.fleetVolume), unmatched_skills: check.unmatchedSkills, }); return; // don't call optimize — we already know the answer } ``` This catches 90% of `no_result_found` errors before they happen and gives the supervisor actionable information (overage in kg / m³ / skill names) instead of a generic optimizer rejection. Note: pre-flight is **necessary but not sufficient**. The aggregate supply-vs-demand check passes even when **per-time-window** capacity is broken (e.g. all stops want service between 10:00 and 12:00 but only two trucks are available in that window). The optimizer is the only check that catches geometry + capacity interactions. ### Cold chain — model the chain, not just the truck A frozen-foods route has additional patterns the basic `requires: ["frozen"]` doesn't enforce: 1. **Time-temperature ceilings.** A frozen item out of the cold chamber for more than N minutes is no longer frozen. Routal doesn't enforce this — but you can keep route `duration` bounded by setting `default_max_time` on the vehicle, indirectly capping how long any single stop can stretch. 2. **Pre-cooling and post-cooling time.** The truck takes 20 minutes at the start of the day to chill down. Model it as the first stop of the route or by setting the driver's `default_time_window` to start 20 minutes after their actual depot arrival. 3. **Chamber-by-chamber capacity.** A frozen truck typically has a frozen chamber AND a fridge chamber AND ambient space. The single `default_max_volume` covers all of them. If you need per-chamber capacity, split into multiple "virtual" vehicles in Routal — one per chamber — using `chain_id` is **not** the right tool for this. If your cold chain requirements need stronger guarantees than `requires` gives you, talk to support — there are options that aren't part of the public REST surface today. ### Oversized loads — when `volume` isn't enough `default_max_volume` is total m³. It doesn't model **shape**: a 2m³ mattress won't fit in a van with 2m³ of remaining capacity if the remaining capacity is fragmented or short. Two patterns to compensate: - **Use `requires: ["oversized"]`** on the stop and **`provides: ["oversized"]`** only on vehicles that can geometrically take the item. The match becomes yes/no, not "do you have enough m³ left". - **Constrain to one oversized stop per route.** Set `default_max_services: 1` on a vehicle dedicated to a single oversized delivery. Crude but effective when the geometry truly demands it. ### Mixed fleets — explicit `provides` saves you A fleet of 20 ambient trucks + 3 frozen trucks + 2 ADR-class-3 trucks looks ambiguous to the optimizer until you set `default_provides` correctly: ```jsonc // ambient trucks { "default_provides": ["ambient"] } // frozen trucks (also carry ambient) { "default_provides": ["ambient", "refrigerated", "frozen"] } // ADR-class-3 trucks (specialized — only ADR cargo) { "default_provides": ["ambient", "adr-class-3"] } ``` A stop with `requires: ["frozen"]` only matches the 3 frozen trucks. A stop with `requires: ["adr-class-3"]` only matches the 2 ADR trucks. A stop with no `requires` matches every truck. Don't over-engineer it: skills you never `require` on a stop are harmless on the vehicle side. Keep the catalog focused on what actually differentiates your operation. ### What to do when no_result_found fires anyway Despite the pre-flight, the optimizer may still reject. Common causes: - A stop's time window is shorter than the **minimum service + travel time** from any vehicle's depot. Pre-flight misses this because it doesn't compute travel. - All `frozen` capacity is full by 12:00 but stops at 16:00 also require frozen — single-chamber-day capacity exhausted, even though aggregate supply matches aggregate demand. - A specific `requires` skill is provided by only one vehicle, and that vehicle's time window doesn't overlap with all the stops that need it. Recovery hierarchy: 1. **Page the supervisor** with the diagnostic (which stops, which constraints). Don't auto-relax. 2. **Add a vehicle** for the day from a backup pool, or **borrow capacity** from a sister depot. 3. **Reschedule overflow stops** to the next day's plan (`POST /v2/stop/move`). 4. **Renegotiate with the customer** — windows that systematically can't be met aren't customer service, they're operational debt. ### Observability — the metrics that matter | Metric | What it tells you | |---|---| | `capacitated.fleet_utilization{dimension}` | `weight` / `volume` / `services` — average % of capacity used per route. \>85% is great. \>100% means you're forecasting wrong. \<50% is missed efficiency. | | `capacitated.demand_supply_ratio{dimension}` | Aggregate stop demand / aggregate fleet supply for the day. ≥1.0 means you can't ship without overflow. | | `capacitated.unmatched_skill_total{skill}` | Times a `requires` skill had no `provides` match. Spike = vehicle was sick or you onboarded a new product type and forgot to update the fleet. | | `capacitated.no_result_found_rate` | % of optimize calls that returned no_result_found. Sustained > 5% is a routing problem, not a one-off. | | `capacitated.overweight_alerts_total` | Drivers reporting their truck was over capacity at dispatch time. Should be zero — if not, your weight data upstream is wrong. | Tag every metric by depot (if multi-depot) and by skill so the dispatcher can break down "frozen capacity utilization" separately from "ambient utilization". ## Common errors | `message_id` | Cause | What to do | |---|---|---| | `highway.optimization.error.no_result_found` | Demand exceeds supply on at least one dimension (weight, volume, skill, or time window). | Run pre-flight first. When it still fires, page the supervisor with the diagnostic; don't auto-relax. | | `highway.geocoding.error.wrong_lat_lng` | A stop's `location.lat` / `lng` is out of the valid range. | Validate coords upstream. Refuse to push a stop with `lat: 0, lng: 0` or values outside `[-90, 90]` / `[-180, 180]`. | | `highway.stop.error.custom_fields_invalid` | A stop sent custom fields that aren't defined in the project. | Update the project's custom-field schema, or fix the payload. | | `400 Bad Request` (no `message_id`) — `weight` / `volume` validation | Negative number, NaN, or value out of the schema's allowed range. | Validate upstream. Treat `null` as "missing" → either default to 0 (and explicitly accept the risk) or refuse. | | `highway.optimization.error.too_much_requests` | Optimizer rate-limited the request. Heavy capacitated optimizations are more compute-intensive. | Back off 60–90 s and retry. If persistent on large fleets, talk to support about dedicated optimizer capacity. | ## Next steps - [Nightly batch — B2B distribution](/docs/recipes/nightly-batch-b2b) — the daily-plan scaffolding this recipe inherits. - [Field service with appointments](/docs/recipes/field-service-with-appointments) — when capacity (parts in the van) **and** skills (technician certifications) both matter, combine this recipe's vehicle setup with that one's stop shape. --- # Field service with appointments URL: https://developers.routal.com/docs/recipes/field-service-with-appointments For mobile workforces — technicians, installers, home health, equipment maintenance. The unit of work is a service appointment, not a delivery. This recipe covers skill matching, tight appointment windows, multi-task checklists, and how to keep the customer-facing ticket in sync with what the technician did on site. ## Who this is for You run a **mobile workforce** — technicians, installers, home-care workers, equipment maintenance crews — and your unit of work is a **scheduled appointment**, not a delivery. Common shapes: - Telecom installers connecting new fibre subscribers. - HVAC, plumbing, or electrical repair calls. - Home health visits to elderly or post-surgery patients. - Field maintenance of utility meters, ATMs, vending machines, sanitation equipment. - White-glove furniture or appliance installation. What makes you different from a B2B delivery operation: - The **time window is a customer appointment** ("between 10am and noon"), not a delivery slot. A driver running 30 minutes late on the previous stop affects the next customer's morning, not just their porch. - The **work itself takes a meaningful amount of time** (15 minutes to 3 hours), so on-site duration is the dominant factor in routing. - Your technicians have **specializations**. An A/C specialist can't fix a boiler. A nurse with paediatric certification can't do adult home health visits in some jurisdictions. - The **proof of work is structured**: signed work order, photos of the installation, scanned barcodes of parts used, an explicit checklist of what was done. If that sounds like your operation, this recipe walks through the four moving parts that distinguish field service from delivery: **skill matching**, **appointment windows**, **multi-task checklists**, and **ticket-to-stop mapping**. ## What the rhythm looks like ``` Day -2 to -1 Day 0 (execution) ───────────────────────────────────── ───────────────────────────────────── Customer books an appointment 08:00 Technicians dispatched through your scheduling tool → routes flip to in_transit → window confirmed 09:30 Tech arrives at first appointment → opens the stop in the app → ticks tasks one by one → captures signature + photo → submits the report Day -1 evening 11:00 Next appointment 17:00 Scheduling tool closes books for tomorrow's appointments 13:00 Lunch break (vehicle break window) 17:30 ⚡ Daily plan job 14:30 Afternoon appointments 1. Create tomorrow's plan 2. Bulk-add appointments with requires, time_windows, duration, tasks 18:00 Last appointment 3. Run the optimizer → routes finish 4. Dispatch each technician → tickets close in source system 21:00 Supervisor reviews, adjusts manually if needed ``` The daily plan job is essentially the [nightly batch B2B](/docs/recipes/nightly-batch-b2b) pattern — the differences are in the **stop payload** (the skills, the narrow window, the tasks) and in the **webhook handling** (the report carries structured task results that have to flow back into the customer's ticket). ## The four moving parts ### 1. Skill matching — `requires` on stops, `provides` on vehicles Each technician (modelled as a Vehicle) declares `default_provides` — a list of strings representing what they can do. Each appointment (modelled as a Stop) declares `requires` — a list of strings the assigned route must cover. The optimizer **will not** assign a stop to a route that doesn't cover its requires. There is no partial match, no warning — it's hard exclusion. ```jsonc // vehicle (technician's van) { "external_id": "TECH-Maria-AC", "label": "María — A/C senior", "default_provides": ["ac-install", "ac-repair", "ac-maintenance", "language-en"] } // stop (appointment) { "external_id": "TICKET-44521", "label": "Customer A — A/C install", "requires": ["ac-install", "language-en"] } ``` Convention tips: - Use **stable string identifiers**, not human labels. `ac-install` not `"AC installation (residential)"`. The optimizer treats them as opaque strings. - Be **precise**: `ac-install-residential` vs `ac-install-commercial` may matter for routing and definitely matters for SLA and pricing. - Stable enums map to your source system: technician certifications and ticket types live there; Routal just reflects them. ### 2. Appointment windows — narrow `time_windows`, not delivery slots A B2B delivery to a restaurant has a window like "between 08:00 and 13:00". A telecom installation has a window like "10:00 to 12:00". Narrower means: - **The optimizer has less slack**, and the same number of appointments needs more technicians. - **Buffer matters more.** A 90-minute appointment scheduled in a 120-minute window leaves 30 minutes for transit + slip. Less than that and a single delay cascades to every later stop. - **`time_windows[]` is an array** — you can offer the customer two windows ("morning OR afternoon"), and the optimizer picks the best one. ```jsonc { "external_id": "TICKET-44521", "label": "Boiler install — Mr. Pérez", "time_windows": [ [36000, 39600] // 10:00–11:00 (single offered slot) ], "duration": 5400 // 90 minutes of on-site work } ``` ### 3. Multi-task checklists — `POST /v2/task` Once a stop exists, attach **tasks** — labelled checklist items the technician ticks one by one in the app: | Task pattern | Why | |---|---| | "Diagnose problem" + `comments` carrying the customer's symptoms | First thing the tech does on arrival. | | "Replace part X" + `barcode` of the SKU | Tech scans the part as proof it was installed. | | "Test pressure / continuity / signal" | Functional verification step. | | "Sign work order" + `comments` carrying the form template | Captures customer acknowledgement. | Tasks are different from a **report**: a report is the free-form POD (signature image, photos, notes) the tech submits at the end of the visit, and it triggers the `routal.planner.2.stop.reported` webhook. Tasks are the structured checklist tracked separately and persisted on the stop. ### 4. Ticket-to-stop mapping — `external_id` carrying your ticket number Your source system owns the ticket (job number, work order, customer call reference). Routal owns the stop. The bridge is `external_id`: - Stops get `external_id: "TICKET-44521"`. - Vehicles get `external_id: "TECH-Maria-AC"`. - Tasks reference your SKU via `barcode`, and your form fields via `custom_fields`. When the stop reports (driver completes / fails / cancels), the webhook payload includes `data.stop_id` and you `GET /v2/stop/{stop_id}` to fetch the `external_id` → join back to your ticket. ## Quickstart The Quickstart below creates a single appointment, attaches three tasks, and shows the dispatch path. For the full daily-plan job that creates many appointments at once, use the same skeleton as [nightly batch B2B](/docs/recipes/nightly-batch-b2b) but with the field-service stop shape from this page. **cURL:** ```bash # (1) Create the appointment on tomorrow's plan. requires + time_windows are # the field-service-specific bits. curl -X POST "https://api.routal.com/v2/stops?private_key=YOUR_KEY&plan_id=PLAN_ID&project_id=PROJECT_ID" \ -H 'Content-Type: application/json' \ -d '[ { "external_id": "TICKET-44521", "label": "Mr. Pérez — Boiler repair", "location": { "lat": 41.20, "lng": 2.05, "address": "Customer address line" }, "duration": 5400, "time_windows": [[36000, 39600]], "requires": ["plumbing", "boiler-emergency"], "phone": "+10000044521", "custom_fields": { "ticket_id": "44521", "customer_segment": "premium" } } ]' # → returns the created stop. Save the stop_id. # (2) Attach checklist tasks. One POST per task. curl -X POST "https://api.routal.com/v2/task?private_key=YOUR_KEY&stop_id=STOP_ID" \ -H 'Content-Type: application/json' \ -d '{ "label": "Diagnose problem", "comments": "Customer reports no hot water since this morning." }' curl -X POST "https://api.routal.com/v2/task?private_key=YOUR_KEY&stop_id=STOP_ID" \ -H 'Content-Type: application/json' \ -d '{ "label": "Replace ignition module", "barcode": "PART-IGN-7715", "comments": "Part to be scanned from the technician van." }' curl -X POST "https://api.routal.com/v2/task?private_key=YOUR_KEY&stop_id=STOP_ID" \ -H 'Content-Type: application/json' \ -d '{ "label": "Verify pressure", "comments": "Hold for 60s at 1.5 bar." }' # (3) From here, optimize the plan + dispatch as usual. curl -X POST "https://api.routal.com/v2/plan/PLAN_ID/optimize?private_key=YOUR_KEY" curl -X POST "https://api.routal.com/v2/route/ROUTE_ID/dispatch?private_key=YOUR_KEY" ``` **TypeScript:** ```ts import createClient from 'openapi-fetch'; import type { paths } from './routal'; const routal = createClient({ baseUrl: 'https://api.routal.com' }); const ROUTAL_API_KEY = process.env.ROUTAL_API_KEY!; const PROJECT_ID = process.env.ROUTAL_PROJECT_ID!; type Appointment = { ticketId: string; customerLabel: string; lat: number; lng: number; address?: string; phone?: string; windowFromSec: number; windowToSec: number; serviceMinutes: number; requiredSkills: string[]; // e.g. ['plumbing', 'boiler-emergency'] customFields?: Record; checklist: Array<{ label: string; comments?: string; barcode?: string; }>; }; /** * Create an appointment as a stop with its task checklist attached. * Returns the stop_id so dispatch / reporting flows can resolve back to the ticket. */ export async function createAppointment( planId: string, appt: Appointment, ): Promise { // (1) Create the stop. const { data, error } = await routal.POST('/v2/stops', { params: { query: { private_key: ROUTAL_API_KEY, plan_id: planId, project_id: PROJECT_ID }, }, body: [ { external_id: appt.ticketId, label: appt.customerLabel, location: { lat: appt.lat, lng: appt.lng, address: appt.address }, duration: appt.serviceMinutes * 60, time_windows: [[appt.windowFromSec, appt.windowToSec]], requires: appt.requiredSkills, phone: appt.phone, custom_fields: appt.customFields, }, ] as never, }); if (error) throw error; const stopId = (data as { id: string }[])[0].id; // (2) Attach the checklist. One call per task — Routal serializes per stop. for (const item of appt.checklist) { const { error: taskErr } = await routal.POST('/v2/task', { params: { query: { private_key: ROUTAL_API_KEY, stop_id: stopId } }, body: { label: item.label, comments: item.comments, barcode: item.barcode, } as never, }); if (taskErr) throw taskErr; } return stopId; } ``` **Python:** ```python import os import requests ROUTAL_API_KEY = os.environ["ROUTAL_API_KEY"] PROJECT_ID = os.environ["ROUTAL_PROJECT_ID"] BASE = "https://api.routal.com" def create_appointment(plan_id: str, appt: dict) -> str: """ Create an appointment as a stop with its task checklist attached. Returns the stop_id. """ payload = [{ "external_id": appt["ticket_id"], "label": appt["customer_label"], "location": { "lat": appt["lat"], "lng": appt["lng"], "address": appt.get("address"), }, "duration": appt["service_minutes"] * 60, "time_windows": [[appt["window_from_sec"], appt["window_to_sec"]]], "requires": appt["required_skills"], "phone": appt.get("phone"), "custom_fields": appt.get("custom_fields"), }] resp = requests.post( f"{BASE}/v2/stops", params={"private_key": ROUTAL_API_KEY, "plan_id": plan_id, "project_id": PROJECT_ID}, json=payload, timeout=30, ) resp.raise_for_status() stop_id = resp.json()[0]["id"] for item in appt["checklist"]: task_resp = requests.post( f"{BASE}/v2/task", params={"private_key": ROUTAL_API_KEY, "stop_id": stop_id}, json={ "label": item["label"], "comments": item.get("comments"), "barcode": item.get("barcode"), }, timeout=30, ) task_resp.raise_for_status() return stop_id ``` ## Production hardening ### Defining the skill catalog Skill strings have to be **stable** and **shared** between your source system and Routal. The two patterns that work: 1. **Define the catalog in your source system.** Your dispatch software / ticketing tool already knows what certifications a technician has and what ticket types exist. Mirror those strings into Routal at vehicle creation time (`default_provides`) and stop creation time (`requires`). 2. **Document a fixed enumeration in a shared registry.** A markdown file or a tiny config service that lists every legal skill string. Both teams read from it. Mixing the two — letting individual technicians declare custom skills in the field — produces a graveyard of typos (`acrepair`, `ac-repair`, `AC repair`) and routes that look fine until the day the optimizer matches them to a stop that misspells the same word. ### Custom fields — define them in the dashboard first Custom fields must be **declared in the project settings** ([planner.routal.com](https://planner.routal.com)) before you can use them on stops, routes, or vehicles. The dashboard accepts strings, numbers, booleans, dates, and dropdowns. Once defined, reference them by name from the API: ```jsonc { "external_id": "TICKET-44521", "custom_fields": { "ticket_id": "44521", "ticket_priority": "P2", // dropdown "customer_id": "CUST-991", "follow_up_required": true // boolean } } ``` If you POST a custom field that isn't defined in the project, you get `highway.stop.error.custom_fields_invalid` and the whole stop fails. Set up the schema once, version it like code in your source repo, and let the integration mirror it. ### Tasks vs. report — pick the right tool **Tasks** (this recipe) are structured checklist items the technician ticks in the app: - Each task has a `status` (`pending` / `completed` / `canceled`). - Tasks persist with the stop. - Best for SKU scanning, multi-step procedures, and items where you need a per-line outcome ("part X was installed", "calibration passed"). **Reports** (delivered via the `stop.reported` webhook) are the free-form POD at the end of the visit: - Carries `images`, `signature`, free-text `comments`, `cancel_reason`. - One per stop completion, attempt, or cancellation. - Fires the `routal.planner.2.stop.reported` webhook. A typical field service stop produces **both**: the technician ticks tasks during the visit, then submits a report at the end (with the customer's signature, photos of the completed work, optional notes). When designing the data flow: - Your **ticket status** in the source system should come from the report (`completed` / `incomplete` / `canceled`). - Your **per-line outcomes** (parts installed, tests passed) should come from the task statuses in the same payload. - Your **photos and signature** should be downloaded from the report's `images[].url` and `signature.url` to your own storage during webhook processing. ### Sequence within an appointment The driver app shows the task list in **the order they were created**. If sequence matters ("scan in before you scan out"), create tasks in that order. There is no `position` field on tasks today — re-ordering after creation requires deleting and recreating. ### The "no available technician" scenario `POST /v2/plan/{id}/optimize` returns `highway.optimization.error.no_result_found` when: - The skills required by some appointment aren't `provided` by any technician scheduled for the day. - The total appointment minutes plus driving time exceed the technicians' combined working windows. For field service this is **more common** than for delivery — narrow windows and required skills shrink the feasible set sharply. Recovery patterns: 1. **Suggest an alternate day** to the customer. Move the ticket's preferred date by one day in your source system. 2. **Add an on-call technician** for that day. The integration's response to `no_result_found` should page the supervisor with a list of unfittable appointments + the missing skills. 3. **Relax the skill requirement** (with the dispatcher's explicit consent — not automatically). A general-purpose technician can sometimes cover a specialist appointment in a pinch. ### Webhook handling — task statuses arrive in the report When the technician submits at the end of the visit, the `routal.planner.2.stop.reported` webhook carries: - `data.tasks[]` — each task with its final `status` (`completed` / `canceled`) and optional `comments` filled in by the technician. - `data.signature.url` / `data.images[].url` — the proof-of-work assets. - `data.type` — overall outcome (`service_report_completed` / `service_report_attempted` / `service_report_canceled`). The integration walks `data.tasks[]` against the original checklist and writes back to your ticket. Skipping a task (status remains `pending` after the report) usually means the technician deemed it unnecessary or impossible — your ticket logic decides whether that's acceptable. ### Reschedule, don't recreate When a customer asks to reschedule: - **Stop is still `pending`** → use `POST /v2/stop/move` to move it to the new day's plan. The `external_id` (your ticket number) stays the same; everything else carries over. - **Stop was already attempted / failed** → create a **new stop** with a new `external_id` derived from the same ticket (`TICKET-44521-RESCHED-1`). Moving a terminal stop rewrites delivery history and breaks the technician's performance metrics for the original day. ### Observability — the metrics that matter | Metric | What it tells you | |---|---| | `field_service.appointments_created_total{required_skill}` | Demand by skill. Spot which certifications you need to hire more of. | | `field_service.unmatched_appointments_total` | `requires` strings that no technician provides — typically a typo or a new ticket type that hasn't been mirrored. | | `field_service.no_result_found_rate` | Days where the optimizer couldn't fit every appointment. Spike means understaffing or windows too narrow. | | `field_service.task_skip_rate{task_label}` | Tasks the technician marks `canceled` rather than `completed`. Investigate the top one — usually a process problem. | | `field_service.stop_report_latency_min` | Minutes between appointment end and report submission. A long tail means technicians are submitting in batches; encourage app submission on site. | Tag every appointment with the source-system ticket id; the dispatcher always needs to grep "where did ticket 44521 end up?". ## Common errors | `message_id` | Cause | What to do | |---|---|---| | `highway.stop.error.custom_fields_invalid` | Stop sent `custom_fields` with a key that isn't defined in the project. | Define the field in the dashboard. Don't try to create field definitions from the API — set them up once and version. | | `highway.optimization.error.no_result_found` | No combination of skills + windows fits all appointments. | Page the supervisor; resolve with one of the recovery patterns above. | | `highway.task.error.not_found` | `PUT /v2/task/{task_id}` or `DELETE /v2/task/{task_id}` referenced a task that's been removed (or never existed). | Read the stop's current task list before mutating. | | `400 Bad Request` (no `message_id`) on `POST /v2/task` | Required field missing (`label`) or a `barcode` that doesn't fit the expected length. | Read the response `message`, fix the payload. | | `highway.stop.error.move_not_pending_stops` | Tried to reschedule a stop after it was attempted / failed / cancelled. | Create a new stop with a new external_id derived from the same ticket. | ## Next steps - [Capacitated distribution](/docs/recipes/capacitated-distribution) — if your technicians also carry inventory (spare parts as `requires` / van as `weight`/`volume`), combine this recipe with capacity constraints. - [Idempotency](/docs/idempotency) — reschedule patterns and how to identify a retry vs. a genuine new appointment. - [API Reference → `POST /v2/task`](/docs) — the exact task body schema. --- # Grocery — same-day delivery URL: https://developers.routal.com/docs/recipes/grocery-same-day Dark store and grocery e-commerce — the customer ordered 90 minutes ago and expects the doorbell to ring inside a two-hour window. Orders land continuously, the integration micro-batches them into short waves, the optimizer slots each wave onto active vehicles, and the customers app sends arrival ETAs as the driver gets close. This recipe covers the wave cadence, the cold-chain stop shape, and the rate-control patterns that keep dispatchers ahead of the order book. ## Who this is for You run a **same-day grocery or dark-store operation**. The customer placed their order 30-120 minutes ago, paid online, and expects the doorbell to ring inside a tight delivery window. Common shapes: - A **dark-store grocer** (10-30 minute fulfillment SLA) operating from hyperlocal hubs in dense urban areas, dispatching on bikes / e-bikes / vans. - A **legacy supermarket chain** offering same-day from their existing stores via a fulfillment partner, with a slightly longer window (90-120 min) and van-based fleet. - A **specialty grocer** (organic, premium, gourmet) with a smaller daily volume but tight cold-chain requirements (frozen meat, fresh produce, refrigerated dairy on the same van). - A **convenience / on-demand vertical** — pharmacy, beverage, late-night essentials — where the order shape mirrors grocery (small items, residential delivery, customer waiting) even if the product catalog differs. What makes you different from last-mile parcel e-commerce: - **The customer is actively waiting.** A 2-hour ETA window for a non-perishable parcel is fine; for an order with ice cream in it, the SLA is the product. - **Orders are not known the night before.** You can't run a nightly batch — the day's plan grows continuously and the optimizer must absorb new stops into in-progress routes without disrupting drivers already on the road. - **Cold-chain compliance is binding, not nice-to-have.** A van without a freezer cannot serve a frozen-foods stop. Use `requires` / `provides` skill matching (see [Capacitated distribution](/docs/recipes/capacitated-distribution) for the full mechanics). ## What the rhythm looks like Continuous push + wave optimization + selective dispatch: ``` T-0 T+5 min T+10 min T+15 min T+20 min ───────── ────────── ─────────── ─────────── ─────────── Order A Order C Order E Order G Order I arrives arrives arrives arrives arrives Order B Wave 1 fires Wave 2 fires Wave 3 fires Wave 4 fires arrives ⚡ optimize ⚡ optimize ⚡ optimize ⚡ optimize (A + B → R1) (C onto R1 (E new R2, (G onto R2, if feasible, D onto R1) I new R3) else R1.b) Dispatch R1 Dispatch R2 Dispatch R3 Driver gets Driver R1 Driver R2 Driver R3 magic link already gets magic gets magic on the road link link T+60 min ────────── Order A delivered → stop.reported → OMS shows "Delivered" → customer rating UI ``` Two notes specific to same-day grocery: - **Wave cadence is your operational lever.** Faster waves (every 3-5 min) = fresher commitments but more optimizer churn and more dispatch noise. Slower waves (every 15-20 min) = larger batches but worse customer-promise accuracy. Most ops settle at 5-10 min waves. - **`keep_current_assignment=true` is load-bearing.** Without it, every wave re-optimization could rewrite the route of a driver already on the road, and the customers app would push a new ETA based on a new sequence. Drivers and customers both hate this. Always use it on live re-optimize. ## The integration shape ``` OMS / dark-store backend │ │ order-created event (webhook from OMS to your integration) ▼ [ order: { id, items[], cold_chain_skus[], delivery_address, slot_window } ] │ │ (1) push as Routal stop on today's plan ▼ POST /v2/stops (idempotent on external_id = OMS order ID) │ │ (2) cron fires every N minutes ▼ POST /v2/plan/{id}/optimize?keep_current_assignment=true │ │ (3) dispatch only newly formed routes ▼ POST /v2/route/{id}/dispatch (one per route, skip those already in_transit) │ │ (4) webhooks during execution ▼ stop.reported → OMS marks "delivered" + customer notification route.finished → driver returns to depot or accepts next wave ``` The two main differences vs. nightly-batch + live-dispatch: - **Plan duration is shorter.** A grocery plan often covers a single shift (e.g., 14:00-22:00) instead of a full day. Some ops create a fresh plan every 4 hours; others use one plan per day but optimize many times. - **Wave optimization runs against a fixed cron.** Each fire is fast (≤ 30s) because of `keep_current_assignment=true` — most of the existing assignment is locked, only new stops + a few unassigned ones are placed. ## Production code **cURL:** ```bash # (1) Push a new order as a stop as soon as it lands in your OMS. # external_id = OMS order ID. cold-chain SKUs add requires=["frozen"]. curl -X POST "https://api.routal.com/v2/stops?private_key=YOUR_KEY&plan_id=PLAN_ID&project_id=YOUR_PROJECT_ID" \ -H 'Content-Type: application/json' \ -d '[ { "external_id": "ORD-DKS-22301", "label": "B. Garcia — 18 Sant Pere més Alt", "location": { "lat": 41.3870, "lng": 2.1741, "address": "Sant Pere més Alt 18, 08003 Barcelona" }, "duration": 120, "time_windows": [[51000, 58200]], "phone": "+34699111222", "email": "garcia@example.com", "weight": 4.2, "requires": ["refrigerated"] } ]' # (2) Wave optimize — fires from a cron every N minutes. Always pass # keep_current_assignment=true to protect drivers on the road. curl -X POST "https://api.routal.com/v2/plan/PLAN_ID/optimize?private_key=YOUR_KEY&keep_current_assignment=true" # (3) Dispatch each route that's not already in_transit. # Use GET /v2/plan/{id}/routes to enumerate and check status. curl -X POST "https://api.routal.com/v2/route/ROUTE_ID/dispatch?private_key=YOUR_KEY" ``` **TypeScript:** ```ts import createClient from 'openapi-fetch'; import type { paths } from './routal'; const routal = createClient({ baseUrl: 'https://api.routal.com' }); const ROUTAL_API_KEY = process.env.ROUTAL_API_KEY!; const PROJECT_ID = process.env.ROUTAL_PROJECT_ID!; type GroceryOrder = { id: string; customer: string; lat: number; lng: number; address: string; phoneE164: string; email: string; weightKg: number; windowFromSec: number; // promised slot start (seconds from midnight) windowToSec: number; // promised slot end coldChain: 'ambient' | 'refrigerated' | 'frozen'; }; /** Push a single order as soon as it lands in the OMS. */ export async function pushOrder(planId: string, order: GroceryOrder) { const { error } = await routal.POST('/v2/stops', { params: { query: { private_key: ROUTAL_API_KEY, plan_id: planId, project_id: PROJECT_ID } }, body: [ { external_id: order.id, label: `${order.customer} — ${order.address}`, location: { lat: order.lat, lng: order.lng, address: order.address }, duration: 120, time_windows: [[order.windowFromSec, order.windowToSec]], phone: order.phoneE164, email: order.email, weight: order.weightKg, requires: order.coldChain === 'ambient' ? [] : [order.coldChain], }, ] as never, }); if (error) throw error; } /** Wave optimize. Fires from cron every N minutes. */ export async function waveOptimize(planId: string) { const { error } = await routal.POST('/v2/plan/{id}/optimize', { params: { path: { id: planId }, query: { private_key: ROUTAL_API_KEY, keep_current_assignment: true }, }, }); if (error) { // sync_optimization_already_progress is expected if a wave overlaps — // just skip this tick; the next one will catch up. if ((error as { message_id?: string }).message_id === 'highway.optimization.error.sync_optimization_already_progress') { return { skipped: 'overlap' }; } throw error; } return { skipped: null }; } /** Dispatch every route that isn't already in_transit. */ export async function dispatchNewRoutes(planId: string) { const { data } = await routal.GET('/v2/plan/{id}/routes', { params: { path: { id: planId }, query: { private_key: ROUTAL_API_KEY } }, }); const routes = (data as Array<{ id: string; status: string }>) ?? []; for (const route of routes) { if (route.status === 'not_started') { await routal.POST('/v2/route/{id}/dispatch', { params: { path: { id: route.id }, query: { private_key: ROUTAL_API_KEY } }, }); } } } ``` **Python:** ```python import os, requests ROUTAL_API_KEY = os.environ["ROUTAL_API_KEY"] PROJECT_ID = os.environ["ROUTAL_PROJECT_ID"] BASE = "https://api.routal.com" def push_order(plan_id: str, order: dict) -> None: """order: {id, customer, address, lat, lng, phone, email, weight_kg, window_from_sec, window_to_sec, cold_chain}""" requires = [] if order["cold_chain"] == "ambient" else [order["cold_chain"]] payload = [{ "external_id": order["id"], "label": f"{order['customer']} — {order['address']}", "location": {"lat": order["lat"], "lng": order["lng"], "address": order["address"]}, "duration": 120, "time_windows": [[order["window_from_sec"], order["window_to_sec"]]], "phone": order["phone"], "email": order["email"], "weight": order["weight_kg"], "requires": requires, }] requests.post( f"{BASE}/v2/stops", params={"private_key": ROUTAL_API_KEY, "plan_id": plan_id, "project_id": PROJECT_ID}, json=payload, timeout=30, ).raise_for_status() def wave_optimize(plan_id: str) -> dict: resp = requests.post( f"{BASE}/v2/plan/{plan_id}/optimize", params={"private_key": ROUTAL_API_KEY, "keep_current_assignment": "true"}, timeout=120, ) if not resp.ok: body = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {} if body.get("message_id") == "highway.optimization.error.sync_optimization_already_progress": return {"skipped": "overlap"} resp.raise_for_status() return {"skipped": None} def dispatch_new_routes(plan_id: str) -> None: resp = requests.get( f"{BASE}/v2/plan/{plan_id}/routes", params={"private_key": ROUTAL_API_KEY}, timeout=30, ) resp.raise_for_status() for route in resp.json(): if route.get("status") == "not_started": requests.post( f"{BASE}/v2/route/{route['id']}/dispatch", params={"private_key": ROUTAL_API_KEY}, timeout=30, ).raise_for_status() ``` ## Production hardening ### Wave cadence — pick a number and stick to it The cron interval is the most consequential operational tunable. Three failure modes if you get it wrong: - **Too fast (1-3 min):** waves overlap, `sync_optimization_already_progress` fires constantly, drivers see new ETAs more often than makes sense. - **Too slow (20+ min):** an order placed at minute 14 of a 20-min wave waits ~6 minutes before any optimize considers it — bad for a sub-2-hour SLA. - **Variable:** worse than either fixed extreme. Drivers and customers learn to expect rhythm; randomness erodes trust. Start at **5-minute waves** for a sub-2-hour SLA, **3-minute waves** for sub-60. Tune only after a week of metrics. ### Cold chain — explicit requires/provides per SKU class A van without a freezer cannot serve a frozen stop. Routal enforces this as a hard constraint when you set the matching `requires` on the stop and the matching `default_provides` on the vehicle: ```ts // vehicle { default_provides: ['ambient', 'refrigerated', 'frozen'] } // fully-equipped truck { default_provides: ['ambient'] } // bike or unrefrigerated van // stop { requires: ['frozen'] } // ice cream, frozen meat { requires: ['refrigerated'] } // dairy, fresh produce { requires: [] } // ambient — any vehicle is fine ``` If a stop's `requires` can't be served by any active vehicle, the optimizer returns `no_result_found`. Catch it, log which skill was unmet, and either scale the fleet (call in an extra cold-chain van) or refund the order. See [Capacitated distribution](/docs/recipes/capacitated-distribution) for the full constraint matrix (weight, volume, skill matching). ### Idempotency on the wave push Your OMS may fire the order-created event more than once (retry on its own network failure, replay during deploy). Make the per-order push idempotent via `external_id`: ```ts async function pushOrderIdempotent(planId: string, order: GroceryOrder) { // Cheap pre-check: skip the POST if the order is already on the plan. const { data } = await routal.POST('/v2/stops/search', { params: { query: { private_key: ROUTAL_API_KEY, project_id: PROJECT_ID } }, body: { limit: 1, predicates: [ { field: 'external_id', operator: 'eq', value: order.id, type: 'string' }, ], } as never, }); const existing = (data as { docs?: Array<{ id: string }> } | undefined)?.docs?.[0]; if (existing) return { skipped: 'already_exists' }; await pushOrder(planId, order); return { skipped: null }; } ``` ### Rate limit — cap concurrent pushes Same-day grocery has spiky traffic: lunch and dinner pushes can land 100+ orders in a 90-second burst. The Routal API caps you at 2,000 req/min per credential. Single-stop pushes are cheap individually but the per-stop POST shape doesn't take an array efficiently for many small orders. Compromise: micro-batch by **10-25 orders per push** with a single-worker queue that drains at most every 200ms. This stays well under 2,000 req/min and keeps P95 push latency under 1 second. ### Observability — the metrics that matter | Metric | What it tells you | |---|---| | `grocery.orders_pushed_per_minute` | Real-time order velocity. Spike = lunch/dinner rush; drop = OMS broken. | | `grocery.wave_skipped_overlap_total` | How often a wave fired while a previous one was still running. Sustained > 5% per hour = waves too fast or optimizer slow. | | `grocery.median_optimize_seconds` | Wall-clock optimize duration. Trending up = plan is too large; chunk into shifts. | | `grocery.unassigned_at_dispatch_total` | Stops that didn't get a route at the moment of dispatch. Spike = cold-chain or capacity overflow. | | `grocery.median_promise_to_delivery_seconds` | Time from customer order to `stop.reported`. The headline customer-experience metric. | ## Common errors | `message_id` | Cause | What to do | |---|---|---| | `highway.optimization.error.sync_optimization_already_progress` | A wave is still running when the next one fires. | Skip this tick. Don't retry — the next cron will pick up. | | `highway.optimization.error.no_result_found` | Cold-chain capacity exhausted, or all vans full. | Scale fleet (extra cold van), or refund unfittable orders with apology + voucher. | | `highway.geocoding.error.wrong_lat_lng` | Address resolves to nonsense (out of region, lake, etc.). | Reject the order in the OMS before push; customer outreach. | | `highway.stop.error.report_already_exists` | Trying to push a terminal report on a stop that already has one. | Webhook dedup is broken — fix the deduper, don't fight Routal. | | `429 Too Many Requests` | Push velocity exceeded 2,000 req/min. | Throttle the per-order pusher; switch to micro-batches of 10-25. | ## Next steps - [Nightly batch + live dispatch](/docs/recipes/nightly-batch-plus-live-dispatch) — the architectural cousin of this recipe; the wave pattern is the same, the cadence is much faster. - [Capacitated distribution](/docs/recipes/capacitated-distribution) — the full constraint matrix for cold-chain operations, including pre-flight feasibility checks before optimize. - [Webhooks](/docs/webhooks) — `stop.reported` is the single event your customer-facing notifications hang off of. - [Idempotency](/docs/idempotency) — pattern 1 (search-then-create) is the load-bearing guard for the order-created handler. --- # Recipes URL: https://developers.routal.com/docs/recipes/index Complete, runnable integration walkthroughs — one per common operation rhythm. The pages in this section are **complete, runnable scenarios** — not snippets. Each one walks from the business problem (the conversation a sales rep has with a prospect) to a production-ready integration (the runbook a platform team needs), in the three languages most Routal integrations ship today. Find the one closest to your operation: **B2B / ERP-driven** - [Nightly batch — B2B distribution](/docs/recipes/nightly-batch-b2b) — orders close yesterday at 18:00, fleet rolls out today at dawn. The default mode for food distributors, beverage wholesalers, and industrial supply. - [Nightly batch + live dispatch](/docs/recipes/nightly-batch-plus-live-dispatch) — most of the work is the nightly batch, but new orders keep arriving during operations and have to be slotted into active routes without disrupting in-progress drivers. - [Capacitated distribution](/docs/recipes/capacitated-distribution) — weight, volume, cold chain, oversized loads. The optimizer respects every constraint; failing to declare them costs you trips. - [Reverse logistics](/docs/recipes/reverse-logistics) — pickups, returns, RMA, asset recovery. Pure pickup routes or mixed delivery + pickup via Routal's chain mechanic. **B2C / consumer-facing** - [Last-mile e-commerce](/docs/recipes/last-mile-ecommerce) — B2C parcel delivery with high stop count per route and customer-facing tracking links sent automatically by Routal's customers app. - [Grocery — same-day delivery](/docs/recipes/grocery-same-day) — dark stores and same-day grocers. Continuous order flow, wave optimization every N minutes, cold-chain enforcement. **Service & calendar-driven** - [Field service with appointments](/docs/recipes/field-service-with-appointments) — technicians, installers, mobile workforce. Tight time windows, skill-matched assignment, signature/photo/checklist tasks on completion. - [Recurring services](/docs/recipes/recurring-services) — maintenance, ITV, gas refills, elevators. Same customer every N weeks at the same contracted slot — the schedule itself is the product. Each recipe opens with a TL;DR card (industry, ERPs, restrictions, flow in N steps) plus an "Open in AI" button that pastes the recipe context into your assistant of choice. Below the card, a one-paragraph business profile (who this is for, what their pain is, what their daily rhythm looks like), then the architectural decisions and the production code. ## Conventions across all recipes - **IDs in code samples are placeholders.** `YOUR_KEY`, `YOUR_PROJECT_ID`, `PLAN_ID`, `ROUTE_ID`, `STOP_ID` — replace with real 24-char hex values from your tenant. - **`external_id` is your idempotency lever.** Every recipe assumes you carry a stable identifier from your source system (order number, vehicle plate, job ID, ticket ID). If you don't have one, generate it before the integration starts — retries become much cheaper. - **Timestamps are ISO 8601 UTC.** Time windows on stops and vehicles use **seconds from midnight** (`[28800, 61200]` = 8am–5pm in the project's local time). - **All snippets redact `private_key` from log lines.** The key in a URL is the single biggest leak vector — see [Authentication](/docs/authentication#security). - **Examples are deliberately geography-agnostic.** Routal customers run on five continents; placeholder coordinates and customer names work in any jurisdiction. --- # Last-mile e-commerce URL: https://developers.routal.com/docs/recipes/last-mile-ecommerce B2C parcel delivery — orders flow from your OMS or WMS into Routal throughout the day, the optimizer batches them into dense neighborhood routes, drivers execute with barcode-scan tasks, and customers get tracking links automatically. This recipe covers the OMS→Routal→customer notification loop and the high-density stop patterns specific to parcel logistics. ## Who this is for You run a **B2C parcel operation** delivering physical goods to end customers who placed the order online. Common shapes: - A **direct-to-consumer brand** (apparel, beauty, home goods) shipping out of one or two fulfillment centers to households across a region. - A **marketplace fulfillment operation** (3PL serving multiple Shopify / WooCommerce stores) consolidating outbound flow into the same daily fleet. - A **same-day or next-day urban delivery** service (food retail, pharmacy e-commerce, beverage on-demand) where the customer expects an arrival window notification before the driver shows up. - A **post-checkout fulfillment** layer for retail (click-and-collect's cousin — click-and-ship-from-store) where the order originates in the store POS and needs to be sequenced into a delivery route. What makes you different from a B2B distributor: - **Every stop has a real human at the door** who wants to know "when will the driver be here?". The integration must populate `phone` and `email` on every stop and rely on Routal's customers app at [c.routal.com](https://c.routal.com) to send tracking links automatically. - **Stops per route are high.** 50-150 stops per van per day is normal for parcel; a B2B route is usually 8-25 stops. - **Time windows are softer.** The customer accepts "between 9am and 9pm", but the integration still has to surface a real ETA when the driver gets close (handled by Routal — see [Customer notifications](#customer-notifications)). - **Address quality is variable.** Customers type their own delivery addresses; you'll geocode misses constantly. The integration must handle `highway.geocoding.error.wrong_lat_lng` gracefully — flag the order for customer outreach instead of dropping it on the floor. ## What the rhythm looks like E-commerce flow is **continuous push during the day**, **optimize-and-dispatch at a cutoff**, **react to webhooks during execution**: ``` Throughout the day Cutoff (e.g. 16:00) Execution day ────────────────────────────── ───────────────────── ────────────────────────────── 08:00 Orders arrive in OMS 16:00 ORDER BOOK CLOSES 05:00 Drivers open the app (Shopify webhook, manual → routes flip in_transit entry, store POS, etc.) 16:05 ⚡ Push job fires 1. Create tomorrow's plan 09:00 First deliveries land 2. Bulk-push stops (with → c.routal.com sends phone + email) tracking link 3. Optimize (returns highway.optimization. 10:00-19:00 Drivers complete error.no_result_found stops, scan barcodes, if overflow) attach POD 4. Dispatch routes (driver email + SMS) 19:00 Last delivery → routes flip finished → plan flips finished 22:00 Reconciliation job verifies all stops closed in OMS ``` Two notes specific to last-mile e-commerce: - **Cutoff is your operational lever.** Move it earlier and you ship the same day; move it later and you risk no-result-found because the optimizer can't fit all overflow on the available fleet. - **Customer notifications are NOT your responsibility once the stop has `phone` and `email`.** Routal's customers app sends the tracking link on dispatch and an ETA SMS when the driver gets close. Your integration just makes sure the contact fields are populated. ## The integration shape ``` OMS / WMS (Shopify / Woo / Magento / custom) │ │ (1) read pending orders, normalize address + contact ▼ [ orders: { external_id, address, phone, email, weight?, volume? } ] │ │ (2) bulk-create as Routal stops in tomorrow's plan ▼ POST /v2/stops (idempotent on external_id) │ │ (3) optimize once order book closes ▼ POST /v2/plan/{id}/optimize │ │ (4) dispatch — driver gets magic link, customer gets tracking link ▼ POST /v2/route/{id}/dispatch (one call per route) │ │ (5) webhooks during execution ▼ stop.reported → close order in OMS, attach POD route.finished → reconcile day-of metrics ``` The single biggest difference vs. nightly-batch B2B: every stop carries `phone` + `email`, and Routal's customers app handles tracking outreach automatically. You don't build a tracking page — you populate two fields. ## Production code **cURL:** ```bash # (1) Ensure tomorrow's plan exists (idempotent on execution_date + label). curl -X POST "https://api.routal.com/v2/plan?private_key=YOUR_KEY&project_id=YOUR_PROJECT_ID" \ -H 'Content-Type: application/json' \ -d '{ "label": "Daily — 2026-05-22", "execution_date": "2026-05-22" }' # (2) Bulk-push the day's orders. Each stop carries phone + email so the # customers app at c.routal.com can send tracking links automatically. curl -X POST "https://api.routal.com/v2/stops?private_key=YOUR_KEY&plan_id=PLAN_ID&project_id=YOUR_PROJECT_ID" \ -H 'Content-Type: application/json' \ -d '[ { "external_id": "ORD-10042", "label": "Alice Doe — 12 Carrer del Bruc", "location": { "lat": 41.3955, "lng": 2.1734, "address": "Carrer del Bruc 12, 08010 Barcelona" }, "duration": 180, "time_windows": [[32400, 75600]], "phone": "+34600111222", "email": "alice@example.com", "weight": 1.4, "tasks": [ { "type": "scan", "label": "Scan tracking barcode" }, { "type": "signature", "label": "Customer signature" } ] } ]' # (3) Optimize. curl -X POST "https://api.routal.com/v2/plan/PLAN_ID/optimize?private_key=YOUR_KEY" # (4) Dispatch — fire one call per route. The driver gets a magic-link email; # the customer auto-gets a tracking link from c.routal.com. curl -X POST "https://api.routal.com/v2/route/ROUTE_ID/dispatch?private_key=YOUR_KEY" ``` **TypeScript:** ```ts import createClient from 'openapi-fetch'; import type { paths } from './routal'; const routal = createClient({ baseUrl: 'https://api.routal.com' }); const ROUTAL_API_KEY = process.env.ROUTAL_API_KEY!; const PROJECT_ID = process.env.ROUTAL_PROJECT_ID!; type EcommerceOrder = { id: string; // order number — becomes external_id customerName: string; addressLine: string; lat: number; lng: number; phoneE164: string; // E.164, always — Routal validates email: string; weightKg: number; windowFromSec: number; // e.g. 32400 = 9:00 windowToSec: number; // e.g. 75600 = 21:00 }; /** Idempotent: re-running with the same orders is a no-op. */ export async function pushDailyOrders(planId: string, orders: EcommerceOrder[]) { if (orders.length === 0) return { created: 0 }; // 1. De-dup against what's already in the plan (cron retries, deploy windows). const existing = await listExternalIdsInPlan(planId); const newOnes = orders.filter((o) => !existing.has(o.id)); if (newOnes.length === 0) return { created: 0 }; // 2. Chunk by 250 — bulk endpoints accept arrays but very large arrays // become harder to debug if one item fails validation. for (let i = 0; i < newOnes.length; i += 250) { const chunk = newOnes.slice(i, i + 250); const { error } = await routal.POST('/v2/stops', { params: { query: { private_key: ROUTAL_API_KEY, plan_id: planId, project_id: PROJECT_ID } }, body: chunk.map((o) => ({ external_id: o.id, label: `${o.customerName} — ${o.addressLine}`, location: { lat: o.lat, lng: o.lng, address: o.addressLine }, duration: 180, time_windows: [[o.windowFromSec, o.windowToSec]], phone: o.phoneE164, email: o.email, weight: o.weightKg, tasks: [ { type: 'scan', label: 'Scan tracking barcode' }, { type: 'signature', label: 'Customer signature' }, ], })) as never, }); if (error) throw error; } return { created: newOnes.length }; } async function listExternalIdsInPlan(planId: string): Promise> { const { data } = await routal.GET('/v2/plan/{id}/stops', { params: { path: { id: planId }, query: { private_key: ROUTAL_API_KEY } }, }); return new Set(((data as Array<{ external_id?: string }>) ?? []).map((s) => s.external_id ?? '')); } /** Optimize + dispatch every route in the plan. */ export async function dispatchPlan(planId: string) { const { error: optErr } = await routal.POST('/v2/plan/{id}/optimize', { params: { path: { id: planId }, query: { private_key: ROUTAL_API_KEY } }, }); if (optErr) throw optErr; const { data: routes } = await routal.GET('/v2/plan/{id}/routes', { params: { path: { id: planId }, query: { private_key: ROUTAL_API_KEY } }, }); for (const route of (routes as Array<{ id: string }>) ?? []) { await routal.POST('/v2/route/{id}/dispatch', { params: { path: { id: route.id }, query: { private_key: ROUTAL_API_KEY } }, }); } } ``` **Python:** ```python import os, requests ROUTAL_API_KEY = os.environ["ROUTAL_API_KEY"] PROJECT_ID = os.environ["ROUTAL_PROJECT_ID"] BASE = "https://api.routal.com" def list_external_ids_in_plan(plan_id: str) -> set[str]: resp = requests.get( f"{BASE}/v2/plan/{plan_id}/stops", params={"private_key": ROUTAL_API_KEY}, timeout=30, ) resp.raise_for_status() return {s.get("external_id", "") for s in resp.json()} def push_daily_orders(plan_id: str, orders: list[dict]) -> dict: """orders: [{id, customer, address, lat, lng, phone, email, weight_kg, window_from_sec, window_to_sec}, ...]""" if not orders: return {"created": 0} existing = list_external_ids_in_plan(plan_id) new_ones = [o for o in orders if o["id"] not in existing] if not new_ones: return {"created": 0} for i in range(0, len(new_ones), 250): chunk = new_ones[i:i + 250] payload = [ { "external_id": o["id"], "label": f"{o['customer']} — {o['address']}", "location": {"lat": o["lat"], "lng": o["lng"], "address": o["address"]}, "duration": 180, "time_windows": [[o["window_from_sec"], o["window_to_sec"]]], "phone": o["phone"], "email": o["email"], "weight": o["weight_kg"], "tasks": [ {"type": "scan", "label": "Scan tracking barcode"}, {"type": "signature", "label": "Customer signature"}, ], } for o in chunk ] resp = requests.post( f"{BASE}/v2/stops", params={"private_key": ROUTAL_API_KEY, "plan_id": plan_id, "project_id": PROJECT_ID}, json=payload, timeout=60, ) resp.raise_for_status() return {"created": len(new_ones)} def dispatch_plan(plan_id: str) -> None: requests.post( f"{BASE}/v2/plan/{plan_id}/optimize", params={"private_key": ROUTAL_API_KEY}, timeout=120, ).raise_for_status() resp = requests.get( f"{BASE}/v2/plan/{plan_id}/routes", params={"private_key": ROUTAL_API_KEY}, timeout=30, ) resp.raise_for_status() for route in resp.json(): requests.post( f"{BASE}/v2/route/{route['id']}/dispatch", params={"private_key": ROUTAL_API_KEY}, timeout=30, ).raise_for_status() ``` ## Production hardening ### Customer notifications — populate phone + email, the rest is automatic The single biggest cost for e-commerce ops is "where's my driver?" tickets. Routal's customers app at [c.routal.com](https://c.routal.com) handles this for you **as long as every stop has `phone` and/or `email`**: - **At dispatch** the customer receives a link to a live page showing the route on a map and the estimated arrival window for their stop. - **As the driver gets close** the customer is pinged again with a tighter ETA. - **On completion** the customer can rate the experience and download the POD. Your integration's job: validate phone (E.164) and email at ingestion time, and **refuse to push a stop without at least one**. A stop with neither is a guaranteed support ticket. ```ts function requireContact(order: EcommerceOrder) { if (!order.phoneE164 && !order.email) { throw new Error(`order ${order.id} has no phone or email — refusing to push`); } if (order.phoneE164 && !/^\+[1-9]\d{6,14}$/.test(order.phoneE164)) { throw new Error(`order ${order.id} phone is not E.164: ${order.phoneE164}`); } } ``` ### Address quality — geocode early, fail the order, never the route Customer-entered addresses are inconsistent. Routal will reject a stop with an out-of-range lat/lng (`highway.geocoding.error.wrong_lat_lng`) and the optimizer will move stops to the unassigned bucket if they geocode to a point with no roads (e.g., the middle of a lake). Pattern: 1. **Geocode before push.** Either call `POST /v2/stops/geocode` upstream, or geocode in your OMS using your own provider (Google Maps, Mapbox) before the nightly push. 2. **Cache and revalidate.** Address strings rarely change between a customer's orders; cache the geocoded `(lat, lng)` against a normalized address hash. 3. **Refuse, don't auto-fix.** If geocoding returns confidence below a threshold (e.g., Google's `GEOMETRIC_CENTER` or `APPROXIMATE`), flag the order for manual review in your OMS. Don't ship a probably-wrong stop and hope. ### Overflow — when the day's orders don't fit `POST /v2/plan/{id}/optimize` returns `highway.optimization.error.no_result_found` when not all stops fit on the available fleet within their time windows. For e-commerce this usually means "order book grew past fleet capacity at the cutoff". The integration must gracefully roll over. ```ts async function dispatchOrRollover(planId: string) { const { error } = await routal.POST('/v2/plan/{id}/optimize', { params: { path: { id: planId }, query: { private_key: ROUTAL_API_KEY } }, }); if (!error) return { rolled_over: 0 }; // Identify unassigned stops via /v2/plan/{id}/stops (those with no route_id) // and roll them to tomorrow's plan via POST /v2/stop/move, then mark them // 'rolled-over' in the source OMS so customer service can notify. const overflow = await listUnassigned(planId); await moveStopsToTomorrow(overflow); await notifyOmsRollover(overflow); return { rolled_over: overflow.length }; } ``` ### Reconciling stop.reported back into the OMS The webhook fires once per terminal report. Map the three `type` values into your OMS state machine: | `report.type` | OMS action | |---|---| | `service_report_completed` | Mark order **delivered**, attach signature + photo URLs, close ticket. | | `service_report_incomplete`| Mark order **delivery failed**, capture cancel_reason, trigger retry rule (next-day re-attempt? customer outreach?). | | `service_report_canceled` | Mark order **canceled**, refund flow if applicable. | Authenticate the webhook handler with a URL token, dedup on `(event_id, data.id, data.updated_at)`, and ACK 2xx fast — Routal auto-disables the webhook after 50 consecutive failures. ### Observability — the metrics that matter | Metric | What it tells you | |---|---| | `lastmile.orders_pushed_total{plan_id}` | Daily order volume reaching Routal. Drops to 0 = push job broken. | | `lastmile.geocoding_failures_total` | Bad addresses caught before push. Spike = OMS data quality regressing. | | `lastmile.rollover_orders_total` | Overflow from no_result_found. Trending up = need more vehicles. | | `lastmile.first_attempt_success_rate` | `completed` over (`completed` + `incomplete` + `canceled`). \>92% = healthy. | | `lastmile.driver_app_open_lag_seconds` | Time from `route/dispatch` to `route.started` webhook. Long lag = driver onboarding issue. | ## Common errors | `message_id` | Cause | What to do | |---|---|---| | `highway.geocoding.error.wrong_lat_lng` | Stop `location.lat`/`lng` out of range, or address geocoded to nowhere. | Geocode upstream; flag for manual review when confidence is low. | | `highway.optimization.error.no_result_found` | Orders exceed fleet capacity inside the day's windows. | Roll overflow to next day's plan; notify customer service via OMS flag. | | `highway.optimization.error.sync_optimization_already_progress` | Another optimization is still running for the same plan. | Wait, then re-check plan status. Do not retry. | | `highway.stop.error.custom_fields_invalid` | Custom fields on the stop don't match project definitions. | Fix the field schema in the dashboard, or strip the offending field from the payload. | | `400 Bad Request` — phone validation | Phone is not E.164 (must start with `+` and be 7-15 digits). | Validate upstream — refuse the order before pushing. | | `429 Too Many Requests` | Exceeded the 2,000 req/min cap with too many parallel pushes. | Chunk by 250, sleep 200ms between chunks, or run with a single worker. | ## Next steps - [Nightly batch — B2B distribution](/docs/recipes/nightly-batch-b2b) — the scaffolding pattern this recipe builds on; reuse the cron + recovery logic. - [Nightly batch + live dispatch](/docs/recipes/nightly-batch-plus-live-dispatch) — if your e-commerce flow accepts orders during execution (same-day cutoffs) instead of a single nightly cut. - [Webhooks](/docs/webhooks) — the full event catalog, envelope shape, and authentication patterns. - [Idempotency](/docs/idempotency) — the search-then-create pattern that keeps the daily push safe under cron retries. --- # Nightly batch — B2B distribution URL: https://developers.routal.com/docs/recipes/nightly-batch-b2b The default mode for B2B distributors. Orders close yesterday at 18:00, optimization runs overnight, the fleet rolls out at dawn. This recipe walks the full nightly job — when to fire it, how to make it idempotent against a daily cron, and how to recover when something misses the window. ## Who this is for You run a **B2B distribution operation** that serves the same network of customers — restaurants, supermarkets, bakeries, hospitals, industrial facilities, hotel chains — on a roughly fixed weekly rhythm. Your customers place orders **the day before delivery**, you close the order book at a fixed hour (typically 16:00–18:00), and your drivers leave the depot **at dawn the next morning**. Routal sits between your **order management system** (an ERP, a WMS, a home-grown order portal) and your **drivers**. Every night the integration pushes the day's orders, runs the optimizer, and emails the resulting routes to each driver so they can open the app before they get in the van. If that sounds like your operation, this is your recipe. If you take orders throughout the day and have to slot them into routes that have already left the depot, jump to [Nightly batch + live dispatch](/docs/recipes/nightly-batch-plus-live-dispatch). ## What the rhythm looks like ``` Day -1 Day 0 (execution) ───────────────────────────────────── ───────────────────────────────────── 08:00 Sales team takes orders 05:00 Drivers open the app throughout the day → routes flip to in_transit → plan flips to in_progress 17:50 Sales team finishes 08:00 First deliveries land 18:00 ORDER BOOK CLOSES 13:00 Mid-day check (any straggler?) 18:05 ⚡ Nightly batch fires 18:00 Last delivery 1. Create tomorrow's plan → routes flip to finished 2. Bulk-add the day's stops → plan flips to finished 3. Run the optimizer 4. Dispatch each route ⚡ Reconciliation job (driver gets a magic-link email) checks no event was missed 22:00 Operations supervisor reviews in planner.routal.com, tweaks if needed ``` The integration runs **once per business day** at the cutoff hour. If it fires twice (deploy, manual retry, scheduler hiccup) it must be a no-op the second time. If it never fires (cron host down, network blip) the supervisor must be able to run it manually from the dashboard or by hitting the same endpoint. ## What this recipe builds A single nightly job, idempotent on `(project_id, execution_date)`, that: 1. **Pulls today's order book** from your source system. 2. **Ensures the plan exists** for tomorrow's `execution_date`. 3. **Reconciles by `external_id`** so stops already pushed aren't duplicated. 4. **Bulk-creates the missing stops** with locations, time windows, and capacity. 5. **Runs the optimizer.** 6. **Dispatches each route** (sends the magic-link email). It's the canonical daily-orders flow specialized for the cutoff-at-18:00 cadence — with the cron, the rolling window, and the recovery logic that production crews actually need. ## Quickstart The Quickstart below is a single function you run from your scheduler at the cutoff hour. It assumes your source system gives you the list of orders for a specific `execution_date`. **cURL:** ```bash # This recipe orchestrates 4 calls in sequence. The cURL form is useful for # debugging individual steps — for the full nightly job, see the TS or Python # tabs. Run these at 18:05 against tomorrow's execution_date. EXEC_DATE="2026-05-22" # tomorrow, in the project's local time zone # (1) Create tomorrow's plan (or fetch it if it already exists from a retry). curl -X POST "https://api.routal.com/v2/plan?private_key=YOUR_KEY&project_id=YOUR_PROJECT_ID" \ -H 'Content-Type: application/json' \ -d "{ \"label\": \"${EXEC_DATE} nightly\", \"execution_date\": \"${EXEC_DATE}\" }" # → { "id": "PLAN_ID", "status": "planning", "total_stops": 0, ... } # (2) Bulk-add the day's orders. Each one carries the source order number as # external_id so a retry of step 2 is a no-op for stops already created. curl -X POST "https://api.routal.com/v2/stops?private_key=YOUR_KEY&plan_id=PLAN_ID&project_id=YOUR_PROJECT_ID" \ -H 'Content-Type: application/json' \ -d '[ { "external_id": "ORDER-9001", "label": "Acme Restaurant — Centre", "location": { "lat": 41.20, "lng": 2.05, "address": "123 Industrial Park, Unit B" }, "duration": 600, "time_windows": [[28800, 43200]], "weight": 32, "volume": 0.4, "phone": "+10000000001" }, { "external_id": "ORDER-9002", "label": "Beta Bakery Supply", "location": { "lat": 41.25, "lng": 2.10 }, "duration": 480, "time_windows": [[32400, 50400]], "weight": 12, "volume": 0.15 } ]' # (3) Optimize. Not safe to retry blindly — see Production hardening. curl -X POST "https://api.routal.com/v2/plan/PLAN_ID/optimize?private_key=YOUR_KEY" # (4) For each route the optimizer produced, dispatch (sends magic-link email). curl -G "https://api.routal.com/v2/plan/PLAN_ID/routes" \ --data-urlencode 'private_key=YOUR_KEY' # → list of routes. For each: dispatch. curl -X POST "https://api.routal.com/v2/route/ROUTE_ID/dispatch?private_key=YOUR_KEY" ``` **TypeScript:** ```ts import createClient from 'openapi-fetch'; import type { paths } from './routal'; const routal = createClient({ baseUrl: 'https://api.routal.com' }); const ROUTAL_API_KEY = process.env.ROUTAL_API_KEY!; const PROJECT_ID = process.env.ROUTAL_PROJECT_ID!; type SourceOrder = { id: string; // order number from your ERP/WMS customer: string; lat: number; lng: number; address?: string; serviceMinutes: number; windowFromSec: number; windowToSec: number; weightKg?: number; volumeM3?: number; phone?: string; }; /** * Run the nightly batch for a given execution date. * Designed to be called from a scheduler at the order cutoff hour. * Idempotent on (PROJECT_ID, executionDate). */ export async function runNightlyBatch( executionDate: string, // 'YYYY-MM-DD' orders: SourceOrder[], log = console, ): Promise<{ planId: string; stopsCreated: number; routesDispatched: number }> { const label = `${executionDate} nightly`; const planId = await ensurePlan(executionDate, label); log.info('nightly_batch.plan_ready', { planId, executionDate, orderCount: orders.length }); // Reconcile: only push stops whose external_id is not already on the plan. const alreadyThere = await findExistingExternalIds(planId, orders.map((o) => o.id)); const newOrders = orders.filter((o) => !alreadyThere.has(o.id)); if (newOrders.length > 0) { await bulkAddStops(planId, newOrders); } log.info('nightly_batch.stops_added', { planId, new: newOrders.length, skipped: orders.length - newOrders.length, }); await optimizeOnce(planId); log.info('nightly_batch.optimized', { planId }); const dispatched = await dispatchAllRoutes(planId); log.info('nightly_batch.dispatched', { planId, routes: dispatched }); return { planId, stopsCreated: newOrders.length, routesDispatched: dispatched }; } async function ensurePlan(executionDate: string, label: string): Promise { // Plans are listed in reverse chrono — paginate just enough to find today's. // If your project produces many plans/day, narrow with a search by label. const { data, error } = await routal.GET('/v2/plans', { params: { query: { private_key: ROUTAL_API_KEY, project_id: PROJECT_ID, limit: 50, sort: 'created_at:desc', }, }, }); if (error) throw error; const existing = data!.docs?.find( (p) => p.label === label && p.execution_date?.startsWith(executionDate), ); if (existing) return existing.id!; const { data: created, error: createErr } = await routal.POST('/v2/plan', { params: { query: { private_key: ROUTAL_API_KEY, project_id: PROJECT_ID } }, body: { label, execution_date: executionDate } as never, }); if (createErr) throw createErr; return created!.id!; } async function findExistingExternalIds(planId: string, orderIds: string[]): Promise> { if (orderIds.length === 0) return new Set(); const { data, error } = await routal.POST('/v2/stops/search', { params: { query: { private_key: ROUTAL_API_KEY, project_id: PROJECT_ID } }, body: { limit: 1000, predicates: [ { field: 'plan_id', operator: 'eq', value: planId, type: 'string' }, { field: 'external_id', operator: 'in', value: orderIds, type: 'string' }, ], } as never, }); if (error) throw error; return new Set((data!.docs ?? []).map((s) => s.external_id!).filter(Boolean)); } async function bulkAddStops(planId: string, orders: SourceOrder[]): Promise { const { error } = await routal.POST('/v2/stops', { params: { query: { private_key: ROUTAL_API_KEY, plan_id: planId, project_id: PROJECT_ID } }, body: orders.map((o) => ({ external_id: o.id, label: o.customer, location: { lat: o.lat, lng: o.lng, address: o.address }, duration: o.serviceMinutes * 60, time_windows: [[o.windowFromSec, o.windowToSec]], weight: o.weightKg, volume: o.volumeM3, phone: o.phone, })) as never, }); if (error) throw error; } async function optimizeOnce(planId: string): Promise { const { error } = await routal.POST('/v2/plan/{id}/optimize', { params: { path: { id: planId }, query: { private_key: ROUTAL_API_KEY } }, }); if (error?.message_id === 'highway.optimization.error.sync_optimization_already_progress') { throw new Error( 'optimization already running for ' + planId + ' — wait, then check plan state before re-firing', ); } if (error) throw error; } async function dispatchAllRoutes(planId: string): Promise { const { data: routes, error } = await routal.GET('/v2/plan/{id}/routes', { params: { path: { id: planId }, query: { private_key: ROUTAL_API_KEY } }, }); if (error) throw error; let count = 0; for (const route of routes ?? []) { const { error: dispatchErr } = await routal.POST('/v2/route/{id}/dispatch', { params: { path: { id: route.id! }, query: { private_key: ROUTAL_API_KEY } }, }); if (dispatchErr) throw dispatchErr; count++; } return count; } ``` **Python:** ```python import os import requests from datetime import date ROUTAL_API_KEY = os.environ["ROUTAL_API_KEY"] PROJECT_ID = os.environ["ROUTAL_PROJECT_ID"] BASE = "https://api.routal.com" def run_nightly_batch(execution_date: str, orders: list[dict]) -> dict: """ Run the nightly batch for a given execution date. Designed to be called from a scheduler at the order cutoff hour. Idempotent on (PROJECT_ID, execution_date). """ label = f"{execution_date} nightly" plan_id = ensure_plan(execution_date, label) already = find_existing_external_ids(plan_id, [o["id"] for o in orders]) new_orders = [o for o in orders if o["id"] not in already] if new_orders: bulk_add_stops(plan_id, new_orders) optimize_once(plan_id) dispatched = dispatch_all_routes(plan_id) return {"plan_id": plan_id, "stops_created": len(new_orders), "routes_dispatched": dispatched} def ensure_plan(execution_date: str, label: str) -> str: resp = requests.get( f"{BASE}/v2/plans", params={"private_key": ROUTAL_API_KEY, "project_id": PROJECT_ID, "limit": 50, "sort": "created_at:desc"}, timeout=30, ) resp.raise_for_status() for p in resp.json().get("docs", []): if p.get("label") == label and (p.get("execution_date") or "").startswith(execution_date): return p["id"] created = requests.post( f"{BASE}/v2/plan", params={"private_key": ROUTAL_API_KEY, "project_id": PROJECT_ID}, json={"label": label, "execution_date": execution_date}, timeout=30, ) created.raise_for_status() return created.json()["id"] def find_existing_external_ids(plan_id: str, order_ids: list[str]) -> set[str]: if not order_ids: return set() resp = requests.post( f"{BASE}/v2/stops/search", params={"private_key": ROUTAL_API_KEY, "project_id": PROJECT_ID}, json={ "limit": 1000, "predicates": [ {"field": "plan_id", "operator": "eq", "value": plan_id, "type": "string"}, {"field": "external_id", "operator": "in", "value": order_ids, "type": "string"}, ], }, timeout=30, ) resp.raise_for_status() return {s["external_id"] for s in resp.json().get("docs", []) if s.get("external_id")} def bulk_add_stops(plan_id: str, orders: list[dict]) -> None: payload = [ { "external_id": o["id"], "label": o["customer"], "location": {"lat": o["lat"], "lng": o["lng"], "address": o.get("address")}, "duration": o["service_minutes"] * 60, "time_windows": [[o["window_from_sec"], o["window_to_sec"]]], "weight": o.get("weight_kg"), "volume": o.get("volume_m3"), "phone": o.get("phone"), } for o in orders ] resp = requests.post( f"{BASE}/v2/stops", params={"private_key": ROUTAL_API_KEY, "plan_id": plan_id, "project_id": PROJECT_ID}, json=payload, timeout=60, ) resp.raise_for_status() def optimize_once(plan_id: str) -> None: resp = requests.post( f"{BASE}/v2/plan/{plan_id}/optimize", params={"private_key": ROUTAL_API_KEY}, timeout=180, ) if resp.status_code == 400: body = resp.json() if body.get("message_id") == "highway.optimization.error.sync_optimization_already_progress": raise RuntimeError( f"optimization already running for {plan_id} — " "wait, then check plan state before re-firing" ) resp.raise_for_status() def dispatch_all_routes(plan_id: str) -> int: resp = requests.get( f"{BASE}/v2/plan/{plan_id}/routes", params={"private_key": ROUTAL_API_KEY}, timeout=30, ) resp.raise_for_status() count = 0 for r in resp.json(): requests.post( f"{BASE}/v2/route/{r['id']}/dispatch", params={"private_key": ROUTAL_API_KEY}, timeout=30, ).raise_for_status() count += 1 return count ``` ## Production hardening ### Scheduling — fire from a real scheduler, not from `cron` Two reasons: - **Idempotency on accidental double-fires.** Cloud schedulers (Cloud Scheduler, EventBridge, GitHub Actions cron, Temporal, Airflow) call your endpoint instead of running locally. The endpoint is the natural place to add the "already ran today?" check. - **Visibility.** Cron in a single host fails silently if the host is down. A scheduler at least logs the missed window and lets you alert on it. Trigger pattern: ``` 18:05 every working day → HTTPS POST /internal/run-nightly-batch with body { "execution_date": "" } ``` Your endpoint loads the orders for that date, calls `runNightlyBatch`, and returns the summary. Wrap the whole thing in a "if I already wrote to my own DB that the batch ran for this date, return early" guard. ### Time zones — the cutoff is **local**, not UTC If your scheduler runs in UTC and the operation is in a different time zone, "18:00 cutoff" depends on which 18:00 you mean. Two patterns that work: 1. **Schedule in local time.** Most cloud schedulers accept a time zone. Use the depot's time zone so 18:00 always means "18:00 at the depot". 2. **Schedule in UTC and compute the local date inside the job.** Useful if you operate across multiple time zones from a single job runner. ```ts // pattern 2 — compute tomorrow in the depot's TZ from UTC const depotTz = 'America/Mexico_City'; const tomorrowLocal = new Date().toLocaleDateString('en-CA', { timeZone: depotTz }); // → '2026-05-22' regardless of what the runner host thinks the date is ``` Avoid using `Date.now()` and adding 24h — daylight saving will burn you twice a year. ### Rolling window — keep 14 days of plans, archive the rest Plans accumulate. After a year of daily plans you have ~250 plans per project and `GET /v2/plans` pagination becomes a real cost when the integration boots. Two operational patterns: - **Don't list — search by execution date.** `POST /v2/stops/search` is for stops, but the equivalent for plans is `GET /v2/plans` with the date already encoded in the label. Cache the plan id locally after creation; only call `ensurePlan` when the cache misses. - **Soft-delete old plans.** `DELETE /v2/plan/{id}` removes plans you no longer need to query (stops and routes cascade-soft-delete). Routal is a planning tool, not a system of record — your ERP should keep the authoritative copy of every order's outcome via webhooks. ### Recover from a missed window If 18:05 came and went without the batch firing (cron host down, deploy collision, network), the supervisor on call needs to be able to run it manually. Three options, cheapest first: 1. **Re-trigger the same endpoint.** Idempotency on `(project_id, execution_date)` means a manual POST does the right thing. 2. **Run from a CLI.** Ship a small script that calls the same `runNightlyBatch` function locally — useful when the network from the runner host to your ERP is down but the supervisor's laptop is fine. 3. **Plan it in the dashboard.** Worst case the supervisor opens [planner.routal.com](https://planner.routal.com) and creates the plan manually. Stops can be uploaded from a CSV. This is the always-available escape hatch. Add a heartbeat: at 18:30 every working day, an alerting job checks that the day's plan exists with stops and at least one dispatched route. If it doesn't, page the on-call supervisor. ### Daily quota — stay under the rate limit The nightly batch tends to spike right after the cutoff hour. A 2,000-stop import as 4 chunks of 500 + one optimize + N dispatches stays well under the **2,000 requests/minute** cap. If you're operating multiple projects from the same credential, stagger them: ``` 18:05 Project A — run batch 18:10 Project B — run batch 18:15 Project C — run batch ``` Don't have all projects fire at the exact same minute — that's the single fastest way to get rate-limited at midnight. ### What happens when a stop has no feasible window `POST /v2/plan/{id}/optimize` can return `highway.optimization.error.no_result_found` when the constraints can't be satisfied — the most common cause in a B2B nightly batch is a time window narrower than the driving time from the depot. Recovery path: 1. Log the optimization request (stops + vehicles + their constraints). 2. Email the supervisor with the plan id and a list of "stops with windows shorter than 60 min" or other diagnostic flags. 3. The supervisor relaxes constraints in the dashboard, manually adds a vehicle, or moves the impossible stop to the next day. 4. Re-run the optimizer (this time without the offending stop or with the relaxed window). Don't auto-relax constraints in the integration — the integrator is not the one who decides whether "deliver to Acme Restaurant before 9am" can slip. ### Observability — the metrics that matter Three counters and one gauge cover 95% of the production support load: | Metric | What it tells you | |---|---| | `routal.nightly_batch.runs_total{outcome}` | Did the nightly job actually run? Alert on `outcome=missing` for \>1h after the cutoff. | | `routal.nightly_batch.stops_created` | Volume sanity check. Alert if today's count is \<50% or \>200% of the 7-day rolling average. | | `routal.nightly_batch.routes_dispatched` | Did each route get its email? Should equal route count. | | `routal.nightly_batch.errors_total{message_id}` | Cumulative `highway.*` codes hit. Investigate any new code. | Tag every log line with `plan_id` and `execution_date` so the supervisor can grep "what happened with tomorrow's plan" without a war room. ### The reconciliation job The nightly batch is **push**. The execution day is **react** — driver opens the app, stop reports come in, plan flips to finished. The integration listens via [webhooks](/docs/webhooks). Add one more job that runs every 6 hours on the execution day: list every stop on the day's plan and reconcile its status with your source system. Catches webhook losses, deploy windows, the rare event Routal didn't deliver. Cheap to build, expensive when missing. ## Common errors | `message_id` | Cause | What to do | |---|---|---| | `highway.optimization.error.sync_optimization_already_progress` | A previous optimization for the same plan is still running. | Do not retry. Wait, then check `GET /v2/plan/{id}` for the latest state. The first run will complete and update the plan. | | `highway.optimization.error.no_result_found` | The optimizer can't fit all stops to the available vehicles under the declared constraints. | Page the supervisor with diagnostic data. Don't auto-relax. | | `highway.optimization.error.too_much_requests` | The optimizer backend itself was rate-limited. Separate from the public 429. | Wait 60–90 seconds, re-fire once. If persistent, check [statuspage](https://routal.statuspage.io/). | | `highway.stop.error.custom_fields_invalid` | A stop sent custom fields that don't match the project definitions. | Fetch the project's custom-field schema, fix the payload — usually a typo in a field name. | | `400 Bad Request` (no `message_id`) | Payload validation failure — most commonly a missing `label` or `time_windows` outside `[0, 172800]`. | Read `message` for the offending field, fix the source data. | | `429 Too Many Requests` | Too many projects firing the nightly batch at the same minute. | Stagger schedules; bulk-create in chunks of 500 instead of looping. | ## Next steps - [Nightly batch + live dispatch](/docs/recipes/nightly-batch-plus-live-dispatch) — if some orders arrive during the day instead of all the night before. - [Capacitated distribution](/docs/recipes/capacitated-distribution) — when weight, volume, or cold chain are non-negotiable constraints. - [Resource lifecycle](/docs/lifecycle) — what changes status automatically and what doesn't. --- # Nightly batch + live dispatch URL: https://developers.routal.com/docs/recipes/nightly-batch-plus-live-dispatch Most of the work is the nightly batch — but new orders keep arriving during operations and have to be slotted into routes that already left the depot. This recipe covers the architectural decision of where to slot, the three optimizer strategies that are safe with drivers already on the road, and the operational patterns that keep dispatcher panic to a minimum. ## Who this is for You run an operation where **the majority of orders close the night before** (the [nightly batch B2B](/docs/recipes/nightly-batch-b2b) pattern is your default), **but new orders keep landing during the day** and you can't tell the customer "wait until tomorrow": - An e-commerce platform with a same-day SLA on a small fraction of orders. - A meal-box delivery service taking late additions through the morning. - A B2B distributor whose top customers can phone in an emergency order at 10am. - A pharmacy distribution network with stat-of-care orders the same day. You are **not** a 100% on-demand operation (that's a different recipe entirely — closer to a dispatch engine than a planner). You have a structured nightly batch and a tail of incremental orders during execution. The incremental tail has to be slotted into routes that already left the depot, without disrupting drivers who have a clipboard with their morning's sequence. If that sounds like your operation, this recipe walks you through the three choices you have to make for every late order: **which driver, when to re-optimize, and how to notify them**. ## What the rhythm looks like ``` Day -1 Day 0 (execution) ───────────────────────────────────── ───────────────────────────────────── 18:00 Order cutoff for nightly batch 06:00 Drivers dispatched → /v2/plan + bulk /v2/stops → routes flip to in_transit → /v2/plan/{id}/optimize → plan flips to in_progress 09:30 ⚡ NEW ORDER arrives 1. Pick the best driver 2. POST /v2/stops to active plan 3. Decide: re-optimize? manual? 4. Notify driver via dispatch 11:15 ⚡ Another late order (same flow, different driver) 13:45 ⚡ Yet another 18:00 Last route finishes → plan flips to finished ``` The integration gets two responsibilities on the execution day: 1. **Push late orders** into the active plan as soon as they arrive. 2. **Tell the driver** about the new stop without breaking what they're already doing. This recipe assumes the nightly batch is already running — if it isn't yet, start with [Nightly batch — B2B distribution](/docs/recipes/nightly-batch-b2b) and come back here. ## The three injection strategies Picking the right one depends on **how disruptive** you want a late order to be. | Strategy | What it does | When to use | |---|---|---| | **Naive append** | Add stop to a manually-picked route. Driver sees it at the bottom of their queue on next refresh. | Cheap. Use when the new stop is "whenever you can fit it" and the driver chooses the moment. | | **Per-route re-sequence** | Add stop to a manually-picked route, then `POST /v2/route/{route_id}/optimize`. Routal re-sequences only this driver's stops, keeping the assigned vehicle fixed. Stops that no longer fit come back as unassigned. | Most common. The driver's morning is preserved but the new stop lands in the right spot of the sequence. | | **Conservative plan re-balance** | Add stop(s) to the plan, then `POST /v2/plan/{id}/optimize?keep_current_assignment=true`. Routal keeps every already-assigned stop where it is and only assigns the **new** stops to whichever driver minimizes cost. | Use when you don't want to pick the driver yourself and there's no in-flight driver context to disrupt — typically just before drivers actually arrive at their first stop. | **Never use** `POST /v2/plan/{id}/optimize` with `keep_current_assignment=false` once any route has flipped to `in_transit`. Routal will happily reshuffle stops between drivers, and the driver who already started their morning will have stops mysteriously disappear from their queue. ## Quickstart The Quickstart below implements the **per-route re-sequence** strategy — the most common one. The TypeScript and Python tabs include a `pickBestRoute` helper that ranks routes by remaining capacity; replace it with your own heuristic if you have driver-position data. **cURL:** ```bash # Late order arrives at 09:30. The current execution day's plan_id is in your # cache (set by the nightly batch). Driver positions came from the # route.started webhook earlier this morning. PLAN_ID="..." # active plan, status=in_progress ROUTE_ID="..." # driver you picked (see TS/Python for picking logic) # (1) Add the new stop to the active plan. external_id is the late order's ID # from your source system — makes the add retry-safe. curl -X POST "https://api.routal.com/v2/stops?private_key=YOUR_KEY&plan_id=${PLAN_ID}&project_id=YOUR_PROJECT_ID" \ -H 'Content-Type: application/json' \ -d '[ { "external_id": "ORDER-9501", "label": "Acme Late Order", "location": { "lat": 41.22, "lng": 2.07, "address": "456 Industrial Park" }, "duration": 480, "time_windows": [[36000, 64800]], "weight": 8, "volume": 0.1 } ]' # → returns the created stop. Save the stop_id. # (2) The stop landed on the plan but isn't yet on any route. Two options: # # 2a. Assign manually to the route you picked, then re-sequence that route. curl -X PUT "https://api.routal.com/v2/route/${ROUTE_ID}/stops?private_key=YOUR_KEY" \ -H 'Content-Type: application/json' \ -d '{ "stop_ids": [ "EXISTING_STOP_1", "EXISTING_STOP_2", "NEW_STOP_ID", "EXISTING_STOP_3" ] }' # Then ask Routal to find the best sequence for this route: curl -X POST "https://api.routal.com/v2/route/${ROUTE_ID}/optimize?private_key=YOUR_KEY" # 2b. Let Routal pick the best driver — conservative re-balance. curl -X POST "https://api.routal.com/v2/plan/${PLAN_ID}/optimize?private_key=YOUR_KEY&keep_current_assignment=true" ``` **TypeScript:** ```ts import createClient from 'openapi-fetch'; import type { paths } from './routal'; const routal = createClient({ baseUrl: 'https://api.routal.com' }); const ROUTAL_API_KEY = process.env.ROUTAL_API_KEY!; const PROJECT_ID = process.env.ROUTAL_PROJECT_ID!; type LateOrder = { id: string; customer: string; lat: number; lng: number; serviceMinutes: number; windowFromSec: number; windowToSec: number; weightKg?: number; volumeM3?: number; requires?: string[]; // skills the order needs }; /** * Inject a late order into the active plan using the * per-route re-sequence strategy. * * Steps: * 1. Resolve the active plan for today. * 2. Pick the best route (driver) for this order. * 3. Add the stop to the plan. * 4. Insert it into the chosen route's stop list. * 5. Ask Routal to re-sequence that route. * 6. Return the new stop position so dispatch can notify the driver. */ export async function injectLateOrder(order: LateOrder): Promise<{ planId: string; routeId: string; stopId: string; sequence: number; }> { const planId = await resolveActivePlanForToday(); const route = await pickBestRoute(planId, order); const stopId = await addStopToPlan(planId, order); await appendStopToRoute(route.id, route.currentStopIds, stopId); const reoptimized = await reSequenceRoute(route.id); const sequence = reoptimized.stop_ids.indexOf(stopId); return { planId, routeId: route.id, stopId, sequence }; } async function resolveActivePlanForToday(): Promise { const today = new Date().toISOString().slice(0, 10); const { data, error } = await routal.GET('/v2/plans', { params: { query: { private_key: ROUTAL_API_KEY, project_id: PROJECT_ID, limit: 5, sort: 'created_at:desc', }, }, }); if (error) throw error; const active = data!.docs?.find( (p) => p.execution_date?.startsWith(today) && (p.status === 'in_progress' || p.status === 'planning'), ); if (!active) throw new Error(`no active plan for ${today}`); return active.id!; } type RouteSummary = { id: string; currentStopIds: string[]; remainingCapacity: number }; async function pickBestRoute(planId: string, order: LateOrder): Promise { // Naive heuristic: pick the route with the most remaining max_volume that // also covers the order's `requires` skills. Replace with your own logic // when you have driver-position data — typically lowest detour cost wins. const { data: routes, error } = await routal.GET('/v2/plan/{id}/routes', { params: { path: { id: planId }, query: { private_key: ROUTAL_API_KEY } }, }); if (error) throw error; const candidates = (routes ?? []) .filter((r) => r.status !== 'finished') .map((r) => ({ id: r.id!, currentStopIds: (r.stops ?? []).map((s: { id?: string }) => s.id!).filter(Boolean), remainingCapacity: (r.max_volume ?? 0) - (r.volume ?? 0), provides: r.provides ?? [], })); const matching = candidates.filter((r) => (order.requires ?? []).every((skill) => r.provides.includes(skill)), ); if (matching.length === 0) throw new Error('no route can serve this order today'); matching.sort((a, b) => b.remainingCapacity - a.remainingCapacity); return matching[0]; } async function addStopToPlan(planId: string, order: LateOrder): Promise { const { data, error } = await routal.POST('/v2/stops', { params: { query: { private_key: ROUTAL_API_KEY, plan_id: planId, project_id: PROJECT_ID } }, body: [ { external_id: order.id, label: order.customer, location: { lat: order.lat, lng: order.lng }, duration: order.serviceMinutes * 60, time_windows: [[order.windowFromSec, order.windowToSec]], weight: order.weightKg, volume: order.volumeM3, requires: order.requires, }, ] as never, }); if (error) throw error; return (data as { id: string }[])[0].id; } async function appendStopToRoute( routeId: string, currentStopIds: string[], newStopId: string, ): Promise { const { error } = await routal.PUT('/v2/route/{id}/stops', { params: { path: { id: routeId }, query: { private_key: ROUTAL_API_KEY } }, body: { stop_ids: [...currentStopIds, newStopId] } as never, }); if (error) throw error; } async function reSequenceRoute(routeId: string): Promise<{ stop_ids: string[] }> { const { data, error } = await routal.POST('/v2/route/{id}/optimize', { params: { path: { id: routeId }, query: { private_key: ROUTAL_API_KEY } }, }); if (error) throw error; return { stop_ids: ((data as { stops?: { id: string }[] }).stops ?? []).map((s) => s.id), }; } ``` **Python:** ```python import os import requests from datetime import date ROUTAL_API_KEY = os.environ["ROUTAL_API_KEY"] PROJECT_ID = os.environ["ROUTAL_PROJECT_ID"] BASE = "https://api.routal.com" def inject_late_order(order: dict) -> dict: """ Inject a late order into the active plan using the per-route re-sequence strategy. """ plan_id = resolve_active_plan_for_today() route = pick_best_route(plan_id, order) stop_id = add_stop_to_plan(plan_id, order) append_stop_to_route(route["id"], route["currentStopIds"], stop_id) new_sequence = re_sequence_route(route["id"]) sequence = new_sequence.index(stop_id) return { "plan_id": plan_id, "route_id": route["id"], "stop_id": stop_id, "sequence": sequence, } def resolve_active_plan_for_today() -> str: today = date.today().isoformat() resp = requests.get( f"{BASE}/v2/plans", params={"private_key": ROUTAL_API_KEY, "project_id": PROJECT_ID, "limit": 5, "sort": "created_at:desc"}, timeout=30, ) resp.raise_for_status() for plan in resp.json().get("docs", []): if (plan.get("execution_date") or "").startswith(today) and \ plan.get("status") in ("in_progress", "planning"): return plan["id"] raise RuntimeError(f"no active plan for {today}") def pick_best_route(plan_id: str, order: dict) -> dict: resp = requests.get( f"{BASE}/v2/plan/{plan_id}/routes", params={"private_key": ROUTAL_API_KEY}, timeout=30, ) resp.raise_for_status() candidates = [] for r in resp.json(): if r.get("status") == "finished": continue candidates.append({ "id": r["id"], "currentStopIds": [s["id"] for s in (r.get("stops") or []) if s.get("id")], "remainingCapacity": (r.get("max_volume") or 0) - (r.get("volume") or 0), "provides": r.get("provides") or [], }) requires = order.get("requires") or [] matching = [r for r in candidates if all(s in r["provides"] for s in requires)] if not matching: raise RuntimeError("no route can serve this order today") matching.sort(key=lambda r: r["remainingCapacity"], reverse=True) return matching[0] def add_stop_to_plan(plan_id: str, order: dict) -> str: payload = [{ "external_id": order["id"], "label": order["customer"], "location": {"lat": order["lat"], "lng": order["lng"]}, "duration": order["service_minutes"] * 60, "time_windows": [[order["window_from_sec"], order["window_to_sec"]]], "weight": order.get("weight_kg"), "volume": order.get("volume_m3"), "requires": order.get("requires"), }] resp = requests.post( f"{BASE}/v2/stops", params={"private_key": ROUTAL_API_KEY, "plan_id": plan_id, "project_id": PROJECT_ID}, json=payload, timeout=30, ) resp.raise_for_status() return resp.json()[0]["id"] def append_stop_to_route(route_id: str, current_stop_ids: list[str], new_stop_id: str) -> None: resp = requests.put( f"{BASE}/v2/route/{route_id}/stops", params={"private_key": ROUTAL_API_KEY}, json={"stop_ids": current_stop_ids + [new_stop_id]}, timeout=30, ) resp.raise_for_status() def re_sequence_route(route_id: str) -> list[str]: resp = requests.post( f"{BASE}/v2/route/{route_id}/optimize", params={"private_key": ROUTAL_API_KEY}, timeout=60, ) resp.raise_for_status() body = resp.json() return [s["id"] for s in body.get("stops", [])] ``` ## Production hardening ### Where to slot — picking the driver matters The naive heuristic in the Quickstart (most remaining capacity, skills match) is the right starting point. Three improvements your platform team will want before scaling: 1. **Use driver positions.** The `routal.drivers.2.route.started` webhook tells you a route is now in transit. After that, the public REST API does not stream driver coordinates today — but you can read the route's progress (`route.stops[].status`) to know which stops have been visited, and infer roughly where the driver is. If your business needs live coordinates, talk to support — there are options for real-time tracking that are not part of the public REST surface. 2. **Account for already-committed time.** A driver whose remaining route ends at 14:30 can't take a 16:00 delivery. Filter candidates by overlap between the route's remaining `time_window` and the late order's `time_windows`. 3. **Detour cost, not Euclidean distance.** If two routes have similar capacity but route A passes 200m from the new stop while route B passes 3km away, route A wins regardless of who has more headroom. Routal's per-route optimizer computes detour cost during `POST /v2/route/{id}/optimize` — you can call it on candidate routes with the stop temporarily attached and compare the resulting durations. ### When NOT to re-optimize at all Sometimes the right move is to **append and let the driver decide**: - The order is "whenever you can fit it" without a hard time window. - The driver is using their judgment for sequencing today (e.g. construction delays, customer was just on the phone). - The integration is offering a hint, not enforcing it. In those cases skip the `POST /v2/route/{id}/optimize` call — just `PUT /v2/route/{id}/stops` to append the new stop ID at the end and stop there. The driver app refreshes within a minute and the stop appears at the bottom of their queue. They reorder it themselves on the device if they want to. ### When to use plan-level re-balance (`keep_current_assignment=true`) Reach for it when **multiple late orders** arrive in a short window and you'd rather let Routal pick the driver for each than write your own `pickBestRoute`: ```ts // add all the late orders to the plan first for (const order of recentLateOrders) { await addStopToPlan(planId, order); } // then let Routal slot them into existing routes without disturbing // already-assigned stops await routal.POST('/v2/plan/{id}/optimize', { params: { path: { id: planId }, query: { private_key: ROUTAL_API_KEY, keep_current_assignment: true }, }, }); ``` This is safe even when routes are `in_transit`. Existing assignments are kept; only the new stops get assigned. Cheaper than running `pickBestRoute` + per- route optimize once per order — but more disruptive than appending without optimization, because Routal re-sequences any route that receives a new stop. ### Notifying the driver — and the customer `POST /v2/route/{id}/dispatch` re-sends the magic-link email to the driver, which is **not** what you want during the day — the driver already has the route open on their device. The driver app auto-refreshes the stop list periodically; new stops appear without a redispatch. If you need an instant "you have a new stop" push, the driver app does not currently expose a public push API — talk to support if this is a hard requirement for your operation. For the **customer**, the new stop now has a `phone` and possibly an `email` field. Routal sends communications via the customers app ([c.routal.com](https://c.routal.com)) — see [Webhooks](/docs/webhooks) for the shape that triggers when the driver actually completes. ### Cap the daily injection rate A live-dispatch integration that fires `POST /v2/route/{id}/optimize` once per late order can add up. A worst case of 1 late order every 2 minutes from 09:00 to 17:00 = 240 optimizes/day per project, which is fine against the **2,000 req/min** cap but doubles your optimization spend if Routal bills by optimization volume. Two patterns to soften the cost: - **Batch late orders in a 60-second window.** Buffer arrivals, add them all at once, then re-balance the plan once. - **Skip re-optimization for low-priority orders.** Use the `routal.planner.2.stop.created` webhook to confirm the stop landed; only re-optimize when the new stop's priority demands it. ### What happens when the new stop doesn't fit `POST /v2/route/{id}/optimize` will return the new sequence — but if the route's time window or capacity is now violated, **the stop comes back as unassigned** on the route (it's not deleted; it just doesn't appear in the re-sequenced list). Detect this by reading the response and checking whether your new stop ID is still in `route.stops[]`. Recovery options: 1. Try a different route via `pickBestRoute` excluding the one you just tried. 2. Fall back to `POST /v2/plan/{id}/optimize?keep_current_assignment=true` so Routal picks the best driver globally. 3. Page the dispatcher — the new order may genuinely not be servable today and needs to be moved to tomorrow's plan (`POST /v2/stop/move` with the `plan_id` set to tomorrow's plan). ### Idempotency on the live add The same `external_id`-based reconciliation pattern that's load-bearing in every Routal integration applies here. Before calling `addStopToPlan`: ```ts const existing = await findExistingExternalIds(planId, [order.id]); if (existing.has(order.id)) { // already pushed — fetch it and continue with the existing stop_id const stop = await fetchStopByExternalId(planId, order.id); return slotIntoRoute(stop.id, ...); } ``` If your source system retries the webhook that delivered the late order to your integration, this guard prevents two stops for the same order. ### What changes when the plan flips to `in_progress` A few operations behave slightly differently once any route in the plan has gone `in_transit`: - `POST /v2/plan/{id}/optimize` (default `keep_current_assignment=false`) is **never** what you want at this point. Routal will gladly reshuffle the driver's morning. Always pass `keep_current_assignment=true` during the day. - `POST /v2/route/{id}/optimize` is fine — vehicle assignment is fixed and only this route gets re-sequenced. - `DELETE /v2/route/{id}` is rejected with `highway.route.error.locked` if the route was locked ahead of dispatch (a common pattern: lock routes at 06:00 just before dispatch to prevent accidents). - `POST /v2/stop/move` to **tomorrow's plan** is safe for stops still in `pending`. Never move stops that are already `completed` / `incomplete` / `canceled` — those represent a delivery attempt and moving them rewrites history. ### Observability — the metrics that matter | Metric | What it tells you | |---|---| | `routal.live_dispatch.late_orders_total{outcome}` | Volume of late orders and how they were slotted (`appended` / `re_sequenced` / `re_balanced` / `bounced_to_tomorrow`). | | `routal.live_dispatch.injection_latency_ms` | P50 / P99 from "order arrives in our queue" to "stop is on a route". The dispatcher cares about this. | | `routal.live_dispatch.optimize_route_calls_total` | If this is anywhere near 1,000/day per project, batch your late orders. | | `routal.live_dispatch.bounce_rate` | Late orders that couldn't fit any route today. \>5% means the nightly plan is too tight; the optimizer is at its constraint limit. | Log every injection with `plan_id`, `route_id`, `stop_id`, `external_id`, strategy used, and the resulting sequence position. The dispatcher will ask. ## Common errors | `message_id` | Cause | What to do | |---|---|---| | `highway.optimization.error.sync_optimization_already_progress` | Another optimization (probably your previous late-order injection) is still running for the same plan. | Wait. The first injection completes within seconds for a single route. If you're calling plan-level optimize back-to-back, batch instead. | | `highway.optimization.error.no_result_found` | The new stop cannot be fitted on any active route. | Bounce to tomorrow's plan with `POST /v2/stop/move`, or page the dispatcher. | | `highway.route.error.not_in_transit` | An action required the route to be in transit but it isn't yet. | Rare in live dispatch — typically means the driver hasn't opened the app yet. Wait for `routal.drivers.2.route.started`. | | `highway.stop.error.move_not_pending_stops` | You tried `POST /v2/stop/move` on a stop that's already terminal. | Never move stops past `pending`. Create a new stop for any retry. | ## Next steps - [Nightly batch — B2B distribution](/docs/recipes/nightly-batch-b2b) — the prerequisite for this recipe. If your nightly batch isn't running yet, start there. - [Idempotency](/docs/idempotency) — Pattern 1 (search-then-create) is the load-bearing guard for the live add. - [API Reference → `POST /v2/route/{id}/optimize`](/docs) — the single-route re-sequence endpoint, exact response shape. --- # Recurring services — calendar-driven dispatch URL: https://developers.routal.com/docs/recipes/recurring-services Operations that run on a calendar, not an order book. Bulk gas refills, ITV inspections, elevator maintenance, water cooler service, pest control. The same customer is visited every N weeks at roughly the same time slot, and the value the customer pays for is the reliability of the schedule. This recipe covers schedule-to-Routal materialisation, customer-stable time slots, and the deviation patterns that keep recurring routes honest. ## Who this is for You operate a fleet where **the schedule, not the order book, drives the day**. Common shapes: - A **butane / propane gas distributor** swapping bottles on a quarterly rotation. Customer expects the truck to arrive within their contracted window every 90 days, full stop. - A **vehicle inspection (ITV in Spain, MOT in the UK, TÜV in Germany)** service that books recurring inspection slots for fleet customers — same garage, same time, same week of the month, year after year. - An **elevator / lift maintenance** company on a strict regulatory cadence — monthly inspections per cabin, missing one creates a compliance liability. - A **water-cooler / coffee-machine vendor** topping up consumables and doing preventive maintenance every 4-6 weeks per location. - A **pest control** route servicing restaurants, hotels, supermarkets on a bi-weekly visit cycle to stay ahead of infestation. What makes you different from event-driven operations: - **Visits are scheduled weeks or months ahead.** The integration doesn't react to an order arriving today; it materialises a known future schedule into Routal day-by-day. - **The customer is paying for the schedule.** If you miss a contracted visit or shift it by an hour, you've broken the product. The day's plan is much closer to a fixed itinerary than an optimization target. - **Same customer, same slot, every cycle.** Loyalty and predictability matter more than density. The optimizer is mostly arranging visits inside the constraints set by the customer's contract. ## What the rhythm looks like Calendar in the FSM → daily materialisation → execution: ``` T-N weeks T-1 day T (visit day) ───────────────────── ────────────── ───────────────────── FSM holds the recurring 17:00 Nightly 06:00 Drivers open schedule. For each contract: job fires the app - customer - service type 1. Read FSM for 08:00-17:00 Visits - skill required visits due run on contracted - cadence (every N weeks) tomorrow slots - contracted window 2. For each visit: → stop.reported (e.g. Mon 09:00-11:00) - generate → bumps next_visit_at - next_visit_at external_id = in FSM by cadence VISIT-- - push as stop 18:00 Daily reconciliation - chain_id when - any stop with multiple visits service_report_incomplete at same location → FSM creates a make-up visit within SLA 3. Optimize 4. Dispatch ``` Two notes specific to recurring services: - **The external_id is a deterministic function of (customer, scheduledDate).** This makes the nightly push idempotent — re-running it generates the same IDs, so duplicates are impossible. - **`time_windows` are narrow.** A visit contracted for "Monday 9-11am" is pushed with `time_windows: [[32400, 39600]]`. The optimizer respects it as a hard constraint; if the day's fleet can't fit all narrow-window stops, `no_result_found` fires — investigate the schedule density, don't auto-relax. ## The integration shape ``` FSM (recurring schedule database) │ │ nightly cron — read visits due in look-ahead window ▼ [ visits: { customer_id, scheduled_date, contracted_slot, skill, location } ] │ │ (1) generate deterministic external_ids ▼ [ stops: { external_id: 'VISIT--', time_windows, requires, ... } ] │ │ (2) push to tomorrow's plan ▼ POST /v2/stops (idempotent: re-runs = no-op) │ │ (3) optimize once ▼ POST /v2/plan/{id}/optimize │ │ (4) dispatch every route ▼ POST /v2/route/{id}/dispatch │ │ (5) webhooks during execution ▼ stop.reported → FSM: bump next_visit_at, attach POD, close service ticket if service_report_incomplete: FSM schedules a make-up ``` The single biggest difference vs. the other recipes: the source of truth for the day's stops is **not an order book**, it's a **calendar plus cadence rule per customer**. Your integration's first job is to walk the calendar and generate visits; the second is to push them to Routal. ## Production code **cURL:** ```bash # (1) Push tomorrow's calendar-derived visits as stops. Each carries a # narrow window matching the customer's contracted slot and a skill # requirement matching the service type. curl -X POST "https://api.routal.com/v2/stops?private_key=YOUR_KEY&plan_id=PLAN_ID&project_id=YOUR_PROJECT_ID" \ -H 'Content-Type: application/json' \ -d '[ { "external_id": "VISIT-CUST-0042-2026-05-22", "label": "Acme Café — quarterly butane refill", "location": { "lat": 41.3851, "lng": 2.1734, "address": "Carrer Aragó 215, 08010 Barcelona" }, "duration": 900, "time_windows": [[32400, 39600]], "phone": "+34699445566", "requires": ["butane-licensed"], "tasks": [ { "type": "scan", "label": "Scan returned cylinder serials" }, { "type": "photo", "label": "Photo of installed cylinders" }, { "type": "signature", "label": "Customer signature on receipt" } ] } ]' # (2) Optimize. curl -X POST "https://api.routal.com/v2/plan/PLAN_ID/optimize?private_key=YOUR_KEY" # (3) Dispatch. curl -X POST "https://api.routal.com/v2/route/ROUTE_ID/dispatch?private_key=YOUR_KEY" ``` **TypeScript:** ```ts import createClient from 'openapi-fetch'; import type { paths } from './routal'; const routal = createClient({ baseUrl: 'https://api.routal.com' }); const ROUTAL_API_KEY = process.env.ROUTAL_API_KEY!; const PROJECT_ID = process.env.ROUTAL_PROJECT_ID!; type Visit = { customerId: string; scheduledDate: string; // 'YYYY-MM-DD' in the project's local TZ customerName: string; lat: number; lng: number; address: string; phoneE164: string; slotFromSec: number; // contracted window start (seconds from midnight) slotToSec: number; serviceDurationSec: number; skillRequired: string; // e.g. 'butane-licensed' }; /** Deterministic external_id makes the nightly push idempotent. */ function visitExternalId(visit: Visit): string { return `VISIT-${visit.customerId}-${visit.scheduledDate}`; } export async function materialiseDay(planId: string, visits: Visit[]) { if (visits.length === 0) return { pushed: 0 }; const payload = visits.map((v) => ({ external_id: visitExternalId(v), label: `${v.customerName} — ${v.scheduledDate}`, location: { lat: v.lat, lng: v.lng, address: v.address }, duration: v.serviceDurationSec, time_windows: [[v.slotFromSec, v.slotToSec]] as [number, number][], phone: v.phoneE164, requires: [v.skillRequired], tasks: [ { type: 'scan', label: 'Scan equipment serials' }, { type: 'photo', label: 'Service-completed photo' }, { type: 'signature', label: 'Customer signature' }, ], })); for (let i = 0; i < payload.length; i += 250) { const chunk = payload.slice(i, i + 250); const { error } = await routal.POST('/v2/stops', { params: { query: { private_key: ROUTAL_API_KEY, plan_id: planId, project_id: PROJECT_ID } }, body: chunk as never, }); if (error) throw error; } return { pushed: payload.length }; } /** On stop.reported, advance the FSM's next_visit_at by the contract cadence. */ export async function onVisitReported( externalId: string, reportType: 'service_report_completed' | 'service_report_incomplete' | 'service_report_canceled', fsm: { bumpNextVisit: (extId: string) => Promise; scheduleMakeUp: (extId: string) => Promise }, ) { if (reportType === 'service_report_completed') { await fsm.bumpNextVisit(externalId); } else { // incomplete or canceled — schedule a make-up visit within SLA. await fsm.scheduleMakeUp(externalId); } } ``` **Python:** ```python import os, requests ROUTAL_API_KEY = os.environ["ROUTAL_API_KEY"] PROJECT_ID = os.environ["ROUTAL_PROJECT_ID"] BASE = "https://api.routal.com" def visit_external_id(visit: dict) -> str: return f"VISIT-{visit['customer_id']}-{visit['scheduled_date']}" def materialise_day(plan_id: str, visits: list[dict]) -> dict: if not visits: return {"pushed": 0} payload = [ { "external_id": visit_external_id(v), "label": f"{v['customer_name']} — {v['scheduled_date']}", "location": {"lat": v["lat"], "lng": v["lng"], "address": v["address"]}, "duration": v["service_duration_sec"], "time_windows": [[v["slot_from_sec"], v["slot_to_sec"]]], "phone": v["phone"], "requires": [v["skill_required"]], "tasks": [ {"type": "scan", "label": "Scan equipment serials"}, {"type": "photo", "label": "Service-completed photo"}, {"type": "signature", "label": "Customer signature"}, ], } for v in visits ] for i in range(0, len(payload), 250): chunk = payload[i:i + 250] requests.post( f"{BASE}/v2/stops", params={"private_key": ROUTAL_API_KEY, "plan_id": plan_id, "project_id": PROJECT_ID}, json=chunk, timeout=60, ).raise_for_status() return {"pushed": len(payload)} ``` ## Production hardening ### Deterministic external_id — the load-bearing idempotency lever For event-driven flows, idempotency is usually about handling duplicate webhooks. For recurring services, idempotency is about **safe cron retries**. A visit is uniquely identified by `(customer_id, scheduled_date)`. Use that pair as the `external_id`: ```ts external_id = `VISIT-${customerId}-${scheduledDate}` ``` Re-running the nightly materialisation must produce identical IDs. Routal's storage doesn't enforce uniqueness on `external_id`, but with deterministic generation + a search-before-create check on your side, duplicates become impossible: ```ts async function materialiseVisitIdempotent(planId: string, visit: Visit) { const extId = visitExternalId(visit); const { data } = await routal.POST('/v2/stops/search', { params: { query: { private_key: ROUTAL_API_KEY, project_id: PROJECT_ID } }, body: { limit: 1, predicates: [ { field: 'external_id', operator: 'eq', value: extId, type: 'string' }, ], } as never, }); if ((data as { docs?: unknown[] })?.docs?.length) return { skipped: true }; await materialiseDay(planId, [visit]); return { skipped: false }; } ``` ### Skill matching — the technician fit per service type Recurring services nearly always involve a technical skill (gas certification, elevator licensing, ITV authorisation, pesticide handling). The optimizer respects skill matching via `requires` (on the stop) and `default_provides` (on the vehicle / technician). ```ts // technician vehicle { default_provides: ['gas-licensed', 'butane-licensed', 'propane-licensed'] } // visit stop { requires: ['butane-licensed'] } ``` Maintain the skill catalog in your FSM and mirror it into Routal vehicles nightly (or whenever certifications change). A visit pushed with a `requires` no active vehicle satisfies returns `no_result_found` — the same error you'd get from a capacity overflow, with a different root cause. ### Missed visit handling — make-up SLA per contract `stop.reported` carries one of three types. Map them into the FSM: - `service_report_completed` — bump `next_visit_at` by the contract cadence (e.g., 28 days), close the service record. - `service_report_incomplete` — driver tried but couldn't complete (customer not home, equipment unavailable, access denied). Create a make-up record in the FSM within the contract SLA (typically 1-3 business days). - `service_report_canceled` — customer cancelled (or the office did). Bump `next_visit_at` by the cadence as if completed, but don't bill. Each contract usually has a **make-up SLA** field (1 day for compliance-bound services like elevators, 3-5 days for grooming-style services like pest control). The FSM enforces it. ### Look-ahead window — pick a horizon and stick to it Generating too few days at once = more pushes, more chance of cron failures shifting the plan window. Too many days = lots of stops in distant-future plans that may need re-adjustment if customers reschedule. Pragmatic defaults: - **Daily push, T+1 horizon.** Simple, robust, but every reschedule means a customer-service human in the loop. - **Weekly push, T+7 horizon.** Most common. Schedule for the whole week on Sunday night; customer reschedules trigger a targeted `stop.move` / `stop.delete` + re-create on the affected day's plan. - **Monthly push, T+30 horizon.** Only for very stable contracts (regulatory inspections, multi-year service agreements). Reschedules are rare and manual. ### Time-window enforcement — narrow is the product A customer paying for "Monday morning service" doesn't accept the technician arriving Tuesday afternoon. The integration must push narrow `time_windows` that match the contracted slot. If the optimizer can't fit them all, the right behaviour is to escalate, not to relax. ```ts function visitTimeWindow(visit: Visit): [number, number][] { // 30-minute buffer around the contracted slot for travel + greeting. return [[visit.slotFromSec, visit.slotToSec + 1800]]; } ``` If a particular customer is flexible ("any time on Monday"), encode it explicitly: `time_windows: [[0, 86400]]`. Don't widen everyone to "8am-6pm" by default — that erodes the schedule discipline that's your product. ### Observability — the metrics that matter | Metric | What it tells you | |---|---| | `recurring.visits_materialised_total{date}` | Visits pushed for a given execution date. Match expected = good. | | `recurring.missed_visits_total{reason}` | Incomplete + canceled visits, grouped by `cancel_reason`. Tells you if churn is technician-side, customer-side, or operations-side. | | `recurring.make_up_sla_breaches_total` | Make-up visits not scheduled within the contract SLA. Goes to compliance dashboard. | | `recurring.window_breach_rate` | Stops where the driver arrived outside the contracted slot. Headline customer-satisfaction signal. | | `recurring.no_result_found_rate{day}` | Optimizer failures. Sustained > 2% = schedule density exceeds fleet capacity. | ## Common errors | `message_id` | Cause | What to do | |---|---|---| | `highway.optimization.error.no_result_found` | Schedule density exceeds fleet capacity, OR required skill unavailable on any active vehicle. | Either scale the fleet for that day, defer flexible visits, or escalate compliance-bound visits to a supervisor. | | `highway.stop.error.move_not_pending_stops` | Tried to reschedule a visit that was already attempted. | The visit already happened; create a make-up via the FSM make-up workflow, don't move. | | `highway.stop.error.custom_fields_invalid` | Custom fields (contract_id, service_type) don't match project schema. | Update the project's custom-field definitions, then re-push. | | `400 Bad Request` — time_window | Contracted slot was modelled with seconds outside `[0, 172800]`. | Verify slot conversion to seconds from midnight; cap at `[0, 86400]` for single-day windows. | | `highway.optimization.error.too_much_requests` | Optimizer rate-limited. | Wait 60-90s; if persistent on a large plan, contact support about dedicated capacity. | ## Next steps - [Field service with appointments](/docs/recipes/field-service-with-appointments) — the closest sibling recipe. If your operation is more order-driven (tickets arrive, technician dispatched) than calendar-driven (visit due every N weeks), start there. - [Webhooks](/docs/webhooks) — `stop.reported` drives the FSM's next-visit scheduling. Make sure the handler is fast and idempotent. - [Idempotency](/docs/idempotency) — deterministic `external_id` is the load-bearing pattern; this recipe leans on it harder than any other. - [Capacitated distribution](/docs/recipes/capacitated-distribution) — if your visits also carry physical goods (gas bottles, replacement parts) with hard capacity limits, combine the schedule materialisation here with the constraint matrix there. --- # Reverse logistics — pickups & returns URL: https://developers.routal.com/docs/recipes/reverse-logistics Distributors and retailers that don't just deliver — they pick up. Empty kegs from bars, empty pallets from supermarkets, returned items from customers, malfunctioning equipment from clients. This recipe covers pure pickup routes, mixed delivery+pickup routes via Routal's chain mechanic, and the operational patterns that prevent drivers from showing up to "pick up" stops the customer never agreed to. ## Who this is for You operate a fleet that **picks things up**, not just delivers them. Common shapes: - A **beverage distributor** dropping off full kegs / cases of bottled product to bars and restaurants, and picking up the empties at the same visit. - A **palletized distributor** to retail (supermarkets, hardware chains) whose drivers leave loaded pallets and bring back the empty ones for re-use. - A **B2C e-commerce returns operation** sending a van to pick up returned parcels from households after the customer initiated an RMA online. - A **field service shop** that swaps malfunctioning equipment — the technician drops off a working unit and recovers the broken one for repair / refurb. - A **pure reverse-only specialist** (e-waste collection, retail returns consolidator, recycling) where every stop is a pickup. What makes you different from a forward-only delivery operation: - **Two different stop semantics on the same plan.** A delivery stop is "give the customer this thing"; a pickup stop is "take this thing from the customer". Routal models both; your integration has to push the right shape. - **Load grows during the day.** A typical delivery van leaves loaded and empties out; a typical reverse-logistics van leaves empty and fills up. A mixed van does both, and the optimizer needs accurate capacity to plan a feasible day. - **Pickup coordination is a customer-trust failure mode.** A driver showing up to "pick up" a pallet the supermarket didn't agree to send back is an escalation. The integration must only push pickups your source system confirmed. ## What the rhythm looks like ``` Day -1 (afternoon) Day 0 (execution) ───────────────────────────────── ───────────────────────────────── 14:00 Customer-service team confirms 06:00 Drivers open the app tomorrow's pickup requests with → routes flip in_transit customers (call, portal, scheduled in ERP) 08:00-17:00 Mixed deliveries + pickups by stop sequence 17:30 ERP runs the pickup-list export → driver scans on pickup → CSV / JSON for the integration → POD attaches photos of received returns 17:35 ⚡ Nightly job fires 1. Create tomorrow's plan 17:00 Last stop 2. Push pickup stops → routes flip finished (with chain_id pairing → plan flips finished when they share a vehicle with deliveries) 18:00 Reconciliation 3. Push delivery stops → ERP closes each RMA (same plan) with the POD evidence 4. Optimize 5. Dispatch routes ``` Two notes specific to reverse logistics: - **Chain stops when pickup and delivery share a location.** If the bar at Calle Mayor 14 receives 3 kegs of full beer AND returns 5 empty kegs, model them as TWO stops with the same `chain_id`. The optimizer keeps them on the same vehicle in the right order (delivery first via `chain_position: 0`, pickup second via `chain_position: 1`). - **`weight` and `volume` on a pickup stop are POSITIVE values.** They represent what the van picks up, which accumulates against the vehicle's `default_max_weight` / `default_max_volume` as the day progresses. The optimizer plans correctly when these are accurate. ## The integration shape ``` Source systems ├─ ERP / RMA portal: confirmed pickup requests for tomorrow └─ ERP / OMS: deliveries for tomorrow (if mixed flow) │ │ (1) merge into a single tomorrow's plan ▼ [ stops: { external_id, type: 'pickup' | 'delivery', location, weight, volume, chain_id? } ] │ │ (2) push all stops in one bulk call ▼ POST /v2/stops (idempotent on external_id) │ │ (3) optimize. Capacity tracks running load including pickups. ▼ POST /v2/plan/{id}/optimize │ │ (4) dispatch every route ▼ POST /v2/route/{id}/dispatch │ │ (5) webhooks during execution ▼ stop.reported → close RMA ticket / mark empties received in ERP + attach POD (signed receipt, photos) for chain-of-custody ``` The two key fields: - **`chain_id`** — a string ID shared by stops that must end up on the same vehicle. Both delivery and pickup at the same customer share one chain_id. - **`chain_position`** — 0-indexed ordering within the chain. Delivery is position 0 (driver arrives, hands off the new stuff), pickup is position 1 (same vehicle, same visit, now collecting empties). ## Production code **cURL:** ```bash # (1) Push a mixed batch — one delivery stop and one paired pickup stop # at the same supermarket, chained so they end up on the same vehicle. curl -X POST "https://api.routal.com/v2/stops?private_key=YOUR_KEY&plan_id=PLAN_ID&project_id=YOUR_PROJECT_ID" \ -H 'Content-Type: application/json' \ -d '[ { "external_id": "DEL-90100", "label": "BigMart — Calle Mayor 14 — delivery", "location": { "lat": 40.4153, "lng": -3.7079, "address": "Calle Mayor 14, 28013 Madrid" }, "duration": 900, "time_windows": [[28800, 43200]], "weight": 320, "volume": 2.4, "chain_id": "VISIT-BIGMART-MAYOR-14-2026-05-22", "chain_position": 0 }, { "external_id": "PICKUP-RMA-44521", "label": "BigMart — Calle Mayor 14 — empty pallets pickup", "location": { "lat": 40.4153, "lng": -3.7079, "address": "Calle Mayor 14, 28013 Madrid" }, "duration": 600, "time_windows": [[28800, 43200]], "weight": 180, "volume": 1.8, "chain_id": "VISIT-BIGMART-MAYOR-14-2026-05-22", "chain_position": 1 } ]' # (2) Optimize. The vehicle's default_max_weight covers loaded outbound goods # AND collected inbound empties simultaneously — set it generously. curl -X POST "https://api.routal.com/v2/plan/PLAN_ID/optimize?private_key=YOUR_KEY" # (3) Dispatch. curl -X POST "https://api.routal.com/v2/route/ROUTE_ID/dispatch?private_key=YOUR_KEY" ``` **TypeScript:** ```ts import createClient from 'openapi-fetch'; import type { paths } from './routal'; const routal = createClient({ baseUrl: 'https://api.routal.com' }); const ROUTAL_API_KEY = process.env.ROUTAL_API_KEY!; const PROJECT_ID = process.env.ROUTAL_PROJECT_ID!; type ReverseStop = { id: string; kind: 'delivery' | 'pickup'; customer: string; lat: number; lng: number; address: string; weightKg: number; volumeM3: number; windowFromSec: number; windowToSec: number; durationSec: number; /** If pickup + delivery share a customer visit, give both the same chainId. */ chainId?: string; }; /** Push a batch of stops with chained delivery+pickup pairs. */ export async function pushDailyMixed(planId: string, stops: ReverseStop[]) { // Group by chain — for each chain, assign chain_position by 'delivery'-first. const byChain = new Map(); const standalone: ReverseStop[] = []; for (const s of stops) { if (!s.chainId) standalone.push(s); else byChain.set(s.chainId, [...(byChain.get(s.chainId) ?? []), s]); } type StopPayload = { external_id: string; label: string; location: { lat: number; lng: number; address: string }; duration: number; time_windows: [number, number][]; weight: number; volume: number; chain_id?: string; chain_position?: number; }; const payload: StopPayload[] = []; for (const s of standalone) { payload.push(toPayload(s)); } for (const group of byChain.values()) { // Deliveries before pickups on the same chain. group.sort((a, b) => Number(a.kind === 'pickup') - Number(b.kind === 'pickup')); group.forEach((s, idx) => { payload.push({ ...toPayload(s), chain_id: s.chainId, chain_position: idx }); }); } for (let i = 0; i < payload.length; i += 250) { const chunk = payload.slice(i, i + 250); const { error } = await routal.POST('/v2/stops', { params: { query: { private_key: ROUTAL_API_KEY, plan_id: planId, project_id: PROJECT_ID } }, body: chunk as never, }); if (error) throw error; } } function toPayload(s: ReverseStop) { return { external_id: s.id, label: `${s.customer} — ${s.kind}`, location: { lat: s.lat, lng: s.lng, address: s.address }, duration: s.durationSec, time_windows: [[s.windowFromSec, s.windowToSec]] as [number, number][], weight: s.weightKg, volume: s.volumeM3, }; } ``` **Python:** ```python import os, requests ROUTAL_API_KEY = os.environ["ROUTAL_API_KEY"] PROJECT_ID = os.environ["ROUTAL_PROJECT_ID"] BASE = "https://api.routal.com" def to_payload(s: dict) -> dict: return { "external_id": s["id"], "label": f"{s['customer']} — {s['kind']}", "location": {"lat": s["lat"], "lng": s["lng"], "address": s["address"]}, "duration": s["duration_sec"], "time_windows": [[s["window_from_sec"], s["window_to_sec"]]], "weight": s["weight_kg"], "volume": s["volume_m3"], } def push_daily_mixed(plan_id: str, stops: list[dict]) -> None: """Push delivery + pickup stops; chain pairs that share a chain_id.""" by_chain: dict[str, list[dict]] = {} standalone: list[dict] = [] for s in stops: if not s.get("chain_id"): standalone.append(s) else: by_chain.setdefault(s["chain_id"], []).append(s) payload = [to_payload(s) for s in standalone] for chain_id, group in by_chain.items(): # Deliveries before pickups within the same chain. group.sort(key=lambda x: 1 if x["kind"] == "pickup" else 0) for idx, s in enumerate(group): item = to_payload(s) item["chain_id"] = chain_id item["chain_position"] = idx payload.append(item) for i in range(0, len(payload), 250): chunk = payload[i:i + 250] requests.post( f"{BASE}/v2/stops", params={"private_key": ROUTAL_API_KEY, "plan_id": plan_id, "project_id": PROJECT_ID}, json=chunk, timeout=60, ).raise_for_status() ``` ## Production hardening ### Never push a pickup the customer didn't confirm The single biggest reverse-logistics escalation is a driver showing up to collect something the customer didn't agree to send back. Treat the pickup record as a **two-phase commit**: 1. **Phase 1 — Confirm** in your source system (RMA portal acknowledgement, customer service callback, scheduled in the ERP with a confirmed-by user stamp). Set a `confirmed_at` timestamp on the pickup record. 2. **Phase 2 — Push** to Routal only after `confirmed_at` is set. ```ts function pickupsReadyForDispatch(allPickups: ReverseStop[]): ReverseStop[] { return allPickups.filter((p) => p.kind !== 'pickup' || p.confirmed_at); } ``` Build a daily report in your ERP for unconfirmed pickups so customer service can chase them before tomorrow's push. ### Chain delivery + pickup at the same visit Use `chain_id` when a single customer visit has both a delivery and a pickup. Without chaining, the optimizer is free to assign them to different vehicles that arrive minutes (or hours) apart — both drivers honking, customer escalating. The convention used in the code above: - `chain_id` = stable identifier for the visit (`VISIT---`) - `chain_position: 0` = delivery (the driver hands stuff off first) - `chain_position: 1` = pickup (then collects the empties) A visit with only a pickup has no `chain_id`; standalone pickups optimize like any other stop. ### Capacity that GROWS as the day progresses A van leaves the depot empty (or partially loaded with delivery cargo) and fills up with empties / returns as the day goes on. `default_max_weight` and `default_max_volume` on the vehicle represent the **maximum simultaneous load**. The optimizer tracks running load over the day: - After a delivery, load decreases by the stop's `weight` (cargo leaves the van). - After a pickup, load increases by the stop's `weight` (empties enter the van). - At any moment, the running load must stay ≤ `default_max_weight`. A common gotcha: the depot return trip. If your van ends the day empty (drops returns at the depot before parking), the integration doesn't have to model that — the optimizer doesn't care what happens after the last stop. If your van **does** another loop or transfers to a second vehicle, model the depot visit as an explicit stop. ### Chain-of-custody — photos and signatures on the pickup For returns / RMA / asset recovery, the POD on a pickup is the legal evidence that the customer handed the goods over. Use Routal's tasks: ```ts { external_id: 'PICKUP-RMA-44521', // ... rest of stop tasks: [ { type: 'photo', label: 'Photo of returned items at pickup' }, { type: 'signature', label: 'Customer signature on receipt' }, { type: 'checklist', label: 'Serial numbers match RMA ticket?' }, ], } ``` `stop.reported` webhook delivers the photo URLs, the signature image, and the checklist answers. Store them against the RMA record in your ERP. ### Observability — the metrics that matter | Metric | What it tells you | |---|---| | `reverse.pickups_pushed_total{day}` | Daily volume of confirmed pickups. Drift down = customer-service team falling behind on confirmations. | | `reverse.unconfirmed_pickups_total` | Pickups in the source system without `confirmed_at`. Trending up = ops backlog. | | `reverse.chained_stops_total` | Stops with `chain_id`. Should match expected delivery-pickup pairs. | | `reverse.van_peak_load_pct` | Max load during the day as a percentage of `default_max_weight`. \>90% sustained = need bigger vans or smaller windows. | | `reverse.rma_close_lag_seconds` | Time from `stop.reported` to RMA closed in ERP. Spikes = webhook handler slow / broken. | ## Common errors | `message_id` | Cause | What to do | |---|---|---| | `highway.optimization.error.no_result_found` | Capacity exhausted considering running load (loaded outbound + empties picked up later). | Reduce load by splitting heavy pickups across two vans or two days; relax time windows. | | `highway.stop.error.custom_fields_invalid` | Pickup-type custom fields (RMA reason, serial number) don't match project schema. | Add the field definition in the dashboard, then re-push. | | `highway.stop.error.move_not_pending_stops` | Tried to move a pickup that was already attempted / failed. | Create a new pickup stop with a new `external_id` derived from the same RMA ticket. | | `400 Bad Request` — chain_position | Two stops on the same `chain_id` have the same `chain_position`. | Re-number positions starting from 0; each must be unique within a chain. | | `429 Too Many Requests` | Push velocity too high. | Chunk by 250 stops per bulk POST. | ## Next steps - [Capacitated distribution](/docs/recipes/capacitated-distribution) — the full constraint matrix; reverse logistics shares all the weight / volume / skill mechanics, just with semantics flipped. - [Field service with appointments](/docs/recipes/field-service-with-appointments) — if your pickups are equipment swaps (technician brings new unit + takes broken one), the field-service template is closer than this one. - [Webhooks](/docs/webhooks) — `stop.reported` is the canonical event for closing RMA tickets and recording chain-of-custody. - [Idempotency](/docs/idempotency) — make the nightly push idempotent against cron retries; pickups confirmed twice in the ERP should never become two Routal stops. --- # Webhooks URL: https://developers.routal.com/docs/webhooks Subscribe your endpoint to plan, route, and stop lifecycle events. Routal pushes them as they happen. Webhooks let Routal notify your systems when something changes — without you polling. Subscribe an HTTPS endpoint to one or more event types and Routal will `POST` a JSON envelope to it. Webhooks are configured per project in the planner dashboard at [planner.routal.com/h/settings/developers/webhooks](https://planner.routal.com/h/settings/developers/webhooks). The API endpoints `POST /v2/webhook`, `PUT /v2/webhook/{id}`, `DELETE /v2/webhook/{id}` are dashboard-only today and are not part of the public REST surface. ## Configuration A webhook record has three fields you control: | Field | Type | Description | |---|---|---| | `url` | string | The HTTPS endpoint that will receive the `POST`. | | `enabled_events` | string[] | The event types this webhook should fire for (see [Event catalog](#event-catalog)). | | `enabled` | boolean | Whether the webhook is active. Disabled webhooks receive nothing. | A single project can have multiple webhooks, each subscribed to a different subset of events. ## Delivery When a subscribed event happens, Routal sends an HTTPS `POST` to your `url` with a JSON body. There are no custom Routal headers today — no `X-Routal-Signature`, no `X-Routal-Event`, no distinguishing `User-Agent`. Identify the call by the body's `event_id` and `project_id`, not by headers. ### Envelope ```json { "created_at": "2026-05-21T10:30:00.000Z", "project_id": "4f75d991ac359f8c4c79d762", "event_id": "routal.planner.2.plan.created", "meta": { /* event metadata */ }, "data": { /* event payload */ } } ``` | Field | Type | Description | |---|---|---| | `created_at` | ISO 8601 string | When the event was emitted server-side. | | `project_id` | string | The project the event belongs to. Useful if you proxy multiple projects through a single endpoint. | | `event_id` | string | The event **type** (despite the name). One of the values in the [Event catalog](#event-catalog). | | `meta` | object | Event-type-specific metadata. Schema varies by `event_id`. | | `data` | object | Event-type-specific payload — the resource that changed. Schema varies by `event_id`. | `event_id` is the event **type**, not a unique delivery identifier. There is no per-delivery ID in the envelope today. Idempotency by event ID is **not** a viable pattern — see [Idempotency](#idempotency) below for what to do instead. ## Event catalog These are the eight event types currently emitted to webhooks: | `event_id` | When it fires | |---|---| | `routal.planner.2.plan.created` | A plan was created. | | `routal.planner.2.plan.updated` | A plan was updated (metadata, status, or contents). | | `routal.planner.2.plan.deleted` | A plan was deleted. | | `routal.planner.2.stop.created` | A stop was created. | | `routal.planner.2.stop.deleted` | A stop was deleted. | | `routal.planner.2.stop.reported` | A stop received a report (proof of delivery, completion, or failure). | | `routal.drivers.2.route.started` | A driver opened the route in the driver app. | | `routal.drivers.2.route.finished` | A driver marked the route finished. | More domain events fire inside Routal (route created/updated/deleted, optimization started/succeeded, vehicle CRUD, etc.) but are **not** delivered via webhooks today. If you need one of them for your integration, [tell us](mailto:developers@routal.com). ## Acknowledgement Your endpoint should respond with any `2xx` HTTP status code. Anything else (including timeouts) counts as a failure. There is **no documented timeout SLA** for webhook responses today. Aim to respond in under a few seconds and offload heavy work to a background queue. ## Failure handling and disable Failures are tracked per webhook with a `failure_count` that resets on the first successful delivery. | Threshold | What happens | |---|---| | **5 consecutive failures** | Routal sends an email to the organization owner warning that the webhook is failing. | | **50 consecutive failures** | The webhook is automatically disabled (`enabled` is set to `false`) and another email is sent to the owner. Future events are not delivered until you re-enable it from the dashboard. | There is no scheduled retry queue with documented intervals (e.g. 30s → 5m → 30m). The failure counter simply increments each time an event fires and the delivery fails; the next attempt happens whenever the next subscribed event fires. You can manually replay failed deliveries from the webhook delivery log in the planner dashboard. ## Idempotency Because the envelope does not carry a unique per-delivery ID, you have two practical options: 1. **Dedup using fields inside `data`.** Most event payloads include the resource `id` and a timestamp. For example, two `routal.planner.2.plan.updated` events for the same plan can be deduped by `data.id` + `data.updated_at`. 2. **Make your handler naturally idempotent.** Instead of "INSERT a new row", "UPSERT on (project_id, resource_id, event_id)". The exact pattern depends on what your handler does. A future iteration may add a unique `delivery_id` field; track the [changelog](/docs/changelog) for updates. ## Signature verification **Routal does not sign webhook payloads today.** There is no HMAC, no `X-Routal-Signature` header, no shared secret stored on the webhook record. If you need to authenticate that a request came from Routal, options today: - **IP allowlist.** Restrict your endpoint to accept only requests from Routal's egress IPs (ask support for the current list). - **Secret in the URL.** Subscribe a URL containing an opaque token, e.g. `https://your-host/webhooks/routal?token=...`. Verify the token server-side. Lower-trust than HMAC but workable. - **mTLS or VPC private link.** If you have stronger requirements, talk to us. Signed deliveries with HMAC-SHA256 are on the roadmap. ## Local development To test webhooks against a local server, use a tunnel like [ngrok](https://ngrok.com) or [Cloudflare Tunnel](https://www.cloudflare.com/products/tunnel/) to expose your `localhost` URL. Subscribe the tunnel URL from the planner dashboard. ## What's not supported today - **HMAC signing** — see above. - **Per-delivery unique ID** in the envelope — see [Idempotency](#idempotency). - **Configurable retry intervals** — only the failure-count → disable lifecycle. - **Public REST endpoints for webhook CRUD** — manage from the dashboard. - **Webhook events for routes** (`route.created`/`updated`/`deleted`) and **stops other than created/deleted/reported** — not delivered today. - **Webhook events for vehicles** — not delivered today. When any of the above ships, the [changelog](/docs/changelog) is the canonical place to find out. --- Machine-readable spec: https://developers.routal.com/openapi.json