توثيق ربط أنظمة الكاشير
باب واحد موحّد، تتصل فيه بنفس الطريقة أي شركة كاشير — تسجيل زيارة، استعلام عميل، استبدال نقاط، تطبيق عرض، وحجوزات. هذا الملف مولَّد من الكود نفسه: كل جدول حقول تحته يُقرأ من نماذج Pydantic اللي تتحقق منها المسارات فعلياً، لا موثَّق يدوياً — فما يفوت تحديثه أبداً.
المصادقة
بيانات اعتماد آلية واحدة لكل محل، صادرة كمنحة OAuth2 client-credentials. تُرسل كتوكن Bearer عادي:
Authorization: Bearer <your access token>
المحل (tenant) يُقرأ من التوكن نفسه، أبداً لا من جسم الطلب — ما فيه حقل
tenant على أي طلب أدناه، عمداً. بيانات اعتماد صدرت لمحل واحد لا تقدر فيزيائياً
تسمّي محلاً آخر — ما فيه حقل أصلاً يضبط ذلك.
صلاحية واحدة: operations.record. تقدر تسجّل زيارة، وتقرأ ما
يخص عميل تلك الزيارة، وتسلّم مكافأة. لا شي غير ذلك — لا الفوترة، ولا محلات أخرى، ولا أي
واجهة إدارية.
حدود الطلبات
استعلامات العميل (GET /customer) محسوبة كنسبة من الزيارات اللي فعلاً
سجّلتها، لا نافذة ثابتة. كاشير مشغول يسجّل مبيعات فعلية ما يقترب من السقف أبداً؛
بيانات اعتماد تستعلم أكثر بكثير مما تسجّل تصطدم به، لأن هذا هو الشكل الوحيد اللي يأخذه
استخراج البيانات. تجاوز السقف يرجّع 429 مع رسالة توضّح السبب — أبداً لا
403: بيانات الاعتماد لسا صالحة وما أُلغيت.
1. تسجيل الزيارات
دفعة حتى 50 زيارة. الرد يحمل حكماً لكل عنصر — accepted،
duplicate، أو rejected مع سبب — أبداً لا كل شي أو لا شي: عنصر
واحد غير صالح دائمياً بدفعة لازم أبداً يوقف الـ49 عنصر الباقين خلفه بنفس الطلب.
طابق كل نتيجة برقمها (index) — موقع الزيارة بمصفوفة visits اللي
أرسلتها (يبدأ من صفر). النتائج مرتّبة حسبه والقائمة بنفس طول ما أرسلت. لا تطابق على
idempotency_key: لو ما أرسلت واحد، نشتقّه من رقم فاتورتك، والقيمة الراجعة ما
كانت يوماً بحوزتك.
حقول الزيارة
| الحقل | النوع | مطلوب | السبب |
|---|---|---|---|
phone | str | لا | رقم جوال عميل المحل. بدونه ما نقدر ننسب الزيارة أو نراسله. |
name | str | لا | اسمه، إن كان متوفر. اختياري. |
plate | str | لا | لوحة المركبة — تُستخدم عملياً بمحلات الإطارات وغسيل السيارات والتنجيد. |
branch_id | str | نعم | الفرع اللي حصلت فيه. مطلوب، ولازم يكون فرعاً يملكه هذا المحل فعلاً — معرّف ما نعرفه يُرفض ويُسمّى لك بالرد. |
operator_id | str | لا | معرّف حساب الكاشير عندك. يُخزَّن كما أُرسل — ما نبني نظام حسابات ثانٍ. |
meter | int | لا | قراءة العداد، لقطاع المركبات. |
note | str | لا | ملاحظة نصية حرة، تظهر لصاحب المحل. |
lines | list[TillLineIn] | لا | بنود الفاتورة. كتالوج المحل يُبنى منها تلقائياً — أرسلها كما هي. |
invoice_id | str | لا | رقم فاتورتك أنت. أفضل مفتاح تكرار، لأنه معرّفك الخاص لهذا البيع بالذات. |
idempotency_key | str | لا | مفتاح التكرار الخاص فيك، إن وُجد. |
occurred_at | int | لا | وقت حدوث البيع بساعتك أنت (ثوانٍ يونكس). يُستخدم كمفتاح تكرار إن ما فيه رقم فاتورة. |
package_id | str | لا | — |
بند الفاتورة
| الحقل | النوع | مطلوب | السبب |
|---|---|---|---|
product_id | str | لا | معرّف المنتج/الصنف عندك. |
name | str | لا | اسم المنتج/الخدمة كما بفاتورتك بالضبط — كتالوج المحل يُبنى منه. |
qty | float | لا | الكمية. |
amount_minor | int | لا | المبلغ بالهللات — أبداً لا ريالات، أبداً لا كسور عشرية. |
إزالة التكرار — القاعدة الوحيدة بلا استثناء
كل زيارة لازم تحمل واحداً على الأقل من: idempotency_key، أو
invoice_id، أو occurred_at. بدون أي من الثلاثة تُرفض الزيارة
وتُسمّى الثلاثة بالسبب.
السبب: إعادة المحاولة بعد رد ضائع هي الحالة العادية للكاشير، وبلا مفتاح ثابت يصير بيع واحد زيارتين — يضاعف سجل العميل وتذكيراته ونقاطه.
مثال طلب
POST /api/v1/till/visits
Authorization: Bearer <token>
Content-Type: application/json
{
"visits": [
{
"phone": "0501234567",
"branch_id": "brn_main",
"invoice_id": "INV-2026-00042",
"lines": [
{"product_id": "sku_wash_1", "name": "Full wash", "qty": 1, "amount_minor": 5000}
]
}
]
}
مثال رد
{
"accepted": 1,
"total": 1,
"results": [
{"index": 0, "status": "accepted", "idempotency_key": "inv:<tenant>:INV-2026-00042"}
]
}
عنصر مرفوض يبدو هكذا:
{"index": 3, "status": "rejected",
"reason": "unknown branch_id «brn-typo» — send one of this shop's own branches"}
2. البحث عن عميل
استعلام بـphone أو plate. يجاوب هل هو عميل معروف، اسمه، عدد
زياراته، رصيد نقاطه، وما يقدر يستبدله الآن.
عميل جديد يرجّع found: false، أبداً لا 404 — عميل أول مرة
هو الحالة العادية عند الكاشير، مو خطأ يُسجَّل ويُعاد.
ثلاث حالات لا يجوز خلطها: ما فيه عميل بهذا الرقم · نقاط غير قابلة للقراءة
مؤقتاً (points_unavailable: true) · ما فيه برنامج نقاط بهذا المحل أصلاً (قسم
النقاط غائب تماماً). لا يُرسَل 0 مكان "ما قدرنا نقرأها" — إخبار عميل يملك 300
نقطة إنه بلا نقاط يخلق شكوى من لا شي.
مثال: عميل معروف، برنامج نقاط فعّال
{
"found": true,
"name": "Ahmad",
"visits": 12,
"points": 340,
"to_next_reward": 60,
"manager_threshold": 500,
"rewards": [
{"id": "rwd_free_wash", "name": "Free wash", "cost": 300, "needs_approval": false}
]
}
needs_approval: true على مكافأة تعني إنها فوق سقف اعتماد المدير بهذا المحل —
شوف approved_by بمسار الاستبدال بالأسفل.
مثال: عميل جديد كلياً
{"found": false}
مثال: عميل معروف، نقاطه غير قابلة للقراءة مؤقتاً
{"found": true, "name": "Ahmad", "visits": 12, "points_unavailable": true}
3. استبدال مكافأة
| الحقل | النوع | مطلوب | السبب |
|---|---|---|---|
phone | str | لا | رقم جوال العميل المستبدِل. |
plate | str | لا | أو لوحته، إن كانت طريقتك بتعريف العملاء. |
reward_id | str | نعم | معرّف المكافأة، بالضبط كما رجعت من GET /till/customer. |
idempotency_key | str | نعم | مفتاحك لهذي العملية بالذات — مطلوب. بلا مفتاح ثابت، إعادة المحاولة تصرف نقاط العميل مرتين. |
actor | str | لا | حساب الكاشير اللي سلّم المكافأة. |
approved_by | str | لا | اسم/معرّف المدير المعتمِد — مطلوب فقط لمكافأة فوق سقف اعتماد هذا المحل (تُعلَم لك بـneeds_approval من GET /till/customer). بدونه، تلك المكافآت تُرفض دائماً. نص حر غير موثَّق — استخدم approver_operator_id/approver_ticket بالأسفل لهوية حقيقية موثَّقة. |
approver_operator_id | str | لا | معرّف الموظف اللي تحقّقنا من PIN حقه للتو عبر POST /till/operators/verify-pin — المسار الموثَّق؛ يتجاوز approved_by عند إرساله. |
approver_ticket | str | لا | التذكرة الصادرة من POST /till/operators/verify-pin — صالحة لدقائق قليلة فقط. |
branch_id | str | نعم | الفرع اللي حصل فيه — لازم فرعاً يملكه هذا المحل فعلاً. |
idempotency_key مطلوب، بلا قيمة افتراضية نملأها عنك —
مفتاح نصنعه نحن راح يكون جديداً بكل إعادة محاولة، واستبدال مكرر يصرف نقاط العميل مرتين.الرفض يرجع كجملة كاملة — اقرأها للعميل مباشرة. فرّق بين REFUSED
(نهائي — مثلاً رصيد غير كافٍ) وUNAVAILABLE (مؤقت — أعد المحاولة).
مثال طلب
{
"phone": "0501234567",
"reward_id": "rwd_free_wash",
"idempotency_key": "redeem-2026-00042",
"branch_id": "brn_main",
"actor": "cashier_3"
}
أمثلة ردود
{
"ok": true,
"transaction_ref": "rdm_...",
"discount_halalas": 5000,
"reward_name": "Free wash",
"cost": 300,
"balance": 40,
"duplicate": false
}
duplicate: true تعني إن هذا الـidempotency_key بالذات استُبدل
من قبل — نفس الإيصال يُرجَّع، بلا صرف ثانٍ.
{"ok": false, "reason": "REFUSED", "message": "رصيده لا يكفي"}
{"ok": false, "reason": "NOT_FOUND", "message": "لا يوجد عميل بهذا الرقم."}
transaction_ref هو ما لازم تطبعه بالإيصال الورقي، عشان يكون له أثر ثانٍ.
4. تطبيق عرض على زيارة
العرض اللي يحمله العميل يُطبَّق على الزيارة — أبداً لا يُخمَّن. لا شي
يصرف عرضاً لمجرد وصول زيارة؛ خدمة reminders وحدها تقرر أي عرض ينطبق، لأنها
وحدها تعرف ما أُرسل ونطاقه وتاريخ انتهائه.
| الحقل | النوع | مطلوب | السبب |
|---|---|---|---|
visit_id | str | نعم | معرّف الزيارة المسجَّلة للتو — العرض يُختَم على هذي، أبداً لا على العرض نفسه. |
phone | str | لا | رقم جوال العميل حامل العرض. |
plate | str | لا | أو لوحته، إن كانت طريقتك. |
actor | str | لا | حساب الكاشير اللي طبّق العرض. |
مرة واحدة لكل زيارة — تطبيق ثانٍ على نفس الزيارة يرجع نفس العرض بلا صرف إضافي: ضغطة ثانية بالكاشير نية واحدة، لا خصمين.
مثال طلب
{"visit_id": "vis_...", "phone": "0501234567", "actor": "cashier_3"}
أمثلة ردود
{"applied": true, "offer": {"ref": "off_...", "text": "20% off your next wash",
"used_at": 1735689600}}
{"applied": false, "reason": "لا يوجد عرضٌ ساري يشمل هذه الزيارة"}
5. الحجوزات
محل يشغّل الحجوزات وكاشيراً مربوطاً معاً يحتاج طريقة يخبر فيها الحجز إن عميله حضر فعلاً ودفع — بدونها، تقويم الحجوزات وسجل مبيعات الكاشير يختلفان بصمت حول ما حصل.
المسار الأول يسرد حجوزات العميل المفتوحة — أرسل جواله أو لوحته، يرجع لك أي شي لسا ما حضر أو انلغى أو تغيّب عنه:
{"found": true, "bookings": [{"id": "bkg_123", "starts_at": 1786860000, "branch_id": "brn_1", "service_ids": ["svc_1"], "package_id": ""}]}
المسار الثاني يؤكد الحضور فعلياً، ويحوّل الحجز إلى زيارة حقيقية:
| الحقل | النوع | مطلوب | السبب |
|---|---|---|---|
phone | str | لا | رقم جوال العميل صاحب الحجز. |
plate | str | لا | أو لوحته، إن كانت طريقتك. |
branch_id | str | لا | الفرع اللي حصل فيه، إن أردت تأكيد إن الحجز يخص هذا الفرع بالذات. اختياري. |
occurred_at | int | لا | وقت الحضور الفعلي بساعتك (ثوانٍ يونكس). فارغ يعني الآن. |
آمن لإعادة المحاولة — ضغطة ثانية على حجز حاضر بالفعل ترجع نفس الحجز بلا أثر إضافي: إعادة محاولة بعد انقطاع اتصال ما تقدر تضاعف الزيارة أو تصرف مكافأة مرتين.
{"ok": true, "booking_id": "bkg_123", "status": "attended"}
الرفض جملة تُقرأ بصوت عالٍ، أبداً لا كود مجرّد:
{"ok": false, "reason": "NOT_FOUND", "message": "لا يوجد حجز بهذا الرقم يخصّ هذا العميل."}
6. الفروع
فروع محلك النشطة — تحقّق من branch_id قبل الإرسال، بدل ما تكتشف خطأ إملائي
فقط برفض زيارة:
{"branches": [{"id": "brn-main", "name": "الفرع الرئيسي"}]}
7. حالة الاعتماد
هل ربطك البرمجي فعّال الآن فعلياً، وكم تبقى من سقف الاستعلام اليومي — اسأل هذا قبل إرسال دفعة مبيعات أو استعلام عميل، لا بعد ما تُرفض:
{"feature_api": "active", "feature_visit": "active", "lookup_allowance_remaining_today": 187}
"unknown" تعني ما قدرنا نسأل — لا رفض ولا اعتماد.
8. اعتماد موظف موثَّق
قبل هذا الباب، أي نص بـapproved_by كان يُقبَل بلا أي تحقق.
هذا يتحقق فعلياً من PIN الموظف الحقيقي المكوّن من 4 أرقام، ويصدر تذكرة قصيرة العمر —
أرسلها مع approver_operator_id بمسار الاستبدال بدل اسم مكتوب حراً.
| الحقل | النوع | مطلوب | السبب |
|---|---|---|---|
operator_id | str | نعم | معرّف الموظف — من قائمة فريق هذا المحل نفسها. |
pin | str | نعم | رمز PIN الخاص بالموظف، 4 أرقام. |
{"ok": true, "operator": {"id": "opr_1", "name": "خالد"}, "ticket": "...", "ticket_ttl": 300}
9. طلب تقييم فوري
ما يبني مسار إرسال جديد — يسحب طلب تقييم مجدولاً أصلاً لهذا العميل ليصير "مستحقاً الآن"، فيصله خلال دقائق بدل تأخير المحل المعتاد. يجاوب برفض واضح، أبداً لا خطأ، لمّا ما فيه شي مجدول (بلا تطبيق تقييم، أو أُرسل مسبقاً):
| الحقل | النوع | مطلوب | السبب |
|---|---|---|---|
phone | str | لا | رقم جوال العميل صاحب الزيارة. |
plate | str | لا | أو لوحته، إن كانت طريقتك. |
asset_id | str | لا | معرّف المركبة، لمحلات قطاع المركبات فقط — إن كان جدول التقييم مرتبطاً بالمركبة لا العميل. |
{"ok": true, "message": "سيصل طلب التقييم للعميل خلال دقائق قليلة."}
جاهز تربط نظامك؟
نوفّر محلاً تجريبياً حقيقياً على نفس المسارات، مجاناً وبلا احتساب — راسلنا وناخذك خطوة بخطوة.
تواصل معنا للربط ↗Raje3 POS/Till Integration
One unified door — every POS company connects the same way: post visits, look up a customer, redeem points, apply an offer, bookings. Every field table below is read from the Pydantic models the routes actually validate against, not typed by hand — so it can never fall out of date with the code.
Authentication
One machine credential per shop, issued as an OAuth2 client-credentials grant. Send it as a normal Bearer token:
Authorization: Bearer <your access token>
The tenant (shop) is read from the token itself, never from the request
body — there is no tenant field on any request below, on purpose.
A credential minted for one shop is physically unable to name another one; there is no
field to set.
One capability: operations.record. It can record a
visit, read what belongs to that visit's customer, and hand over a reward. Nothing
else — not billing, not other shops, not any admin surface.
Rate limits
Customer lookups (GET /customer) are budgeted as a RATIO of visits
you have actually recorded, not a fixed window. A busy till that is genuinely
recording sales never comes close to the ceiling; a credential making far more lookups
than the visits it records does, because that is the one shape data extraction takes.
Over the budget, the response is 429 with a message explaining why — never
403: the credential is still valid and was never revoked.
1. Post visits — POST /api/v1/till/visits
Batch of up to 50 visits. The response reports a verdict per item —
accepted, duplicate, or rejected with a reason —
never all-or-nothing: one permanently invalid item in a batch must never block the
other 49 behind it in the same post.
Match each result back to what you sent by index — the visit's position
in the visits array you posted (0-based). Results are ordered by it and
the list is exactly as long as what you sent. Do not match on
idempotency_key: if you did not send one, we derive it from your invoice
number, and the string that comes back was never one you held.
Visit fields
| Field | Type | Required | Why |
|---|---|---|---|
phone | str | no | The shop's customer's phone number. Without it we cannot attribute the visit or message them. |
name | str | no | Their name, if you have it. Optional. |
plate | str | no | Vehicle plate — used in practice by tyre shops, car washes and upholstery shops. |
branch_id | str | yes | The branch where this happened. Required, and must be a branch this shop actually has — an id we don't recognise is rejected and named back to you. |
operator_id | str | no | Your own cashier account id. Stored as sent — we do not build a second accounts system. |
meter | int | no | Odometer reading, for the vehicle sector. |
note | str | no | A free-text note, visible to the owner. |
lines | list[TillLineIn] | no | Invoice line items. The shop's catalogue is built from these automatically — send them as they are. |
invoice_id | str | no | Your own invoice number. The best idempotency key, because it is your own identifier for this exact sale. |
idempotency_key | str | no | Your own idempotency key, if you have one. |
occurred_at | int | no | When the sale happened, by your own clock (unix seconds). Used as the idempotency key when there is no invoice number. |
package_id | str | no | — |
Invoice line
| Field | Type | Required | Why |
|---|---|---|---|
product_id | str | no | Your own product/item id. |
name | str | no | The product/service name exactly as on your invoice — the shop's catalogue is built from this. |
qty | float | no | Quantity. |
amount_minor | int | no | Amount in halalas — never riyals, never a decimal. |
Deduplication — the one rule with no exception
Every visit must carry at least one of: idempotency_key,
invoice_id, or occurred_at. Without any of the three the visit
is rejected, naming all three in the reason.
Why: retrying after a lost reply is the normal case for a till, and without a stable key one sale becomes two visits — doubling the customer's history, their reminders, and their points.
Example request
POST /api/v1/till/visits
Authorization: Bearer <token>
Content-Type: application/json
{
"visits": [
{
"phone": "0501234567",
"branch_id": "brn_main",
"invoice_id": "INV-2026-00042",
"lines": [
{"product_id": "sku_wash_1", "name": "Full wash", "qty": 1, "amount_minor": 5000}
]
}
]
}
Example response
{
"accepted": 1,
"total": 1,
"results": [
{"index": 0, "status": "accepted", "idempotency_key": "inv:<tenant>:INV-2026-00042"}
]
}
A rejected item looks like:
{"index": 3, "status": "rejected",
"reason": "unknown branch_id «brn-typo» — send one of this shop's own branches"}
2. Look up a customer — GET /api/v1/till/customer
Query with phone or plate. Answers whether they are a known
customer, their name, visit count, points balance, and what they can redeem right now.
A new customer is found: false, never a 404 — a
first-time customer is the ordinary case at a counter, not a fault to log and retry.
Three states that must not be conflated: no such customer · points
temporarily unreadable (points_unavailable: true) · no loyalty programme
at this shop at all (the points section is simply absent). A 0 is never
sent in place of "could not read it" — telling a customer holding 300 points that they
have none creates a complaint over nothing.
Example: known customer, loyalty programme active
{
"found": true,
"name": "Ahmad",
"visits": 12,
"points": 340,
"to_next_reward": 60,
"manager_threshold": 500,
"rewards": [
{"id": "rwd_free_wash", "name": "Free wash", "cost": 300, "needs_approval": false}
]
}
needs_approval: true on a reward means it is above this shop's
manager-approval threshold — see approved_by on the redeem endpoint below.
Example: brand-new customer
{"found": false}
Example: customer known, points temporarily unreadable
{"found": true, "name": "Ahmad", "visits": 12, "points_unavailable": true}
3. Redeem a reward — POST /api/v1/till/redeem
| Field | Type | Required | Why |
|---|---|---|---|
phone | str | no | The phone number of the customer redeeming. |
plate | str | no | Or their plate, if that is how you identify customers. |
reward_id | str | yes | The reward id, exactly as returned by GET /till/customer. |
idempotency_key | str | yes | Your key for THIS operation — required. Without a stable key, a retry spends the customer's points a second time. |
actor | str | no | The cashier account that handed the reward over. |
approved_by | str | no | Name/id of the manager who approved this — required ONLY for a reward above this shop's approval threshold (flagged to you as needs_approval from GET /till/customer). Without it, those specific rewards are always refused. UNVERIFIED free text — use approver_operator_id/approver_ticket below instead for a real, checked identity. |
approver_operator_id | str | no | The id of the operator whose PIN was just verified via POST /till/operators/verify-pin — the VERIFIED path; overrides approved_by when sent. |
approver_ticket | str | no | The ticket issued by POST /till/operators/verify-pin — good for a few minutes only. |
branch_id | str | yes | The branch where this happened. Required, and must be a branch this shop actually has. |
idempotency_key is required, with no default we can
fill in for you — a key we mint ourselves would be new on every retry, and a doubled
redemption spends the customer's points twice.A refusal comes back as a full sentence — read it to the customer
directly. Distinguish REFUSED (final — e.g. insufficient balance) from
UNAVAILABLE (transient — retry).
Example request
{
"phone": "0501234567",
"reward_id": "rwd_free_wash",
"idempotency_key": "redeem-2026-00042",
"branch_id": "brn_main",
"actor": "cashier_3"
}
Example responses
{
"ok": true,
"transaction_ref": "rdm_...",
"discount_halalas": 5000,
"reward_name": "Free wash",
"cost": 300,
"balance": 40,
"duplicate": false
}
duplicate: true means this exact idempotency_key was already
redeemed — the SAME receipt is returned, nothing is spent a second time.
{"ok": false, "reason": "REFUSED", "message": "رصيده لا يكفي"}
{"ok": false, "reason": "NOT_FOUND", "message": "لا يوجد عميل بهذا الرقم."}
transaction_ref is what the receipt must carry — print it on the paper
receipt so it has a second trail.
4. Apply an offer to a visit — POST /api/v1/till/offer/apply
The offer the customer is holding is applied to the visit — never
guessed. Nothing spends an offer just because a visit arrived;
reminders alone decides which offer applies, because it alone knows what
was sent, its scope, and its expiry.
| Field | Type | Required | Why |
|---|---|---|---|
visit_id | str | yes | The id of the visit just recorded — the offer is stamped onto THIS, never onto the offer itself. |
phone | str | no | The phone number of the customer holding the offer. |
plate | str | no | Or their plate, if that is how you identify customers. |
actor | str | no | The cashier account that applied the offer. |
One per visit — a second apply on the same visit returns the SAME offer and spends nothing further: a second tap at the counter is one intention, not two discounts.
Example request
{"visit_id": "vis_...", "phone": "0501234567", "actor": "cashier_3"}
Example responses
{"applied": true, "offer": {"ref": "off_...", "text": "20% off your next wash",
"used_at": 1735689600}}
{"applied": false, "reason": "لا يوجد عرضٌ ساري يشمل هذه الزيارة"}
5. Bookings — GET /api/v1/till/bookings and POST /api/v1/till/bookings/{booking_id}/attend
A shop running both bookings and an integrated till needs a way to tell a booking that its customer actually showed up and paid — without this, the booking calendar and the till's own sale ledger silently disagree about what happened.
The first route lists a customer's OPEN bookings — send their phone or plate, get back anything not yet attended, cancelled, or marked no-show:
{"found": true, "bookings": [{"id": "bkg_123", "starts_at": 1786860000, "branch_id": "brn_1", "service_ids": ["svc_1"], "package_id": ""}]}
The second actually confirms attendance, turning the booking into a real visit:
| Field | Type | Required | Why |
|---|---|---|---|
phone | str | no | The phone number of the customer who made the booking. |
plate | str | no | Or their plate, if that is how you identify customers. |
branch_id | str | no | The branch this happened at, if you want to confirm the booking belongs to this specific branch. Optional. |
occurred_at | int | no | When attendance actually happened, by your own clock (unix seconds). Empty means now. |
Safe to retry — a second tap on an already-attended booking returns the SAME booking with no further effect: a retry after a dropped connection cannot double the visit or spend a reward twice.
{"ok": true, "booking_id": "bkg_123", "status": "attended"}
A refusal is a sentence to read aloud, never a bare code:
{"ok": false, "reason": "NOT_FOUND", "message": "لا يوجد حجز بهذا الرقم يخصّ هذا العميل."}
6. Branches — GET /api/v1/till/branches
Your shop's own active branches — validate branch_id before sending it,
rather than discovering a typo only by having a visit rejected:
{"branches": [{"id": "brn-main", "name": "الفرع الرئيسي"}]}
7. Credential status — GET /api/v1/till/status
Is your programmatic link actually active right now, and how much of today's lookup allowance is left — ask this BEFORE sending a batch of sales or a customer lookup, not after being refused:
{"feature_api": "active", "feature_visit": "active", "lookup_allowance_remaining_today": 187}
"unknown" means we could not ask — neither a refusal nor an approval.
8. A real, verified approver — POST /api/v1/till/operators/verify-pin
Before this door, ANY text in approved_by was accepted with no
check at all. This actually verifies the operator's real 4-digit PIN and
issues a short-lived ticket — send it with approver_operator_id on
/till/redeem instead of a free-typed name.
| Field | Type | Required | Why |
|---|---|---|---|
operator_id | str | yes | The operator's id — from this shop's own staff list. |
pin | str | yes | The operator's own 4-digit PIN. |
{"ok": true, "operator": {"id": "opr_1", "name": "خالد"}, "ticket": "...", "ticket_ttl": 300}
9. Rating request, on demand — POST /api/v1/till/rating-request
Builds no new send path — pulls an already-scheduled rating request for this customer forward to "due now", so it arrives in minutes instead of the shop's usual delay. Answers a clear refusal, never an error, when nothing is scheduled (no rating app, or already sent):
| Field | Type | Required | Why |
|---|---|---|---|
phone | str | no | The phone number of the customer who made the visit. |
plate | str | no | Or their plate, if that is how you identify customers. |
asset_id | str | no | The vehicle id, vehicle-sector shops only — if the rating schedule is keyed by the vehicle, not the customer. |
{"ok": true, "message": "سيصل طلب التقييم للعميل خلال دقائق قليلة."}
Ready to connect your system?
We provision a real test shop on the same routes, free and uncharged — reach out and we'll walk you through it.
Contact us to connect ↗