# EfiRoute API — agent instructions

You can plan and optimize multi-courier delivery routes through the EfiRoute REST API.

- **Base URL:** `https://api.efiroute.com/api/v1`
- **Auth:** every request needs `Authorization: Bearer efr_live_…`
  (the user's company API key; ask them for it and never print it back in full)
- **Machine-readable spec:** `https://api.efiroute.com/api/v1/openapi.json` — fetch it when
  you need an exact field list; it is generated from the server's own validation schemas.

## Rules you must follow

1. **Optimization costs real money.** `POST /projects/{projectId}/optimize` charges
   **$1 per courier per run**. Never call it on your own
   initiative. State the courier count and the resulting cost, get an explicit yes, then call it
   once. Do not retry a failed run automatically — read the error code first.
2. **Geocode before you optimize.** The optimizer ignores delivery points that have no
   coordinates, and it will quietly produce a shorter plan instead of failing. After uploading
   stops, always call `POST /projects/{projectId}/stops/geocode` and report any failures.
3. **Branch on `code`, never on `message`.** Every response is
   `{ "success": true, "data": {…} }` or `{ "success": false, "message", "code", "details" }`.
   Messages are English prose and may change; codes are stable.
4. **Two different courier objects.** `/couriers` is the company's driver list. Shift hours,
   start/end depots, capacity and breaks live on the *assignment*
   (`/projects/{projectId}/couriers`), and only the assignment is used for planning. Creating a
   courier without assigning them to the project does nothing for the plan.
5. **Check the balance first.** `GET /account` returns remaining subscription credit, wallet
   balance, plan limits and this month's usage. Call it before proposing a run so you can tell
   the user whether it will go through.
6. **Ask, don't guess, about destructive calls.** `DELETE` on a project removes its stops and
   routes. Confirm before deleting anything. Note that a project can only be deleted while it is
   still `draft`; once optimized you retire it with `PATCH /projects/{id}`
   `{"status":"cancelled"}` instead of deleting it (`400 PROJECT_NOT_DRAFT` otherwise).

## Order of operations

```
GET  /account                                  → plan, credit, limits
POST /projects                                 → { name, defaultStartDate, defaultStartTime }
POST /projects/{id}/stops                      → single object, or { "stops": [ … ] } up to 1000
POST /projects/{id}/stops/geocode              → REQUIRED before optimizing
POST /couriers                                 → driver record (once per driver, reusable)
POST /projects/{id}/couriers                   → assignment: courierId + depots + shift + capacity
POST /projects/{id}/optimize                   → BILLED. 200 with routes, or 202 with jobId
GET  /optimization-jobs/{jobId}                → poll when you got 202
GET  /projects/{id}/routes                     → final routes with per-stop plannedArrivalAt
```

A `202` means the plan was large enough to solve in the background. Poll the job every 5–10
seconds, or tell the user to register the `optimization.completed` webhook instead of polling.

## Fields that matter for good routes

- **Stop `serviceMinutes`** — how long the courier spends at that stop. Accepts a number of
  minutes, `"1:30"`, or text like `"2 hours"` / `"90 min"`. Without it the courier's
  `stopDurationMinutes` default is used, and arrival times will be optimistic.
- **Stop `timeWindows`** — `[{ startTime, endTime }]` in ISO-8601. Use these for "must arrive
  before 12:00" requirements rather than putting it in `notes`, which the optimizer cannot read.
- **Stop `priority`** — higher wins when not everything fits in the shift.
- **Stop `allowedCourierIds`** — restricts a stop to specific *assignment* ids.
- **Assignment `workStartTime` / `workEndTime`** — a hard shift limit by default; whatever does
  not fit is left unassigned. Set `softTimeWindowMode: true` to let the optimizer overrun the
  shift when that makes the route materially better.
- **Assignment `loadLimits`** — e.g. `{ "weight": 800, "count": 40 }`.

Send times as `HH:MM`; they come back as `HH:MM:SS`. Project lifecycle runs through
`PATCH /projects/{id}` `{"status": …}`: `draft` → `optimized` → `in_progress` →
`completed`, with `on_hold`, `postponed` and `cancelled` available at any point.

After a run, read `unassignedStops`. A non-empty list is normal and usually means the shift,
a time window or a capacity limit was binding — say which, instead of re-running.

## Errors

| Code | HTTP | Meaning |
|---|---|---|
| `API_KEY_MISSING` | 401 | No `Authorization: Bearer` header. |
| `API_KEY_INVALID` | 401 | Unknown key, or wrong prefix. |
| `API_KEY_REVOKED` | 401 | The key was revoked in the dashboard. |
| `API_KEY_EXPIRED` | 401 | The key passed its expiry date. |
| `API_KEY_SCOPE_MISSING` | 403 | Key lacks `read`, `write` or `optimize`. |
| `API_REQUIRES_PAID_PLAN` | 402 | API access needs the Pro or Business plan. |
| `INVALID_BODY` | 400 | Body failed validation; `details.issues` lists each field. |
| `INVALID_QUERY` | 400 | Query string failed validation. |
| `INVALID_PATH_PARAMS` | 400 | A path id is not a UUID. |
| `PROJECT_NOT_FOUND` | 404 | No such project for this company. |
| `PROJECT_NOT_DRAFT` | 400 | Only a draft project can be deleted; PATCH its status to `cancelled` instead. |
| `STOP_NOT_FOUND` | 404 | No such delivery point in this project. |
| `PROJECT_COURIER_NOT_FOUND` | 404 | No such courier assignment on this project. |
| `COMPANY_NOT_FOUND` | 404 | The company behind the key no longer exists. |
| `OPTIMIZATION_NO_STOPS` | 400 | The project has no delivery points. |
| `OPTIMIZATION_NO_COURIERS` | 400 | No courier is assigned to the project. |
| `JOB_NOT_FOUND` | 404 | No such optimization job. |
| `WEBHOOK_NOT_FOUND` | 404 | No such webhook endpoint. |
| `WEBHOOK_URL_INVALID` | 400 | URL must be public https (no localhost/private ranges). |
| `WEBHOOK_LIMIT_REACHED` | 400 | At most 5 endpoints per company. |
| `PROJECT_MONTHLY_LIMIT` | 402 | Monthly project quota for your plan is used up. |
| `COURIER_PER_PROJECT_LIMIT` | 402 | Too many couriers on this project for your plan. |
| `DELIVERY_POINTS_PER_PROJECT_LIMIT` | 402 | Too many delivery points for your plan. |
| `DAILY_OPTIMIZATION_LIMIT_EXCEEDED` | 402 | Daily optimization cap reached. |
| `OPTIMIZATION_PREREQUISITES_MISSING` | 400 | Project has no couriers, or no geocoded stops. |
| `NO_ASSIGNED_POINTS` | 400 | `onlyAssignedPoints` was set but nothing is assigned. |
| `GEOCODE_NO_RESULT` | 400 | The address could not be resolved; `details.address` says which. |
| `INSUFFICIENT_BALANCE` | 402 | Not enough subscription credit + wallet balance. |
| `PROJECT_COURIER_INVALID_COURIER` | 400 | The `courierId` you sent no longer exists or is inactive. |
| `FOREIGN_KEY_CONSTRAINT` | 400 | An id in the request refers to a missing record. |
| `BAD_REQUEST` | 400 | Generic validation/state failure with no more specific code. |
| `UNAUTHORIZED` | 401 | Generic authentication failure. |
| `PAYMENT_REQUIRED` | 402 | Generic plan/credit failure. |
| `FORBIDDEN` | 403 | Generic authorization failure. |
| `NOT_FOUND` | 404 | Generic missing resource. |
| `CONFLICT` | 409 | The request conflicts with the current state. |
| `GONE` | 410 | The resource is no longer available. |
| `RATE_LIMITED` | 429 | Generic rate-limit rejection. |
| `INTERNAL_ERROR` | 500 | Unexpected server error — safe to retry once. |

`INVALID_BODY` puts the offending fields in `details.issues` as
`[{ path, code, message }]` — read it and fix the request rather than retrying blindly.
`402 API_REQUIRES_PAID_PLAN` means the account is on the Free plan; the API needs Pro or
Business. Rate limit is 120 requests/minute per key.

## Webhooks

`POST /webhooks` with `{ "url": "https://…", "events": ["optimization.completed"] }` returns a
signing `secret` **once**. Each delivery carries:

```
X-EfiRoute-Event: optimization.completed
X-EfiRoute-Delivery: <uuid>
X-EfiRoute-Timestamp: <unix seconds>
X-EfiRoute-Signature: sha256=<hex>
```

Verify with `HMAC-SHA256(secret, timestamp + "." + rawBody)` over the **raw** body, compare in
constant time, and reject timestamps older than 5 minutes. Deliveries retry with backoff and
can repeat, so key your handling on `X-EfiRoute-Delivery`. Respond 2xx immediately.

## Worked example

```bash
KEY="efr_live_…"
BASE="https://api.efiroute.com/api/v1"

# 1) Can we afford it?
curl -s -H "Authorization: Bearer $KEY" "$BASE/account"

# 2) Today's plan
PID=$(curl -s -X POST "$BASE/projects" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"name":"Monday deliveries","defaultStartTime":"08:00"}' | jq -r .data.project.id)

# 3) Stops, then geocoding
curl -s -X POST "$BASE/projects/$PID/stops" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"stops":[
        {"name":"Acme Ltd","address":"Bahnhofstrasse 1, 8001 Zurich","serviceMinutes":"15 min"},
        {"name":"Beta AG","address":"Langstrasse 20, 8004 Zurich","serviceMinutes":"1:00",
         "timeWindows":[{"endTime":"2026-10-01T12:00:00Z"}]}
      ]}'
curl -s -X POST "$BASE/projects/$PID/stops/geocode" -H "Authorization: Bearer $KEY"

# 4) Courier, then the assignment that actually drives planning
CID=$(curl -s -X POST "$BASE/couriers" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"firstName":"Max","lastName":"Muster","email":"max@example.com"}' | jq -r .data.courier.id)

curl -s -X POST "$BASE/projects/$PID/couriers" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d "{\"courierId\":\"$CID\",
       \"startAddress\":\"Depot, 8005 Zurich\",
       \"endAddress\":\"Depot, 8005 Zurich\",
       \"workStartTime\":\"08:00\",\"workEndTime\":\"17:00\",
       \"stopDurationMinutes\":10}"

# 5) BILLED — only after the user says yes
curl -s -X POST "$BASE/projects/$PID/optimize" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" -d '{}'
```

## Reporting back

Summarise a completed run as: routes per courier, stop count, distance, total duration, first
and last `plannedArrivalAt`, anything in `unassignedStops` with the likely binding constraint,
and `chargedUsd`. Do not dump raw JSON unless asked.