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
- Dashboard → Settings → API & Webhooks.
- Add endpoint with an
https://URL. - Choose events (start with all delivery events).
- 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:
- Read
data.object(id, pickup lat/lng,rider_assigned). - Skip if
rider_assignedis alreadytrue. GET /riders?pickup_lat=…&pickup_lng=…— this ranks by distance. It does not hide busy riders. Pending invites never appear.- Pick a rider (
on_deliveryis OK — same as the dashboard). Prefer smallestdistance_km. POST /deliveries/{id}/assignwith{ "riderId": "…" }.
Then you will get delivery.status_changed with status matched. Full walkthrough: Assign riders. Copy-ready prompt: Auto-assign agent.
Headers on every webhook
| Header | Meaning |
|---|---|
Content-Type |
application/json |
Kilo-Signature |
t=<unix>,v1=<hex> |
Kilo-Event |
Event name |
Need the full webhook body shape? Jump to API reference → Webhook POST.
Verify the signature (do this for real)
- Read
Kilo-Signature. - Split into
tandv1. - Reject if
tis older than 5 minutes. - Compute HMAC-SHA256 of
${t}.${rawBody}using yourwhsec_...secret. - Compare hex digests in constant time.
Example Node.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));
}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:
- Dashboard → Settings → Notifications → turn Customer delivery messages off.
- Subscribe to the events above (especially
delivery.status_changed/delivery.completed). - Send WhatsApp / SMS / push from your product when those webhooks arrive — use the
summary/customer_message_hint/agent_notesfields on each webhook body (plusdocs_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)
- Open the business dashboard → Settings → API & Webhooks.
- Add a webhook endpoint if you do not have one yet (must be
https://). - Make sure the endpoint listens for
delivery.otp_issued(or all events /*). - Confirm Require OTP at drop-off is still on (Delivery settings) — otherwise there is nothing to send.
- Turn OTP via webhook only ON.
- From that moment:
- When staff marks the delivery fee as paid, Kilo does not WhatsApp/SMS the OTP.
- Instead Kilo POSTs
delivery.otp_issuedto 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
{
"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 …"]
}{
"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.codefrom 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.