Resource 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, that's a doc bug.
Plan
A Plan groups stops, routes, and vehicles for an execution window.
| Value | Meaning |
|---|---|
planning | Default working state — stops and vehicles can be added, optimization can be run. |
in_progress | At least one route inside the plan has moved to `in_transit` (the driver has started executing). |
finished | Every route in the plan is `finished`. |
draft | The plan exists but isn't being actively worked on. |
Automatic transitions
Plan status is recomputed by Routal, not set directly by your client:
- A plan in
draftorplanningtransitions toin_progresswhen any of its routes becomesin_transit. - A plan in
in_progresstransitions tofinishedwhen all of its routes becomefinished. finishedis 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(ordraftif you pass that explicitly). POST /v2/plan/{id}/optimize/async— runs the solver and may reassign stops between routes; status is unchanged. The call returns immediately and the solve runs in the background (see Optimization runs asynchronously). When the plan already hasin_transitroutes, 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 toin_transit.
Optimization runs asynchronously
POST /v2/plan/{id}/optimize/async returns immediately — a 200 acknowledges that the request was accepted, not that the plan is optimized. The solve runs in the background. Because optimization does not change the plan's status, the practical completion signal is the routes themselves:
- Call
POST /v2/plan/{id}/optimize/async(query params likekeep_current_assignment=truestill apply). - Poll
GET /v2/plan/{id}/routeson a short interval until the routes reflect the solve (stops assigned to routes), with a sensible timeout. - Only then
POST /v2/route/{id}/dispatch.
Dispatching before the solve finishes will dispatch stale or empty routes — so every recipe that optimizes then dispatches follows this poll-then-dispatch shape.
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 in-flight solve to finish (poll the plan's routes) 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.
| Value | Meaning |
|---|---|
not_started | Created and possibly dispatched, but the driver has not opened it. |
in_transit | The driver opened the route in the driver app. |
finished | Every stop is in a terminal status. |
Automatic transitions
- Created in
not_started. - Moves to
in_transitwhen the driver opens the magic link / opens the route in the driver app. - Moves to
finishedwhen all of its stops are in a terminal status (completed,incomplete, orcanceled).
Hard guards on routes
DELETE /v2/route/{id}is rejected withhighway.route.error.lockedif the route hasis_locked: true. Status is not part of the check — a lockednot_startedroute is rejected, and an unlockedin_transitroute is not. Lock routes you don't want deleted.
Stop
A Stop is one delivery, pickup, or service task.
| Value | Meaning |
|---|---|
pending | Not yet attempted. Default at creation. |
incomplete | Driver attempted the stop but could not complete it. |
completed | Driver completed the stop. Proof of delivery is attached. |
canceled | Canceled before the driver attempted it. |
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_completedorservice_report_canceledreport exists,POST /v2/report(any report type) is rejected withhighway.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 topendingregardless 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.
Package
A Package is an individual parcel tracked inside a delivery stop. One stop can carry many, and each has its own status — independent of the stop's. See the Package tracking recipe for the full integration.
| Value | Meaning |
|---|---|
pending | Created via the API, not yet handled in the field. Default at creation. |
picked | Collected / loaded onto the vehicle (scanned out). `picked_at` is set. |
delivered | Handed to the recipient (scanned at the door). `delivered_at` is set. |
Automatic transitions
Package status is server-managed and driven by field scans — you never set it directly:
- On input, only
identifieris accepted (packages: [{ identifier }]). Anystatus/picked_at/delivered_atyou send is ignored. - Status advances
pending → picked → deliveredas the parcel is scanned through your operation. The exact scan points depend on your driver-app configuration. - The package output (
StopData.packages) carriesstatus,picked_at,delivered_at, andupdated_at.
Packages vs. stop status
A package's status is independent of its stop's status. A stop can reach a terminal completed / incomplete while its packages are in mixed states — e.g. 8 delivered, 2 still picked (returned on the van). This is the partial-delivery case a single stop outcome can't express; reconcile per parcel.
Hard guards on packages
- Deliveries only. A pickup stop rejects the
packagesfield — it is reserved on the pickup leg. Track parcels on the delivery leg. - No package webhook. There is no
package.*event, and thestop.reportedpayload does not carry package status. Reconcile by reading the stop (GET /v2/stop/{stop_id}) afterstop.reported, or pollGET /v2/plan/{id}/stopsfor in-flightpickedvisibility.
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 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:
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.
