Geliştiriciler

LQM Menü API'si

Kasa (POS), web sitesi, kiosk ya da kendi uygulamanızı bir işletmenin LQM menüsüne bağlayın. Menünün sahibi LQM'dir: ürün, kategori, fiyat, çeviri ve fotoğraf orada yönetilir, uygulamanız okur. Tek istisna "tükendi" bilgisidir; stok bitince kasa yazar. Menüde bir şey değişince webhook ile haber verilir.

Temel adres: https://qr.ladom.co/api/v1 · Sürüm: v1

Genel bakış

  • REST ve JSON, UTF-8. Alan adları snake_case. Zamanlar ISO 8601, UTC: 2026-10-22T14:07:23Z.
  • Tutarlar kuruş cinsinden tam sayıdır (18500 = 185,00 ₺), yanında currency.
  • Her kaydın değişmeyen, string bir id'si vardır; silinen bir kaydın id'si yeniden kullanılmaz.
  • Bir işletme bir mekândır (venue) ve şimdilik tek şubesi vardır; şube kimliği mekân kimliğiyle aynıdır. Şube kimliğini GET /venue'dan alın.
  • Sürüm yoldadır (/v1). v1 içinde alan kaldırılmaz ve adı değişmez; yeni alanlar eklenebilir, bilmediğiniz alanları yok sayın. Uyumsuz değişiklik /v2 olur.

Erişim ve yetkiler

API anahtarlarını Ladom oluşturur: her işletme ve bağlanan her uygulama için ayrı bir anahtar. Anahtar için işletmenin adını, uygulamanızın adını ve gereken yetkileri hello@ladom.co adresine ya da WhatsApp'tan yazın.

http
Authorization: Bearer lqm_YOUR_KEY
YetkiNeye izin verir
menu:readTüm okuma uçları; webhook kaydetme.
menu:write"Tükendi" yazma uçları.
Anahtarı sadece sunucunuzda tutun; tarayıcıya, mobil uygulamaya ya da herkese açık bir depoya koymayın. Ladom anahtarı yenileyebilir (yeni anahtar verilir, eskisi hemen çalışmaz olur; webhook kaydınız yeni anahtara geçer) ya da iptal edebilir.

Hız sınırı

Her anahtar dakikada en çok 120 istek yapabilir. Her yanıtta X-RateLimit-Limit, X-RateLimit-Remaining başlıkları vardır. Aşılırsa 429 + Retry-After: 60 döner.

Hatalar

http
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json; charset=utf-8

{ "error": { "code": "validation_error", "message": "is_sold_out: Invalid input: expected boolean, received undefined" } }
HTTPcodeNe zaman
401unauthorizedAnahtar yok, tanınmıyor ya da iptal edilmiş.
403forbiddenAnahtarın bu yetkisi yok ya da şube başka bir işletmenin.
404not_foundBu şubede böyle bir kayıt yok.
409conflictElle kapatılmış ürünü açmaya çalıştınız (bkz. Tükendi).
422validation_errorGövde JSON değil, bilinmeyen alan ya da geçersiz değer; geçersiz sorgu parametresi.
429rate_limitedDakikalık sınır aşıldı.
500server_errorBizim tarafımızda bir sorun. Tekrar deneyin.

Diller

Metinler translations altındadır: { "tr": { "name": "…", "description": "…" }, "en": { … } }. Diller: tr, en, ar, ru.

  • ?lang verilmezse dört dil de gelir. ?lang=tr ile sadece o dil gelir.
  • Çevirisi girilmemiş bir alan boş dönmez: o dilde yoksa İngilizcesi, o da yoksa işletmenin asıl dili (default_language), o da yoksa Türkçesi verilir. Hiç girilmemiş alan null olur (ör. açıklama).

Sayfalama ve değişiklikler

  • Liste uçları ?limit=100 (1–500) ve ?cursor=… alır. Yanıt: { "data": [...], "next_cursor": "…" | null }. Sonraki sayfa için next_cursor'ı aynen gönderin; null ise son sayfadır.
  • ?updated_since=2026-10-22T14:00:00Z o andan beri değişen kayıtları verir ve silinenleri de: silinen kayıt sadece id, updated_at, deleted_at ile gelir. updated_since olmadan silinenler gelmez.
  • Kayıtlar (updated_at, id) sırasıyla gelir. Eşitlemeden sonra gördüğünüz en büyük updated_at'i saklayıp bir sonrakinde updated_since olarak gönderin (sınır dahildir; aynı kaydı tekrar görebilirsiniz, işlem tekrar edilebilir olmalı).
json
GET /branches/8beb7e3e-852c-428c-828b-62658089a800/products?updated_since=2026-10-22T14:30:00Z&limit=100

{
  "data": [
    { "id": "923fb739-…", "…": "…", "updated_at": "2026-10-22T14:30:36Z", "deleted_at": null },
    { "id": "1f3922e8-8018-48e4-bae6-e04a8801991d", "updated_at": "2026-10-22T14:31:15Z", "deleted_at": "2026-10-22T14:31:15Z" }
  ],
  "next_cursor": null
}

Veri modeli

Ürün (product)

json
{
  "id": "af04129a-aa38-4625-8033-2deecf2fbc39",
  "category_id": "6c118c2d-8693-411e-8885-83b345aa5f6a",
  "sort_order": 0,
  "sku": "BRG-001",
  "translations": {
    "tr": { "name": "Hamburger", "description": "El yapımı köfte", "ingredients": "Dana köfte, brioche ekmek, marul" },
    "en": { "name": "Hamburger", "description": "Handmade patty", "ingredients": "Beef patty, brioche bun, lettuce" },
    "ar": { "…": "…" },
    "ru": { "…": "…" }
  },
  "price": 25000,
  "currency": "TRY",
  "tax_rate": 10,
  "images": [{ "url": "https://ladomapps-media.ladom.co/menu/…/….webp", "is_primary": true }],
  "portion": "220 g",
  "calories": 650,
  "ingredients": ["Dana köfte", "brioche ekmek", "marul"],
  "allergens": ["gluten", "eggs", "milk", "sesame"],
  "allergens_declared": true,
  "contains_alcohol": false,
  "contains_pork": false,
  "option_group_ids": ["og_af04129a-aa38-4625-8033-2deecf2fbc39"],
  "is_active": true,
  "is_sold_out": false,
  "sold_out_source": null,
  "campaign": null,
  "updated_at": "2026-10-22T14:30:47Z",
  "deleted_at": null
}
AlanAçıklama
priceGüncel satış fiyatı (kuruş). Seçenekli üründe en ucuz seçeneğin fiyatı; diğer seçenekler price_delta kadar fazladır.
tax_rateKDV oranı (%): 0, 1, 10, 20 ya da işletme girmediyse null.
skuKasadaki ürün kodu; işletme panelde girer. Eşleştirme için.
translations.*.ingredientsİçindekiler (yönetmelik gereği menüde gösterilir), dillere göre metin.
ingredientsİçindekilerin liste hali (istenen ya da asıl dilde, virgülle ayrılmış metinden).
portionPorsiyon metni, ör. "220 g", "330 ml", "6 adet"; yoksa null.
allergensAlerjen kodları (aşağıda). allergens_declared: false ise işletme henüz girmemiştir; boş liste o zaman "bilinmiyor" demektir.
is_activefalse ise müşteri menüsünde görünmez; siz de göstermeyin.
is_sold_outTükendi. sold_out_source: manual (panelden elle) ya da pos; açıksa null.
campaignŞimdilik her zaman null (bkz. Henüz olmayanlar).

Seçenek grubu (option group)

Bir ürünün option_group_ids listesinde iki tür grup olabilir; ikisi de aynı biçimdedir:

  • Seçenek ve ekstra grupları (pişirme derecesi, ekstra malzeme, soslar…): işletme bir kez tanımlar, birçok ürüne bağlar. selection: single ya da multiple; min/max seçim sayısı (max null = sınır yok); price_delta ek ücret.
  • Boyutlar (küçük/büyük, tek/çift köfte): o ürüne özel, tek seçimli, zorunlu grup; kimliği og_<product id> ve product_id taşır. Ürünün price'ı en ucuz boyutun fiyatıdır, diğerleri price_delta kadar fazladır.

Seçenek kimlikleri kalıcıdır; tükendi yazarken bunları kullanın.

json
{
  "id": "og_af04129a-aa38-4625-8033-2deecf2fbc39",
  "product_id": "af04129a-aa38-4625-8033-2deecf2fbc39",
  "translations": { "tr": { "name": "Seçenek" }, "en": { "name": "Option" }, "…": "…" },
  "selection": "single",
  "required": true,
  "min": 1,
  "max": 1,
  "options": [
    { "id": "93e8a968-dd2f-427c-bbc5-80b842874dec", "translations": { "tr": { "name": "Tek köfte" }, "…": "…" },
      "price_delta": 0, "is_default": true, "is_sold_out": false, "sold_out_source": null, "sort_order": 0 },
    { "id": "944b2d3d-1a79-4a85-b817-3f296156a450", "translations": { "tr": { "name": "Çift köfte" }, "…": "…" },
      "price_delta": 9000, "is_default": false, "is_sold_out": true, "sold_out_source": "pos", "sort_order": 1 }
  ],
  "updated_at": "2026-10-22T14:30:47Z",
  "deleted_at": null
}
json
{
  "id": "17468dea-c551-49e5-9d3c-3e155dada881",
  "translations": { "tr": { "name": "Ekstralar" }, "en": { "name": "Extras" }, "…": "…" },
  "selection": "multiple",
  "required": false,
  "min": 0,
  "max": 3,
  "options": [
    { "id": "5c0b…", "translations": { "tr": { "name": "Ekstra peynir" }, "…": "…" }, "price_delta": 1500,
      "is_default": false, "is_sold_out": false, "sold_out_source": null, "sort_order": 0 },
    { "id": "285d1169-afd9-4330-bf38-39063626d609", "translations": { "tr": { "name": "Bacon" }, "…": "…" }, "price_delta": 3000,
      "is_default": false, "is_sold_out": false, "sold_out_source": null, "sort_order": 1 }
  ],
  "updated_at": "2026-10-22T14:52:10Z",
  "deleted_at": null
}

Alerjen kodları

Türk Gıda Kodeksi'nin 14 alerjen grubu (AB 1169/2011 ile aynı):

glutencrustaceanseggsfishpeanutssoymilknutscelerymustardsesamesulphiteslupinmolluscs

Okuma uçları

Hepsi menu:read yetkisi ister; listeler ?lang, ?limit, ?cursor ve ?updated_since alır.

GET/venue

İşletme, dilleri ve şubesi.

json
{
  "id": "8beb7e3e-852c-428c-828b-62658089a800",
  "name": "GG Burger",
  "currency": "TRY",
  "default_language": "tr",
  "languages": ["tr", "en", "ar", "ru"],
  "menu_url": "https://qr.ladom.co/ggburger",
  "branches": [
    { "id": "8beb7e3e-852c-428c-828b-62658089a800", "name": "GG Burger", "timezone": "Europe/Istanbul" }
  ]
}

GET/branches/{branch_id}/menu

Tüm menü tek yanıtta: kategoriler, ürünler, seçenek grupları (silinenler hariç). ETag döner; If-None-Match ile gönderirseniz menü değişmediyse 304 alırsınız. version menüdeki son değişikliğin zamanıdır.

bash
curl https://qr.ladom.co/api/v1/branches/8beb7e3e-852c-428c-828b-62658089a800/menu \
  -H "Authorization: Bearer lqm_YOUR_KEY" \
  -H 'If-None-Match: "kq3…"'

{
  "branch_id": "8beb7e3e-852c-428c-828b-62658089a800",
  "version": "2026-10-22T14:31:15Z",
  "categories": [ { "id": "6c118c2d-…", "parent_id": null, "sort_order": 0,
                    "translations": { "tr": { "name": "Burgerler" }, "…": "…" },
                    "image_url": null, "is_active": true, "updated_at": "…", "deleted_at": null } ],
  "products": [ { "…": "the product above" } ],
  "option_groups": [ { "…": "the option group above" } ]
}

GET/branches/{branch_id}/categories

GET/branches/{branch_id}/products

Ek filtre: ?category_id=… (bu filtreyle silinen ürünler gelmez).

GET/branches/{branch_id}/products/{product_id}

GET/branches/{branch_id}/option-groups

Tükendi (yazma)

Kasanın menüye yazabildiği tek bilgi "tükendi"dir; menu:write yetkisi ister.

PATCH/branches/{branch_id}/products/{product_id}/availability

PATCH/branches/{branch_id}/options/{option_id}/availability

Gövde: { "is_sold_out": true, "source": "pos" } (source isteğe bağlı, verilirse "pos" olmalı).

bash
curl -X PATCH https://qr.ladom.co/api/v1/branches/8beb7e3e-852c-428c-828b-62658089a800/products/923fb739-2339-41fe-b846-ba5c41ad21e5/availability \
  -H "Authorization: Bearer lqm_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 4f1c2a9e-stock-out-0001" \
  -d '{ "is_sold_out": true, "source": "pos" }'

{ "id": "923fb739-2339-41fe-b846-ba5c41ad21e5", "type": "product", "is_sold_out": true, "sold_out_source": "pos" }
Şu anİstekSonuç
Satıştais_sold_out: trueTükendi, kaynak pos.
Tükendi (pos)is_sold_out: falseSatışa açılır.
Tükendi (manual)is_sold_out: false409 conflict
Tükendi (herhangi)is_sold_out: trueDeğişmez, 200.

Kural: kasanın kapattığını sadece kasa açar; panelden elle kapatılanı kasa açamaz (409). Böylece yöneticinin bilerek kapattığı ürün stok girişiyle yanlışlıkla açılmaz. İşletme panelden her şeyi açabilir.

http
HTTP/1.1 409 Conflict

{ "error": { "code": "conflict", "message": "It was marked sold out by hand in the LQM panel; only the business can reopen it." } }

POST/branches/{branch_id}/availability/batch

En çok 200 satır; satırlar sırayla, tek tek uygulanır ve her biri için sonuç döner (ok, not_found, conflict).

json
POST /branches/8beb7e3e-852c-428c-828b-62658089a800/availability/batch

{
  "items": [
    { "type": "product", "id": "923fb739-2339-41fe-b846-ba5c41ad21e5", "is_sold_out": true },
    { "type": "option",  "id": "944b2d3d-1a79-4a85-b817-3f296156a450", "is_sold_out": false }
  ],
  "source": "pos"
}

200 OK
{
  "results": [
    { "status": "ok", "id": "923fb739-…", "type": "product", "is_sold_out": true, "sold_out_source": "pos" },
    { "status": "conflict", "id": "944b2d3d-…", "type": "option", "message": "It was marked sold out by hand…" }
  ]
}

Idempotency-Key

Yazma isteklerinde Idempotency-Key başlığı gönderebilirsiniz (en çok 200 karakter). Aynı anahtarla 24 saat içinde gelen istek ilk yanıtı aynen döner (Idempotent-Replayed: true); sunucu hataları saklanmaz, tekrar denenebilir.

Webhooks

Menüde bir şey değiştiğinde (işletmenin panelinden ya da API'den) adresinize bir POST gelir. Adresinizi ve gizli anahtarınızı kendiniz kaydedersiniz; anahtar başına bir webhook.

PUT/webhooks

Kaydeder ya da değiştirir (menu:read yeterli). events verilmezse tüm olaylar gelir. secret en az 16 karakter; adres https olmalı. Yeniden kaydetmek, kapanmış bir webhook'u açar.

bash
curl -X PUT https://qr.ladom.co/api/v1/webhooks \
  -H "Authorization: Bearer lqm_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://ladapp.example/api/webhooks/qr-menu",
    "events": ["product.updated", "product.deleted", "category.updated", "category.deleted",
               "option_group.updated", "option_group.deleted", "availability.changed"],
    "secret": "whsec_a-long-random-secret-you-choose"
  }'

GET/webhooks

Kayıtlı webhook ve son teslim durumu (last_status, last_attempt_at) ya da { "webhook": null }.

DELETE/webhooks

OlaydataNe zaman
product.updated{ id, changed }Ürün eklendi ya da herhangi bir alanı değişti (fiyat, metin, fotoğraf, tükendi, sıra…).
product.deleted{ id }Ürün silindi.
category.updated{ id, changed }Kategori eklendi ya da değişti.
category.deleted{ id }Kategori silindi.
option_group.updated{ id, changed }Bir grup eklendi ya da değişti (ad, kural, seçenekler, ek ücret, tükendi). Ürüne grup bağlanınca/çıkarılınca product.updated (option_group_ids) gelir.
option_group.deleted{ id }Grup silindi, ya da ürünün boyutları kaldırıldı / ürün silindi.
availability.changed{ id, type, is_sold_out, source }Ürün ya da seçenek tükendi/açıldı (kaynağı fark etmeksizin). type: product | option.
http
POST /api/webhooks/qr-menu HTTP/1.1
Content-Type: application/json
User-Agent: Ladom-Webhooks/1
X-Event-Id: evt_3b8f6c2e-0d4a-4f61-9a57-2c1e9d7b4a10
X-Timestamp: 2026-10-22T14:30:47Z
X-Signature: sha256=5f2c0e…a91b

{
  "id": "evt_3b8f6c2e-0d4a-4f61-9a57-2c1e9d7b4a10",
  "type": "product.updated",
  "branch_id": "8beb7e3e-852c-428c-828b-62658089a800",
  "occurred_at": "2026-10-22T14:30:47Z",
  "data": { "id": "af04129a-aa38-4625-8033-2deecf2fbc39", "changed": ["sku", "tax_rate"] }
}
  • Olay sadece değişen kaydın id'sini ve değişen alanları (changed, ör. translations.en.name) taşır; güncel veriyi ilgili GET ucundan çekin. Böylece sırası bozuk gelen olaylar sorun çıkarmaz.
  • "Tükendi" değişince hem availability.changed hem product.updated (ya da option_group.updated) gelir.
  • X-Event-Id benzersizdir; aynı olayı iki kez işlemeyin.
  • 5 saniye içinde 2xx dönün; yönlendirme (3xx) izlenmez. Üst üste 50 başarısız teslimden sonra webhook kapanır; yeniden kaydedince açılır.
v1'de her olay bir kez gönderilir, başarısızsa tekrar denenmez (üstel gecikmeli yeniden deneme planlandı, bkz. Henüz olmayanlar). Bu yüzden güvenlik ağı olarak her 10 dakikada bir updated_since ile eşitleme yapın.

İmzayı doğrulama

X-Signature: sha256=<hex>, ham gövdenin gizli anahtarınızla HMAC-SHA256'sıdır. İmzası tutmayan isteği işlemeyin.

node.js
import { createHmac, timingSafeEqual } from "node:crypto";

// rawBody: the request body exactly as received (before JSON.parse).
export function verifyLadomSignature(rawBody, signatureHeader, secret) {
  const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
  const got = String(signatureHeader ?? "");
  return got.length === expected.length && timingSafeEqual(Buffer.from(got), Buffer.from(expected));
}
php
<?php
// $rawBody = file_get_contents('php://input');
// $header  = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
function verify_ladom_signature(string $rawBody, string $header, string $secret): bool {
    $expected = 'sha256=' . hash_hmac('sha256', $rawBody, $secret);
    return hash_equals($expected, $header);
}
python
import hashlib, hmac

def verify_ladom_signature(raw_body: bytes, header: str, secret: str) -> bool:
    expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, header or "")

Örnek entegrasyon: kasa (POS)

  1. GET /venue ile şube kimliğini alın; GET /branches/{id}/menu ile menüyü çekin. Ürünleri id (ya da işletmenin girdiği sku) ile kendi ürünlerinizle eşleştirin.
  2. PUT /webhooks ile adresinizi kaydedin.
  3. Stok bitince PATCH …/availability { is_sold_out: true }, gelince false. Birden çok ürün için batch ucunu kullanın. 409 gelirse ürün elle kapatılmıştır; açmaya çalışmayın.
  4. Webhook gelince ilgili kaydı GET ile çekin; product.deleted gelince eşleşmeyi kaldırın.
  5. Her 10 dakikada bir updated_since ile eşitleyin; arada If-None-Match ile tam menü kontrolü de yapabilirsiniz.

Henüz olmayanlar

  • Webhook teslimlerinin üstel gecikmeyle yeniden denenmesi.
  • Birden çok şube ve şubeye göre fiyat; alt kategori (parent_id her zaman null); kategori görseli (image_url null).
  • Kampanya fiyatı ve rozeti (campaign her zaman null); fiyat geçmişi ucu.

Sürümler

TarihDeğişiklik
22 Ekim 2026Seçenek ve ekstra grupları (tek/çoklu seçim, min/max, ek ücret); option-groups listesinde ve ürünlerin option_group_ids'inde. Bu grupların seçeneklerine de tükendi yazılabilir.
22 Ekim 2026v1: mekân ve şube, tam menü (ETag), listeler (updated_since, silinenler, sayfalama), tükendi yazma (tekil, seçenek, toplu, Idempotency-Key), webhook kaydı ve olayları.

Sorular ve anahtar talepleri: hello@ladom.co