Kilo Docs

Docs / Reference/ API reference

API reference

Updated Aug 17, 2026

Base URL: https://business.kiloapp.org/api/v1

Auth on every request:

curl -X GET 'https://business.kiloapp.org/api/v1' \
  -H '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<T> — JSON array

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<string, string[]> (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.

TypeScript
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

TypeScript
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

TypeScript
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

TypeScript
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)

TypeScript
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

TypeScript
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

TypeScript
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

TypeScript
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

TypeScript
type CancelBody = {
  reason: string;   // 3–500 chars
};

Success 200

TypeScript
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

TypeScript
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

TypeScript
type AssignBody = {
  riderId: string;   // UUID from GET /riders
};

Success 200

TypeScript
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

TypeScript
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=<unix>,v1=<hex>

Body

TypeScript
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.