KNZ | كنز للمطورينDevelopers
الموقعWebsite OpenAPI

KNZ API · v1

اربط أي نظام بكنزConnect any system to KNZ

واجهة REST بصيغة JSON لكل ما يحتاجه نظام البيع: تعرّف العميل، سجّل الطلب المدفوع، استبدل المكافأة، وسجّل المرتجع. والأحداث تصلك عبر Webhooks موقّعة لحظة حدوثها.

A JSON REST API for everything a point of sale needs: find the customer, record the paid order, redeem a reward and record a refund. Events reach you as signed webhooks the moment they happen.

البداية السريعةQuick start

  1. التاجر يفتح لوحة كنز ← الربط ← نقطة بيع أخرى / API ويختار الفرع، فيحصل على كود ربط يبدأ بـ PLC1-.
  2. نظامك يفك الكود (Base64URL لكائن JSON) فيعرف منه: عنوان الـ API، ورقم التاجر، والفرع، ورقم الربط، والمفتاح.
  3. جرّب الربط بطلب GET /v1/integration/profile: يرجع اسم التاجر والفروع والبرامج النشطة بمكافآتها ورابط الاشتراك.
  1. The merchant opens the KNZ dashboard → Integrations → Other POS / API, picks the branch and gets a connection code starting with PLC1-.
  2. Your system decodes it (Base64URL of a JSON object): API address, merchant id, branch, connection id and key.
  3. Test the connection with GET /v1/integration/profile: it returns the merchant, branches and active programs with their rewards and join link.
// PLC1-<base64url(JSON)>
const code = "PLC1-eyJ2IjoxLCJhcGkiOi…";
const c = JSON.parse(atob(code.slice(5).replace(/-/g, "+").replace(/_/g, "/")));
// c = { v: 1, api: "https://…", tenant: "<uuid>", branch: "<uuid>", connection: "<uuid>", key: "<secret>" }
import base64, json
code = "PLC1-eyJ2IjoxLCJhcGkiOi…"
raw = code[5:] + "=" * (-len(code[5:]) % 4)
c = json.loads(base64.urlsafe_b64decode(raw))
# c = {"v": 1, "api": "https://…", "tenant": "<uuid>", "branch": "<uuid>", "connection": "<uuid>", "key": "<secret>"}
$code = "PLC1-eyJ2IjoxLCJhcGkiOi…";
$c = json_decode(base64_decode(strtr(substr($code, 5), "-_", "+/")), true);
// $c = ["v" => 1, "api" => "https://…", "tenant" => "<uuid>", "branch" => "<uuid>", "connection" => "<uuid>", "key" => "<secret>"]
curl {{API}}/v1/integration/profile \
  -H "Authorization: Bearer $KNZ_KEY" \
  -H "X-Tenant-ID: $KNZ_TENANT"
const BASE = "{{API}}";
const res = await fetch(`${BASE}/v1/integration/profile`, {
  headers: { Authorization: `Bearer ${process.env.KNZ_KEY}`, "X-Tenant-ID": process.env.KNZ_TENANT },
});
if (!res.ok) throw new Error((await res.json()).code);   // application/problem+json
const profile = await res.json();
import os, requests

BASE = "{{API}}"
r = requests.get(f"{BASE}/v1/integration/profile", timeout=10, headers={
    "Authorization": f"Bearer {os.environ['KNZ_KEY']}",
    "X-Tenant-ID": os.environ["KNZ_TENANT"],
})
r.raise_for_status()
profile = r.json()
$ch = curl_init("{{API}}/v1/integration/profile");
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer " . getenv("KNZ_KEY"),
        "X-Tenant-ID: " . getenv("KNZ_TENANT"),
    ],
]);
$profile = json_decode(curl_exec($ch), true);
المفتاح يعمل لفرعه فقط، ويُعطَّل من اللوحة في أي وقت. احفظه كسرّ، ولا تضعه في كود يعمل في المتصفح.The key works for its own branch only and can be disabled from the dashboard at any time. Keep it secret and never ship it in browser code.

الدخول والهيدرزAuthentication and headers

الهيدرHeaderالقيمةValueمتىWhen
AuthorizationBearer <key>كل طلبevery request
X-Tenant-ID<tenant uuid>كل طلبevery request
Idempotency-Key16–128 حرفاً، ثابت لنفس العملية16–128 characters, stable per operationكل POSTevery POST
Content-Typeapplication/jsonطلبات لها جسمrequests with a body
X-Request-IDاختياري (UUID)optional (UUID)يظهر في request_id بالأخطاء لتتبع الطلبechoed as request_id in errors, for tracing

مفتاح الربط (نقطة البيع) يملك صلاحيات الكاشير والمشتريات والمرتجعات لفرعه. أما إدارة Webhooks فتحتاج صلاحية connection.manage: المالك، ومن أعطيته صلاحية الربط.

A connection (POS) key carries cashier, purchase and refund permissions for its branch. Managing webhooks needs the connection.manage permission: the owner, plus anyone you gave connection access.

المبالغ والنقاطMoney and points

  • المبالغ أعداد صحيحة كنصوص بالوحدات الصغرى (هللة)، مع currency وcurrency_exponent: ‏"1840" مع أس 2 = 18.40 ر.س.
  • النقاط تُكسب على الصافي بعد الخصم وقبل الضريبة.
  • النقاط في الأحداث والأرصدة أعداد صحيحة.
  • الأوقات بصيغة ISO-8601 بتوقيت UTC.
  • Money is integer strings in minor units (halalas), with currency and currency_exponent: "1840" with exponent 2 = SAR 18.40.
  • Points are earned on the net after discounts, before VAT.
  • Points in events and balances are integers.
  • Times are ISO-8601 in UTC.

منع التكرار (Idempotency)Idempotency

كل طلب POST أو DELETE يحمل هيدر Idempotency-Key بطول 16 إلى 128 حرفاً. أعد استخدام المفتاح نفسه عند إعادة المحاولة، فلا تُنفَّذ العملية مرتين.

Every POST and DELETE carries an Idempotency-Key header of 16 to 128 characters. Reuse the same key when you retry and the operation never runs twice.

الحالةCaseالردAnswer
نفس المفتاح ونفس الجسمSame key, same bodyالرد الأول نفسهThe original answer, replayed
نفس المفتاح وجسم مختلفSame key, different body409 IDEMPOTENCY_CONFLICT
الطلب الأول ما زال قيد التنفيذThe first request is still running409 OPERATION_IN_PROGRESS (قابل للإعادة؛ استعلم GET /v1/operations/{id})(retryable; query GET /v1/operations/{id})
هيدر مفقود أو قصيرHeader missing or too short400 IDEMPOTENCY_KEY_REQUIRED

يُحفظ سجل المفتاح 30 يوماً. ومعرّفات العمل مثل external_order_id وexternal_refund_id فريدة للأبد: الطلب نفسه لا يكسب مرتين حتى بمفتاح جديد.

A key's record is kept for 30 days. Business ids such as external_order_id and external_refund_id are unique forever: the same order never earns twice, even under a new key.

الأخطاءErrors

كل خطأ يرجع بصيغة application/problem+json. اعتمد على code في منطقك، وأعد المحاولة فقط عندما يكون retryable صحيحاً أو الحالة 429 أو ‎5xx.

Every error is application/problem+json. Branch on code, and retry only when retryable is true or on 429 and 5xx.

HTTP/1.1 409 Conflict
Content-Type: application/problem+json

{
  "type": "…/idempotency_conflict",
  "title": "Idempotency conflict",
  "status": 409,
  "code": "IDEMPOTENCY_CONFLICT",
  "request_id": "0b7f3c1e-5a2d-4c8e-9f61-2d4a7b9c0e13",
  "retryable": false,
  "detail": "…",
  "field_errors": []
}
HTTPالمعنىMeaningأمثلة على codeExample codes
400الطلب غير صالح؛ التفاصيل في field_errorsInvalid request; details in field_errorsVALIDATION_FAILED IDEMPOTENCY_KEY_REQUIRED
401الدخول مفقود أو منتهٍMissing or expired credentialsINVALID_TOKEN SESSION_REVOKED TWO_FACTOR_REQUIRED
403لا صلاحية لهذا الإجراء أو هذا التاجرNot allowed for this action or merchantPERMISSION_DENIED TENANT_MISMATCH
404غير موجودNot foundNOT_FOUND OPERATION_NOT_FOUND WEBHOOK_NOT_FOUND WEBHOOK_DELIVERY_NOT_FOUND
409تعارض مع طلب سابقConflicts with an earlier requestIDEMPOTENCY_CONFLICT OPERATION_IN_PROGRESS
422قاعدة عمل رفضت الطلبA business rule refused the requestRESERVATION_EXPIRED BASKET_CHANGED WEBHOOK_URL_INVALID WEBHOOK_URL_PRIVATE WEBHOOK_LIMIT
429تجاوز حد محدد (انظر الحدود)A specific limit was hit (see rate limits)RECEIPT_DAILY_LIMIT OTP_RATE_LIMITED TOO_MANY_ATTEMPTS
500خطأ غير متوقع؛ استعلم العملية قبل الإعادةUnexpected; query the operation before retryingINTERNAL

الحدودRate limits

لا يوجد اليوم حد عام لعدد الطلبات لكل مفتاح. توجد حدود محددة لحماية النقاط والحسابات، وكلها ترجع 429:

There is no general per-key request limit today. Specific limits protect points and accounts, and all of them answer 429:

codeالحدLimit
RECEIPT_DAILY_LIMITفواتير ممسوحة لكل عميل في اليوم (3 افتراضياً). قابل للإعادة في اليوم التالي.Scanned receipts per customer per day (3 by default). Retryable the next day.
OTP_RATE_LIMITED5 رموز تحقق لكل رقم كل 10 دقائق.5 verification codes per number per 10 minutes.
OTP_LOCKEDمحاولات رمز خاطئة كثيرة على الرقم نفسه.Too many wrong codes for the same number.
TOO_MANY_ATTEMPTSتسجيل حساب: 10 كل 60 دقيقة. تسجيل دخول: 30 كل 15 دقيقة لكل شبكة، و10 لكل بريد.Sign-up: 10 per 60 minutes. Sign-in: 30 per 15 minutes per network, and 10 per email.
ACCOUNT_LOCKEDقفل الحساب 15 دقيقة بعد محاولات دخول خاطئة.Account locked for 15 minutes after failed sign-ins.
AI_PROVIDER_BUSYمزوّد الذكاء الاصطناعي الذي اختاره التاجر مشغول.The merchant's chosen AI provider is busy.

عند 429 أو ‎5xx أعد المحاولة بتأخير متزايد مع شيء من العشوائية، وبنفس Idempotency-Key.

On 429 or 5xx, back off exponentially with jitter and retry with the same Idempotency-Key.

العملياتOperations

أوامر الكتابة (المشتريات، الاستبدال، المرتجع…) ترجع عملية فيها id وstate وresource_id (رقم ما أُنشئ). لو انقطع الاتصال قبل وصول الرد، استعلمها:

Write commands (purchases, redemptions, refunds…) return an Operation with id, state and resource_id (what was created). If the connection dropped before the answer arrived, query it:

curl {{API}}/v1/operations/<operation id> -H "Authorization: Bearer $KNZ_KEY" -H "X-Tenant-ID: $KNZ_TENANT"

{"id": "…", "state": "COMMITTED", "resource_id": "…", "created_at": "…", "request_id": "…"}

دليل ربط نقطة البيع خطوة بخطوةPOS integration, step by step

ابدأ على مفتاح التجربة، ولما كل خطوة تشتغل بدّل المفتاح بمفتاح الفرع الحقيقي. مفيش أي سطر كود تاني بيتغير.

Build on the sandbox key; when every step works, swap it for the branch's real key. No other line of code changes.

  1. خد مفتاح التجربة من صاحب المحل (لوحة كنز ← الربط ← مفتاح تجربة للمطورين).
  2. فك الكود واستدعِ GET /v1/integration/profile: لازم يرجع "sandbox": true (البداية السريعة).
  3. عند الكاشير: امسح QR العميل أو اكتب رقمه (الخطوة ١). جرّب على العميل التجريبي.
  4. بعد الدفع: ابعت الطلب المدفوع، والعميل يكسب (الخطوة ٢). ابعت كل طلب بـ Idempotency-Key ثابت (منع التكرار).
  5. المكافأة: احجزها واستخدمها على نفس الطلب (الخطوة ٣).
  6. المرتجع: ابعته على الطلب الأصلي، والنقاط بتتخصم لوحدها (الخطوة ٤).
  7. اختياري: استقبل الأحداث على نظامك (Webhooks).
  8. التشغيل: صاحب المحل يعمل «ربط جديد ← نقطة بيع أخرى / API» لكل فرع ويديك الكود الحقيقي (PLC1-). بدّل الكود، وخلاص.
  1. Get the sandbox key from the shop owner (KNZ dashboard → Integrations → Developer sandbox key).
  2. Decode the code and call GET /v1/integration/profile: it must answer "sandbox": true (Quick start).
  3. At the till: scan the customer's QR or type the phone (step 1). Try it on the demo customer.
  4. After payment: send the paid order and the customer earns (step 2). Send each order with a stable Idempotency-Key (Idempotency).
  5. Rewards: hold one and use it on the same order (step 3).
  6. Refunds: send them against the original order; the points come off by themselves (step 4).
  7. Optional: receive the events on your system (Webhooks).
  8. Going live: the owner adds "New connection → Other POS / API" for each branch and gives you the real code (PLC1-). Swap the code; that is all.
قبل التشغيل اتأكد إنBefore going live, check that
الطلب اللي اتبعت مرتين بنفس Idempotency-Key بيتسجل مرة واحدة.An order sent twice with the same Idempotency-Key is recorded once.
لو النت قطع، الطلبات بتستنى وتتبعت بعدين بنفس المفتاح، ووقتها occurred_at هو وقت البيع الحقيقي.When the network drops, orders wait and are sent later with the same key, with occurred_at the real time of sale.
المبالغ بالهللة (أصغر وحدة) ونصوص أرقام، مش كسور (المبالغ).Amounts are in minor units, as digit strings, never fractions (Money).
خطأ 409 أو 422 بيظهر للكاشير برسالة مفهومة، و429 و5xx بيتعاد بعد شوية (الأخطاء).A 409 or 422 shows the cashier a clear message; 429 and 5xx are retried a little later (Errors).

مفتاح التجربة (Sandbox)The sandbox key

صاحب المحل يطلعه من اللوحة بزرار واحد. بيفتح محل تجربة منفصل: نسخة من برنامجه (القواعد والمكافآت)، وفرع «فرع التجربة»، وعميل تجريبي معاه رصيد. المفتاح ده بيشتغل زي مفتاح الكاشير بالظبط، لكن على محل التجربة بس: مستحيل يشوف أو يغيّر عملاء المحل الحقيقيين أو أرصدتهم.

The shop owner makes it from the dashboard with one button. It opens a separate test shop: a copy of the program (rules and rewards), a branch "Sandbox branch" and a demo customer with a balance. The key behaves exactly like a till key, but only on the test shop: it can never see or change the real customers or their balances.

# the code decodes like any PLC1- code: tenant = the sandbox shop, branch = the sandbox branch
curl {{API}}/v1/integration/profile -H "Authorization: Bearer $KNZ_KEY" -H "X-Tenant-ID: $KNZ_TENANT"
# → {"sandbox": true, "programs": [ … the copied program and rewards … ], …}

# the demo customer: phone +966500000001
curl -X POST {{API}}/v1/cashier/lookup -H "Authorization: Bearer $KNZ_KEY" -H "X-Tenant-ID: $KNZ_TENANT" \
  -H "Idempotency-Key: lookup-$(uuidgen)" -H "Content-Type: application/json" \
  -d '{"phone": "0500000001", "branch_id": "<branch from the code>", "terminal_id": "<your till uuid>"}'
  • كل اللي بتعمله بيفضل في محل التجربة، وصاحب المحل يشوف عدد عمليات الشراء التجريبية في اللوحة.
  • لو المفتاح اتسرّب: صاحب المحل يدوس «مفتاح جديد» والقديم يقف فوراً. بيانات التجربة بتفضل زي ما هي.
  • مفتاح التجربة مبيتحسبش على باقة المحل ومبيدفعش عليه حاجة.
  • Everything you do stays in the test shop; the owner sees the number of test purchases on the dashboard.
  • If the key leaks, the owner presses "New key" and the old one stops at once. The test data stays.
  • The sandbox does not count against the shop's plan and costs nothing.

١. العميل عند الكاشير1. The customer at the till

الكاشير يمسح الـ QR من جوال العميل (يبدأ بـ plc1.) أو يكتب رقمه. المسح يسمح باستخدام المكافآت، والرقم يكفي لكسب النقاط فقط.

The cashier scans the QR on the customer's phone (starts with plc1.) or types the phone. A scan allows spending rewards; a phone number is enough to earn.

curl -X POST {{API}}/v1/cashier/resolve -H "Authorization: Bearer $KNZ_KEY" -H "X-Tenant-ID: $KNZ_TENANT" \
  -H "Idempotency-Key: resolve-$(uuidgen)" -H "Content-Type: application/json" \
  -d '{"barcode_token": "plc1.…", "branch_id": "<branch>", "terminal_id": "<your till uuid>"}'

curl -X POST {{API}}/v1/cashier/lookup  … -d '{"phone": "+9665XXXXXXXX", "branch_id": "<branch>", "terminal_id": "<till>"}'
curl -X POST {{API}}/v1/cashier/enrollments … -d '{"phone": "+9665XXXXXXXX", "branch_id": "<branch>", "terminal_id": "<till>", "display_name": "Sara"}'

الرد فيه membership.id (احفظه على الطلب)، والرصيد، والمكافآت المتاحة. رقم غير مسجل يرجع 404: اعرض «اشترك الآن» واستدعِ enrollments.

The answer carries membership.id (keep it on the order), the balance and the available rewards. An unknown phone answers 404: offer "join now" and call enrollments.

٢. الطلب المدفوع يكسب2. A paid order earns

بعد الدفع أرسل الطلب مرة واحدة. لو انقطع الاتصال أعد الإرسال بنفس Idempotency-Key ونفس external_order_id، ولن تُحسب النقاط مرتين.

After payment send the order once. If the network drops, resend with the same Idempotency-Key and external_order_id; points are never given twice.

curl -X POST {{API}}/v1/purchases -H "Authorization: Bearer $KNZ_KEY" -H "X-Tenant-ID: $KNZ_TENANT" \
  -H "Idempotency-Key: purchase:order-10045" -H "Content-Type: application/json" -d '{
  "connection_id": "<connection>", "external_order_id": "order-10045", "source_revision": 1,
  "membership_id": "<membership>", "branch_id": "<branch>", "source_mode": "INTEGRATED",
  "occurred_at": "2026-10-29T16:45:00Z", "currency": "SAR", "currency_exponent": 2, "paid": true,
  "lines": [{"line_id": "1", "product_id": "SKU-APPLE-PIE", "quantity": "2",
             "net_minor": "1600", "tax_minor": "240", "discount_minor": "0", "gross_minor": "1840", "is_reward": false}],
  "net_minor": "1600", "tax_minor": "240", "gross_minor": "1840", "receipt_reference": "R-10045"}'
const order = {
  connection_id: conn.connection, external_order_id: "order-10045", source_revision: 1,
  membership_id, branch_id: conn.branch, source_mode: "INTEGRATED",
  occurred_at: new Date().toISOString(), currency: "SAR", currency_exponent: 2, paid: true,
  lines: [{ line_id: "1", product_id: "SKU-APPLE-PIE", quantity: "2",
            net_minor: "1600", tax_minor: "240", discount_minor: "0", gross_minor: "1840", is_reward: false }],
  net_minor: "1600", tax_minor: "240", gross_minor: "1840", receipt_reference: "R-10045",
};
const res = await fetch(`${BASE}/v1/purchases`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.KNZ_KEY}`, "X-Tenant-ID": process.env.KNZ_TENANT,
    "Idempotency-Key": `purchase:${order.external_order_id}`, "Content-Type": "application/json",
  },
  body: JSON.stringify(order),
});
const operation = await res.json();   // operation.resource_id = the purchase id
order = {
    "connection_id": conn["connection"], "external_order_id": "order-10045", "source_revision": 1,
    "membership_id": membership_id, "branch_id": conn["branch"], "source_mode": "INTEGRATED",
    "occurred_at": "2026-10-29T16:45:00Z", "currency": "SAR", "currency_exponent": 2, "paid": True,
    "lines": [{"line_id": "1", "product_id": "SKU-APPLE-PIE", "quantity": "2",
               "net_minor": "1600", "tax_minor": "240", "discount_minor": "0", "gross_minor": "1840", "is_reward": False}],
    "net_minor": "1600", "tax_minor": "240", "gross_minor": "1840", "receipt_reference": "R-10045",
}
r = requests.post(f"{BASE}/v1/purchases", json=order, timeout=10, headers={
    "Authorization": f"Bearer {os.environ['KNZ_KEY']}", "X-Tenant-ID": os.environ["KNZ_TENANT"],
    "Idempotency-Key": f"purchase:{order['external_order_id']}",
})
operation = r.json()   # operation["resource_id"] = the purchase id
$order = [
    "connection_id" => $c["connection"], "external_order_id" => "order-10045", "source_revision" => 1,
    "membership_id" => $membershipId, "branch_id" => $c["branch"], "source_mode" => "INTEGRATED",
    "occurred_at" => gmdate("Y-m-d\TH:i:s\Z"), "currency" => "SAR", "currency_exponent" => 2, "paid" => true,
    "lines" => [["line_id" => "1", "product_id" => "SKU-APPLE-PIE", "quantity" => "2",
                 "net_minor" => "1600", "tax_minor" => "240", "discount_minor" => "0", "gross_minor" => "1840", "is_reward" => false]],
    "net_minor" => "1600", "tax_minor" => "240", "gross_minor" => "1840", "receipt_reference" => "R-10045",
];
$ch = curl_init("{{API}}/v1/purchases");
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode($order),
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer " . getenv("KNZ_KEY"), "X-Tenant-ID: " . getenv("KNZ_TENANT"),
        "Idempotency-Key: purchase:" . $order["external_order_id"], "Content-Type: application/json",
    ],
]);
$operation = json_decode(curl_exec($ch), true);   // $operation["resource_id"] = the purchase id

الرد عملية: رقم المشتريات في resource_id، احفظه للمرتجع. الصافي بعد الخصم هو ما يكسب النقاط.

The answer is an Operation: the purchase id is resource_id; keep it for refunds. The net after discounts is what earns.

٣. استخدام مكافأة3. Using a reward

  1. عرض سعر: POST /v1/redemption-quotes بالسلة وبصمتها ← قيمة الخصم والنقاط.
  2. حجز: POST /v1/redemptions ← النقاط محجوزة دقيقتين. أضف سطر خصم بالقيمة.
  3. تأكيد بعد الدفع: POST /v1/redemptions/{id}/commit بنفس رقم الطلب وبصمة السلة.
  4. لو أُلغي السطر أو الطلب: POST /v1/redemptions/{id}/cancel فتعود النقاط.
  5. لو رجّع العميل الطلب بالمكافأة بعد الدفع: POST /v1/redemptions/{id}/reverse فتعود نقاط المكافأة (يحتاج صلاحية المرتجع).
  1. Quote: POST /v1/redemption-quotes with the basket and its hash → discount and points.
  2. Hold: POST /v1/redemptions → points held for two minutes. Add a discount line for the amount.
  3. Commit after payment: POST /v1/redemptions/{id}/commit with the same order id and basket hash.
  4. Line or order dropped: POST /v1/redemptions/{id}/cancel and the points come back.
  5. Order returned with its reward after payment: POST /v1/redemptions/{id}/reverse gives the reward's points back (needs the refund permission).
# basket_hash = sha256 of the basket sorted by line_id, compact JSON with sorted keys
import hashlib, json
canon = json.dumps(sorted(basket, key=lambda l: l["line_id"]), sort_keys=True, separators=(",", ":"), ensure_ascii=False)
basket_hash = hashlib.sha256(canon.encode()).hexdigest()

لو انتهى الحجز قبل التأكيد يرجع RESERVATION_EXPIRED: اطلب عرض سعر جديد واحجز وأكّد مرة أخرى. ولو تغيّرت السلة: BASKET_CHANGED.

A hold that ran out answers RESERVATION_EXPIRED: quote, hold and commit again. A changed basket answers BASKET_CHANGED.

٤. المرتجع4. Refunds

أرسل الأصناف المرتجعة مقابل سطور المشتريات الأصلية. النظام يسحب نقاط ما رُجع فقط، ويتعامل مع النقاط المصروفة بدَين يُسدَّد من المشتريات القادمة.

Send the returned items against the original purchase's lines. Only the points those items earned are taken back; points already spent become a debt repaid by future earning.

curl -X POST {{API}}/v1/refunds … -H "Idempotency-Key: refund:order-10045-r1" -d '{
  "external_refund_id": "order-10045-r1", "original_purchase_id": "<resource_id of the purchase>",
  "occurred_at": "2026-10-29T18:00:00Z", "reason_code": "POS_REFUND",
  "lines": [{"original_line_id": "1", "quantity": "1", "net_minor": "800", "tax_minor": "120", "gross_minor": "920"}]}'

قواعد مهمةRules that matter

  • لا توقف البيع أبداً: لو لم يرد الـ API أكمل البيع وأرسل الطلب لاحقاً بنفس المفتاح.
  • الخصوصية: الأرقام ترجع مقنّعة، ولا تخزّن عن العميل أكثر من رقم العضوية.
  • الأخطاء: اعتمد على code وليس على نص الرسالة.
  • الوقت: أرسل occurred_at بوقت البيع الفعلي، وليس وقت الإرسال.
  • Never block the sale: if the API does not answer, finish the sale and send the order later with the same key.
  • Privacy: phones come back masked; keep nothing about the customer beyond the membership id.
  • Errors: branch on code, never on the message text.
  • Time: send occurred_at as the real sale time, not the send time.

Webhooks

أحداث موقّعة إلى نظامكSigned events to your system

عندما ينضم عميل أو يكسب نقاطاً أو يستبدل مكافأة، يرسل كنز طلب POST بصيغة JSON إلى عنوانك، موقّعاً بسرّ مشترك بينك وبيننا فقط.

When a customer joins, earns points or redeems a reward, KNZ sends a JSON POST to your address, signed with a secret only you and we know.

POST /webhooks/knz HTTP/1.1
Content-Type: application/json
User-Agent: KNZ-Webhooks/1.0
KNZ-Webhook-Id: evt_3b1f0c9e2a7d4e5f8a6b1c2d3e4f5a6b
KNZ-Webhook-Timestamp: 1793607247
KNZ-Signature: v1=5b0c4e7d…e91

{"id":"evt_3b1f0c9e2a7d4e5f8a6b1c2d3e4f5a6b","type":"points.earned","created_at":"2026-11-02T09:14:07+00:00",
 "tenant_id":"6f1c9b2e-4d7a-4c1e-9a52-1b7f0e3d2a11","data":{…}}
الهيدر / الحقلHeader / fieldالمعنىMeaning
KNZ-Webhook-Idمعرّف الحدث، يساوي id في الجسم. استخدمه لمنع المعالجة المكررة.The event id, equal to id in the body. Use it to de-duplicate.
KNZ-Webhook-Timestampوقت الإرسال بثواني يونكس.Send time in Unix seconds.
KNZ-Signature"v1=" + hex(HMAC-SHA256(secret, timestamp + "." + raw_body))
User-AgentKNZ-Webhooks/1.0
الجسمBodyJSON مضغوط بلا مسافات، UTF-8. وقّع على البايتات كما وصلت، لا على JSON أعدت تنسيقه.Compact JSON with no spaces, UTF-8. Sign the bytes exactly as received, never re-serialised JSON.
idevt_ + 32 حرفاً سداسياً عشرياً.+ 32 hex characters.
typeنوع الحدث (انظر الأحداث).The event type (see events).
created_atوقت وقوع الحدث، ISO-8601 بتوقيت UTC.When it happened, ISO-8601 in UTC.
tenant_idرقم التاجر (UUID).The merchant id (UUID).
dataبيانات الحدث.The event's data.

إدارة عناوين الاستقبال (Endpoints)Managing endpoints

أضف العنوان من لوحة التاجر ← الربط ← Webhooks، أو عبر الـ API بصلاحية connection.manage (المالك، ومن أعطيته صلاحية الربط). حتى 10 عناوين استقبال. العنوان يجب أن يكون https:// على عنوان عام؛ العناوين الخاصة وlocalhost تُرفض بـ 422 WEBHOOK_URL_PRIVATE، ولا نتبع التحويلات (redirects). كل POST وDELETE يحتاج Idempotency-Key. السر يظهر مرة واحدة فقط عند الإنشاء، بصيغة whsec_ متبوعة بـ 32 حرف base64url.

Add the address in the merchant dashboard → Integrations → Webhooks, or through the API with the connection.manage permission (the owner, plus anyone given connection access). Up to 10 endpoints. The address must be public https://; private and loopback hosts are refused with 422 WEBHOOK_URL_PRIVATE, and redirects are not followed. Every POST and DELETE needs an Idempotency-Key. The secret is shown only once, at creation: whsec_ followed by 32 base64url characters.

POST/v1/webhooksإنشاء: {url, events, description?} ← 201 {id, url, events, description, status, created_at, secret_hint, secret}Create: {url, events, description?} → 201 {id, url, events, description, status, created_at, secret_hint, secret}
GET/v1/webhooks{items: [{…, deliveries_24h: {delivered, pending, failed}}], events: […]}؛ events هي الأحداث المتاحة للاشتراك{items: [{…, deliveries_24h: {delivered, pending, failed}}], events: […]}; events lists what you can subscribe to
DELETE/v1/webhooks/{id}حذف ← {id, status: "DELETED"}Delete → {id, status: "DELETED"}
POST/v1/webhooks/{id}/testيرسل test.ping فوراً ويعيد التسليم ومعه last_status (رد خادمك)Sends a test.ping at once and returns the delivery with last_status (your server's answer)
GET/v1/webhooks/{id}/deliveriesآخر 100 تسليم: {id, endpoint_id, event_id, event_type, status, attempts, last_status, last_error, created_at, next_attempt_at, delivered_at, body}، والحالة PENDING أو DELIVERED أو FAILEDThe last 100: {id, endpoint_id, event_id, event_type, status, attempts, last_status, last_error, created_at, next_attempt_at, delivered_at, body}, status PENDING, DELIVERED or FAILED
POST/v1/webhooks/deliveries/{delivery_id}/retryإعادة إرسال تسليم (ومنها FAILED)Send a delivery again (including a FAILED one)
curl -X POST {{API}}/v1/webhooks \
  -H "Authorization: Bearer $KNZ_TOKEN" -H "X-Tenant-ID: $KNZ_TENANT" \
  -H "Idempotency-Key: webhook-create-erp-0001" -H "Content-Type: application/json" \
  -d '{"url": "https://erp.example.com/webhooks/knz", "events": ["points.earned", "reward.redeemed"], "description": "ERP"}'

# 201
{"id": "c2d7…", "url": "https://erp.example.com/webhooks/knz", "events": ["points.earned", "reward.redeemed"],
 "description": "ERP", "status": "ACTIVE", "created_at": "2026-11-02T09:14:07+00:00",
 "secret_hint": "…Ga5e", "secret": "whsec_q3Zt8VbH1nR6kP0sXw4yLm2cFj9dGa5e"}
const res = await fetch(`${BASE}/v1/webhooks`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.KNZ_TOKEN}`, "X-Tenant-ID": process.env.KNZ_TENANT,
    "Idempotency-Key": `webhook-create-${crypto.randomUUID()}`, "Content-Type": "application/json",
  },
  body: JSON.stringify({
    url: "https://erp.example.com/webhooks/knz",
    events: ["points.earned", "reward.redeemed"],
    description: "ERP",
  }),
});
const { id, secret } = await res.json();   // store the secret now: it is never shown again
import uuid
r = requests.post(f"{BASE}/v1/webhooks", timeout=10, json={
    "url": "https://erp.example.com/webhooks/knz",
    "events": ["points.earned", "reward.redeemed"],
    "description": "ERP",
}, headers={
    "Authorization": f"Bearer {os.environ['KNZ_TOKEN']}", "X-Tenant-ID": os.environ["KNZ_TENANT"],
    "Idempotency-Key": f"webhook-create-{uuid.uuid4()}",
})
hook = r.json()   # store hook["secret"] now: it is never shown again
$ch = curl_init("{{API}}/v1/webhooks");
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => json_encode([
        "url" => "https://erp.example.com/webhooks/knz",
        "events" => ["points.earned", "reward.redeemed"],
        "description" => "ERP",
    ]),
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer " . getenv("KNZ_TOKEN"), "X-Tenant-ID: " . getenv("KNZ_TENANT"),
        "Idempotency-Key: webhook-create-" . bin2hex(random_bytes(12)), "Content-Type: application/json",
    ],
]);
$hook = json_decode(curl_exec($ch), true);   // store $hook["secret"] now: it is never shown again

$KNZ_TOKEN هنا رمز دخول لحساب يملك صلاحية connection.manage؛ مفتاح ربط نقطة البيع لا يدير Webhooks.

$KNZ_TOKEN here is a sign-in token for an account with connection.manage; a POS connection key cannot manage webhooks.

الأحداث وبياناتهاEvents and payloads

يمكن الاشتراك في أربعة أحداث: member.joined وpoints.earned وpoints.reversed وreward.redeemed. أما test.ping فيصل فقط عند الاختبار. هذا ما يحمله data في كل حدث:

Four events can be subscribed to: member.joined, points.earned, points.reversed and reward.redeemed; test.ping arrives only when you test. This is what data carries for each one:

التحقق من التوقيعVerifying signatures

  1. اقرأ الجسم الخام كما وصل، قبل أي تحويل إلى JSON. أي إعادة تنسيق تكسر التوقيع.
  2. احسب "v1=" + hex(HMAC-SHA256(secret, timestamp + "." + raw_body))، والمفتاح هو السر كاملاً بما فيه whsec_.
  3. قارن مع KNZ-Signature بمقارنة ثابتة الوقت.
  4. ارفض الطلب لو كان الفرق بين الآن وKNZ-Webhook-Timestamp أكثر من 300 ثانية.
  5. تجاهل أي KNZ-Webhook-Id عالجته من قبل، ورد عليه بـ 200.
  1. Read the raw body exactly as it arrived, before any JSON parsing. Any re-serialising breaks the signature.
  2. Compute "v1=" + hex(HMAC-SHA256(secret, timestamp + "." + raw_body)); the key is the whole secret, whsec_ included.
  3. Compare it with KNZ-Signature in constant time.
  4. Reject the request if now and KNZ-Webhook-Timestamp are more than 300 seconds apart.
  5. Skip any KNZ-Webhook-Id you already handled, and answer it with 200.
import express from "express";
import crypto from "node:crypto";

const app = express();
const SECRET = process.env.KNZ_WEBHOOK_SECRET;   // whsec_…

// express.raw keeps the body as bytes: the signature covers the raw body
app.post("/webhooks/knz", express.raw({ type: "application/json" }), async (req, res) => {
  const id = req.get("KNZ-Webhook-Id");
  const ts = req.get("KNZ-Webhook-Timestamp");
  const sig = req.get("KNZ-Signature") || "";
  if (!id || !/^\d+$/.test(ts || "") || Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
    return res.sendStatus(400);                       // missing headers or too old
  }
  const expected = "v1=" + crypto.createHmac("sha256", SECRET)
    .update(`${ts}.`).update(req.body).digest("hex");
  const a = Buffer.from(sig), b = Buffer.from(expected);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(401);

  if (await alreadyHandled(id)) return res.sendStatus(200);   // your own store of handled ids
  res.sendStatus(200);                                         // answer fast…
  queue.push(JSON.parse(req.body));                            // …then do the work
});
import hashlib, hmac, json, os, time
from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["KNZ_WEBHOOK_SECRET"].encode()   # whsec_…

@app.post("/webhooks/knz")
def knz_webhook():
    raw = request.get_data()                            # raw bytes: the signature covers them
    event_id = request.headers.get("KNZ-Webhook-Id", "")
    ts = request.headers.get("KNZ-Webhook-Timestamp", "")
    sig = request.headers.get("KNZ-Signature", "")
    if not event_id or not ts.isdigit() or abs(time.time() - int(ts)) > 300:
        abort(400)                                      # missing headers or too old
    expected = "v1=" + hmac.new(SECRET, ts.encode() + b"." + raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(sig, expected):
        abort(401)
    if already_handled(event_id):                       # your own store of handled ids
        return "", 200
    enqueue(json.loads(raw))                            # do the work outside the request
    return "", 200
<?php
$secret = getenv("KNZ_WEBHOOK_SECRET");             // whsec_…
$raw = file_get_contents("php://input");             // raw body: the signature covers it
$id  = $_SERVER["HTTP_KNZ_WEBHOOK_ID"] ?? "";
$ts  = $_SERVER["HTTP_KNZ_WEBHOOK_TIMESTAMP"] ?? "";
$sig = $_SERVER["HTTP_KNZ_SIGNATURE"] ?? "";

if ($id === "" || !ctype_digit($ts) || abs(time() - (int) $ts) > 300) {
    http_response_code(400); exit;                   // missing headers or too old
}
$expected = "v1=" . hash_hmac("sha256", $ts . "." . $raw, $secret);
if (!hash_equals($expected, $sig)) {
    http_response_code(401); exit;
}
if (already_handled($id)) {                          // your own store of handled ids
    http_response_code(200); exit;
}
enqueue(json_decode($raw, true));                    // do the work outside the request
http_response_code(200);

التسليم وإعادة المحاولةDelivery and retries

  • ننتظر الرد 10 ثوانٍ. أي حالة 2xx تعني «وصل».
  • غير ذلك نعيد المحاولة بعد 1 دقيقة، 5 دقائق، 30 دقيقة، ساعتين، 6 ساعات، 24 ساعة: 7 محاولات إجمالاً، ثم يُعلَّم التسليم FAILED. يمكنك إعادة إرساله من سجل التسليمات أو عبر retry.
  • الترتيب غير مضمون: قد يصل حدث أحدث قبل أقدم. اعتمد على created_at وbalance.
  • رد بسرعة ثم عالج في الخلفية، حتى لا تتجاوز المهلة فتصلك نسخ مكررة.
  • We wait 10 seconds for an answer. Any 2xx means delivered.
  • Otherwise we retry after 1 min, 5 min, 30 min, 2 h, 6 h and 24 h: 7 attempts in all, then the delivery is marked FAILED. You can send it again from the delivery log or with retry.
  • Ordering is not guaranteed: a newer event can arrive before an older one. Rely on created_at and balance.
  • Answer fast and process in the background, so you never time out and receive duplicates.

اختبر توقيعكSignature tester

احسب التوقيع هنا وقارنه بما يحسبه كودك. الحساب يتم في متصفحك فقط، ولا يُرسل أي شيء.

Compute the signature here and compare it with what your code produces. It runs in your browser only; nothing is sent anywhere.

رسائل المحفظةWallet messages

سطر واحد على بطاقات عملائكOne line on your customers' cards

نفس ما تفعله من لوحة التاجر: رسالة قصيرة (حتى 120 حرفاً، بالعربية أو الإنجليزية أو كلتيهما) تظهر على شاشة القفل في iPhone وكإشعار في Android. الجمهور: كل العملاء، أو من لم يتعاملوا منذ عدد أيام (7 إلى 365). حتى 3 رسائل يومياً لكل برنامج، والرابعة ترجع 429 WALLET_NOTE_DAILY_LIMIT. الإرسال يحتاج صلاحية campaign.manage وIdempotency-Key.

The same as the merchant dashboard: a short line (up to 120 characters, Arabic, English or both) that shows on the iPhone lock screen and as an Android notification. Audience: all customers, or those inactive for N days (7 to 365). At most 3 a day per program; a fourth returns 429 WALLET_NOTE_DAILY_LIMIT. Sending needs the campaign.manage permission and an Idempotency-Key.

GET/v1/programs/{id}/wallet-notes/audience?inactive_days=Nكم عميلاً ستصله ← {audience, recipients, wallet_cards}How many it would reach → {audience, recipients, wallet_cards}
POST/v1/programs/{id}/wallet-notesإرسال: {text: {ar?, en?}, audience?}Send: {text: {ar?, en?}, audience?}
GET/v1/programs/{id}/wallet-notesالرسائل السابقة ← {items, left_today}Past messages → {items, left_today}
curl -X POST {{API}}/v1/programs/$PROGRAM/wallet-notes \
  -H "Authorization: Bearer $KNZ_TOKEN" -H "X-Tenant-ID: $KNZ_TENANT" \
  -H "Idempotency-Key: wallet-note-2026-11-02-1" -H "Content-Type: application/json" \
  -d '{"text": {"ar": "اشتقنا لك! قهوتك الثانية علينا هذا الأسبوع", "en": "We miss you! Your second coffee is on us this week"},
       "audience": {"kind": "inactive", "days": 30}}'

Odoo 19: بدون كودOdoo 19: no code at all

ربط Odoo ضمن باقة السلاسل (Chain).The Odoo connector is part of the Chain plan.

للتجار على Odoo 19 (حتى Odoo Online): أولاً في Odoo اضغط صورتك ← التفضيلات ← أمان الحساب ← مفتاح API جديد. ثم في لوحة التاجر ← الربط مع Odoo اضغط «ابدأ ربط Odoo» وأدخل الرابط وقاعدة البيانات والمستخدم والمفتاح. النظام يقرأ الطلبات المدفوعة والمرتجعات بنفسه. ولتجربة كاملة عند الكاشير ثبّت موديول prof_loyalty_pos (Prof Loyalty for Point of Sale) فيُضبط تلقائياً.

Merchants on Odoo 19 (Odoo Online too): first in Odoo click your picture → Preferences → Account Security → New API Key. Then in the merchant dashboard → Connect Odoo press "Connect Odoo" and enter the URL, database, user and key. Paid orders and refunds are read automatically. For the full till experience install the prof_loyalty_pos module (Prof Loyalty for Point of Sale); it configures itself.

المرجع الكاملFull reference

جارٍ التحميل…Loading…