Kaya Leta

On this page

  1. Quick start
  2. Auth and shops
  3. Envelope and errors
  4. Catalogue
  5. Stock
  6. Orders
  7. Chat
  8. Finance and store
  9. Webhooks
  10. Event feed

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

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

  2. Find your shops' ids.

    curl https://api.kayaleta.com/partner/v1/shops \
      -H "Authorization: Bearer lk_live_…"
  3. 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}]
      }'
  4. 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":[…]}}}
  5. 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: 429 with Retry-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:

ScopeAllows
catalog:readRead sections and products
catalog:writeCreate and change sections and products
stock:writeStock and availability
orders:readOrders and the event feed
orders:writeseen, accept, reject, ready, collected, cancel, own-driver, delivered, kayago
commsChat on orders
store:writeOpen and close a shop
finance:readBalance, 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.

RouteBodyAnswer
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 statusHTTPMeaning
1001400Something missing in the body
1003400 / 409Wrong info, or the order moved on (already answered)
1020400Limit reached (products per shop, variants, sections)
1021409Name taken, or a barcode on another live variant
1041409This Idempotency-Key was used with a different body
404404Not 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 . _ : -).

RouteBodyAnswer
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}
ProductInput
{
  "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 PUT sends 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. Send status: "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 warnings and 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/sync takes up to 5,000 products, one job at a time per shop; prune: true removes your products not in the payload. You get catalog.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

RouteBodyAnswer
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. quantity sets the count outright. Address a variant by its external_id or, failing that, its barcode.
  • Send a sequence and a write older than the last applied one for that variant is ignored as stale.
  • complete: true sets 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 get catalog.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.changed back.

Orders

RouteBodyAnswer
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 Order is 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 with product_external_id, variant_external_id, barcode). Never the full name, the rest of the address, the driver note or the delivery fee.
  • updated_after returns orders changed since then, oldest first — use it to catch up. Without it, newest first with before. Limit 50.
  • reject with unavailable sets those variants to 0 first. cancel works while preparing, ready or (delivery) when the driver has arrived. collected is for ready pickup orders: with the customer's code it is verified, without it is an override.
  • own-driver is "Use my own driver", offered while delivery.capabilities.own_driver is 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 becomes ON_DELIVERY for good: the customer pays your courier what you agree. It cannot be undone.
  • kayago looks for a Kaya Go driver again (delivery.capabilities.retry): after no driver was found, or after own-driver, when the customer pays the Kaya Go driver in cash. Repeat it safely with the same request_id.
  • delivered completes 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:

  1. On placement order.created is sent at once.

  2. The same event (same id) is sent again at 5 s, 10 s, 1 min, then every 20 s — until you call seen, accept or reject. A 2xx only means delivered.

  3. The shop has the normal decision window (5 minutes). No answer and the order expires: the customer is refunded and you get order.cancelled.

  4. accept moves it to preparing and the customer hears straight away.

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

RouteBodyAnswer
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

RouteScopeAnswer
GET /finance/balancefinance: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}/storestore:write{"store":{shop_id, name, is_open, reopen_at, is_pickup_allowed, paused_at}}
PUT /shops/{shop_id}/store/statusstore:write{is_open, reopen_at?} → {"store":…}

The balance is the owner's one business wallet, across all shops.

Webhooks

Envelope
{"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.

Node (Express)
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
});
Go
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 ping and its delivery log.
Typedata
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

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

Back to Kaya Leta