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/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 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.

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:

  1. Call POST /v2/plan/{id}/optimize/async (query params like keep_current_assignment=true still apply).
  2. Poll GET /v2/plan/{id}/routes on a short interval until the routes reflect the solve (stops assigned to routes), with a sensible timeout.
  3. 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.

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.

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.

ValueMeaning
pendingCreated via the API, not yet handled in the field. Default at creation.
pickedCollected / loaded onto the vehicle (scanned out). `picked_at` is set.
deliveredHanded 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 identifier is accepted (packages: [{ identifier }]). Any status / picked_at / delivered_at you send is ignored.
  • Status advances pending → picked → delivered as the parcel is scanned through your operation. The exact scan points depend on your driver-app configuration.
  • The package output (StopData.packages) carries status, picked_at, delivered_at, and updated_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 packages field — it is reserved on the pickup leg. Track parcels on the delivery leg.
  • No package webhook. There is no package.* event, and the stop.reported payload does not carry package status. Reconcile by reading the stop (GET /v2/stop/{stop_id}) after stop.reported, or poll GET /v2/plan/{id}/stops for in-flight picked visibility.

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.