Skip to main content
Routal

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.

ValueMeaning
planningDefault working state — stops and vehicles can be added, optimization can be run.
in_progressAt least one route inside the plan has moved to `in_transit` (the driver has started executing).
finishedEvery route in the plan is `finished`.
draftThe 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 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.

ValueMeaning
not_startedCreated and possibly dispatched, but the driver has not opened it.
in_transitThe driver opened the route in the driver app.
finishedEvery stop is in a terminal status.

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.

ValueMeaning
pendingNot yet attempted. Default at creation.
incompleteDriver attempted the stop but could not complete it.
completedDriver completed the stop. Proof of delivery is attached.
canceledCanceled 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.typeEffect on the stop
service_report_attemptedNon-terminal. Increments the stop's report_attempts counter. Status stays pending. The driver can try again.
service_report_completedTerminal. Status moves to completed. Further reports on this stop are rejected with highway.stop.error.report_already_exists.
service_report_canceledTerminal. 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:

ResourceWebhook event_id
Planroutal.planner.2.plan.created, routal.planner.2.plan.updated, routal.planner.2.plan.deleted
Routeroutal.drivers.2.route.started, routal.drivers.2.route.finished
Stoproutal.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.