# Kilo Docs — full markdown dump Generated from the same source as the human site. Catalog: https://docs.kiloapp.org/llms.txt If a path is not in that catalog, it is not a Kilo Docs page. ======================================================================== URL: https://docs.kiloapp.org/introduction Title: Introduction Section: Get started Updated: Aug 17, 2026 Description: What the Kilo Business Delivery API is, in plain words. ======================================================================== You are helping a developer integrate the Kilo Business Delivery API. Document: Introduction Description: What the Kilo Business Delivery API is, in plain words. # Introduction The Kilo Business Delivery API lets **your server** create and manage fleet deliveries the same way the public request form does. ## Who this is for - You run a business on Kilo. - You have (or are building) a WhatsApp / AI bot that talks to customers. - Your bot's **backend** will call Kilo with an API key. ## Who this is NOT for - Do **not** put your API key in a browser, mobile app, or WhatsApp client. - Do **not** call Kilo directly from a customer's phone. ## What you can do 1. **Discover** what the business offers (services, warehouses, service locations, payments). 2. **Create** a delivery (pickup, dropoff, package photos, payment method). 3. **Get** one delivery by id. 4. **List** deliveries. 5. **List riders** and **assign / unassign** them (or let Kilo Auto-dispatch do it). 6. **Cancel** a delivery when the rules allow it. 7. Receive **webhooks** when something changes (created, cancelled, completed, OTP ready, …). 8. Optionally **turn off** Kilo's customer SMS / WhatsApp / email and send those yourself from webhook events. 9. Optionally **send the delivery OTP yourself** via webhook (if Meta WhatsApp / SMS from Kilo is a problem for you). ## Own your customer notifications If your bot already talks to customers (WhatsApp, SMS, in-app): 1. Dashboard → **Settings → Notifications**. 2. Turn **Customer delivery messages** **off**. 3. Keep webhooks on (Settings → **API & Webhooks**) and send status updates from your side. Kilo still runs the delivery. Rider ops messages are unchanged — only customer-facing status / tracking / feedback messages stop. ### Want to send the delivery code (OTP) yourself too? That is a **separate** switch (only applies when drop-off OTP is still required — see Delivery settings → **Require OTP at drop-off**, on by default): 1. Dashboard → **Settings → API & Webhooks**. 2. Turn **OTP via webhook only** **on**. 3. Subscribe your webhook to `delivery.otp_issued`. Then Kilo will **not** WhatsApp/SMS the code. You get `data.otp.code` on the webhook and send it in your own chat. Details: [Webhooks → Send the delivery OTP yourself](/webhooks#send-the-delivery-otp-yourself). If **Require OTP at drop-off** is **off**, there is no code to send — riders complete with photo proof only. ## Auto-assign riders (optional) Two ways to get a rider on a job: 1. **Kilo Auto-dispatch** — turn it on in Delivery settings; Kilo picks a nearby active rider on create. Guide: [Auto-assign riders](/auto-assign). 2. **Your own agent** — listen for `delivery.created`, `GET /riders`, then `POST /deliveries/{id}/assign`. Guide: [Assign riders](/assign-riders) and the [Auto-assign agent](/template-auto-assign-agent) prompt. ## Mental model (read this twice) Think of three boxes: 1. **Your bot** — talks to the human in natural language. 2. **Your backend** — turns that chat into a clean JSON body + downloads/hosts package images. 3. **Kilo** — creates the delivery for your fleet, applying your coverage, pricing, and settings. Kilo does **not** parse the WhatsApp text for you. Your backend must send complete data. ## Next step Prefer building with an AI coding agent? Start at [Build with AI](/build-with-ai) — copy a ready-made implementation prompt (WhatsApp bot, store fulfillment, auto-assign agent) that includes the API reference and a human setup checklist. Otherwise start with [Capabilities & locations](/capabilities) — that page is the allow-list mirror of **Dashboard → Settings → Delivery** (what this fleet may offer). Then [Authentication](/authentication) and [Rate limits](/rate-limits). For exact JSON shapes, see [API reference](/api-reference). ## For AI agents & crawlers This site publishes a **complete page catalog** so agents know every URL and what it contains **before** opening pages: - Plain-text index (llms.txt): [/llms.txt](/llms.txt) — if a path is not listed there, **it does not exist** in Kilo Docs. - Full markdown dump: [/llms-full.txt](/llms-full.txt) - Search sitemap: [/sitemap.xml](/sitemap.xml) ======================================================================== URL: https://docs.kiloapp.org/authentication Title: Authentication Section: Get started Updated: Aug 17, 2026 Description: How to send your secret API key safely. ======================================================================== You are helping a developer integrate the Kilo Business Delivery API. Document: Authentication Description: How to send your secret API key safely. # Authentication Every API request must prove it comes from your business. ## Get a key 1. Open the Kilo business dashboard. 2. Go to **Settings → API & Webhooks**. 3. Click **Create API key**. 4. **Copy the key immediately.** We show the full secret only once. Keys look like `sk_live_...`. ## Send the key Use **either** header (pick one): ```http Authorization: Bearer sk_live_YOUR_SECRET ``` or: ```http X-Api-Key: sk_live_YOUR_SECRET ``` ## Base URL Partner API calls go to the **business app** host (same Vercel project as the dashboard), not a customer custom domain: ```text https://business.kiloapp.org/api/v1 ``` Example create URL: ```text POST https://business.kiloapp.org/api/v1/deliveries ``` ## If auth fails | HTTP | Meaning | |------|---------| | 401 | Missing / wrong / revoked key | | 403 | Key lacks scope, or billing blocks API access | | 429 | Plan rate limit — wait, then retry (`Retry-After`) | ## Safety rules for kids (and adults) 1. Never paste a live key into Slack, email, or a Discord screenshot. 2. Store it in environment variables on your server. 3. If it leaks, **revoke** it in the dashboard and create a new one. 4. Respect [rate limits](/rate-limits). Your plan sets requests per minute per key. ======================================================================== URL: https://docs.kiloapp.org/rate-limits Title: Rate limits Section: Get started Updated: Aug 17, 2026 Description: How many Delivery API calls you can make per minute, by plan. ======================================================================== You are helping a developer integrate the Kilo Business Delivery API. Document: Rate limits Description: How many Delivery API calls you can make per minute, by plan. # Rate limits Every live API key is limited **per minute**. The number comes from your Kilo Business plan and is enforced consistently across your integration. | Plan | Requests / min per key | |------|------------------------| | Starter | 60 | | Growth | 180 | | Enterprise | Custom (set on the account) | There is also a network-level protection layer to prevent abusive bursts. `GET /capabilities` includes `rateLimits.requestsPerMinute` (`null` means a custom/unlimited per-key cap). ## When you hit the cap HTTP **429**: ```json { "ok": false, "error": "Too many requests. Slow down and retry after Retry-After seconds.", "code": "rate_limited", "retryAfterSeconds": 12 } ``` Headers: - `Retry-After` — seconds to wait - `RateLimit-Limit` — your plan cap - `RateLimit-Remaining` - `RateLimit-Reset` — unix time when the window ends Backoff and retry. Do not tight-loop. ## Unusual key activity Kilo may flag unusual key activity and notify owners/admins before access is suspended. If you confirm the traffic is yours, dismiss the warning in **Settings → API & Webhooks**. If not, revoke the key and issue a new one. ## Changing the numbers Plan defaults are set by your Kilo Business plan. For custom contracts, Kilo support can set an organization-specific ceiling. ======================================================================== URL: https://docs.kiloapp.org/capabilities Title: Capabilities & locations Section: Get started Updated: Aug 12, 2026 Description: Read-only mirror of Delivery settings: what this fleet allows, where to set it in the dashboard, and what your bot may send on create. ======================================================================== You are helping a developer integrate the Kilo Business Delivery API. Document: Capabilities & locations Description: Read-only mirror of Delivery settings: what this fleet allows, where to set it in the dashboard, and what your bot may send on create. # Capabilities & locations ## Why this page exists (read this first) `GET /capabilities` is not a “nice to have.” It is the **read-only mirror of the business dashboard** for one org. | Layer | Role | |-------|------| | **Dashboard → Settings → Delivery** | Owner turns services, payments, POD, coverage, auto-dispatch on/off | | **`GET /capabilities`** | Your backend learns those same rules (already clamped by plan + trust) | | **Your bot / form** | Only asks for fields that are **allowed** for this org | | **`POST /deliveries`** | Rejects jobs that ignore those rules (`service_disabled`, bad payment method, etc.) | The **public request form** already does this: it loads delivery settings, then only shows enabled service kinds, warehouse mode, errand modes, payment options, and saved places. Your API integration should behave the same way. **If you skip discovery**, bots invent services the fleet never enabled, ask for cash when only online checkout is on, or guess hostel coordinates — create fails or riders get bad pins. --- ## Mental picture (dashboard → API → chat) ```text ┌─────────────────────────────────────────────────────────────┐ │ Kilo Business dashboard │ │ Settings → Delivery │ │ • Pickup & deliver / Errand toggles │ │ • Warehouse drop-off │ │ • Errand modes (at place / link / photo) │ │ • Payment methods + when customer pays │ │ • POD, coverage, auto-dispatch, pricing │ │ Settings → Warehouses │ │ Settings → Service locations (hostels, gates, …) │ └───────────────────────────┬─────────────────────────────────┘ │ same truth (plan-clamped) ▼ ┌─────────────────────────────────────────────────────────────┐ │ GET /capabilities (+ /warehouses + /service-locations) │ │ → enabledServiceKinds, payments, POD, features, … │ └───────────────────────────┬─────────────────────────────────┘ │ drive the conversation ▼ ┌─────────────────────────────────────────────────────────────┐ │ Your bot / checkout │ │ only offer what capabilities say is allowed │ │ peep coords from warehouses / service locations │ │ then POST /deliveries │ └─────────────────────────────────────────────────────────────┘ ``` **Rule for AI agents:** treat `capabilities` as the allow-list. If a value is missing or `false`, that option is **not allowed** for this org — do not offer it and do not send it on create. --- ## Discover org settings ```http GET https://business.kiloapp.org/api/v1/capabilities Authorization: Bearer sk_live_... ``` Scope: `deliveries:read`. Cache for the chat/order session; refresh after create errors about service/coverage/payment. ### Field → dashboard → allowed / not allowed Use this table as the contract. Left column = JSON path on `capabilities`. | Field | Where the owner sets it | If … | Your bot / create **must** | |-------|-------------------------|------|----------------------------| | `enabledServiceKinds` | Delivery → service type toggles | kind **not** in the array | **Do not** offer that job type; create with that `serviceKind` → `service_disabled` | | | | only one kind listed | Skip “which service?” — use that kind | | | | both listed | Ask pickup & deliver vs errand (or infer clearly) | | `defaultServiceKind` | Delivery → default service | user has not chosen yet | Pre-select this kind | | `allowWarehouseDropoff` | Delivery → “Warehouse drop-off” | `false` | **Do not** use `deliveryMode: "warehouse"` | | | | `true` | May offer warehouse pickup; load `GET /warehouses` | | `errand.enabledModes` | Delivery → errand modes | mode missing | **Do not** collect that errand flow (`at_place` / `link` / `photo`) | | `errand.defaultMode` | same | starting an errand | Pre-select this mode | | `errand.curatedLinks` | Delivery → curated shop links | link mode | Offer these as suggested shops (name + url) | | `errand.allowUnknownShopLocation` | Delivery → unknown shop | `false` | Require a known shop pin / curated link — don’t accept vague “any shop” | | `paymentCollection.onlineCheckout` | Delivery → Operations / payments | `false` | **Do not** send `customerCollectionMethod: "online_checkout"` | | `paymentCollection.cashOnDelivery` | same | `false` | **Do not** send `cash_on_delivery` | | | | both false (shouldn’t happen after reconcile) | Treat as misconfigured — tell human to fix Delivery settings | | `currency` | Operations → currency | any | Display money in this code; don’t invent another | | `paymentTiming` | Delivery → when customer pays | `before_dispatch` | Expect pay-before-rider; rider may wait until fee is paid | | | | `after_delivery` | Job can run; collect fee later | | | | `off_platform` | No portal checkout step — fee handled outside Kilo | | `pod.enabled` | Delivery → Payment on delivery (POD) | `false` | **Do not** enable POD / `isPodEnabled` on create | | | | `true` | May offer cash-to-collect on **pickup_delivery** only (never errands) | | `requireDeliveryOtp` | Delivery → Require OTP at drop-off | `true` (default) | Expect a drop-off code (Kilo SMS/WhatsApp or `delivery.otp_issued` if OTP-via-webhook) | | | | `false` | Photo proof only — **no** code, **no** `delivery.otp_issued`, do not ask the customer for an OTP | | `publicRequest.requireApproval` | Delivery → intake | `true` | Creates may sit awaiting staff price/approval (like slower portal intake) | | | | `false` | Faster intake (still subject to pricing rules) | | `publicRequest.autoDispatch` | Delivery → Auto-dispatch | `true` | Nearby rider may be assigned on create — see [Auto-assign](/auto-assign) | | | | `false` | Staff (or your assign flow) must dispatch | | `publicRequest.pricingMode` / `pricingAmountNgn` | Delivery → public pricing | `fixed` / `starting_from` | Show amount when explaining fees; still don’t invent coverage | | `coverage.*` | Delivery → Coverage | any | Soft hint only — create still validates addresses; out-of-area → coverage errors | | `plan` | Billing | `basic` | Fewer warehouses; service locations may be off | | | | `pro` | Full features (see `features`) | | `features.serviceLocations` | plan feature | `false` | `GET /service-locations` returns `[]` — use geocoding / free-form carefully | | | | `true` but list empty | Tell human to **add service locations** in the dashboard before chat bookings work well | | `features.maxWarehouses` | plan | `1` | Basic: only first warehouse is usable | | | | `null` | Pro: no API-side warehouse cap | | `rateLimits.requestsPerMinute` | Billing plan | number | Per-key cap for this org; HTTP 429 when exceeded | | | | `null` | Custom / no per-key cap (Enterprise) | Typed shape: [API reference → GET /capabilities](/api-reference#get-capabilities). ### Annotated example ```json { "ok": true, "capabilities": { "organization": { "id": "…", "name": "Campus Couriers", "slug": "campus" }, "plan": "pro", "enabledServiceKinds": ["pickup_delivery"], "defaultServiceKind": "pickup_delivery", "allowWarehouseDropoff": true, "errand": { "enabledModes": ["at_place"], "defaultMode": "at_place", "allowUnknownShopLocation": false, "curatedLinks": [] }, "paymentCollection": { "onlineCheckout": true, "cashOnDelivery": true }, "currency": "NGN", "paymentTiming": "before_dispatch", "coverage": { "mode": "radius_from_hub", "jobScopePolicy": "pickup_and_dropoff_in_coverage", "radiusKm": 15, "regionCount": 0 }, "pod": { "enabled": false }, "requireDeliveryOtp": true, "publicRequest": { "requireApproval": false, "autoDispatch": true, "pricingMode": "fixed", "pricingAmountNgn": 1500 }, "features": { "serviceLocations": true, "maxWarehouses": null }, "rateLimits": { "requestsPerMinute": 180, "windowSeconds": 60 } } } ``` **How an AI should read that example** - Only offer **pickup & deliver** (errand is off even though `errand` object exists — `enabledServiceKinds` wins). - Warehouse pickup **allowed** → call `GET /warehouses`. - Customer may pay **online or cash**; timing is **before dispatch**. - **No POD** → never ask “collect item money from receiver.” - **Drop-off OTP required** → customer needs the code (or your bot sends it via `delivery.otp_issued`). - **Auto-dispatch on** → a rider may appear without staff Assign. - Service locations **on** → peep hostel/gate coords from `GET /service-locations`. --- ## Warehouses Dashboard: **Settings → Warehouses** (active rows only). ```http GET https://business.kiloapp.org/api/v1/warehouses Authorization: Bearer sk_live_... ``` | When | Action | |------|--------| | `allowWarehouseDropoff` is `false` | Do not call this for pickup mode; do not send `deliveryMode: "warehouse"` | | `true` | List warehouses; user picks one → create with `deliveryMode: "warehouse"`, `warehouseId`, and `pickupAddress` / `pickupCoords` copied from that row’s `address` + `longitude`/`latitude` | Same pins the public form uses for hub pickup. Need the exact response shape right now? Jump to [API reference → GET /warehouses](/api-reference#get-warehouses). --- ## Service locations (hostels, gates, landmarks) Dashboard: **Settings → Service locations** (Pro). Same places the public form’s place picker uses. **Why bots need this:** create requires real `pickupCoords` / `deliveryCoords`. Chat says *“Queen’s Hall room 12”* — not lat/lng. The business saves the place **once** with a map pin; the bot **peeps** `coords` and copies them into create. Do not invent `{lng:0,lat:0}` or re-geocode the composed address string (wrong building / out of coverage). ```http GET https://business.kiloapp.org/api/v1/service-locations Authorization: Bearer sk_live_... ``` | `unit.mode` | Allowed bot behavior | |--------------|----------------------| | `none` | Use the place as-is | | `free_text` | Ask for `unit.label` (e.g. Room); required if `unit.required` | | `list` | Offer `unit.ranges` + `unit.values`; custom text only if `unit.allowCustom` | Compose address text like the public form: ```text {name} — {address} — {unit} ``` Keep the same place `coords` after the user picks a room (unit only changes the **string**). Need the exact response shape right now? Jump to [API reference → GET /service-locations](/api-reference#get-service-locations). **Allowed vs not** | Situation | Allowed? | |-----------|----------| | Match user phrase to a service location → copy `coords` | **Yes** (preferred for chat) | | Free-form address + real geocoder when no place fits | **Yes** | | Fake / hardcoded coordinates | **No** | | Use service locations for an **errand shop** | **No** — follow `errand.enabledModes` for the shop | | Use a service location for customer **dropoff** on an errand | **Yes** if they live in a known place | --- ## Conversation pattern (bots) 1. `GET /capabilities` (cache for the session). 2. Build menus **only** from allowed values in the table above. 3. If `pickup_delivery` → load service locations + warehouses; match vague place names; peep `coords`. 4. If `errand` → errand modes only for the shop side; dropoff may still be a service location. 5. Choose `customerCollectionMethod` only if that payment flag is `true`. 6. Then `POST /deliveries` with a complete payload. If create returns `service_disabled`, payment errors, or coverage errors — re-fetch capabilities and tell the human which Delivery setting to change. ## Related - [Auto-assign riders](/auto-assign) — when `publicRequest.autoDispatch` is true - [Assign riders](/assign-riders) — list roster riders and assign from your backend - [Create a delivery](/create-delivery) - [API reference](/api-reference#get-capabilities) - [Build with AI → WhatsApp bot](/template-whatsapp-bot) ======================================================================== URL: https://docs.kiloapp.org/create-delivery Title: Create a delivery Section: Deliveries Updated: Aug 12, 2026 Description: POST /api/v1/deliveries — same rules as the public form. ======================================================================== You are helping a developer integrate the Kilo Business Delivery API. Document: Create a delivery Description: POST /api/v1/deliveries — same rules as the public form. # Create a delivery ```http POST /api/v1/deliveries Content-Type: application/json Authorization: Bearer sk_live_... ``` ## Before you call 1. Call [Capabilities & locations](/capabilities) so you know which services, warehouses, payments, and service locations this org actually offers. 2. For chat / AI bookings: ensure **service locations** (and warehouses) exist in the dashboard so the bot can peep coordinates instead of guessing — see [Capabilities & locations](/capabilities). 3. Delivery services must be enabled in dashboard settings (API create rejects disabled `serviceKind`). 4. Add **allowed image hosts** under API & Webhooks. 5. Package photos must be **https://** URLs on those hosts. Kilo downloads them and stores them. Private IPs are blocked. ## Minimal pickup_delivery example ```json { "serviceKind": "pickup_delivery", "deliveryMode": "pickup", "senderName": "Ada Lovelace", "senderPhone": "+2348012345678", "senderEmail": "ada@example.com", "pickupContactSameAsSender": true, "receiverName": "Grace Hopper", "receiverPhone": "+2348098765432", "pickupAddress": "12 Admiralty Way, Lekki", "pickupCoords": { "lng": 3.4721, "lat": 6.4474 }, "deliveryAddress": "Victoria Island Office", "deliveryCoords": { "lng": 3.4219, "lat": 6.4281 }, "packageDescription": "Documents envelope", "packageSize": "small", "packageImages": [ "https://cdn.yourbot.com/jobs/abc/package-1.jpg" ], "customerCollectionMethod": "cash_on_delivery" } ``` ## Important fields (plain English) | Field | Meaning | |-------|---------| | `serviceKind` | `pickup_delivery` or `errand` | | `deliveryMode` | `pickup` (rider goes to an address) or `warehouse` (needs `warehouseId`) | | `pickupCoords` / `deliveryCoords` | Map coordinates — required for routing. Prefer copying from a service location’s `coords` or a warehouse’s lat/lng (bots rarely know pins) | | `packageImages` | 0–6 HTTPS image URLs from allowlisted hosts. **Required (min 1)** for `pickup_delivery` and for `errand` when `errandMode` is `photo` (reference photos). Optional for `at_place` / `link` errands | | `customerCollectionMethod` | `cash_on_delivery` or `online_checkout` (must be allowed in your settings) | | `isPODEnabled` + `podAmount` | Cash to collect from the receiver (only if enabled for your fleet) | ## What you get back ```json { "ok": true, "delivery": { "id": "uuid", "status": "pending", "access_code": "ABC123", "request_source": "api", "tracking": { "url": "https://track.kiloapp.org/track/uuid/ABC123", "path": "/track/uuid/ABC123", "access_code": "ABC123" }, "rider_assigned": false } } ``` | Field | Plain English | |-------|----------------| | `id` | Save this. Use it for get / cancel / matching webhooks. | | `access_code` | Secret code baked into the tracking link. | | `tracking.url` | Full link to share with the customer (live map + OTP page). | | `tracking.path` | Same page as a path only (if you build your own host). | | `rider_assigned` | `true` if Auto-dispatch already matched a rider (status will often be `matched`). | If Customer delivery messages are on, Kilo may also text the tracking link. If they are off, **you** should paste `tracking.url` into your WhatsApp/SMS reply. Need the exact payload now? - Request fields: [API reference → POST /deliveries](/api-reference#post-deliveries) - Success response: [API reference → POST /deliveries → Success 201](/api-reference#success-201) - Shared delivery object: [API reference → Delivery object](/api-reference#shared-delivery-object) - Error shape: [API reference → Error envelope](/api-reference#shared-error-envelope) ## Common errors | code / message | Fix | |----------------|-----| | Image host not allowlisted | Add the CDN hostname in Settings → API & Webhooks | | Service kind disabled | Enable the service in Delivery settings | | Coverage / location blocked | Job is outside your configured coverage | | Validation failed | Read `fieldErrors` — a required field is missing | | `rate_limited` (429) | Wait `Retry-After` seconds; see [Rate limits](/rate-limits) | ## Next - Typed responses: [API reference](/api-reference) - Listen for `delivery.created` on your [webhook](/webhooks). - Assign a rider yourself: [Assign riders](/assign-riders). ======================================================================== URL: https://docs.kiloapp.org/get-list-cancel Title: Get, list, cancel Section: Deliveries Updated: Aug 17, 2026 Description: Read deliveries and cancel when allowed. ======================================================================== You are helping a developer integrate the Kilo Business Delivery API. Document: Get, list, cancel Description: Read deliveries and cancel when allowed. # Get, list, cancel ## Get one delivery ```http GET /api/v1/deliveries/{deliveryId} Authorization: Bearer sk_live_... ``` Returns the same delivery object shape as create. If a rider is on the job, `rider_assigned` is `true` and `rider` has the roster id / name. ## List deliveries ```http GET /api/v1/deliveries?limit=20&status=pending Authorization: Bearer sk_live_... ``` Query params: | Param | Meaning | |-------|---------| | `limit` | 1–50 (default 20) | | `status` | Optional filter, e.g. `pending` | | `cursor` | Pass the previous page's `next_cursor` | Response includes `deliveries` and `next_cursor` (or `null` when finished). ## Cancel a delivery ```http POST /api/v1/deliveries/{deliveryId}/cancel Content-Type: application/json Authorization: Bearer sk_live_... { "reason": "Customer changed their mind" } ``` Cancel is only allowed in certain statuses (same policy as the dashboard). If the rider already has the package, cancel will fail — tell the customer honestly. ## Need exact request or response shapes? - Get one delivery: [API reference → GET /deliveries/{deliveryId}](/api-reference#get-deliveriesdeliveryid) - List deliveries: [API reference → GET /deliveries](/api-reference#get-deliveries) - Cancel request/response: [API reference → POST /deliveries/{deliveryId}/cancel](/api-reference#post-deliveriesdeliveryidcancel) - Shared delivery object: [API reference → Delivery object](/api-reference#shared-delivery-object) - Error shape: [API reference → Error envelope](/api-reference#shared-error-envelope) ## Assign a rider Listing the roster and assigning is a separate page: [Assign riders](/assign-riders). ======================================================================== URL: https://docs.kiloapp.org/auto-assign Title: Auto-assign riders Section: Deliveries Updated: Aug 12, 2026 Description: Let Kilo pick a nearby rider for you when a job is created. ======================================================================== You are helping a developer integrate the Kilo Business Delivery API. Document: Auto-assign riders Description: Let Kilo pick a nearby rider for you when a job is created. # Auto-assign riders Plain English: **Auto-dispatch** means Kilo tries to give the job to a real rider **by itself** when the delivery is created — so your staff do not have to open the dashboard and click **Assign rider** every time. ## Who it helps - WhatsApp / AI bots that create jobs via the API - Public request form customers - Busy fleets that already have pricing rules and online riders ## Baby steps to turn it on 1. Dashboard → **Settings → Delivery** (client / public request settings). 2. Make sure **Require approval** is **off** (auto-assign cannot wait for a human to approve first). 3. Set pricing to the **rules** engine and add at least one **enabled** pricing rule that covers your areas. 4. Fix any coverage / pricing warnings the screen shows (Kilo blocks auto-dispatch until pricing is safe). 5. Turn **Auto-dispatch** **ON** and confirm the warning modal. 6. Keep riders **active** in the app with recent GPS so Kilo can find someone nearby. ## What happens on create 1. Customer (or your API) creates a delivery. 2. If auto-dispatch is allowed **and** a fee can be calculated **and** the job starts as `pending`: - Kilo picks the best nearby **available** rider with fresh location. - Status becomes `matched` and `rider_assigned` is `true` on the API response. 3. If no rider fits, the job stays `pending` for a human to assign later. That is normal — not an error. ## What auto-dispatch is NOT | Myth | Reality | |------|---------| | “Every dashboard create auto-assigns” | Auto-dispatch runs on the **public request / Partner API create** path. Staff can still assign manually anytime. | | “It works with manual pricing only” | Needs **rules** pricing + coverage set up. | | “It ignores Require approval” | If approval is required, auto-dispatch stays blocked. | | “It invents a rider” | Only your org’s linked, available riders with usable location. | ## How to check it worked - Create response / webhook: `status` is `matched` and `rider_assigned` is `true`. - Dashboard Deliveries: rider name appears without you clicking Assign. ## Want your own rules instead? Keep Auto-dispatch **off**. On `delivery.created`, your backend can `GET /riders` and `POST /deliveries/{id}/assign`. One rider may already have other jobs — that is allowed. See [Assign riders](/assign-riders) and the [Auto-assign agent](/template-auto-assign-agent) prompt. ## Related - [Assign riders](/assign-riders) — list / assign / unassign from the API - [Create a delivery](/create-delivery) — API response includes `rider_assigned` and `tracking.url` - [Webhooks](/webhooks) — listen for `delivery.status_changed` when status becomes `matched` ======================================================================== URL: https://docs.kiloapp.org/assign-riders Title: Assign riders Section: Deliveries Updated: Aug 17, 2026 Description: List fleet riders and assign or unassign them from the Delivery API. ======================================================================== You are helping a developer integrate the Kilo Business Delivery API. Document: Assign riders Description: List fleet riders and assign or unassign them from the Delivery API. # Assign riders Same as the dashboard **Assign rider** button. Your server picks a rider. Kilo does not invent extra limits. ## Who appears on `GET /riders` Only riders who **accepted the fleet invite**. Pending invites are never listed and cannot be assigned. There is no query flag for this — Kilo enforces it. Everyone in the response can take a job. ## One rider, many jobs — yes A rider **can** be on more than one delivery at the same time. That already works in the dashboard. The API is the same. `POST /assign` does **not** reject a rider because they are `on_delivery` or `active_delivery_count` is 2. Stacking is allowed. Do **not** skip assignment just because everyone is busy. If you leave the job with no rider, it will not move. ## What `pickup_lat` / `pickup_lng` do (read this) They **do not** hide riders. They **measure**. When you send pickup coordinates: 1. Each rider with GPS gets `distance_km` (straight line to that pickup). 2. The list is **sorted closer first**. 3. Riders with no GPS stay in the list (`distance_km: null`). So: `GET /riders?pickup_lat=6.45&pickup_lng=3.40` means “show my accepted fleet, tell me who is closer.” It does **not** mean “only people inside a circle.” ## Optional filters There is **no default radius** and **no default idle filter**. We never drop far or busy riders unless **you** ask. | Query | What it does | Default | |-------|----------------|---------| | `pickup_lat` + `pickup_lng` | Fill `distance_km` and sort closer first. **Does not hide anyone** | no distances | | `radius_km` | Hide riders whose **known** distance is greater than this. Needs pickup coords. Riders with unknown GPS are **kept**. | no cutoff | | `idle_only=true` | Hide anyone not idle in the app — including riders already on a job | keep busy riders | - Omit `radius_km` (normal): everyone stays. You pick using `distance_km`. - Pass `radius_km=8` only if you really want a cutoff. Most fleets should omit it so someone still gets the job. - **Do not** add `idle_only=true` unless you only want free riders and you accept that the job may stay pending. ## Baby steps for an agent 1. Listen for webhook `delivery.created`. 2. If `data.object.rider_assigned` is already `true`, stop (someone else assigned). 3. Call: ```http GET /api/v1/riders?pickup_lat={pickup.latitude}&pickup_lng={pickup.longitude} Authorization: Bearer sk_live_... ``` 4. Look at the array. First rows are closer (when GPS exists). 5. Pick one rider. Prefer smallest `distance_km`. A rider with `presence: "on_delivery"` is still OK. 6. Assign: ```http POST /api/v1/deliveries/{deliveryId}/assign Content-Type: application/json Authorization: Bearer sk_live_... { "riderId": "the rider id from step 5" } ``` Use the rider’s `id` from this list. Do not invent ids. Do not use a user id from somewhere else. ## List riders (all query params) ```http GET /api/v1/riders?pickup_lat=6.4474&pickup_lng=3.4721 Authorization: Bearer sk_live_... ``` Scope: `deliveries:read` Each rider includes: | Field | Meaning | |-------|---------| | `id` | Send this as `riderId` on assign | | `name` / `vehicle_type` | Display | | `presence` | `available` (idle in app), `on_delivery` (already has a job — still OK to assign), `unavailable`, `not_in_app` | | `active_delivery_count` | How many in-progress jobs they already have. More than 0 is OK | | `location` | Last GPS if we have it (`fresh` = updated in the last 30 minutes) | | `distance_km` | km to the pickup you passed, or `null` if we have no GPS | ## Assign ```http POST /api/v1/deliveries/{deliveryId}/assign Content-Type: application/json Authorization: Bearer sk_live_... { "riderId": "uuid-from-get-riders" } ``` Scope: `deliveries:write` The only Kilo rules (same as the dashboard): - Delivery status is `awaiting_price`, `pending`, or `matched` (before pickup starts). - Delivery fee is already set (greater than zero). - `riderId` is on **this** fleet and they accepted the invite. Kilo does **not** check “are they free?” You can assign a busy rider. Success: delivery `status` is `matched`, `rider_assigned` is `true`, `rider` has `id` / `name` / `vehicle_type`. Then webhook `delivery.status_changed`. ## Need exact request or response shapes? - List riders: [API reference → GET /riders](/api-reference#get-riders) - Assign request/response: [API reference → POST /deliveries/{deliveryId}/assign](/api-reference#post-deliveriesdeliveryidassign) - Unassign response: [API reference → POST /deliveries/{deliveryId}/unassign](/api-reference#post-deliveriesdeliveryiduntassign) - Shared delivery object: [API reference → Delivery object](/api-reference#shared-delivery-object) - Error shape: [API reference → Error envelope](/api-reference#shared-error-envelope) ### Errors | HTTP | `code` | When | |------|---------|------| | 400 | `validation_error` | Missing / invalid `riderId` | | 400 | `assign_not_allowed` | Pickup already started | | 400 | `fee_required` | No delivery fee yet | | 400 | `rider_not_found` | Id is not on this fleet | | 400 | `rider_not_active` | Invite not accepted | | 404 | `not_found` | Delivery missing | ## Unassign ```http POST /api/v1/deliveries/{deliveryId}/unassign Authorization: Bearer sk_live_... ``` Only while status is still `matched` (before pickup). Status goes back to `pending` (or `awaiting_price` if there is no fee). ## Related - [Auto-assign riders](/auto-assign) — Kilo’s built-in picker (different: it only picks idle nearby riders on create) - [Auto-assign agent](/template-auto-assign-agent) — copy-ready prompt - [Webhooks](/webhooks) - [API reference](/api-reference) ======================================================================== URL: https://docs.kiloapp.org/webhooks Title: Webhooks Section: Webhooks Updated: Aug 17, 2026 Description: Signed HTTPS callbacks when deliveries change. ======================================================================== You are helping a developer integrate the Kilo Business Delivery API. Document: Webhooks Description: Signed HTTPS callbacks when deliveries change. # Webhooks When something important happens, Kilo POSTs JSON to **your** HTTPS URL. ## Why webhooks feel “faster than the HTTP response” Your `POST /deliveries` returns as soon as Kilo saves the job. **Separately**, we enqueue the webhook with **Upstash QStash**, so: - Your create API stays fast. - Your webhook URL is called with retries if it is down. - You still verify that the POST really came from Kilo. ## Create an endpoint 1. Dashboard → **Settings → API & Webhooks**. 2. **Add endpoint** with an `https://` URL. 3. Choose events (start with all delivery events). 4. Copy the `whsec_...` signing secret (shown once). ## Events | Event | When (plain English) | |-------|----------------------| | `delivery.created` | A new delivery was saved | | `delivery.cancelled` | Delivery cancelled | | `delivery.status_changed` | Status moved (rider assigned, picked up, …) | | `delivery.updated` | General update | | `delivery.completed` | Delivery finished | | `delivery.otp_issued` | The delivery code is ready for **you** to send (only when OTP-via-webhook is on) | ## Assign a rider from `delivery.created` If **Auto-dispatch is off**, jobs stay `pending` until someone assigns a rider. Your webhook handler can: 1. Read `data.object` (id, pickup lat/lng, `rider_assigned`). 2. Skip if `rider_assigned` is already `true`. 3. `GET /riders?pickup_lat=…&pickup_lng=…` — this **ranks** by distance. It does not hide busy riders. Pending invites never appear. 4. Pick a rider (`on_delivery` is OK — same as the dashboard). Prefer smallest `distance_km`. 5. `POST /deliveries/{id}/assign` with `{ "riderId": "…" }`. Then you will get `delivery.status_changed` with status `matched`. Full walkthrough: [Assign riders](/assign-riders). Copy-ready prompt: [Auto-assign agent](/template-auto-assign-agent). ## Headers on every webhook | Header | Meaning | |--------|---------| | `Content-Type` | `application/json` | | `Kilo-Signature` | `t=,v1=` | | `Kilo-Event` | Event name | Need the full webhook body shape? Jump to [API reference → Webhook POST](/api-reference#webhook-post-to-your-url). ## Verify the signature (do this for real) 1. Read `Kilo-Signature`. 2. Split into `t` and `v1`. 3. Reject if `t` is older than 5 minutes. 4. Compute HMAC-SHA256 of `${t}.${rawBody}` using your `whsec_...` secret. 5. Compare hex digests in constant time. Example Node.js: ```js import crypto from "node:crypto"; function verify(secret, rawBody, header) { const parts = Object.fromEntries( header.split(",").map((p) => { const [k, ...rest] = p.split("="); return [k, rest.join("=")]; }), ); const t = Number(parts.t); const v1 = parts.v1; if (!t || !v1) return false; if (Math.abs(Date.now() / 1000 - t) > 300) return false; const expected = crypto .createHmac("sha256", secret) .update(`${t}.${rawBody}`) .digest("hex"); return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1)); } ``` ## Respond Return HTTP **2xx** quickly. Do heavy work in your own queue after verifying the signature. ## Handle customer messages yourself Many bots already own the conversation with the customer. In that case: 1. Dashboard → **Settings → Notifications** → turn **Customer delivery messages** **off**. 2. Subscribe to the events above (especially `delivery.status_changed` / `delivery.completed`). 3. Send WhatsApp / SMS / push from your product when those webhooks arrive — use the `summary` / `customer_message_hint` / `agent_notes` fields on each webhook body (plus `docs_url`) so an AI agent knows what to tell the customer without guessing. Kilo will not send tracking links, pickup/nearby/arrived updates, or completion/feedback WhatsApps to your customers while that setting is off. **By default, delivery OTPs are still sent by Kilo** (WhatsApp + SMS) after the fee is marked paid — unless the fleet turned **Require OTP at drop-off** **off** under Delivery settings (photo proof only; no code is issued). Rider notifications still work. Partner API creates use the **same** path as the public request form for tracking messages, so the toggle applies to API-created jobs too. ## Send the delivery OTP yourself ### Why this exists Sometimes Kilo cannot reliably reach the customer on WhatsApp (Meta rules, 24‑hour window, template approval, etc.). If **you** already chat with the customer in your bot, you can deliver the code yourself. ### When there is no OTP at all Dashboard → **Settings → Delivery** → **Require OTP at drop-off** is **on by default**. If your fleet turns it **off**, riders finish drop-off (and returns) with **photo proof only** — Kilo will **not** WhatsApp/SMS a code, will **not** fire `delivery.otp_issued`, and **Resend delivery OTP** is hidden. Use that when speed matters more than a customer code. New jobs snapshot the setting; older jobs keep whatever they were created with. ### Baby steps (do these in order) 1. Open the business dashboard → **Settings → API & Webhooks**. 2. Add a webhook endpoint if you do not have one yet (must be `https://`). 3. Make sure the endpoint listens for **`delivery.otp_issued`** (or all events / `*`). 4. Confirm **Require OTP at drop-off** is still **on** (Delivery settings) — otherwise there is nothing to send. 5. Turn **OTP via webhook only** **ON**. 6. From that moment: - When staff marks the delivery fee as **paid**, Kilo does **not** WhatsApp/SMS the OTP. - Instead Kilo POSTs `delivery.otp_issued` to your URL with the code. - Dashboard **Resend delivery OTP** does the same thing (still needs fee paid + dropoff statuses; **no cool-off** while OTP via webhook is on). ### What the OTP webhook looks like ```json { "id": "delivery.otp_issued_DEL_…_…", "type": "delivery.otp_issued", "created": 1710000000, "data": { "object": { "id": "DEL_…", "status": "…", "tracking": { "url": "…" } }, "previous_status": null, "otp": { "code": "4821", "receiver_phone": "+2348012345678", "reason": "payment", "channel": "webhook" } }, "summary": "Delivery OTP for DEL_… was sent after the fee was marked paid. …", "customer_message_hint": "Your delivery code for DEL_… is ready. …", "docs_url": "https://docs.kiloapp.org/webhooks", "agent_notes": ["Read data.otp.code …"] } ``` | Field | Meaning | |-------|---------| | `data.otp.code` | The 4‑digit (or similar) code the receiver gives the rider | | `data.otp.receiver_phone` | Who should get it | | `data.otp.reason` | `payment` (first send after mark paid) or `resend` | | `data.otp.channel` | Always `webhook` for this event | ### Rules (please do not skip) - **Never** invent an OTP. Only use `data.otp.code` from this event. - **Never** put the OTP in a public group. Send it only to the customer. - If OTP-via-webhook is **off**, Kilo keeps sending WhatsApp + SMS itself (old behaviour). - Turning **Customer delivery messages** off does **not** by itself stop OTP — you need the OTP-via-webhook switch for that (or turn **Require OTP at drop-off** off under Delivery settings). - If **Require OTP at drop-off** is **off**, expect neither Kilo SMS/WhatsApp nor `delivery.otp_issued`. ======================================================================== URL: https://docs.kiloapp.org/image-hosts Title: Allowed image hosts Section: Security Updated: Aug 9, 2026 Description: How package photo URLs are checked. ======================================================================== You are helping a developer integrate the Kilo Business Delivery API. Document: Allowed image hosts Description: How package photo URLs are checked. # Allowed image hosts Package photos on the API are **not** base64 in the JSON. You send HTTPS URLs. Kilo: 1. Checks the hostname is on **your allowlist**. 2. Resolves DNS and blocks private IPs (SSRF protection). 3. Downloads the image (size + content-type limits). 4. Stores it on the delivery like the public form would. ## Configure Dashboard → **Settings → API & Webhooks** → **Allowed image hosts**. One hostname per line, for example: ```text cdn.yourbot.com media.yourcompany.com public.blob.vercel-storage.com ``` ### How matching works - Exact host match, **or** - Any subdomain of an allowlisted host (`shop.cdn.yourbot.com` matches `cdn.yourbot.com`) Do **not** use wildcards like `*.public.blob.vercel-storage.com`. The `*` is treated as a literal character and will not match. For Vercel Blob, allowlist the base host: ```text public.blob.vercel-storage.com ``` That covers store URLs such as `https://xxxxx.public.blob.vercel-storage.com/...`. Localhost and private IPs are always rejected. ## Checklist for AI / WhatsApp bots 1. Host media on a domain you control (or a known CDN / Blob store). 2. Add that hostname to the allowlist **before** creating deliveries (parent domain is enough for subdomains — no `*`). 3. Use `https://` only. 4. Keep each image under 5MB. ======================================================================== URL: https://docs.kiloapp.org/build-with-ai Title: Build with AI Section: Build with AI Updated: Aug 17, 2026 Description: Copy-ready prompts for coding agents that wire Kilo into your app. ======================================================================== You are helping a developer integrate the Kilo Business Delivery API. Document: Build with AI Description: Copy-ready prompts for coding agents that wire Kilo into your app. # Build with AI These templates are for **vibe coders**: you paste one prompt into Cursor, Copilot, Claude Code, or similar — with your project open — and the agent implements against the **Kilo Business Delivery API**. ## How to use 1. Open the template that matches what you are building. 2. Click **Copy prompt for AI**. 3. Paste into your coding agent in the repo you already have. 4. Let it inspect the codebase first (the prompt requires that). 5. When it finishes, follow its short **human setup checklist** (API key, webhook, image hosts, etc.). ## What the prompt already includes - How to integrate into an **existing** project (no “create a new app from zero” assumption) - Full create / get / list / cancel / riders / assign + webhook signature rules - Request / response shapes and common errors - Instructions for the agent to teach **you** the remaining dashboard steps - The **complete docs catalog** (`/llms.txt`) so the agent knows every official page URL — and that anything else does not exist here ## Templates Pick one on this page via the cards below, or jump ahead: - [WhatsApp delivery bot](/template-whatsapp-bot) - [Store & checkout fulfillment](/template-store-fulfillment) - [Auto-assign agent](/template-auto-assign-agent) ## Still write code yourself? Use the rest of the docs — start with [Authentication](/authentication). Agents: fetch [/llms.txt](/llms.txt) for the full index. ======================================================================== URL: https://docs.kiloapp.org/template-whatsapp-bot Title: WhatsApp delivery bot Section: Build with AI Updated: Aug 10, 2026 Description: AI prompt: wire a WhatsApp bot to create deliveries and own status messages. ======================================================================== You are helping a developer integrate the Kilo Business Delivery API. Document: WhatsApp delivery bot Description: AI prompt: wire a WhatsApp bot to create deliveries and own status messages. # WhatsApp delivery bot **Best when:** customers already message you on WhatsApp (or a similar chat channel) and you want the bot to book real fleet deliveries on Kilo — then reply with status updates from webhooks. **You still need:** a Kilo Business org with delivery settings / coverage / riders. The prompt does not create that for you; it implements the code path. The agent is instructed to call **`GET /capabilities`** (and service locations / warehouses) so the bot only offers services this business enabled — same idea as the public form’s service step — and to **peep coordinates** from saved service locations / warehouses when customers name a place (chat rarely has lat/lng). Skip service locations for errand shops. Use the **Copy prompt for AI** card below. After the agent finishes, it should list only the dashboard steps you still need (key, webhook secret, image host allowlist, **create service locations** for places customers name, optional “Customer delivery messages” off). ## Related docs - [Capabilities & locations](/capabilities) - [Create a delivery](/create-delivery) - [Webhooks](/webhooks) - [Allowed image hosts](/image-hosts) - [Own your customer notifications](/introduction#own-your-customer-notifications) ======================================================================== URL: https://docs.kiloapp.org/template-store-fulfillment Title: Store & checkout fulfillment Section: Build with AI Updated: Aug 9, 2026 Description: AI prompt: turn paid orders into Kilo fleet deliveries with webhooks. ======================================================================== You are helping a developer integrate the Kilo Business Delivery API. Document: Store & checkout fulfillment Description: AI prompt: turn paid orders into Kilo fleet deliveries with webhooks. # Store & checkout fulfillment **Best when:** you have (or are building) a shop, headless cart, or Instagram/commerce backend — and after checkout you need local fleet delivery without building dispatch yourself. **Pattern:** order becomes the source of truth → discover warehouses/capabilities → your backend creates a Kilo delivery (idempotent) → webhooks keep fulfillment status in sync. Use the **Copy prompt for AI** card below. The agent should inspect your existing order/payment events and only add the Kilo pieces. ## Related docs - [Capabilities & locations](/capabilities) - [Create a delivery](/create-delivery) - [Get, list, cancel](/get-list-cancel) - [Assign riders](/assign-riders) - [Webhooks](/webhooks) ======================================================================== URL: https://docs.kiloapp.org/template-auto-assign-agent Title: Auto-assign agent Section: Build with AI Updated: Aug 17, 2026 Description: AI prompt: on delivery.created, pick a rider from GET /riders and assign with your own rules. ======================================================================== You are helping a developer integrate the Kilo Business Delivery API. Document: Auto-assign agent Description: AI prompt: on delivery.created, pick a rider from GET /riders and assign with your own rules. # Auto-assign agent **Best when:** you want **your** backend (or coding agent) to choose a rider — nearest, idle-only, vehicle type, max active jobs — instead of (or in addition to) Kilo Auto-dispatch. **Pattern:** webhook `delivery.created` → `GET /riders` with pickup coords (distance ranking, busy riders still listed) → `POST /deliveries/{id}/assign`. Same stacking rules as the dashboard. Use the **Copy prompt for AI** card below. Keep Auto-dispatch **off** unless you want Kilo to race your agent. ## Related docs - [Assign riders](/assign-riders) - [Auto-assign riders](/auto-assign) — Kilo’s built-in picker - [Webhooks](/webhooks) - [API reference](/api-reference) ======================================================================== URL: https://docs.kiloapp.org/api-reference Title: API reference Section: Reference Updated: Aug 17, 2026 Description: Exact request/response JSON shapes, field types, and error codes for every endpoint. ======================================================================== You are helping a developer integrate the Kilo Business Delivery API. Document: API reference Description: Exact request/response JSON shapes, field types, and error codes for every endpoint. # API reference (schemas) Base URL: `https://business.kiloapp.org/api/v1` Auth on every request: ```http Authorization: Bearer sk_live_... ``` Types below use TypeScript-style notation: - `string`, `number`, `boolean` - `string | null` — may be JSON `null` - `"a" | "b"` — string enum - `ISODateTime` — ISO-8601 timestamp string - `Array` — JSON array ## Quick links - [Shared error envelope](#shared-error-envelope) - [Shared delivery object](#shared-delivery-object) - [GET /capabilities](#get-capabilities) - [GET /warehouses](#get-warehouses) - [GET /service-locations](#get-service-locations) - [POST /deliveries](#post-deliveries) - [GET /deliveries/{deliveryId}](#get-deliveriesdeliveryid) - [GET /deliveries](#get-deliveries) - [POST /deliveries/{deliveryId}/cancel](#post-deliveriesdeliveryidcancel) - [GET /riders](#get-riders) - [POST /deliveries/{deliveryId}/assign](#post-deliveriesdeliveryidassign) - [POST /deliveries/{deliveryId}/unassign](#post-deliveriesdeliveryiduntassign) - [Webhook POST](#webhook-post-to-your-url) --- ## Shared: error envelope All failures use this shape (HTTP status varies): ```json { "ok": false, "error": "Human-readable message", "code": "machine_code" } ``` | Field | Type | Notes | |-------|------|--------| | `ok` | `false` | Always false on errors | | `error` | `string` | Safe to show/log | | `code` | `string` (optional but usual) | Stable machine code | | `fieldErrors` | `Record` (optional) | Present on validation failures | ### Common auth / access codes | HTTP | `code` | Meaning | |------|---------|---------| | 401 | `missing_api_key` | No/invalid key format | | 401 | `invalid_api_key` | Unknown or revoked | | 403 | `missing_scope` | Key lacks required scope | | 403 | `test_keys_unsupported` | Legacy test key | | 403 | `billing_terminated` | Org cannot use API | | 429 | `rate_limited` | Request limit exceeded; see `Retry-After` | | 401 | `api_key_auto_revoked` | Key access was suspended after unusual activity | ### Validation error example ```json { "ok": false, "error": "Validation failed.", "code": "validation_error", "fieldErrors": { "receiverPhone": ["Receiver phone is required."], "packageImages": ["Package image URLs must use https://"] } } ``` --- ## Shared: Delivery object Returned by create, get, list items, cancel, and webhook `data.object`. ```ts type Delivery = { id: string; status: string; // e.g. "pending" | "matched" | "picked_up" | "in_transit" | "delivered" | "cancelled" | … access_code: string | null; title: string | null; service_kind: "pickup_delivery" | "errand" | string | null; request_source: "api" | "public_portal" | "dashboard" | string | null; package: { description: string | null; size: "small" | "medium" | "large" | string | null; is_fragile: boolean; }; payment: { delivery_fee: number | null; delivery_payment_status: string | null; currency_code: string | null; // e.g. "NGN" customer_collection_method: "cash_on_delivery" | "online_checkout" | string | null; is_pod_enabled: boolean; pod_amount: number | null; }; pickup: { address: string | null; latitude: number | null; longitude: number | null; warehouse_id: string | null; // UUID when warehouse mode }; dropoff: { address: string | null; latitude: number | null; longitude: number | null; }; distance_km: number | null; estimated_duration_minutes: number | null; customer: { sender_name: string | null; sender_phone: string | null; sender_email: string | null; receiver_name: string | null; receiver_phone: string | null; }; tracking: { url: string | null; // Full customer tracking link path: string | null; // /track/{id}/{accessCode} access_code: string | null; }; rider_assigned: boolean; rider: { id: string; name: string | null; vehicle_type: string | null; } | null; created_at: ISODateTime; updated_at: ISODateTime | null; cancelled_at: ISODateTime | null; cancellation_reason: string | null; }; ``` ### Example Delivery JSON ```json { "id": "d_01HXYZ...", "status": "pending", "access_code": "ABC123", "title": "Documents envelope", "service_kind": "pickup_delivery", "request_source": "api", "package": { "description": "Documents envelope", "size": "small", "is_fragile": false }, "payment": { "delivery_fee": 2500, "delivery_payment_status": "pending", "currency_code": "NGN", "customer_collection_method": "cash_on_delivery", "is_pod_enabled": false, "pod_amount": null }, "pickup": { "address": "12 Admiralty Way, Lekki", "latitude": 6.4474, "longitude": 3.4721, "warehouse_id": null }, "dropoff": { "address": "Victoria Island Office", "latitude": 6.4281, "longitude": 3.4219 }, "distance_km": 4.2, "estimated_duration_minutes": 25, "customer": { "sender_name": "Ada Lovelace", "sender_phone": "+2348012345678", "sender_email": "ada@example.com", "receiver_name": "Grace Hopper", "receiver_phone": "+2348098765432" }, "tracking": { "url": "https://track.kiloapp.org/track/d_01HXYZ.../ABC123", "path": "/track/d_01HXYZ.../ABC123", "access_code": "ABC123" }, "rider_assigned": false, "rider": null, "created_at": "2026-08-09T18:00:00.000Z", "updated_at": "2026-08-09T18:00:00.000Z", "cancelled_at": null, "cancellation_reason": null } ``` --- ## `GET /capabilities` Scope: `deliveries:read` ### Success `200` ```ts type CapabilitiesResponse = { ok: true; capabilities: { organization: { id: string; name: string; slug: string }; plan: "basic" | "pro" | "scale" | "global"; enabledServiceKinds: Array<"pickup_delivery" | "errand">; defaultServiceKind: "pickup_delivery" | "errand"; allowWarehouseDropoff: boolean; errand: { enabledModes: Array<"at_place" | "link" | "photo">; defaultMode: "at_place" | "link" | "photo"; allowUnknownShopLocation: boolean; curatedLinks: Array<{ id: string; name: string; url: string }>; }; paymentCollection: { onlineCheckout: boolean; cashOnDelivery: boolean; }; currency: string; paymentTiming: "before_dispatch" | "after_delivery" | "off_platform"; coverage: { mode: "worldwide" | "selected_regions" | "radius_from_hub"; jobScopePolicy: string; radiusKm: number | null; regionCount: number; }; pod: { enabled: boolean }; /** Default true. false = photo-only drop-off/return; no OTP issued. */ requireDeliveryOtp: boolean; publicRequest: { requireApproval: boolean; autoDispatch: boolean; pricingMode: "quote_on_review" | "fixed" | "starting_from" | "hidden"; pricingAmountNgn: number; }; features: { serviceLocations: boolean; maxWarehouses: number | null; // 1 on basic, null = unlimited on pro }; rateLimits: { requestsPerMinute: number | null; // null = custom / no per-key cap windowSeconds: 60; }; }; }; ``` ### Errors | HTTP | `code` | |------|---------| | 503 | `capabilities_failed` | | 404 | (org missing — rare with valid key) | --- ## `GET /warehouses` Scope: `deliveries:read` ### Success `200` ```ts type WarehousesResponse = { ok: true; warehouses: Array<{ id: string; // UUID name: string; address: string; latitude: number | null; longitude: number | null; }>; }; ``` ### Errors | HTTP | `code` | |------|---------| | 503 | `warehouses_failed` | --- ## `GET /service-locations` Scope: `deliveries:read` Empty array when the plan does not include service locations. ### Success `200` ```ts type ServiceLocationsResponse = { ok: true; serviceLocations: Array<{ id: string; name: string; address: string; category: "hostel" | "building" | "library" | "gate" | "landmark" | "other"; coords: { lng: number; lat: number }; // peep into create pickupCoords/deliveryCoords — do not re-geocode countryCode: string | null; stateCode: string | null; countryName: string | null; stateName: string | null; unit: { label: string; mode: "none" | "free_text" | "list"; ranges: Array<{ prefix: string; suffix: string; start: number; end: number; pad: number; }>; values: string[]; allowCustom: boolean; required: boolean; }; }>; }; ``` --- ## `POST /deliveries` Scope: `deliveries:write` ### Request body (main fields) ```ts type CreateDeliveryBody = { serviceKind: "pickup_delivery" | "errand"; deliveryMode: "pickup" | "warehouse"; warehouseId?: string | null; // required UUID if deliveryMode = "warehouse" senderName: string; senderPhone: string; senderEmail?: string | ""; pickupContactSameAsSender?: boolean; pickupContactName?: string; pickupContactPhone?: string; receiverName?: string; // required for pickup_delivery receiverPhone?: string; // required for pickup_delivery pickupAddress: string; pickupCoords: { lng: number; lat: number }; deliveryAddress: string; deliveryCoords: { lng: number; lat: number }; packageDescription: string; packageSize: "small" | "medium" | "large"; isFragile?: boolean; isPODEnabled?: boolean; podAmount?: number | null; packageImages: string[]; // 0–6 https URLs; required min 1 for pickup_delivery OR errandMode "photo" customerCollectionMethod: "cash_on_delivery" | "online_checkout"; idempotencyKey?: string; // 8–120 chars // errand-only extras when serviceKind = "errand": errandMode?: "at_place" | "link" | "photo"; errandLinks?: Array<{ url: string; note: string; curatedId?: string | null }>; errandBudgetNgn?: string; errandSubstituteOk?: boolean; shopPhone?: string; pickupLocationUnknown?: boolean; distanceKm?: number; estimatedDurationMinutes?: number; }; ``` ### Success `201` ```ts type CreateDeliveryResponse = { ok: true; delivery: Delivery; }; ``` ### Errors | HTTP | `code` | When | |------|---------|------| | 400 | `validation_error` | Bad/missing fields (`fieldErrors`) | | 400 | `image_host_rejected` | Host not allowlisted / SSRF / download fail | | 403 | `service_disabled` | `serviceKind` not enabled for org | | 403 | (various) | Coverage / money / trust rejections via create path | | 503 | `create_failed` / auth lookup | Upstream failure | --- ## `GET /deliveries/{deliveryId}` Scope: `deliveries:read` ### Success `200` ```ts type GetDeliveryResponse = { ok: true; delivery: Delivery; }; ``` ### Errors | HTTP | `code` | |------|---------| | 400 | `validation_error` | | 404 | `not_found` | | 500 | `get_failed` | --- ## `GET /deliveries` Scope: `deliveries:read` ### Query | Param | Type | Default | |-------|------|---------| | `limit` | `number` (1–50) | `20` | | `status` | `string` (optional) | — | | `cursor` | `string` UUID (optional) | — | ### Success `200` ```ts type ListDeliveriesResponse = { ok: true; deliveries: Delivery[]; next_cursor: string | null; }; ``` ### Errors | HTTP | `code` | |------|---------| | 400 | `validation_error` | | 500 | (list failure message) | --- ## `POST /deliveries/{deliveryId}/cancel` Scope: `deliveries:write` ### Request ```ts type CancelBody = { reason: string; // 3–500 chars }; ``` ### Success `200` ```ts type CancelResponse = { ok: true; delivery: Delivery; // status "cancelled" (or minimal { id, status } if reload fails) }; ``` ### Errors | HTTP | `code` | |------|---------| | 400 | `validation_error` | | 400 | `cancel_not_allowed` | | 404 | `not_found` | | 500 | `cancel_failed` | --- ## `GET /riders` Scope: `deliveries:read` Accepted riders on this fleet. Pending invites never appear. Use `id` as `riderId` on assign. **Stacking:** a rider may already have other deliveries. `GET /riders` still returns them. `POST /assign` still accepts them (same as the dashboard). **Distance:** `pickup_lat` + `pickup_lng` only fill `distance_km` and sort closer first. They do **not** hide far or busy riders. There is **no default radius**. **Invite compliance:** this list is **accepted riders only**. Pending fleet invites never appear and cannot be assigned. There is no query flag for that. ### Query | Param | Type | Default | |-------|------|---------| | `pickup_lat` | `number` (optional, with `pickup_lng`) | no `distance_km` | | `pickup_lng` | `number` (optional, with `pickup_lat`) | no `distance_km` | | `radius_km` | `number` 0–500 (optional) | **no cutoff** — omit unless you want to hide far riders | | `idle_only` | `true` / `false` (optional) | keep busy riders (`on_delivery` stays) | `radius_km` requires pickup coords. Riders with `distance_km: null` are kept even when a radius is set. Do **not** pass `idle_only=true` unless you only want riders who are free in the app. ### Success `200` ```ts type Rider = { id: string; // organization_riders.id name: string | null; vehicle_type: string | null; fleet_link_status: string | null; presence: "available" | "on_delivery" | "unavailable" | "not_in_app"; presence_updated_at: ISODateTime; active_delivery_count: number; last_active_at: ISODateTime | null; location: { latitude: number; longitude: number; updated_at: ISODateTime | null; fresh: boolean; // GPS newer than 30 minutes } | null; distance_km: number | null; // set when pickup_lat/lng provided average_rating: number | null; total_deliveries: number | null; }; type ListRidersResponse = { ok: true; riders: Rider[]; }; ``` Riders are sorted by smaller `distance_km`, then presence as a tiebreak. Busy riders stay in the list unless you pass `idle_only=true`. ### Errors | HTTP | `code` | |------|---------| | 400 | `validation_error` | | 500 | `list_failed` | --- ## `POST /deliveries/{deliveryId}/assign` Scope: `deliveries:write` Same policy as Dashboard → Assign rider (fee required; only before pickup starts). A rider who already has other jobs **can** take this one. Kilo does not require `presence: "available"`. ### Request ```ts type AssignBody = { riderId: string; // UUID from GET /riders }; ``` ### Success `200` ```ts type AssignResponse = { ok: true; delivery: Delivery; // status "matched", rider_assigned true }; ``` ### Errors | HTTP | `code` | |------|---------| | 400 | `validation_error` | | 400 | `assign_not_allowed` | | 400 | `fee_required` | | 400 | `rider_not_found` | | 400 | `rider_not_active` | | 404 | `not_found` | | 500 | `assign_failed` | --- ## `POST /deliveries/{deliveryId}/unassign` Scope: `deliveries:write` Only while status is `matched`. No request body. ### Success `200` ```ts type UnassignResponse = { ok: true; delivery: Delivery; // rider_assigned false }; ``` ### Errors | HTTP | `code` | |------|---------| | 400 | `unassign_not_allowed` | | 404 | `not_found` | | 500 | `unassign_failed` | --- ## Webhook POST (to your URL) Kilo → your HTTPS endpoint. ### Headers | Header | Type | |--------|------| | `Content-Type` | `application/json` | | `Kilo-Event` | `string` event name | | `Kilo-Signature` | `string` `t=,v1=` | ### Body ```ts type WebhookEvent = { id: string; type: | "delivery.created" | "delivery.updated" | "delivery.status_changed" | "delivery.cancelled" | "delivery.completed" | "delivery.otp_issued"; created: number; // unix seconds data: { object: Delivery; previous_status: string | null; /** Only on delivery.otp_issued */ otp?: { code: string; receiver_phone: string; reason: "payment" | "resend"; channel: "webhook"; }; }; /** Plain-English what happened — useful for logs and AI routers */ summary: string; /** Suggested customer text when your bot owns messaging (nullable) */ customer_message_hint: string | null; /** Docs deep-link, usually https://docs.kiloapp.org/webhooks */ docs_url: string; /** Extra notes for coding agents / bots */ agent_notes: string[]; }; ``` When **Customer delivery messages** are off, use `customer_message_hint` + `summary` to drive your WhatsApp/SMS replies. **OTP:** By default Kilo WhatsApp/SMS the delivery code after fee-paid when **Require OTP at drop-off** is on (Delivery settings; default). If that setting is off, drop-off/return is photo-only — no code and no `delivery.otp_issued`. If OTP is still required and Dashboard → API & Webhooks → **OTP via webhook only** is on, listen for `delivery.otp_issued` and send `data.otp.code` yourself — Kilo will not WhatsApp/SMS it. ### Your response Return any HTTP `2xx` quickly after verifying the signature. Non-2xx may be retried.