# Kibanda API Kibanda runs a shop inside WhatsApp: customers browse, add to cart, pay (M-Pesa, card, or cash on delivery) and track their delivery without leaving the chat. The API connects that shop to everything else you run — Shopify, Odoo, Zoho, a POS, a spreadsheet, or an AI agent. - **Products in.** Push your catalog and stock; customers see it on WhatsApp instantly. - **Orders and customers out.** Every order, payment and delivery step is an event, delivered to your server as a signed webhook. - **Checkout from anywhere.** Your website, an ad or an AI agent builds the cart; the customer confirms and pays in WhatsApp. - **Agent-ready.** An MCP server for the shop owner's assistant, and one per shop for shoppers' assistants. - **Reconcile with your ids.** Put your own id in `external_id` on products, customers and orders. You never have to store ours. ## Quickstart **1. Get a key.** In the dashboard, open **Developers → API keys → Create key**. Test keys (`kb_test_…`) work on staging; live keys (`kb_live_…`) work in production. ```bash export KIBANDA_KEY=kb_test_... export KIBANDA_API=https://api-staging.kibanda.africa ``` **2. Add a product.** Money is always an integer in cents — `25000` is KES 250. ```bash curl $KIBANDA_API/v1/products \ -H "Authorization: Bearer $KIBANDA_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{"name": "Unga 2kg", "price": 25000, "stock": 40, "external_id": "SKU-1001"}' ``` It's on your WhatsApp menu now. The response includes a `code` like `UNG4`: anyone who sends "P-UNG4" to your number lands on that product. **3. Get told about orders.** ```bash curl $KIBANDA_API/v1/webhook_endpoints \ -H "Authorization: Bearer $KIBANDA_KEY" \ -H "Content-Type: application/json" \ -d '{"url": "https://example.com/kibanda", "enabled_events": ["order.paid", "order.delivered"]}' ``` Save the `secret` (`whsec_…`) from the response and [verify each delivery](https://kibanda.africa/docs/webhooks#verifying-signatures). **4. Send a test order.** Message your shop's WhatsApp number, order something, and watch `order.created` arrive. ## Base URLs | Environment | URL | Keys | |---|---|---| | Production | `https://api.kibanda.africa` | `kb_live_…` | | Staging (test) | `https://api-staging.kibanda.africa` | `kb_test_…` | ## Conventions - JSON in and out. Send `Content-Type: application/json`. - Every object has an `id` (prefixed: `pr_`, `ct_`, `ord_`, `evt_`, `we_`) and an `object` type. - Money: integer minor units with a `currency` (`KES`). Times: Unix seconds. - Updates use `POST /v1//` (or `PATCH`) and change only the fields you send. - Lists are newest first and [paginated](https://kibanda.africa/docs/pagination). - Every POST accepts an [Idempotency-Key](https://kibanda.africa/docs/idempotency). - Errors have a stable `type` and `code`; see [Errors](https://kibanda.africa/docs/errors). ## For AI agents and LLMs These docs are built to be read by machines too: - [`/llms.txt`](https://kibanda.africa/llms.txt) — an index of every page; [`/llms-full.txt`](https://kibanda.africa/llms-full.txt) — all of it in one file. - Any page as Markdown: add `.md` to its URL (e.g. [`/docs/webhooks.md`](https://kibanda.africa/docs/webhooks.md)), or send `Accept: text/markdown`. - The OpenAPI 3.1 spec: [`/v1/openapi.json`](https://kibanda.africa/v1/openapi.json). - Error messages say what to do next, and every error links to its explanation. ## Reference [Authentication](https://kibanda.africa/docs/authentication) · [Checkout sessions](https://kibanda.africa/docs/checkout-sessions) · [AI agents & MCP](https://kibanda.africa/docs/agents) · [Ads & attribution](https://kibanda.africa/docs/ads) · [Shopify & Odoo](https://kibanda.africa/docs/integrations) · [Payments](https://kibanda.africa/docs/payments) · [Errors](https://kibanda.africa/docs/errors) · [Idempotency](https://kibanda.africa/docs/idempotency) · [Pagination](https://kibanda.africa/docs/pagination) · [Webhooks](https://kibanda.africa/docs/webhooks) · [Products](https://kibanda.africa/docs/products) · [Customers](https://kibanda.africa/docs/customers) · [Orders](https://kibanda.africa/docs/orders) · [Events](https://kibanda.africa/docs/events) · [Webhook endpoints](https://kibanda.africa/docs/webhook-endpoints) --- # Authentication Send your secret key as a bearer token on every request: ```bash curl https://api.kibanda.africa/v1/shop -H "Authorization: Bearer kb_live_..." ``` - **One key = one shop.** Everything you read or write is scoped to the shop that made the key. - **Test and live are separate.** `kb_test_…` keys only work on `api-staging.kibanda.africa`; `kb_live_…` keys only on `api.kibanda.africa`. Using one on the other returns `api_key_wrong_environment`. - **Shown once.** We store only a hash. If you lose a key, create a new one and revoke the old one. - **Create and revoke keys in the dashboard** (Developers → API keys). An API key can't create or list keys. - **Server-side only.** Never put a secret key in a browser, mobile app, or a WhatsApp message. If one leaks, revoke it at once; requests with a revoked key fail immediately. ## Errors | Status | Code | Meaning | |---|---|---| | 401 | `unauthenticated` | No `Authorization` header. | | 401 | `api_key_invalid` | Malformed, unknown or revoked key. | | 401 | `api_key_wrong_environment` | A test key on production, or a live key on staging. | | 403 | `session_required` | The endpoint is dashboard-only (API key management). | --- # Checkout sessions A checkout session is a cart made somewhere else that the customer completes in WhatsApp. You create it, you get a `url`, and the customer opens it on their phone: WhatsApp starts a chat with the shop with a message like *"Hi Mama Njeri's! I'd like to complete my order (C-7K2Q9)"* ready to send. When they send it, the shop shows that cart for review, then takes delivery or pickup and payment (M-Pesa, card, or cash on delivery) as usual. Use it for: - **Your website or app.** "Order on WhatsApp" instead of building a checkout. - **Ads.** A landing page or Click-to-WhatsApp ad that drops the customer straight into a ready cart. - **AI agents.** An assistant shops for someone and hands them a link to confirm and pay. - **Sales over chat.** Send a customer a link for exactly what they asked for. The order it becomes carries the session's `source` and `attribution`, so you can see which site, campaign or agent sold what. ## Create a checkout session `POST /v1/checkout_sessions` ```bash curl $KIBANDA_API/v1/checkout_sessions \ -H "Authorization: Bearer $KIBANDA_KEY" -H "Content-Type: application/json" \ -d '{ "items": [{ "external_id": "SKU-1001", "quantity": 2 }, { "product": "pr_9f2c", "quantity": 1 }], "source": "website", "attribution": { "utm_source": "instagram", "utm_campaign": "october" } }' ``` | Parameter | | |---|---| | `items` | **Required.** 1–50 of `{ product \| external_id \| code, quantity }`. | | `source` | `website`, `agent`, `ad`, `link` or `api` (default). Copied to the order. | | `customer` | `{ phone, name }` — if `phone` is set, only that WhatsApp number can use the link. | | `attribution` | Your tracking fields (≤ 2 KB), e.g. UTM tags. Copied to the order. | | `expires_in` | Seconds, 300 to 604800. Default 86400 (24 h). | | `external_id`, `metadata` | | ```json { "id": "cs_…", "object": "checkout_session", "code": "C-7K2Q9", "url": "https://wa.me/254700000000?text=Hi%20Mama%20Njeri%27s!%20I%27d%20like%20to%20complete%20my%20order%20(C-7K2Q9)", "status": "open", "items": [{ "product": "pr_1001", "quantity": 2 }, { "product": "pr_9f2c", "quantity": 1 }], "source": "website", "customer": null, "order": null, "attribution": { "utm_source": "instagram", "utm_campaign": "october" }, "expires_at": 1792802400, "created": 1792716000 } ``` Redirect the customer to `url` (or show it as a QR code on desktop). ### What the customer sees Prices are checked when they open it: if something sold out or was archived since, it's left out and they're told. Quantities are capped at what's in stock. ### Status `open` → `claimed` (someone sent the code; it's now theirs) → `completed` (their order was placed; `order` is set) — or `expired`. A completed link can't be reused; a claimed link can't be used by another number. ## Retrieve, list, expire - `GET /v1/checkout_sessions/{id}` - `GET /v1/checkout_sessions` — filters: `status`, `created_gte`, `created_lt` - `POST /v1/checkout_sessions/{id}/expire` The `checkout_session.completed` [event](https://kibanda.africa/docs/events) fires when its order is placed. ## Without a server: the shop page and button Every shop has a public page at `https://api.kibanda.africa/s/` — a catalog with quantities and an **Order on WhatsApp** button. Use it as a link in bio or an ad landing page; UTM tags on the URL are recorded on the order. To add the button to any website, no API key needed: ```html ``` Or build your own cart and call `Kibanda.checkout([{ product: "pr_9f2c", quantity: 2 }])` — it creates the session and opens WhatsApp. Behind it is the public endpoint `POST /s//checkout` with `{ "items": [...] }`, which returns `{ url, code }`. --- # AI agents & MCP Kibanda works with AI assistants in both directions: - **The shop owner's assistant runs the shop** — "what sold this week?", "restock unga to 40", "cancel order 128", "send Achieng a link for two trays of eggs". This is the **merchant MCP server**. - **A customer's assistant shops** — finds products and hands the person a WhatsApp link to confirm and pay. This is each shop's **shopper MCP server** and `llms.txt`. Payment always happens in WhatsApp, confirmed by the person. Agents never see card or M-Pesa details. ## Merchant MCP server ``` URL: https://api.kibanda.africa/mcp Auth: Authorization: Bearer kb_live_… (an API key from Dashboard → Developers) ``` Transport: Streamable HTTP (JSON responses), MCP protocol versions `2025-11-25`, `2025-06-18` and `2025-03-26`. **Claude Code:** ```bash claude mcp add --transport http kibanda https://api.kibanda.africa/mcp \ --header "Authorization: Bearer $KIBANDA_KEY" ``` **Cursor / any client with a JSON config:** ```json { "mcpServers": { "kibanda": { "url": "https://api.kibanda.africa/mcp", "headers": { "Authorization": "Bearer kb_live_…" } } } } ``` | Tool | | |---|---| | `get_shop` | Name, WhatsApp number, delivery and pickup settings. | | `sales_summary` | Sales, orders, top products, delivery performance, repeat customers over N days. | | `list_orders` | Recent orders, filter by status or customer. | | `get_order` | One order by id or by the number customers know (#128), with its history. | | `cancel_order` | Cancel an unpaid order with a reason the customer sees. | | `list_products` | Catalog with prices, stock and availability. | | `add_product`, `update_product` | Prices in shillings, stock, hide/show. | | `find_customer`, `get_customer`, `update_customer` | Look up by phone/email/tag; notes and tags. | | `create_checkout_link` | A WhatsApp link with a ready cart, to send a customer. | | `recent_activity` | The latest events (orders, payments, deliveries, new customers, abandoned carts). | Every tool goes through the same API as everything else, so it obeys the same rules and fires the same webhooks. ## Shopper MCP server (one per shop) ``` URL: https://api.kibanda.africa/s//mcp Auth: none (public catalog only) ``` | Tool | | |---|---| | `shop_info` | Delivery fees, pickup, payment methods. | | `search_products` | Products and prices (KES). | | `get_product` | One product by id or share code. | | `create_checkout` | Returns a WhatsApp `url` with the cart ready; the person opens it to confirm and pay. | ## llms.txt - These docs: [`/llms.txt`](https://kibanda.africa/llms.txt) and [`/llms-full.txt`](https://kibanda.africa/llms-full.txt); any page as Markdown with `.md`. - Each shop: `https://api.kibanda.africa/s//llms.txt` — what it sells, prices, delivery, and how an agent places an order. Also `/s//products.json`. ## Guidance for agent builders - Quote prices from the tools, not memory; stock changes. - Send the person the checkout `url` and tell them they'll confirm and pay in WhatsApp. - For the merchant server, confirm before cancelling orders or changing prices. --- # Ads & attribution Every order records where it came from: | `order.source` | When | |---|---| | `whatsapp` | The customer messaged the shop directly. | | `ad` | They tapped a Click-to-WhatsApp ad (Facebook or Instagram) within the last 7 days. | | `website` | They came from a [checkout session](https://kibanda.africa/docs/checkout-sessions) your site created, or the button. | | `link` | The shop's public page (`/s/`), e.g. a link in bio. | | `agent` | An AI assistant built the cart. | | `api` | A checkout session created by your server with no other source. | `order.attribution` holds the details: for ads, the ad id (`sourceId`), `headline` and Meta's click id (`ctwaClid`); for sessions, your UTM tags and the `checkoutSession` id. Filter with `GET /v1/orders?source=ad`. ## Click-to-WhatsApp ads Nothing to set up. When someone taps an ad, WhatsApp tells us which ad it was; if they order within 7 days, the order is credited to it. Tip: set the ad's prefilled message to include a product's share code — *"Hi! I'd like the Sunday special (P-7K2Q)"* — and the customer lands on that product. ### Reporting sales back to Meta For ad-attributed orders, Kibanda reports `OrderCreated`, `Purchase` (when paid), `OrderDelivered` and `OrderCanceled` to Meta's Conversions API for Business Messaging, with the click id. That lets Meta optimise your ads for people who actually buy, not just people who message. Each event is sent once. This uses your WhatsApp Business Account's dataset (created automatically). Meta requires advanced access to the `whatsapp_business_manage_events` permission for the app sending them. ## Landing pages and UTM tags Point an ad at your shop page with UTM tags — `https://api.kibanda.africa/s/?utm_source=facebook&utm_campaign=october` — and they're saved on the order. The same works with the website button. --- # Shopify & Odoo Shops connect these from the dashboard (**Integrations**). No code needed; this page explains exactly what happens, so you can rely on it. ## What syncs | | Shopify | Odoo (14 → 19+) | |---|---|---| | **Products in** | Every variant of active products: name, price, stock (tracked variants), photo | Sellable products: name, price, stock (storable products), description | | **How often** | On connect, every 30 minutes, and within a minute of a product change (Shopify webhooks) | On connect and every 30 minutes | | **Orders out** | `orderCreate`: line items on the right variants, delivery fee as a shipping line, paid with the M-Pesa receipt or pending | A `sale.order` with our order id as `client_order_ref`, confirmed (optional), customer matched or created by phone | | **Paid later** (cash at the door) | `orderMarkAsPaid` | Noted on the sale order (payment stays in your accounting flow) | | **Cancelled** | `orderCancel` (restocks) | `action_cancel` | **When orders go:** pay-now orders once paid; pay-on-delivery orders once placed (after the customer's one-minute undo window). Orders are sent in the order they happened, each exactly once — a retry finds the existing Shopify order (by its `kb-` tag) or Odoo sale order (by `client_order_ref`) instead of creating another. **Stock never double-counts.** Shopify (and Odoo, when orders are confirmed) deduct stock as soon as we send an order. Kibanda reserves stock at checkout and deducts it on delivery. When we pull stock, we add back units of sent-but-undelivered orders, so both sides agree. Products that disappear from Shopify or Odoo are hidden on WhatsApp, not deleted (order history keeps them). Products you add only in Kibanda stay; their order lines go to Odoo as notes and to Shopify as custom line items. Each synced product's `external_id` is `shopify:` or `odoo:`, and each sent order's `external_id` is `shopify:#1042` or `odoo:S00042` — visible in the API and webhooks. ## Connecting Shopify Since January 2026, apps are created in Shopify's **Dev Dashboard**: 1. Create an app in the **same Shopify organization** as the store. 2. Scopes: `read_products`, `read_inventory`, `write_orders`. 3. Install it on the store, then paste the store address, **Client ID** and **Client secret** into Kibanda. Kibanda gets short-lived tokens with the client credentials grant and subscribes to product and inventory webhooks itself. The store's currency must be KES. ## Connecting Odoo 1. Create a dedicated user (e.g. "Kibanda") with Sales and Inventory rights. 2. As that user: Preferences → Account Security → **New API Key**. In Odoo 19, ask your admin for a group with a long key lifetime (the default can be 1 day). 3. Paste the Odoo address, database name, the user's login and the API key into Kibanda. Odoo 19+ is called through the JSON-2 API; older versions through JSON-RPC (`execute_kw`). ## Other systems (Zoho, a POS, an ERP) Use the [API](https://kibanda.africa/docs) directly: push your catalog with [`POST /v1/products/batch`](https://kibanda.africa/docs/products#batch-upsert) (upsert by your `external_id`), listen for `order.paid` / `order.created` [webhooks](https://kibanda.africa/docs/webhooks), and write your ids back with `external_id`. That's the same contract the native integrations use. --- # Payments Customers choose at checkout: | Method | How it works | |---|---| | **M-Pesa prompt** | An STK push to their phone; they enter their PIN. | | **Card / other** | A secure checkout page (card, M-Pesa, bank) in WhatsApp's browser. | | **Pay on delivery** | Cash or M-Pesa when the rider arrives — on the shop's own terms (below). | At the door, the rider can send an **M-Pesa prompt** to the customer's phone from their WhatsApp, and hands over once it's paid — no cash to carry. Or they record **cash**, which shows as owed by that rider until the shop marks it handed in. A payment is only ever marked paid after we ask the provider directly; webhooks and redirects are just nudges. Money that arrives for a cancelled order, or twice, raises `order.refund_needed`. ## Payment terms (the shop decides) Each shop sets its own terms in the dashboard (**Settings → Payment terms**). Kibanda applies exactly those and nothing else: | Setting | Effect | |---|---| | **Pay on delivery: on / off** | Off = every delivery is paid in full (M-Pesa or card) before it leaves. Pickup orders can still pay at the counter. | | **Rules** (each optional) | Orders above KES X · customers with fewer than N delivered orders · customers with N or more refusals. | | **When a rule matches** | **Full payment first**, or **a deposit first** — the delivery fee or a percentage — with the rest on delivery. | | **Per customer** | *Your shop's rules* (default) · *Always pays before delivery* · *Trusted: may always pay on delivery*. Set on the Customers page or with `payment_terms` on the [customer](https://kibanda.africa/docs/customers). | | **Per order** | The shop can ask for payment first on any confirmed order that hasn't left — full or the deposit — from WhatsApp (the order card), the dashboard, `POST /v1/orders/{id}/require_payment`, or the MCP `require_payment` tool. | How it plays out: - **Deposit:** the order waits (`status: awaiting_payment`, `payment.upfront_due` = the deposit) and the shop isn't alerted until it's paid. Then it's confirmed with `payment.status: partial`; the rider collects only the balance (cash or an M-Pesa prompt at the door). Events: `order.deposit_paid`, then `order.paid` when the whole total is in. - **Asked to pay first:** the customer gets the amount with M-Pesa and card buttons; the order can't be sent with a rider until it's paid, and the customer can't switch it back to pay on delivery. Event: `order.payment_required` (`payment.upfront_required_by: "shop"`). The shop's current terms are on [`GET /v1/shop`](https://kibanda.africa/docs) as `payment_terms`. ## Bring your own By default payments go through Kibanda's provider. A shop can connect its own account (dashboard → **Payments**) so money goes straight to them: | Account | M-Pesa prompts | Card page | You need | |---|---|---|---| | **M-Pesa till (Buy Goods)** | ✓ | | A Daraja app with Lipa Na M-Pesa Online: consumer key/secret, passkey, the store number and the till | | **M-Pesa paybill** | ✓ | | The same, for the paybill | | **Paystack** | ✓ | ✓ | Secret key (`sk_live_…`); set the webhook URL we show you | | **Flutterwave** | ✓ | ✓ | Secret key (`FLWSECK-…`) | Credentials are checked with the provider before they're used, encrypted at rest (AES-256-GCM, bound to the account), and never shown again. Each payment records which account took it. If a shop's account stops working, new payments fall back to Kibanda's, so customers can always pay. **M-Pesa direct (Daraja):** Safaricom calls back to a unique, unguessable URL per payment, and we still confirm with Safaricom's STK query before marking it paid, so a forged callback can't mark an order paid. The receipt number (e.g. `TJK3H2K9LP`) is saved on the payment. --- # Errors Errors use conventional HTTP statuses and always return the same object: ```json { "error": { "type": "invalid_request_error", "code": "parameter_missing", "message": "Missing required parameter: price.", "param": "price", "doc_url": "https://api.kibanda.africa/docs/errors#parameter_missing" } } ``` Branch on `type` and `code` (stable); show `message` to people (it may change). `param` names the offending field when there is one. ## Types | Type | Status | Retry? | |---|---|---| | `invalid_request_error` | 400 | No — fix the request. | | `authentication_error` | 401 | No — check the key. | | `permission_error` | 403 | No. | | `not_found_error` | 404 | No. | | `conflict_error` | 409 | No — the object's state doesn't allow it. | | `idempotency_error` | 400 / 409 | 409: yes, shortly. 400: no — use a new key. | | `rate_limit_error` | 429 | Yes, with backoff. | | `api_error` | 500 | Yes, with the same `Idempotency-Key`. | ## Codes ### parameter_missing A required field wasn't sent. `param` names it. ### parameter_invalid_string ### parameter_invalid_integer ### parameter_invalid_boolean ### parameter_invalid_number ### parameter_invalid_array ### parameter_invalid_object A field has the wrong type or is out of range. Money is an **integer in cents**: send `25000`, not `250.00` or `"250"`. ### parameter_invalid_metadata `metadata` must be an object of up to 50 keys (≤ 40 characters) with string values (≤ 500 characters). ### parameter_invalid_cursor `starting_after` must be the `id` of an object from the previous page. ### parameter_unknown The field can't be set here. Order status, for example, changes through actions such as `POST /v1/orders/{id}/cancel`. ### invalid_json The body isn't a JSON object. ### phone_invalid Phones may be `+254712345678`, `254712345678` or `0712 345 678`; other countries in E.164 (`+256…`). ### email_invalid ### url_invalid Webhook URLs must be `https://`. ### currency_unsupported Only `KES` for now. ### event_type_unknown See the [event types](https://kibanda.africa/docs/events#types). ### limit_reached Too many API keys (20) or webhook endpoints (16). ### resource_missing No object with that id in this shop (or it belongs to another shop). ### external_id_taken Another object already has that `external_id`. To sync a catalog, use [`POST /v1/products/batch`](https://kibanda.africa/docs/products#batch-upsert), which upserts by `external_id`. ### customer_exists A customer with that phone exists; the message includes their id. Pass `"upsert": true` to update them instead. ### order_not_cancellable The order is paid, completed, already cancelled, or a payment is in progress. ### idempotency_key_reused The key was used with a different request. Each distinct request needs its own key. ### idempotency_key_invalid Keys can be at most 255 characters. ### idempotency_request_in_progress The first request with this key hasn't finished. Retry in a moment. ### unauthenticated ### api_key_invalid ### api_key_wrong_environment ### session_required See [Authentication](https://kibanda.africa/docs/authentication). ### route_not_found No such endpoint. Paths start with `/v1/`. ### internal_error Our fault, and it's been reported. Retrying with the same `Idempotency-Key` is safe. --- # Idempotency Networks fail. To retry a `POST` without doing it twice, send an `Idempotency-Key` header — any unique string up to 255 characters (a UUID is ideal): ```bash curl https://api.kibanda.africa/v1/products \ -H "Authorization: Bearer $KIBANDA_KEY" \ -H "Idempotency-Key: 5f0c2c1e-9a8e-4c55-8f0f-3d2c6f1b2a10" \ -d '{"name": "Milk 500ml", "price": 6500}' ``` - The first response for a key is saved for **24 hours**; retries get the same status and body, with the header `Idempotent-Replayed: true`. - Reusing a key with a **different** request returns `400 idempotency_key_reused`. - If the first request is still running, a retry returns `409 idempotency_request_in_progress`; try again shortly. - Server errors (5xx) aren't saved, so a retry runs again. - Keys are per shop. `GET` and `DELETE` are naturally idempotent and ignore the header. **Tip:** derive the key from your own record — e.g. `odoo-sync-SKU-1001-2026-10-23T08:00` — so a crashed job that restarts sends the same key. --- # Pagination List endpoints return newest first: ```json { "object": "list", "data": [ ... ], "has_more": true, "url": "/v1/orders" } ``` - `limit` — 1 to 100, default 20. - `starting_after` — the `id` of the last object you got. Pass it to get the next page. - `created_gte`, `created_lt` — Unix seconds, to fetch a time window (on lists that support them). ```python params = {"limit": 100} while True: page = requests.get(f"{API}/v1/orders", params=params, headers=auth).json() for order in page["data"]: handle(order) if not page["has_more"]: break params["starting_after"] = page["data"][-1]["id"] ``` Cursors are stable: new objects don't shift pages you're walking. --- # Webhooks When something happens in a shop — an order is placed, paid, dispatched, delivered — Kibanda sends a `POST` to your endpoint with the [event](https://kibanda.africa/docs/events). Create endpoints in the dashboard (**Developers → Webhooks**) or with the [API](https://kibanda.africa/docs/webhook-endpoints). ```json { "id": "evt_8f3a…", "object": "event", "type": "order.paid", "created": 1792716000, "data": { "object": { "id": "ord_…", "object": "order", "status": "confirmed", "total": 31000, … } } } ``` `data.object` is the full object (an [order](https://kibanda.africa/docs/orders), [customer](https://kibanda.africa/docs/customers) or cart) as it was when the event was first sent. ## Delivery - **Never lost.** An event is written in the same database transaction as the change it describes, then delivered. If we crash in between, it's sent when we're back. - **Retries.** Anything but a 2xx within 10 seconds is retried after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h and 24 h (about two days), then marked failed. You can [retry by hand](https://kibanda.africa/docs/webhook-endpoints#retry-a-delivery) any time. - **At least once.** You may get the same event twice. Use `webhook-id` (the event id) to ignore duplicates. - **Order.** Events are usually in order but aren't guaranteed to be. Compare `created`, or fetch the object (`GET /v1/orders/{id}`) when order matters. - **Respond fast.** Return `2xx` as soon as you've stored the event; do slow work afterwards. ## Verifying signatures Deliveries follow the [Standard Webhooks](https://www.standardwebhooks.com) spec, so any of its libraries verifies them. Three headers: | Header | Value | |---|---| | `webhook-id` | The event id. Same on every retry. | | `webhook-timestamp` | Unix seconds when this attempt was sent. | | `webhook-signature` | `v1,` | The signed content is `{webhook-id}.{webhook-timestamp}.{raw body}`, keyed with your endpoint secret **base64-decoded after removing the `whsec_` prefix**. Reject timestamps more than 5 minutes old. **Node.js** (with the `standardwebhooks` package): ```js import { Webhook } from "standardwebhooks"; const wh = new Webhook(process.env.KIBANDA_WEBHOOK_SECRET); // "whsec_…" app.post("/kibanda", express.raw({ type: "application/json" }), (req, res) => { let event; try { event = wh.verify(req.body, req.headers); // throws if invalid } catch { return res.status(400).end(); } if (event.type === "order.paid") fulfil(event.data.object); res.status(204).end(); }); ``` **Python** (no library): ```python import base64, hashlib, hmac, time def verify(secret: str, headers, body: bytes) -> bool: msg_id, ts, sig = headers["webhook-id"], headers["webhook-timestamp"], headers["webhook-signature"] if abs(time.time() - int(ts)) > 300: return False key = base64.b64decode(secret.removeprefix("whsec_")) expected = base64.b64encode(hmac.new(key, f"{msg_id}.{ts}.".encode() + body, hashlib.sha256).digest()).decode() return any(hmac.compare_digest("v1," + expected, s) for s in sig.split(" ")) ``` Always verify against the **raw** request body, before parsing JSON. ## Testing `POST /v1/webhook_endpoints/{id}/test` sends a signed `ping` event and returns what your server answered. Each endpoint's recent deliveries, with response codes and errors, are at `GET /v1/webhook_endpoints/{id}/deliveries` and in the dashboard. ## Rotating the secret `POST /v1/webhook_endpoints/{id}/rotate_secret` returns a new secret, effective immediately. Deploy it and rotate in quick succession. --- # Products What customers browse on WhatsApp. Changes show up in the chat immediately. ## The product object ```json { "id": "pr_…", "object": "product", "name": "Unga 2kg", "description": "Jogoo, sifted", "price": 25000, "currency": "KES", "active": true, "stock": 40, "available": 37, "image_url": "https://api.kibanda.africa/media/…", "code": "UNG4", "external_id": "SKU-1001", "metadata": {}, "created": 1792700000, "updated": 1792716000 } ``` | Field | | |---|---| | `price` | Cents. `25000` = KES 250. | | `stock` | Units on hand, or `null` to not track stock (never sells out). | | `available` | `stock` minus units held by open orders. When it hits 0 the product shows as sold out. | | `code` | Share code: "P-UNG4" sent to the shop opens this product. Also in its `wa.me` share link. | | `active` | `false` hides it from the menu (kept for order history). | | `external_id` | Your id (SKU, Shopify variant id, Odoo `product.product` id). Unique per shop. | Stock is reserved when an order is placed, released if it's cancelled or fails, and sold when it's delivered — two customers can't buy the last one. ## Create a product `POST /v1/products` | Parameter | | |---|---| | `name` | **Required.** 1–60 characters (WhatsApp shows ~24 in lists). | | `price` | **Required.** Integer cents, > 0. | | `description` | ≤ 72 characters. | | `stock` | Integer ≥ 0, or `null`. | | `active` | Default `true`. | | `external_id`, `metadata` | | ## Update a product `POST /v1/products/{id}` — send only what changes. Setting stock is how you restock: ```bash curl $KIBANDA_API/v1/products/pr_123 -H "Authorization: Bearer $KIBANDA_KEY" \ -H "Content-Type: application/json" -d '{"stock": 120}' ``` ## Batch upsert `POST /v1/products/batch` — up to 100 products, matched by `external_id`: existing ones are updated (only the fields you send), new ones created. This is the call for catalog sync. ```json { "products": [ { "external_id": "SKU-1001", "name": "Unga 2kg", "price": 25000, "stock": 40 }, { "external_id": "SKU-1002", "stock": 0 } ] } ``` Returns a list of the products plus `created` and `updated` counts. If any item is invalid the error's `param` points at it (e.g. `products[3].price`). ## Retrieve, list, archive - `GET /v1/products/{id}` - `GET /v1/products` — filters: `active=true|false`, `external_id`, `code`. [Paginated](https://kibanda.africa/docs/pagination), most recently updated first. - `DELETE /v1/products/{id}` — archives (sets `active: false`). --- # Customers Kibanda remembers each customer so they never type the same thing twice: their name, saved delivery addresses, and the M-Pesa number they pay with. The API lets you read that, add your own notes and tags, and link each customer to the record in your CRM or ERP. ## The customer object ```json { "id": "ct_…", "object": "customer", "name": "Achieng Otieno", "phone": "+254712345678", "mpesa_phone": "+254712345678", "whatsapp_user_id": "254712345678", "email": "achieng@example.com", "notes": "Gate B, call on arrival", "tags": ["vip"], "payment_terms": "default", "delivered_orders": 12, "refused_orders": 0, "order_count": 13, "total_spent": 512000, "addresses": [{ "id": "ad_…", "object": "address", "label": "Home", "lat": -1.2921, "lng": 36.7856, "landmark": "Blue gate", "last_used": 1792716000 }], "external_id": "res.partner/4211", "metadata": {}, "last_message_at": 1792716000, "created": 1792000000 } ``` `order_count`, `total_spent` and `addresses` are included when you retrieve one customer. `phone` can be `null` for WhatsApp username users who haven't shared their number. ## Create a customer `POST /v1/customers` — e.g. importing from Shopify or Odoo. When they later message the shop on WhatsApp they're recognised, and their saved details apply. | Parameter | | |---|---| | `phone` | **Required.** `+254712345678`, `0712 345 678`, … | | `name`, `email`, `notes`, `tags`, `mpesa_phone`, `external_id`, `metadata` | | | `upsert` | `true` to update the existing customer with this phone instead of getting `409 customer_exists`. | ## Update a customer `POST /v1/customers/{id}` — `name`, `email`, `notes`, `tags` (replaces the list), `mpesa_phone`, `payment_terms` (`default` = the shop's rules, `pay_first`, `trusted`), `external_id`, `metadata`. ## Saved addresses - `POST /v1/customers/{id}/addresses` — `lat`, `lng` (required), `label`, `landmark`. Offered at checkout so the customer doesn't share a pin. - `DELETE /v1/customers/{id}/addresses/{address_id}` ## Retrieve and list - `GET /v1/customers/{id}` - `GET /v1/customers` — filters: `phone`, `email`, `external_id`, `tag`, `created_gte`, `created_lt`. ## Keeping your CRM in sync 1. Subscribe to `customer.created`. For each, find or create the person in your system (match on `phone`). 2. Write your id back: `POST /v1/customers/{id}` with `{"external_id": "res.partner/4211"}`. 3. From then on, every order carries `customer.external_id`, so you can file it without a lookup. --- # Orders Customers place orders in WhatsApp; you read them here and receive them as [events](https://kibanda.africa/docs/events). ## The order object ```json { "id": "ord_…", "object": "order", "number": 128, "status": "confirmed", "source": "whatsapp", "currency": "KES", "subtotal": 28000, "delivery_fee": 3000, "total": 31000, "payment": { "mode": "pay_now", "status": "paid", "amount_paid": 31000, "amount_due": 0, "upfront_due": 31000, "upfront_required_by": null, "paid_at": 1792716000, "attempts": [{ "id": "pay_…", "method": "mpesa", "amount": 31000, "status": "paid", "receipt": "TJK3H2K9LP", "created": 1792715950 }] }, "items": [{ "product": "pr_…", "product_external_id": "SKU-1001", "name": "Unga 2kg", "quantity": 1, "unit_price": 25000, "amount": 25000 }], "customer": { "id": "ct_…", "object": "customer", "name": "Achieng Otieno", "phone": "+254712345678", "external_id": "res.partner/4211", … }, "fulfilment": "delivery", "delivery": { "location": { "lat": -1.2921, "lng": 36.7856 }, "landmark": "Blue gate opposite the church", "instructions": "Call when you're at the gate", "distance_km": 3.4, "status": "picked_up", "rider": { "id": "rd_…", "name": "Kevin" }, "eta_minutes": 14, "failure_reason": null }, "pickup": null, "attribution": { "source": "ad", "sourceId": "120211…", "headline": "Unga deal", "ctwaClid": "ARAk…" }, "rating": null, "cancel_reason": null, "external_id": null, "metadata": {}, "created": 1792715900, "updated": 1792716000 } ``` ### Status | `status` | | |---|---| | `awaiting_payment` | Pay-now order waiting for payment. | | `confirmed` | Paid, or pay-on-delivery accepted. The shop should prepare it. | | `dispatched` | With a rider (see `delivery.status`: `assigned` → `picked_up` → `arrived`). | | `delivered` | Done. | | `ready` / `collected` | Pickup orders. | | `failed` | Delivery failed; stock returned. | | `cancelled` | See `cancel_reason`. | `payment.status` is separate: `unpaid`, `pending` (a prompt is out), `partial` (a deposit is in), `paid`. `upfront_due` is what must be paid before the order goes ahead — see [payment terms](https://kibanda.africa/docs/payments#payment-terms-the-shop-decides). A pay-on-delivery order can be `delivered` and `paid` with `method: "cash"`. ## Retrieve and list - `GET /v1/orders/{id}` - `GET /v1/orders` — filters: `status`, `payment_status`, `customer` (a customer id), `number`, `source` (see [Ads & attribution](https://kibanda.africa/docs/ads)), `external_id`, `created_gte`, `created_lt`. - `GET /v1/orders/{id}/events` — the order's full history (who did what, when). ## Link to your system `POST /v1/orders/{id}` accepts only `external_id` and `metadata` — e.g. after creating the sale order in Odoo: ```bash curl $KIBANDA_API/v1/orders/ord_123 -H "Authorization: Bearer $KIBANDA_KEY" \ -H "Content-Type: application/json" -d '{"external_id": "SO0042", "metadata": {"odoo_id": "42"}}' ``` ## Ask for payment first `POST /v1/orders/{id}/require_payment` with `{"amount": "full"}` (default) or `{"amount": "deposit"}` — the shop's call on a confirmed order that hasn't left. The customer is asked on WhatsApp; the order waits until it's paid. ## Cancel `POST /v1/orders/{id}/cancel` with an optional `reason` (shown to the customer). Stock is returned and a rider who's on it is told to bring it back. Paid orders and orders with a payment in progress return `409 order_not_cancellable`. --- # Events Every change you can react to is an event. They're delivered as [webhooks](https://kibanda.africa/docs/webhooks) and kept in a log you can read back — handy for catching up after downtime or backfilling a new system. ## The event object | Field | | |---|---| | `id` | `evt_…` — unique; the same id is sent on every webhook retry. | | `object` | `"event"` | | `type` | See below. | | `created` | Unix seconds when it happened. | | `data.object` | The object it's about, as it was when the event was first sent. | ## Types | Type | When | |---|---| | `order.created` | A customer placed an order (sent once the short undo window has passed). | | `order.paid` | The order is fully paid (M-Pesa, card, or cash collected at the door or counter). | | `order.deposit_paid` | A deposit the shop's terms asked for was paid; the order goes ahead with the rest due on delivery. | | `order.payment_required` | The shop asked for payment before delivery on this order. | | `order.payment_failed` | A payment attempt failed or expired. The order stays open; the customer can retry. | | `order.dispatched` | A rider was assigned (or reassigned). | | `order.picked_up` | The rider has the order. | | `order.arrived` | The rider is at the customer. | | `order.delivered` | Handed over (with the delivery code if the shop uses one). | | `order.delivery_failed` | Couldn't be delivered (no answer, wrong location, refused). Stock is returned. | | `order.ready_for_pickup` | A pickup order is ready at the counter. | | `order.cancelled` | Cancelled by the customer, the shop, or because it went unpaid. Stock is returned. | | `order.refund_needed` | Money arrived that must be returned (paid twice, or paid after cancelling). | | `order.rated` | The customer rated the order. | | `customer.created` | A new customer (first message, or created through the API). | | `cart.abandoned` | A customer left items in their cart without ordering. Good for a follow-up. | | `checkout_session.completed` | A [checkout session](https://kibanda.africa/docs/checkout-sessions) became an order. | ## List events `GET /v1/events` — newest first, [paginated](https://kibanda.africa/docs/pagination). | Query | | |---|---| | `type` | An exact type, or a prefix: `order.*` | | `created_gte`, `created_lt` | Unix seconds | ```bash curl "$KIBANDA_API/v1/events?type=order.*&created_gte=1792700000" -H "Authorization: Bearer $KIBANDA_KEY" ``` ## Retrieve an event `GET /v1/events/{id}` --- # Webhook endpoints ## The webhook endpoint object ```json { "id": "we_…", "object": "webhook_endpoint", "url": "https://example.com/kibanda", "description": "Odoo sync", "enabled_events": ["order.paid", "order.delivered"], "status": "enabled", "created": 1792716000 } ``` ## Create `POST /v1/webhook_endpoints` — `url` (https, required), `enabled_events` (event types, or `["*"]` for all; default all), `description`. The response includes `secret` — the only time it's returned without asking. ## Update, delete - `POST /v1/webhook_endpoints/{id}` — `url`, `enabled_events`, `description`, `disabled` (`true` pauses delivery). - `DELETE /v1/webhook_endpoints/{id}` ## Secrets - `POST /v1/webhook_endpoints/{id}/reveal_secret` - `POST /v1/webhook_endpoints/{id}/rotate_secret` ## Test `POST /v1/webhook_endpoints/{id}/test` — sends a signed `ping` now: `{ "ok": true, "status": 200 }`. ## Deliveries `GET /v1/webhook_endpoints/{id}/deliveries` — recent attempts: `status` (`pending`, `sending`, `succeeded`, `failed`), `attempts`, `response_status`, `error`, `next_attempt`. ### Retry a delivery `POST /v1/webhook_endpoints/{id}/deliveries/{delivery_id}/retry` — sends it again now. ## Event types `GET /v1/webhook_endpoints/event_types` — see [Events](https://kibanda.africa/docs/events#types).