Skip to content
Afi Planner

Developers

API integration guide

Send orders from your store, ERP or WMS, plan and send the routes, and get every status back by webhook. Each step has a curl command you can run as it is.

API address

https://app.afi.io/api/v1

Every request

Authorization: Bearer afi_…

Full reference: openapi.json (OpenAPI 3.1, version 1.10.0), which most tools import to generate a client. JSON in and out, times in UTC (ISO 8601), metric units.

1Before you start: an API key

In Afi Planner, open Settings → Developers, give the key a name (the system that uses it) and click Create key. Copy it at once: it is shown only once. The owner creates keys, or a team member whose role has the Developers permission.

  • A key acts for your company with the permissions of the person who created it, never more than a dispatcher’s: it can add orders, plan and send routes, but never change your team, billing or settings.
  • Choose Read-only for a system that only reads (any other method answers 403 key_read_only), and an expiry if you want one.
  • Roll key makes a new key with the same name and keeps the old one working for 1 or 24 hours, so you can swap it without downtime.

Keep the key on your server, never in a browser or a mobile app. The examples below read it, the API address and a delivery day from your shell:

Shell
export AFI_API_KEY="afi_…"          # your key, from Settings → Developers
export AFI="https://app.afi.io/api/v1"
export DAY="2026-11-03"              # a delivery day, YYYY-MM-DD: today or later

Check the key. /me answers with your company, the key and your package’s limits:

Request
curl "$AFI/me" -H "Authorization: Bearer $AFI_API_KEY"
Response 200
{
  "company": { "id": 1, "name": "Bayside Fresh Co." },
  "key": { "id": 1, "name": "Shop backend", "prefix": "afi_wzPUFcTM", "scope": "full", "expiresAt": null },
  "subscription": { "state": "trial", "limits": { "maxStopsPerPlan": 500, "optimizationsPerMonth": 2000, … } }
}

No code yet? Settings → Developers → Test this key sends ready-made requests with your key and shows the answer and the curl command. Download Postman collection gives every request of this guide set up for your company (the key stays a variable you fill in), and API requests lists each call of the last 3 days.

2Send orders for a day

ref is your own order ID and day the delivery day. Send an address, or lat and lng, or both: with coordinates the address is not looked up again. Time windows are local times at the address (HH:MM).

Request
curl -X POST "$AFI/orders" \
  -H "Authorization: Bearer $AFI_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: SHOP-1042-$DAY" \
  -d @- <<EOF
{
  "ref": "SHOP-1042",
  "day": "$DAY",
  "name": "Olivia Shah",
  "phone": "+14155550142",
  "email": "olivia@example.com",
  "address": "1355 Market St, San Francisco, CA 94103",
  "lat": 37.7768,
  "lng": -122.4166,
  "timeWindows": [{ "start": "09:00", "end": "12:00" }],
  "packages": 2,
  "weightKg": 4.5,
  "customerNote": "Leave with the front desk"
}
EOF
Response 201
{
  "id": 1,
  "ref": "SHOP-1042",
  "day": "2026-11-03",
  "status": "new",
  "test": false,
  "phoneIssue": null,
  "addressIssue": null,
  "timeZone": "America/Los_Angeles",
  "timeWindows": [{ "start": "09:00", "end": "12:00", "startAt": "2026-11-03T09:00:00-08:00", "endAt": "2026-11-03T12:00:00-08:00" }],
  "route": null,
  "draftRoute": null,
  "trackingUrl": "https://app.afi.io/t/1.bfc92ec4c7c1da1b30b0ccce",
  …
}

Keep the id: it is the order in every later call. A ref names one open order across all days, not one per day: sending a ref that has an open order on any day updates that order (200) and keeps its day, even if it was moved since (change the day with PATCH /orders/{id}). An order is open until it is delivered, failed or skipped; one left unvisited on a day that is over is not open. Once a driver has worked on the order, the answer is 409 order_worked and PATCH /orders/{id} changes its fields.

With no open order, a ref whose order failed in the 14 days before the new day becomes that order’s next attempt, with the same tracking link; otherwise (an older failure, or a delivered order) it is a new order (201). To always create a new order, send "acceptDuplicateRef": true: the ref then no longer names one order, so keep the id.

FieldWhat it is
refYour order ID, unique among open orders on all days (case and surrounding spaces are ignored)
dayDelivery day, YYYY-MM-DD, today or later
name, phone, emailThe customer. Phones are stored in E.164; a number that cannot get texts is saved with phoneIssue
address, unit, lat, lngWhere to deliver: address is required unless you send lat and lng
timeWindows1 or 2 windows { "start": "09:00", "end": "12:00" }; an end before the start ends the next day
serviceMinutesTime at the door, when it differs from the company’s default
packages, weightKg, volumeM3The load, in metric units whatever the app shows
skills, prioritySkills a driver needs; normal or high
pickupAt, pickupWhere the parcel is picked up first: a saved pickup place’s name or id, "depot", an address, or "none"
customerNote, leaveAtDoorThe customer’s instructions for the driver, e.g. from your checkout
notifyCustomerfalse: no text, WhatsApp or email to this customer
notes, barcodes, customFields, access, languageNotes for the driver, package barcodes, your own fields, gate code and entrance, the customer’s language (en, sv, vi, es)

Sending a ref again changes the fields you send; the others stay as they were. A field the API doesn’t know is refused (400 invalid_request): put your own data in customFields, which comes back on the order and in every webhook.

Many orders at once: POST /orders/batch takes up to 1,000, each with its own day. Each row has its own result, so one bad row does not stop the others:

Request
curl -X POST "$AFI/orders/batch" \
  -H "Authorization: Bearer $AFI_API_KEY" \
  -H "Content-Type: application/json" \
  -d @- <<EOF
{
  "orders": [
    { "ref": "SHOP-1043", "day": "$DAY", "name": "Marcus Lee", "address": "600 Guerrero St, San Francisco, CA 94110", "lat": 37.7612, "lng": -122.4241 },
    { "ref": "SHOP-1044", "day": "$DAY", "name": "Ana Torres", "address": "2101 Irving St, San Francisco, CA 94122", "lat": 37.7637, "lng": -122.4825,
      "timeWindows": [{ "start": "13:00", "end": "17:00" }] },
    { "ref": "SHOP-1045", "day": "$DAY", "name": "Jordan Price", "lat": 120, "lng": -122.4752 }
  ]
}
EOF
Response 200
{
  "results": [
    { "row": 1, "ref": "SHOP-1043", "id": 2, "result": "created", "addressIssue": null },
    { "row": 2, "ref": "SHOP-1044", "id": 3, "result": "created", "addressIssue": null },
    { "row": 3, "ref": "SHOP-1045", "result": "error", "error": "Latitude can't be more than 90", "code": "invalid_order", "field": "lat" }
  ],
  "created": 2, "updated": 0, "failed": 1
}

A ref that appears twice in one batch counts once, whatever the rows’ days: the later rows get ref_repeated. With acceptDuplicateRef (next to orders) that only applies on the same day.

Testing against your real company? Start the ref with API-TEST- (for example API-TEST-0001): such an order is never planned, never messages its customer, counts nowhere and is deleted after 3 days. Its webhooks still go out, with "test": true.

3Plan the routes

List your drivers, choose who works that day, then start planning. Planning runs in the background: the answer is a job to follow.

Drivers are added in the app, on the Drivers page. The API lists them and chooses who works each day; with no driver chosen, planning answers 400 (“Choose at least one driver for this plan.”).

Request
curl "$AFI/drivers" -H "Authorization: Bearer $AFI_API_KEY"

curl -X PUT "$AFI/days/$DAY/drivers" \
  -H "Authorization: Bearer $AFI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "driverIds": [1, 2] }'

curl -X POST "$AFI/days/$DAY/optimize" \
  -H "Authorization: Bearer $AFI_API_KEY" \
  -H "Idempotency-Key: plan-$DAY-1"
Response 202
{ "jobId": "75d50077-6549-449a-bccc-80cc661174c6", "state": "running", "planId": 1, "day": "2026-11-03" }

Ask for the job every few seconds until state is done (or failed, with the reason in error). A day of 176 stops and 8 drivers takes about 40 seconds; smaller days are faster.

Request
curl "$AFI/jobs/$JOB_ID" -H "Authorization: Bearer $AFI_API_KEY"
Response 200
{ "id": "75d50077-…", "state": "done", "kind": "optimize", "planId": 1, "day": "2026-11-03", "error": null, … }

GET /days/{day}/plan gives the routes. Until they are sent, they are in draft: routes is always what the drivers have, and your dispatchers can still change the draft in the app.

Request
curl "$AFI/days/$DAY/plan" -H "Authorization: Bearer $AFI_API_KEY"
Response 200
{
  "day": "2026-11-03", "planId": 1, "plannedAt": null, "dispatchedAt": null,
  "routes": [],
  "notOnRoute": [],
  "draft": {
    "plannedAt": "2026-11-02T22:12:43Z",
    "routes": [
      { "driverId": 1, "driverName": "Daniel Kim", "start": "2026-11-03T16:32:57Z", "end": "2026-11-03T17:12:57Z",
        "distanceMeters": 12623, "travelSeconds": 1233, "breaks": [],
        "visits": [
          { "position": 1, "orderId": 2, "ref": "SHOP-1043", "kind": "delivery", "arrival": "2026-11-03T16:39:51Z", "departure": "2026-11-03T16:44:51Z" },
          { "position": 2, "orderId": 1, "ref": "SHOP-1042", "kind": "delivery", "arrival": "2026-11-03T17:00:00Z", "departure": "2026-11-03T17:05:00Z" }
        ] }
    ],
    "notOnRoute": []
  }
}

4Send the routes to the drivers

Sending gives each driver their route in the driver app, or a link that opens on any phone (url). Call it again after a change to send the updates.

Request
curl -X POST "$AFI/days/$DAY/dispatch" \
  -H "Authorization: Bearer $AFI_API_KEY" \
  -H "Idempotency-Key: send-$DAY-1"
Response 200
{
  "day": "2026-11-03",
  "dispatchedAt": "2026-11-03T14:02:57Z",
  "drivers": [
    { "driverId": 1, "url": "https://app.afi.io/d/1.3uu3J7…" },
    { "driverId": 2, "url": "https://app.afi.io/d/2.Qm9xk2…" }
  ]
}

A day with orders but no planned routes answers 400 (“Plan the routes first”), a day that is over 409 day_over.

5Read where each order stands

One order, with its route as sent to the driver, the ETA, every driver report with its proof of delivery and every attempt:

Request
curl "$AFI/orders/$ORDER_ID" -H "Authorization: Bearer $AFI_API_KEY"
Response 200
{
  "id": 1, "ref": "SHOP-1042", "status": "assigned",
  "route": { "driverId": 1, "driverName": "Daniel Kim", "position": 2, "eta": "2026-11-03T17:00:00Z", "liveEta": null, "pickupEta": null },
  "draftRoute": null,
  "trackingUrl": "https://app.afi.io/t/1.bfc92ec4c7c1da1b30b0ccce",
  "events": [],
  "attempts": [{ "number": 1, "orderId": 1, "day": "2026-11-03", "current": true, "moved": false, "outcome": null }],
  …
}

Every order of a day, or one ref. With limit (1–1,000) the answer comes a page at a time: send its nextCursor as cursor for the next page; it is null on the last one.

Request
curl "$AFI/orders?day=$DAY&ref=SHOP-1042" -H "Authorization: Bearer $AFI_API_KEY"

curl "$AFI/orders?day=$DAY&status=assigned&limit=100" -H "Authorization: Bearer $AFI_API_KEY"

URL-encode a ref in a query: Shopify’s #1001 is %231001 (encodeURIComponent). Unencoded, everything after # is dropped and the answer is every order of the day.

Statuses:

statusMeaning
newNot on a route the drivers have yet
assignedOn a route sent to a driver
unassignedThe routes sent left it out
on_holdKept out of planning (POST /orders/{id}/hold, /release)
needs_fix, locatingThe address could not be used (addressIssue says why), or is being looked up
to_pickup, at_pickup, picked_up, pickup_failedThe driver’s reports at the pickup
in_transit, at_deliveryOn the way, at the door
delivered, failed, skippedFinished: events has the reason, photos, signature and recipient
cancelledCancelled: cancellation has the reason, the note, who cancelled it (by), when (cancelledAt) and returnToDepot when the parcel was in a van

Change an order with PATCH /orders/{id} (only the fields you send), cancel it with POST /orders/{id}/cancel (a reason and an optional note: it stays, with status cancelled), delete one entered by mistake with DELETE /orders/{id} (one already sent to a driver answers 409 order_sent: cancel it instead), and send a failed order out again with POST /orders/{id}/redeliver: a new attempt on the next working day (or the day you send), with the same tracking link.

Request
curl -X PATCH "$AFI/orders/$ORDER_ID" \
  -H "Authorization: Bearer $AFI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "timeWindows": [{ "start": "10:00", "end": "13:00" }], "notes": "Gate code at the side door" }'

6Get every change as a webhook

Instead of asking again and again, let Afi Planner post each change to your server. Add an endpoint in Settings → Developers, or by API. The answer carries the signing secret, shown only once.

Request
curl -X POST "$AFI/webhooks" \
  -H "Authorization: Bearer $AFI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/afi/webhooks",
    "events": ["order.status_changed", "order.eta_changed", "route.dispatched"],
    "description": "Shop backend"
  }'
Response 201
{
  "id": 1,
  "url": "https://example.com/afi/webhooks",
  "events": ["order.status_changed", "order.eta_changed", "route.dispatched"],
  "enabled": true,
  "secret": "whsec_…",
  …
}

Send a test event with POST /webhooks/{id}/test. Each event is a JSON POST:

Event
POST /afi/webhooks
Afi-Event: order.status_changed
Afi-Delivery: 3
Afi-Signature: t=1793078018,v1=5f0c…

{
  "id": "evt_EbCy9y6Dq8yoTQXL",
  "type": "order.status_changed",
  "sequence": 3,
  "occurredAt": "2026-11-03T14:02:58Z",
  "createdAt": "2026-11-03T14:02:58Z",
  "companyId": 1,
  "test": false,
  "data": {
    "order": { "id": 1, "ref": "SHOP-1042", "status": "assigned", "route": { "driverId": 1, "eta": "2026-11-03T17:00:00Z", … }, … },
    "previousStatus": "new",
    "event": null
  }
}
EventWhen
order.created, order.updated, order.deletedAn order was added, changed (changed lists the fields) or removed
order.cancelledThe order was cancelled, in the app or with POST /orders/{id}/cancel, with its cancellation; no order.status_changed or order.deleted comes for it
order.status_changedSent on a route, on the way, delivered, failed…, with the reason and proof
order.eta_changedThe ETA moved by your threshold, 5 minutes by default (live while the driver is on the road)
order.loadedThe driver loaded the parcel into the van
order.rescheduled_by_customer, order.instructions_changed, order.ratedThe customer picked another day, changed the instructions or rated the delivery on the tracking page
order.not_attemptedThe driver’s day ended with the order still open
order.moved_backA move to another day was undone: the order is again the one in movedBackTo
plan.optimized, plan.job_failed, route.dispatchedNew routes on the board, a planning that failed, routes or updates sent

Check the signature before you trust an event: v1 is the HMAC-SHA256 of t.body with your secret, in hex. Reject events older than 5 minutes, answer 2xx within 10 seconds and do the work afterwards. In Node:

Node.js
import http from 'node:http'
import crypto from 'node:crypto'

const secret = process.env.AFI_WEBHOOK_SECRET // whsec_…

function verify(header, rawBody) { // rawBody: the bytes as they came, before any JSON parsing
  const parts = header.split(',').map((p) => p.split('='))
  const t = Number(parts.find(([k]) => k === 't')?.[1])
  if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false // older than 5 minutes
  const expected = crypto.createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest()
  return parts.filter(([k]) => k === 'v1').some(([, sig]) => {
    const got = Buffer.from(sig, 'hex')
    return got.length === expected.length && crypto.timingSafeEqual(got, expected)
  })
}

http.createServer((req, res) => {
  const chunks = []
  req.on('data', (c) => chunks.push(c))
  req.on('end', () => {
    const raw = Buffer.concat(chunks)
    if (!verify(req.headers['afi-signature'] ?? '', raw)) { res.writeHead(401).end(); return }
    res.writeHead(200).end() // answer first, then process
    const event = JSON.parse(raw.toString('utf8'))
    const order = event.data.order ?? event.data // order.deleted, order.moved_back: id, ref, day
    console.log(event.sequence, event.type, order.ref, order.status)
  })
}).listen(3000)
  • Retries: an answer other than 2xx, or none within 10 seconds, is retried after 1 min, 5 min, 30 min, 2 h, 6 h and 12 h. An endpoint failing for 3 days in a row is turned off.
  • Order: events can arrive out of order. Apply an event only if its sequence is higher than the last one you applied for that order; a retry carries the same id and sequence.
  • After a new signing secret (POST /webhooks/{id}/rotate-secret), the old one keeps signing for 24 hours: the header then has two v1 values, accept either.
  • Events while an endpoint is off are not kept for it: after turning it on again, read the day with GET /orders?day=.
  • Every delivery, its payload and your server’s answer stay 3 days in Settings → Developers and in GET /webhooks/{id}/deliveries, with Retry and Resend.
  • Without events, an endpoint gets every event type (*).
  • order.deleted and order.moved_back carry id, ref and day in data, not an order.
  • Proof links (photos, signature) are signed and work for 1 hour: download what you keep, or read the order again for fresh links.

The secret is the whole whsec_… string. Sign the body exactly as it arrived: a framework that parses JSON first must hand you the raw bytes (in Express, express.raw({ type: "application/json" }) on this route).

7Connect a store: Shopify as an example

Afi Planner does not take your store’s webhooks directly: a small service of yours, a bridge, sits in between. It turns each store order into an Afi Planner order, and each Afi Planner event into an update in the store. The same steps work for WooCommerce, an ERP or a WMS.

Flow
orders/create, orders/updated  →  bridge  →  POST /orders
orders/cancelled               →  bridge  →  DELETE /orders/{id}
fulfillment, tracking link     ←  bridge  ←  webhooks (signed)
Shopify orderAfi Planner order
nameref: the order number your customers and staff see (#1001)
shipping_addressname, phone, address in one line, unit from address2, and lat, lng when Shopify has them
email, noteemail, customerNote
idcustomFields: { "shopifyOrderId": "…" }, to find the Shopify order from any event
(delivery date)day: the date chosen at checkout if you collect one, else your next delivery day
Node.js
// Shopify orders/create and orders/updated → Afi Planner (check Shopify's own signature first)
async function sendToAfi(o, day) {
  const a = o.shipping_address
  const res = await fetch(`${process.env.AFI}/orders`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.AFI_API_KEY}`,
      'Content-Type': 'application/json',
      'Idempotency-Key': `shopify-${o.id}-${o.updated_at}`, // Shopify may send a webhook twice
    },
    body: JSON.stringify({
      ref: o.name,
      day,
      name: a.name,
      phone: a.phone ?? o.phone,
      email: o.email,
      address: [a.address1, a.city, a.province_code, a.zip].filter(Boolean).join(', '),
      unit: a.address2 || undefined,
      ...(a.latitude != null ? { lat: a.latitude, lng: a.longitude } : {}),
      customerNote: o.note || undefined,
      customFields: { shopifyOrderId: String(o.id) },
    }),
  })
  const body = await res.json()
  if (!res.ok) throw new Error(`Afi Planner ${res.status} ${body.code}: ${body.error}`)
  return body // keep body.id with the Shopify order
}

Keep the id Afi Planner answers with, next to the Shopify order: cancelling (POST /orders/{id}/cancel) and later changes need it; without it, GET /orders?ref=%231001 finds the order on any day. An update after a driver has worked on the order answers 409 order_worked: change it with PATCH /orders/{id}, or leave it.

Afi Planner eventIn Shopify
order.status_changed → assignedCreate the fulfillment with the order’s trackingUrl as its tracking link: the customer follows the driver live
order.status_changed → in_transitFulfillment event out_for_delivery
order.status_changed → deliveredFulfillment event delivered; data.event.proof has the photos, signature and recipient
order.status_changed → failedFulfillment event attempted_delivery; POST /orders/{id}/redeliver sends it out again
order.eta_changedYour own “arriving at” message, if you show one

Try the order side first with refs starting API-TEST- (for example API-TEST-1001): those orders are never planned or messaged and are deleted after 3 days; their webhooks come with "test": true.

8Errors

Every error has the same shape: a sentence you can show or log, a code your program can act on, and field when one field is wrong.

Response 400
{ "error": "Latitude can't be more than 90", "code": "invalid_request", "field": "lat" }
HTTPcodeWhat to do
400invalid_requestA field is wrong (field), or the body is not JSON sent with Content-Type: application/json
400day_in_pastOrders are for today or later
400invalid_cursorSend the nextCursor of the previous page as it came
401unauthorizedThe key is missing, wrong, revoked or expired
402subscription_endedThe subscription ended: reads still work
403company_pausedThe company is paused
403key_read_onlyA read-only key tried to change something
403creator_forbiddenThe person who created the key may not do this: use a key of someone who may
403webhook_needs_viewAdding a webhook needs a key whose creator can see orders
403plan_fullThe day’s plan has no room for that many new orders in your package (planFull); nothing was saved
404not_found, no_planNo such order, job or webhook, or no plan on that day yet
409order_workedA driver already worked on this order: PATCH /orders/{id} changes its fields
409order_sentA driver has this order: cancel it with POST /orders/{id}/cancel instead of deleting it
409order_cancelledThis ref’s order of that day was cancelled: send acceptDuplicateRef: true for a new order
409order_movedThe order was moved to another day: movedTo names the order it is now
409ref_takenAnother open order already has this ref, on any day
409day_overThat day is over: it can’t be planned or sent
409idempotency_conflictThis Idempotency-Key was used for another request
409idempotency_in_progressThe first request with this key is still running: ask again in a moment
422cannot_planThe day can’t be planned: the sentence says why
429rate_limitedToo many requests: wait the Retry-After seconds

Retrying safely: send an Idempotency-Key header with each POST (any unique string, such as your order ID and an attempt number). The same key and body within 24 hours return the first answer instead of doing it twice.

9Rate limits and package limits

  • 600 requests a minute per key, counted per calendar minute. Every answer says where you are: RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset (seconds until the next minute). Over the limit, the answer is 429 with Retry-After.
  • Up to 1,000 orders per batch request.
  • Your package’s limits apply as in the app: stops per plan (500 on the free trial), plannings and address lookups a month. /me shows them.
  • Up to 10 webhook endpoints per company.
Response headers
HTTP/1.1 200 OK
RateLimit-Limit: 600
RateLimit-Remaining: 599
RateLimit-Reset: 4

Every example on this page was run against the API on Oct 27, 2026 (OpenAPI 1.10.0). Setting it up in the app: the API & webhooks guide

Get an API key in a minute

Start the free 3-month trial: the API and webhooks are part of every plan. No credit card.