Kaya Leta · Developers
Partner API
Let your own system — a till, a stock system, an order screen — run your shops on Kaya Leta: publish the catalogue, keep stock true, answer orders, chat with customers and read payouts. The same rules as every seller apply: no commission, the normal service fee, the same decision window, refunds and payouts. Kaya Go delivers.
Base URL https://api.kayaleta.com/partner/v1
Quick start
As the shop owner, open Integrations in the shop manager and create an integration with a name and your webhook URL — copy the webhook secret it shows once. Switch on the shops your system runs, then create a key (a code is sent to your phone) and copy the token. It is shown once. A developer the owner invites to the integration can do this from their own account.
Find your shops' ids.
curl https://api.kayaleta.com/partner/v1/shops \ -H "Authorization: Bearer lk_live_…"Put one product, addressed by your own id. Writes are upserts.
curl -X PUT https://api.kayaleta.com/partner/v1/shops/{shop_id}/products/8412 \ -H "Authorization: Bearer lk_live_…" \ -H "Content-Type: application/json" \ -d '{ "name": "CeraVe Foaming Cleanser", "brand": "CeraVe", "price": "32000", "original_price": "38000", "images": [{"url": "https://cdn.example/1.jpg"}], "variants": [{"external_id": "8412-236ml", "label": "236 ml", "price": "32000", "barcode": "3337875597197", "quantity": 14}] }'When a customer orders, your webhook receives
order.created. Check the signature, answer 2xx quickly.{"id":"…","type":"order.created","created_at":"…","shop_id":"…", "data":{"order":{"id":"…","reference":"…","status":"RECEIVED","accept_by":"…","items":[…]}}}Tell Leta you have it (this stops the repeat pings), accept it, and mark it ready when it is packed.
curl -X POST https://api.kayaleta.com/partner/v1/orders/{id}/seen -H "Authorization: Bearer lk_live_…" curl -X POST https://api.kayaleta.com/partner/v1/orders/{id}/accept -H "Authorization: Bearer lk_live_…" curl -X POST https://api.kayaleta.com/partner/v1/orders/{id}/ready -H "Authorization: Bearer lk_live_…"
Auth and shops
Authorization: Bearer lk_live_<32 chars>- An integration is one system. It covers the shops the owner switches on in it; an owner may run several, and a shop is on one at a time. A shop left off keeps running on the app and keys cannot touch it.
- A key belongs to one integration and acts as the owner, inside the shops it reaches, within its scopes. It reaches every shop on its integration, or only the shops chosen when it was created (say, one branch's till).
- Unknown or revoked key →
401. Missing scope →403. A shop the key does not reach →404. - Rate limits per key per minute: 600 reads (GET), 300 writes. Over them:
429withRetry-After. - Every response carries
X-Request-Id; every call is in the request log on the Integrations page for 7 days. - Money is a string (
"12500", TZS). Ids are strings. Times are RFC 3339 UTC. New fields and event types may appear — ignore what you don't know.
Scopes:
| Scope | Allows |
|---|---|
catalog:read | Read sections and products |
catalog:write | Create and change sections and products |
stock:write | Stock and availability |
orders:read | Orders and the event feed |
orders:write | seen, accept, reject, ready, collected, cancel, own-driver, delivered, kayago |
comms | Chat on orders |
store:write | Open and close a shop |
finance:read | Balance, payouts, statement |
Shop-level routes live under /shops/{shop_id}/… (catalogue, stock, store). Orders, chat, events and finance are top-level; everything they return carries shop_id, and lists take an optional shop_id filter.
| Route | Body | Answer |
|---|---|---|
GET /shops | {"shops":[{id, name, kind, status, is_open, is_pickup_allowed, paused_at, stock_buffer}]} |
Envelope and errors
{"status": 200, "data": { … }}
{"status": 1003, "error": "that order has already been answered"}The HTTP status is the real one. The body's status is the HTTP code or one of Leta's codes:
| Body status | HTTP | Meaning |
|---|---|---|
| 1001 | 400 | Something missing in the body |
| 1003 | 400 / 409 | Wrong info, or the order moved on (already answered) |
| 1020 | 400 | Limit reached (products per shop, variants, sections) |
| 1021 | 409 | Name taken, or a barcode on another live variant |
| 1041 | 409 | This Idempotency-Key was used with a different body |
| 404 | 404 | Not found, or in a shop this key does not reach |
POST routes take an Idempotency-Key header (up to 64 characters). For 24 hours a replay returns the stored answer.
Catalogue
Sections, products and variants are addressed by your own external_id (1–128 characters, A–Z a–z 0–9 . _ : -).
| Route | Body | Answer |
|---|---|---|
GET /shops/{shop_id}/sections | {"sections":[Section]} | |
PUT /shops/{shop_id}/sections/{external_id} | {name, icon?, position?} | {"section":Section} |
DELETE /shops/{shop_id}/sections/{external_id} | {"success":true} | |
GET /shops/{shop_id}/products?before=&limit= | {"products":[Product]} newest first, limit ≤ 100 | |
GET /shops/{shop_id}/products/{external_id} | {"product":Product} | |
PUT /shops/{shop_id}/products/{external_id} | ProductInput (whole) | {"product":Product} |
PATCH /shops/{shop_id}/products/{external_id} | ProductInput (fields sent) | {"product":Product} |
DELETE /shops/{shop_id}/products/{external_id} | {"success":true} | |
POST /shops/{shop_id}/catalog/sync | {sections?, products, prune?} | 202 {"job":Job} |
GET /shops/{shop_id}/catalog/jobs/{id} | {"job":Job} |
{
"external_id": "8412", // optional copy of the path id
"name": "CeraVe Foaming Cleanser",
"summary": "", "description": "",
"brand": "CeraVe",
"section_external_id": "sc:14", // "" = no section
"status": "ACTIVE", // ACTIVE | HIDDEN
"price": "32000", // when there are no variants
"original_price": "38000", // struck through when above price; "0" = none
"images": [{"url": "https://…"}],// up to 10, https
"variants": [{
"external_id": "8412-236ml", "label": "236 ml",
"measurement": 236, "unit": "ml",
"price": "32000", "original_price": "0",
"barcode": "3337875597197", "image_url": "https://…",
"quantity": 14, // null = stock not tracked
"position": 0
}],
"option_groups": [], "prep_min_minutes": 0, "prep_max_minutes": 0,
"off_days": [] // kitchens, 0 = Sunday
}- With variants, the product's price is its cheapest live variant. A
PUTsends the full variant list; missing ones are archived. - A product whose every tracked variant has quantity 0 is saved HIDDEN with
hidden_reason: "out_of_stock"— not an error. Sendstatus: "ACTIVE"to show it again. - Leta downloads each image once and keeps it while the URL is unchanged. A URL that fails is listed in
warningsand the product saves without it. - Leta tags products itself. Name or description changes re-tag; price, stock, image and status changes never do.
- Limits: 5,000 products per shop (raisable), 40 variants per product, 8 option groups × 24 choices, 40 sections.
/catalog/synctakes up to 5,000 products, one job at a time per shop;prune: trueremoves your products not in the payload. You getcatalog.sync.finished.- Products you manage are locked on Leta's side: the owner and staff can still hide, show and mark sold out in the app, but not edit what your system owns.
Stock
| Route | Body | Answer |
|---|---|---|
GET /shops/{shop_id}/stock?before=&limit= | {"stock":[StockLine]} | |
PUT /shops/{shop_id}/stock | {items:[{external_id?, barcode?, quantity, sequence?}], complete?} | {"applied", "ignored", "hidden", "revealed"} |
PUT /shops/{shop_id}/availability | {product_external_id, choice_id?, is_available, sold_out_for?} | as the app's sold-out switch |
StockLine = {product_external_id, variant_external_id, barcode, quantity, reserved, sellable}
- Your number wins, any time you send it.
quantitysets the count outright. Address a variant by itsexternal_idor, failing that, its barcode. - Send a
sequenceand a write older than the last applied one for that variant is ignored asstale. complete: truesets every tracked variant you did not list to 0.
How Leta counts, in plain words:
- What customers can buy is your quantity, minus what is sitting in orders the shop has not answered yet, minus the shop's stock buffer. It never goes below zero.
- Checkout refuses more than that, the same way it refuses a sold out item.
- When an order is accepted, its units come off your quantity and you get
stock.changed(order_accepted). If that leaves every tracked variant at 0, the product is hidden and you getcatalog.product.hidden. - Cancelled after accepting: the units go back (
stock.changed,order_cancelled). Rejected or expired: only the hold is released, nothing was taken off. - Untracked variants (quantity null) and kitchens are not counted. Your own writes never send
stock.changedback.
Orders
| Route | Body | Answer |
|---|---|---|
GET /orders?shop_id=&status=&updated_after=&before=&limit= | {"orders":[Order]} | |
GET /orders/{id} | {"order":Order} with events | |
GET /orders/{id}/delivery | {"delivery":DeliveryView} | |
POST /orders/{id}/seen | {"success":true} | |
POST /orders/{id}/accept | {"order":Order} | |
POST /orders/{id}/reject | {reason?, unavailable?:[variant external_id]} | {"order":Order} |
POST /orders/{id}/ready | {"order":Order} | |
POST /orders/{id}/collected | {pickup_code?} | {"order":Order} |
POST /orders/{id}/cancel | {reason} | {"order":Order} |
POST /orders/{id}/own-driver | {"order":Order} | |
POST /orders/{id}/delivered | {"order":Order} | |
POST /orders/{id}/kayago | {request_id?, expected_revision?} | {"delivery":DeliveryView} |
- An
Orderis what a seller sees: id, reference, shop_id, status, fulfilment, accept_by, eta_at, subtotal, the customer's first name, phone (to ring about a missing item) and first address line, shop note, items (each withproduct_external_id,variant_external_id,barcode). Never the full name, the rest of the address, the driver note or the delivery fee. updated_afterreturns orders changed since then, oldest first — use it to catch up. Without it, newest first withbefore. Limit 50.rejectwithunavailablesets those variants to 0 first.cancelworks while preparing, ready or (delivery) when the driver has arrived.collectedis for ready pickup orders: with the customer's code it is verified, without it is an override.own-driveris "Use my own driver", offered whiledelivery.capabilities.own_driveris true — until a Kaya Go driver accepts. Kaya Go stops looking and the order stays where it is. A prepaid delivery fee goes back to the customer's wallet and the order becomesON_DELIVERYfor good: the customer pays your courier what you agree. It cannot be undone.kayagolooks for a Kaya Go driver again (delivery.capabilities.retry): after no driver was found, or afterown-driver, when the customer pays the Kaya Go driver in cash. Repeat it safely with the samerequest_id.deliveredcompletes a delivery your own courier carried, or one Kaya Go stopped carrying (delivery.capabilities.mark_delivered). A 409 while a Kaya Go driver has the order — their drop-off completes it.
How an offer reaches you:
On placement
order.createdis sent at once.The same event (same id) is sent again at 5 s, 10 s, 1 min, then every 20 s — until you call
seen,acceptorreject. A 2xx only means delivered.The shop has the normal decision window (5 minutes). No answer and the order expires: the customer is refunded and you get
order.cancelled.acceptmoves it to preparing and the customer hears straight away.Three orders in a row left to expire pause that shop: it stops taking new orders and you get
connection.paused. The owner resumes it on the Integrations page.
Chat
| Route | Body | Answer |
|---|---|---|
GET /orders/{id}/conversation | {"conversation":View,"messages":[MessageView]} | |
GET /conversations/{id}/messages?after_seq=&before_seq=&limit= | {"messages":[MessageView]} | |
POST /conversations/{id}/messages | {id (uuid), text, media?} | MessageView |
POST /conversations/{id}/read | {seq} | {"read_seq":"n","unread":n} |
The same window and limits as sellers in the app: 24 hours from the order, text up to 2,000 characters, up to 4 images. Customer and support messages arrive as chat.message.
Finance and store
| Route | Scope | Answer |
|---|---|---|
GET /finance/balance | finance:read | {available, held, pending_withdrawals} |
GET /finance/payouts?shop_id=&before=&limit= | finance:read | {"payouts":[{shop_id, order_id, reference, amount, released_at, early_reason}]} |
GET /finance/statement?from=&to=&before=&limit= | finance:read | {"lines":[{id, type, amount, balance_after?, shop_id?, order_id?, created_at}]} |
GET /shops/{shop_id}/store | store:write | {"store":{shop_id, name, is_open, reopen_at, is_pickup_allowed, paused_at}} |
PUT /shops/{shop_id}/store/status | store:write | {is_open, reopen_at?} → {"store":…} |
The balance is the owner's one business wallet, across all shops.
Webhooks
{"id":"…","type":"order.created","created_at":"…","shop_id":"…","data":{ … }}Headers: Leta-Event-Id, Leta-Event-Type, Leta-Delivery-Attempt and Leta-Signature: t=<unix seconds>,v1=<hex>, where v1 = HMAC-SHA256(secret, t + "." + raw body). While a secret is being rotated both secrets sign, so there may be two v1 values — accept either. Reject a t more than 300 seconds from now. The id is unique forever: dedupe on it.
import crypto from "node:crypto";
import express from "express";
const secret = process.env.LETA_WEBHOOK_SECRET; // lws_…
function verifyLeta(rawBody, header, secret) {
const parts = (header ?? "").split(",").map((p) => p.trim().split("="));
const t = parts.find(([k]) => k === "t")?.[1];
const signatures = parts.filter(([k]) => k === "v1").map(([, v]) => v);
if (!t || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
return signatures.some(
(s) =>
s.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)),
);
}
const app = express();
// The signature is over the raw bytes: read them before any JSON parser.
app.post("/leta", express.raw({ type: "application/json" }), (req, res) => {
if (!verifyLeta(req.body.toString("utf8"), req.get("Leta-Signature"), secret))
return res.sendStatus(401);
const event = JSON.parse(req.body);
res.sendStatus(200); // answer fast, then work
queue.push(event); // dedupe on event.id
});package leta
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"strconv"
"strings"
"time"
)
func Verify(body []byte, header, secret string) bool {
var t string
var signatures []string
for _, part := range strings.Split(header, ",") {
k, v, _ := strings.Cut(strings.TrimSpace(part), "=")
switch k {
case "t":
t = v
case "v1":
signatures = append(signatures, v)
}
}
ts, err := strconv.ParseInt(t, 10, 64)
if err != nil {
return false
}
if d := time.Now().Unix() - ts; d > 300 || d < -300 {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(t + "."))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
for _, s := range signatures {
if hmac.Equal([]byte(s), []byte(expected)) {
return true
}
}
return false
}
func Handler(secret string, handle func([]byte)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil || !Verify(body, r.Header.Get("Leta-Signature"), secret) {
w.WriteHeader(http.StatusUnauthorized)
return
}
w.WriteHeader(http.StatusOK) // answer fast, then work
go handle(body) // dedupe on the envelope's id
}
}Delivery:
- At least once. A non-2xx or no answer in 10 s is retried after 10 s, 30 s, 2 min, 10 min, 30 min, then hourly up to 24 hours after the event. After that it stays in the feed.
- Events for one order arrive in order: a later one waits while an earlier one for the same order is undelivered.
- The URL must be https and must not point at a private address. Use "Send test event" on the Integrations page to see a
pingand its delivery log.
| Type | data |
|---|---|
ping | {} — the test event |
order.created | {order} |
order.accepted | {order} — also when accepted in the app |
order.ready | {order} |
order.driver_assigned | {order, delivery} |
order.driver_arrived | {order, delivery} |
order.picked_up | {order} — out for delivery |
order.completed | {order} — delivered or collected |
order.cancelled | {order} — meta.cancel_reason, cancel_note |
stock.changed | {reason: order_accepted | order_cancelled | leta_edit, order_id?, items:[StockLine]} |
catalog.product.hidden | {product_external_id, hidden_reason} |
catalog.sync.finished | {job} |
chat.message | {order_id, conversation_id, message} — customer or support side |
payout.released | {order_id, reference, amount, released_at} |
connection.paused | {shop_id, reason, paused_at} |
Event feed
| Route | Body | Answer |
|---|---|---|
GET /events?after=<event id>&shop_id=&limit= | {"events":[Envelope]} oldest first, ≤ 100 |
The same envelopes the webhook sends, kept 30 days, for the shops your key reaches (scope orders:read). Poll it with the last id you processed to catch up after downtime.
Questions: support@kayaleta.com.