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-Keyheader. Routal does not yet accept the Stripe-styleIdempotency-Keyheader. Sending one is a no-op. external_idis the closest thing. Stops, plans, and vehicles all accept anexternal_idfield that you control. Routal stores it asCustom user external identifierand 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:
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:
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:
- Dedup using fields inside
data. Most payloads include the resourceidplus a timestamp likeupdated_at. Use(event_id, data.id, data.updated_at)as your dedup key. - 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 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 forplan.optimizedbefore 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 pastpending.
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.
