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/v1Every 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:
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 laterCheck the key. /me answers with your company, the key and your package’s limits:
curl "$AFI/me" -H "Authorization: Bearer $AFI_API_KEY"{
"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).
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{
"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.
| Field | What it is |
|---|---|
| ref | Your order ID, unique among open orders on all days (case and surrounding spaces are ignored) |
| day | Delivery day, YYYY-MM-DD, today or later |
| name, phone, email | The customer. Phones are stored in E.164; a number that cannot get texts is saved with phoneIssue |
| address, unit, lat, lng | Where to deliver: address is required unless you send lat and lng |
| timeWindows | 1 or 2 windows { "start": "09:00", "end": "12:00" }; an end before the start ends the next day |
| serviceMinutes | Time at the door, when it differs from the company’s default |
| packages, weightKg, volumeM3 | The load, in metric units whatever the app shows |
| skills, priority | Skills a driver needs; normal or high |
| pickupAt, pickup | Where the parcel is picked up first: a saved pickup place’s name or id, "depot", an address, or "none" |
| customerNote, leaveAtDoor | The customer’s instructions for the driver, e.g. from your checkout |
| notifyCustomer | false: no text, WhatsApp or email to this customer |
| notes, barcodes, customFields, access, language | Notes 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:
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{
"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.”).
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"{ "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.
curl "$AFI/jobs/$JOB_ID" -H "Authorization: Bearer $AFI_API_KEY"{ "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.
curl "$AFI/days/$DAY/plan" -H "Authorization: Bearer $AFI_API_KEY"{
"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.
curl -X POST "$AFI/days/$DAY/dispatch" \
-H "Authorization: Bearer $AFI_API_KEY" \
-H "Idempotency-Key: send-$DAY-1"{
"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:
curl "$AFI/orders/$ORDER_ID" -H "Authorization: Bearer $AFI_API_KEY"{
"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.
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:
| status | Meaning |
|---|---|
| new | Not on a route the drivers have yet |
| assigned | On a route sent to a driver |
| unassigned | The routes sent left it out |
| on_hold | Kept out of planning (POST /orders/{id}/hold, /release) |
| needs_fix, locating | The address could not be used (addressIssue says why), or is being looked up |
| to_pickup, at_pickup, picked_up, pickup_failed | The driver’s reports at the pickup |
| in_transit, at_delivery | On the way, at the door |
| delivered, failed, skipped | Finished: events has the reason, photos, signature and recipient |
| cancelled | Cancelled: 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.
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.
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"
}'{
"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:
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
}
}| Event | When |
|---|---|
| order.created, order.updated, order.deleted | An order was added, changed (changed lists the fields) or removed |
| order.cancelled | The 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_changed | Sent on a route, on the way, delivered, failed…, with the reason and proof |
| order.eta_changed | The ETA moved by your threshold, 5 minutes by default (live while the driver is on the road) |
| order.loaded | The driver loaded the parcel into the van |
| order.rescheduled_by_customer, order.instructions_changed, order.rated | The customer picked another day, changed the instructions or rated the delivery on the tracking page |
| order.not_attempted | The driver’s day ended with the order still open |
| order.moved_back | A move to another day was undone: the order is again the one in movedBackTo |
| plan.optimized, plan.job_failed, route.dispatched | New 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:
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
sequenceis higher than the last one you applied for that order; a retry carries the sameidandsequence. - After a new signing secret (
POST /webhooks/{id}/rotate-secret), the old one keeps signing for 24 hours: the header then has twov1values, 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.deletedandorder.moved_backcarryid,refanddayindata, not anorder.- 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.
orders/create, orders/updated → bridge → POST /orders
orders/cancelled → bridge → DELETE /orders/{id}
fulfillment, tracking link ← bridge ← webhooks (signed)| Shopify order | Afi Planner order |
|---|---|
| name | ref: the order number your customers and staff see (#1001) |
| shipping_address | name, phone, address in one line, unit from address2, and lat, lng when Shopify has them |
| email, note | email, customerNote |
| id | customFields: { "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 |
// 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 event | In Shopify |
|---|---|
| order.status_changed → assigned | Create the fulfillment with the order’s trackingUrl as its tracking link: the customer follows the driver live |
| order.status_changed → in_transit | Fulfillment event out_for_delivery |
| order.status_changed → delivered | Fulfillment event delivered; data.event.proof has the photos, signature and recipient |
| order.status_changed → failed | Fulfillment event attempted_delivery; POST /orders/{id}/redeliver sends it out again |
| order.eta_changed | Your 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.
{ "error": "Latitude can't be more than 90", "code": "invalid_request", "field": "lat" }| HTTP | code | What to do |
|---|---|---|
| 400 | invalid_request | A field is wrong (field), or the body is not JSON sent with Content-Type: application/json |
| 400 | day_in_past | Orders are for today or later |
| 400 | invalid_cursor | Send the nextCursor of the previous page as it came |
| 401 | unauthorized | The key is missing, wrong, revoked or expired |
| 402 | subscription_ended | The subscription ended: reads still work |
| 403 | company_paused | The company is paused |
| 403 | key_read_only | A read-only key tried to change something |
| 403 | creator_forbidden | The person who created the key may not do this: use a key of someone who may |
| 403 | webhook_needs_view | Adding a webhook needs a key whose creator can see orders |
| 403 | plan_full | The day’s plan has no room for that many new orders in your package (planFull); nothing was saved |
| 404 | not_found, no_plan | No such order, job or webhook, or no plan on that day yet |
| 409 | order_worked | A driver already worked on this order: PATCH /orders/{id} changes its fields |
| 409 | order_sent | A driver has this order: cancel it with POST /orders/{id}/cancel instead of deleting it |
| 409 | order_cancelled | This ref’s order of that day was cancelled: send acceptDuplicateRef: true for a new order |
| 409 | order_moved | The order was moved to another day: movedTo names the order it is now |
| 409 | ref_taken | Another open order already has this ref, on any day |
| 409 | day_over | That day is over: it can’t be planned or sent |
| 409 | idempotency_conflict | This Idempotency-Key was used for another request |
| 409 | idempotency_in_progress | The first request with this key is still running: ask again in a moment |
| 422 | cannot_plan | The day can’t be planned: the sentence says why |
| 429 | rate_limited | Too 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-RemainingandRateLimit-Reset(seconds until the next minute). Over the limit, the answer is 429 withRetry-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.
/meshows them. - Up to 10 webhook endpoints per company.
HTTP/1.1 200 OK
RateLimit-Limit: 600
RateLimit-Remaining: 599
RateLimit-Reset: 4Every 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.