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_...'
curl -X GET 'https://business.kiloapp.org/api/v1' \
-H 'Authorization: Bearer sk_live_...'const response = await fetch("https://business.kiloapp.org/api/v1", {
method: "GET",
headers: {
"Authorization": "Bearer sk_live_..."
},
});
const data = await response.json();
console.log(data);const response = await fetch("https://business.kiloapp.org/api/v1", {
method: "GET",
headers: {
"Authorization": "Bearer sk_live_..."
},
});
const data = await response.json();
console.log(data);import requests
url = "https://business.kiloapp.org/api/v1"
headers = {
"Authorization": "Bearer sk_live_..."
}
response = requests.request("GET", url, headers=headers)
print(response.json())import requests
url = "https://business.kiloapp.org/api/v1"
headers = {
"Authorization": "Bearer sk_live_..."
}
response = requests.request("GET", url, headers=headers)
print(response.json())<?php
$ch = curl_init("https://business.kiloapp.org/api/v1");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "GET");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer sk_live_...",
]);
$response = curl_exec($ch);
curl_close($ch);
echo $response;<?php
$ch = curl_init("https://business.kiloapp.org/api/v1");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "GET");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer sk_live_...",
]);
$response = curl_exec($ch);
curl_close($ch);
echo $response;Types below use TypeScript-style notation:
string,number,booleanstring | null— may be JSONnull"a" | "b"— string enumISODateTime— ISO-8601 timestamp stringArray<T>— JSON array
Quick links
- Shared error envelope
- Shared delivery object
- GET /capabilities
- GET /warehouses
- GET /service-locations
- POST /deliveries
- GET /deliveries/{deliveryId}
- GET /deliveries
- POST /deliveries/{deliveryId}/cancel
- GET /riders
- POST /deliveries/{deliveryId}/assign
- POST /deliveries/{deliveryId}/unassign
- Webhook POST
Shared: error envelope
All failures use this shape (HTTP status varies):
{
"ok": false,
"error": "Human-readable message",
"code": "machine_code"
}{
"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
{
"ok": false,
"error": "Validation failed.",
"code": "validation_error",
"fieldErrors": {
"receiverPhone": ["Receiver phone is required."],
"packageImages": ["Package image URLs must use https://"]
}
}{
"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.
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;
};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
{
"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
}{
"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
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;
};
};
};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
type WarehousesResponse = {
ok: true;
warehouses: Array<{
id: string; // UUID
name: string;
address: string;
latitude: number | null;
longitude: number | null;
}>;
};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
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;
};
}>;
};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)
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;
};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
type CreateDeliveryResponse = {
ok: true;
delivery: Delivery;
};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
type GetDeliveryResponse = {
ok: true;
delivery: Delivery;
};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
type ListDeliveriesResponse = {
ok: true;
deliveries: Delivery[];
next_cursor: string | null;
};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
type CancelBody = {
reason: string; // 3–500 chars
};type CancelBody = {
reason: string; // 3–500 chars
};Success 200
type CancelResponse = {
ok: true;
delivery: Delivery; // status "cancelled" (or minimal { id, status } if reload fails)
};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
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[];
};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
type AssignBody = {
riderId: string; // UUID from GET /riders
};type AssignBody = {
riderId: string; // UUID from GET /riders
};Success 200
type AssignResponse = {
ok: true;
delivery: Delivery; // status "matched", rider_assigned true
};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
type UnassignResponse = {
ok: true;
delivery: Delivery; // rider_assigned false
};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
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[];
};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.