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— runs the solver and may reassign stops between routes; status is unchanged. 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.
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.
| 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.
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.
