Skip to main content
Routal

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-Key header. Routal does not yet accept the Stripe-style Idempotency-Key header. Sending one is a no-op.
  • external_id is the closest thing. Stops, plans, and vehicles all accept an external_id field that you control. Routal stores it as Custom user external identifier and 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:

  1. Dedup using fields inside data. Most payloads include the resource id plus a timestamp like updated_at. Use (event_id, data.id, data.updated_at) as your dedup key.
  2. 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 for plan.optimized before 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 past pending.

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.