# مرجع الواجهات البرمجية — تكامل المتجر

`API v1` — مرجع تقني

---

> ### ✅ ما هو متاح فعليًا اليوم
>
> سبع واجهات تعمل الآن: `GET /health` · `POST /orders` · `GET /orders/{uuid}` ·
> `GET /orders?external_order_id=` · **`PATCH /orders/{uuid}`** · `POST /orders/{uuid}/cancel` ·
> `POST /orders/{uuid}/payment` — بالإضافة إلى الـ Webhooks الصادرة (القسم ٨).
>
> **لا توجد واجهة `DELETE` ولن توجد.** الإلغاء هو الحذف — التفصيل في **٧.٦**.
>
> واجهة واحدة موصوفة هنا **لم تُبنَ بعد** وتردّ `404`: **٧.٨** التقييم، وهي مُعلَّمة في موضعها.
> المواصفة الحيّة على `/api/documentation/integration` تعرض المتاح فقط، فهي المرجع القاطع عند
> أي اختلاف.

## المحتويات

1. [Base URLs](#1-base-urls)
2. [Headers](#2-headers)
3. [Authentication](#3-authentication)
4. [Response Envelope](#4-response-envelope)
5. [Error Codes](#5-error-codes)
6. [Idempotency](#6-idempotency)
7. [Endpoints](#7-endpoints)
8. [Webhooks](#8-webhooks)
9. [Reference Values](#9-reference-values)

---

## 1. Base URLs

| Environment | Base URL |
|---|---|
| Staging | `https://staging.<domain>/api/integration/v1` |
| Production | `https://<domain>/api/integration/v1` |

`HTTPS` إلزامي. لكل بيئة `access token` و `signing secret` منفصلان.

---

## 2. Headers

| Header | Value | Required |
|---|---|:---:|
| `Accept` | `application/json` | ✔ |
| `Accept-Language` | `en` | ✔ |
| `Content-Type` | `application/json` | ✔ مع `POST` / `PATCH` |
| `Authorization` | `Bearer <access_token>` | ✔ |
| `Idempotency-Key` | `UUID` | ✔ مع `POST` / `PATCH` |
| `X-Kapitano-Signature` | `t=<unix>,v1=<hmac>` | ✔ |

`Accept-Language` تحدّد لغة رسائل الخطأ فقط. لغة العميل تُرسل في `customer.locale`.

نقص `Accept` أو `Accept-Language` ← `401` قبل التحقّق من `Authorization`:

```json
{
  "Model": null,
  "Status": false,
  "Message": null,
  "MessageDebug": {
    "accept_header": ["You should add Accept key in the header request, and the value of Accept key MUST be equal to application/json !"]
  },
  "Total": 0, "Page": 0, "Records": 0
}
```

```json
{
  "Model": null,
  "Status": false,
  "Message": null,
  "MessageDebug": {
    "Language not definite": ["Put the language code in the request header in Accept-Language"]
  },
  "Total": 0, "Page": 0, "Records": 0
}
```

---

## 3. Authentication

طبقتان: `Bearer token` للهوية، و `HMAC signature` لسلامة الرسالة.

### 3.1 Signature

```
signed_payload = "{t}." + raw_request_body
v1             = HMAC_SHA256(signed_payload, signing_secret)   // hex
header         = X-Kapitano-Signature: t={t},v1={v1}
```

`t` = Unix timestamp بالثواني. `raw_request_body` = البايتات المُرسلة فعليًا.

**Test vector:**

```
signing_secret : whsec_sample_do_not_use_in_production
t              : 1758182400
body           : {"external_order_id":"SO-77120"}
signed_payload : 1758182400.{"external_order_id":"SO-77120"}

v1             : 25988882fa0479d9a3e402ecac3d892d9a177bfc4fbdb018e0be0330d86d10b1
```

### 3.2 PHP

```php
$body      = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$timestamp = time();
$signature = hash_hmac('sha256', $timestamp.'.'.$body, $signingSecret);

$headers = [
    'Accept'               => 'application/json',
    'Accept-Language'      => 'en',
    'Content-Type'         => 'application/json',
    'Authorization'        => 'Bearer '.$accessToken,
    'Idempotency-Key'      => $idempotencyKey,
    'X-Kapitano-Signature' => "t={$timestamp},v1={$signature}",
];
// أرسل $body نفسه دون إعادة ترميز
```

### 3.3 Node.js

```js
const crypto = require('crypto');

const body      = JSON.stringify(payload);
const timestamp = Math.floor(Date.now() / 1000);
const signature = crypto.createHmac('sha256', signingSecret)
  .update(`${timestamp}.${body}`).digest('hex');

await fetch(url, {
  method: 'POST',
  headers: {
    'Accept': 'application/json',
    'Accept-Language': 'en',
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${accessToken}`,
    'Idempotency-Key': idempotencyKey,
    'X-Kapitano-Signature': `t=${timestamp},v1=${signature}`,
  },
  body,
});
```

### 3.4 Rules

| Rule | Value |
|---|---|
| Tolerance window | `300` ثانية |
| Replay | كل `v1` يُقبل مرّة واحدة |
| Retry | توقيع جديد + **نفس** `Idempotency-Key` |
| IP allowlist | اختياري، يُفعَّل بالاتفاق |

---

## 4. Response Envelope

كل الاستجابات، نجاحًا أو فشلًا:

```json
{
  "Model": null,
  "Status": true,
  "Message": null,
  "MessageDebug": null,
  "Total": 0,
  "Page": 0,
  "Records": 0
}
```

| Field | Type | الوصف |
|---|---|---|
| `Model` | `object\|null` | البيانات. `null` عند الفشل |
| `Status` | `bool` | نتيجة العملية |
| `Message` | `string\|null` | رسالة مقروءة |
| `MessageDebug` | `object\|null` | تفاصيل الخطأ |
| `Total` | `int` | عدد الصفحات (للقوائم) |
| `Page` | `int` | الصفحة الحالية |
| `Records` | `int` | إجمالي السجلات |

---

## 5. Error Codes

| HTTP | `MessageDebug` key | الوصف |
|---|---|---|
| `200` | — | نجاح، أو `Idempotency` replay |
| `201` | — | تم الإنشاء |
| `202` | — | مقبول، يُعالَج لاحقًا |
| `401` | `accept_header` | `Accept` ناقصة أو خاطئة |
| `401` | `Language not definite` | `Accept-Language` ناقصة |
| `401` | `Unauthorized` | `access_token` غير صالح |
| `401` | `signature_missing` | `X-Kapitano-Signature` غير موجودة |
| `401` | `signature_malformed` | صيغة غير `t=…,v1=…` |
| `401` | `signature_mismatch` | التوقيع لا يطابق `body` |
| `401` | `signature_expired` | خارج نافذة `300` ثانية |
| `401` | `signature_replayed` | `v1` مُستخدَم سابقًا |
| `403` | `ip_not_allowed` | IP خارج `allowlist` |
| `403` | `client_inactive` | الحساب موقوف |
| `403` | `missing_ability` | الرمز بلا صلاحية كافية |
| `404` | `item_not_found` | غير موجود أو لا يخصّكم |
| `409` | `duplicate_external_order` | `external_order_id` مكرّر بمحتوى مختلف |
| `409` | `order_in_transit` | تعديل عنوان التسليم والكابتن في الطريق إليه |
| `409` | `pickup_locked` | تعديل نقطة الاستلام بعد إسناد كابتن بناءً عليها |
| `409` | `order_already_collected` | تعديل الأصناف أو المبالغ بعد استلام الكابتن |
| `409` | `order_not_editable` | الطلب انتهى، فلا يتغيّر فيه شيء |
| `409` | `invalid_transition` | غير ممكن من الحالة الحالية |
| `422` | `validation` | أخطاء الحقول |
| `422` | `nothing_to_update` | `PATCH` لا يطلب تغيير أي شيء |
| `422` | `idempotency_key_required` | ترويسة `Idempotency-Key` ناقصة أو أطول من ١٢٨ حرفًا |
| `429` | `too_many_requests` | تجاوز `rate limit` |
| `500` | — | خطأ خادم |
| `503` | `integration_paused` | الاستقبال موقوف مؤقتًا |
| `503` | `replay_check_unavailable` | تعذّر التحقّق من تكرار التوقيع. أعد الإرسال بنفس `Idempotency-Key` |

**Retry:** عند `429`, `500`, `502`, `503`, `504` و أخطاء الشبكة فقط.

**`422` body:**

```json
{
  "Model": null,
  "Status": false,
  "Message": "The given data was invalid.",
  "MessageDebug": {
    "validation": {
      "pickup.lat": ["The pickup latitude field is required."],
      "payment.status": ["The payment status field is required."]
    }
  },
  "Total": 0, "Page": 0, "Records": 0
}
```

---

## 6. Idempotency

`Idempotency-Key` واحد لكل عملية، يُعاد إرساله مع كل محاولة.

| Case | Result |
|---|---|
| مفتاح جديد | `201` — إنشاء |
| نفس المفتاح + نفس `body` | `200` + header `Idempotency-Replayed: true` — الاستجابة الأصلية، بلا إنشاء |
| نفس المفتاح + `body` مختلف | `409` `duplicate_external_order` |
| مفتاح جديد + `external_order_id` موجود | `409` `duplicate_external_order` |
| نفس المفتاح بعد محاولة أولى لم تكتمل | يُعاد التنفيذ عاديًا ← `201` |

مدّة الحفظ: `24` ساعة.

> **مهم:** إعادة المحاولة تحتاج **توقيعًا جديدًا** (وقت جديد) مع **الإبقاء على نفس**
> `Idempotency-Key`. التوقيع يُقبل مرّة واحدة فقط، فإعادة إرسال نفس الترويسات حرفيًا تُرفض بـ
> `signature_replayed` قبل أن يصل الطلب إلى فحص منع التكرار أصلًا.

---

## 7. Endpoints

### 7.1 `GET /health`

**Response `200`**

```json
{
  "Model": {
    "client": "Kapitano Marketplace",
    "environment": "staging",
    "server_time": "2026-09-18T11:04:22+03:00",
    "clock_skew_seconds": 2,
    "signature_tolerance_seconds": 300,
    "accepting_orders": true
  },
  "Status": true,
  "Message": "OK",
  "MessageDebug": null,
  "Total": 0, "Page": 0, "Records": 0
}
```

`clock_skew_seconds` = وقتنا − قيمة `t` في التوقيع المُرسل. موجب = ساعتكم متأخّرة.

---

### 7.2 `POST /orders`

#### Request

```json
{
  "external_order_id": "SO-77120",
  "external_order_number": "77120",

  "customer": {
    "external_id": "CUST-4417",
    "name": "عبدالله الحربي",
    "phone": "+966501234567",
    "locale": "ar",
    "note": "الرجاء الاتصال قبل الوصول"
  },

  "pickup": {
    "branch_ref": "BR-RUH-07",
    "name": "فرع طريق الملك فهد",
    "address": "طريق الملك فهد، حي العليا، الرياض",
    "lat": 24.7136,
    "lng": 46.6753,
    "ready_at": "2026-09-18T11:20:00+03:00"
  },

  "dropoff": {
    "address": "شارع التخصصي، حي المروج، الرياض",
    "lat": 24.7520,
    "lng": 46.6580,
    "address_ref": "ADDR-9981",
    "details": {
      "building": "برج السلام",
      "floor": "4",
      "apartment": "402",
      "landmark": "مقابل الصيدلية"
    }
  },

  "payment": {
    "method": "cash_on_delivery",
    "status": "unpaid",
    "amount_to_collect": 148.00,
    "reference": null
  },

  "currency": "SAR",
  "delivery_fee": 15.00,
  "promised_at": "2026-09-18T12:15:00+03:00",
  "note": "طلب أولوية",

  "items": [
    { "name": "برجر لحم", "quantity": 2, "unit_price": 45.00, "sku": "BRG-01", "note": "بدون بصل" },
    { "name": "بطاطس كبير", "quantity": 1, "unit_price": 18.00, "sku": "FRS-03", "note": null }
  ]
}
```

#### Root

| Field | Type | Required | Rules |
|---|---|:---:|---|
| `external_order_id` | `string` | ✔ | `max:64`, فريد ولا يُعاد استخدامه |
| `external_order_number` | `string` | ✔ | `max:64` |
| `currency` | `string` | ✘ | `size:3`, default `SAR` |
| `delivery_fee` | `decimal` | ✘ | `min:0` |
| `promised_at` | `datetime` | ✘ | `ISO 8601`, `after:now` |
| `note` | `string` | ✘ | `max:1000`, داخلي |
| `items` | `array` | ✔ | `min:1`, `max:50` |

#### `customer`

| Field | Type | Required | Rules |
|---|---|:---:|---|
| `external_id` | `string` | ✔ | `max:64`, ثابت لكل عميل |
| `name` | `string` | ✔ | `max:255` |
| `phone` | `string` | ✔ | `E.164` |
| `locale` | `string` | ✘ | `ar` \| `en` |
| `note` | `string` | ✘ | `max:1000`, يُعرض للكابتن |

#### `pickup`

| Field | Type | Required | Rules |
|---|---|:---:|---|
| `branch_ref` | `string` | ✔ | `max:64` |
| `name` | `string` | ✔ | `max:255` |
| `address` | `string` | ✔ | `max:255` |
| `lat` | `decimal` | ✔ | `between:-90,90` |
| `lng` | `decimal` | ✔ | `between:-180,180` |
| `ready_at` | `datetime` | ✘ | `ISO 8601` |

#### `dropoff`

| Field | Type | Required | Rules |
|---|---|:---:|---|
| `address` | `string` | ✔ | `max:255` |
| `lat` | `decimal` | ✔ | `between:-90,90` |
| `lng` | `decimal` | ✔ | `between:-180,180` |
| `address_ref` | `string` | ✘ | `max:64` |
| `details.building` | `string` | ✘ | `max:120` |
| `details.floor` | `string` | ✘ | `max:120` |
| `details.apartment` | `string` | ✘ | `max:120` |
| `details.landmark` | `string` | ✘ | `max:120` |

#### `payment`

| Field | Type | Required | Rules |
|---|---|:---:|---|
| `method` | `enum` | ✔ | `cash_on_delivery` \| `prepaid` |
| `status` | `enum` | ✔ | `paid` \| `unpaid` \| `refunded` |
| `amount_to_collect` | `decimal` | ✔ إذا `cash_on_delivery` | `min:0` |
| `reference` | `string` | ✘ | `max:120` |

#### `items[]`

| Field | Type | Required | Rules |
|---|---|:---:|---|
| `name` | `string` | ✔ | `max:255` |
| `quantity` | `integer` | ✔ | `min:1`, `max:999` |
| `unit_price` | `decimal` | ✘ | `min:0` |
| `sku` | `string` | ✘ | `max:255` |
| `note` | `string` | ✘ | `max:255` |

#### Response `201`

```json
{
  "Model": {
    "uuid": "9c4f2e10-7b3a-4d61-9f28-0a1b2c3d4e5f",
    "order_number": "ORD-000482",
    "external_order_id": "SO-77120",
    "status": "pending",
    "status_label": "Pending",
    "tracking_url": "https://<domain>/t/9c4f2e10-7b3a-4d61-9f28-0a1b2c3d4e5f",
    "created_at": "2026-09-18T11:04:22+03:00"
  },
  "Status": true,
  "Message": "Order received.",
  "MessageDebug": null,
  "Total": 0, "Page": 0, "Records": 0
}
```

`uuid` هو المعرّف المستخدم في كل `endpoint` و `webhook` لاحقًا.

---

### 7.3 `GET /orders/{uuid}`

**Response `200`**

```json
{
  "Model": {
    "uuid": "9c4f2e10-7b3a-4d61-9f28-0a1b2c3d4e5f",
    "external_order_id": "SO-77120",
    "order_number": "ORD-000482",
    "status": "on_the_way",
    "status_label": "On the way",
    "captain": {
      "name": "سعيد",
      "phone": "+966500000001",
      "vehicle_type": "motorcycle"
    },
    "eta_at": "2026-09-18T12:05:00+03:00",
    "timestamps": {
      "received_at": "2026-09-18T11:04:22+03:00",
      "assigned_at": "2026-09-18T11:09:41+03:00",
      "picked_up_at": "2026-09-18T11:26:03+03:00",
      "on_the_way_at": "2026-09-18T11:27:15+03:00",
      "delivered_at": null,
      "failed_at": null,
      "cancelled_at": null
    },
    "cash": {
      "method": "cash_on_delivery",
      "amount_to_collect": "148.00",
      "amount_collected": null,
      "currency": "SAR"
    },
    "failure_reason": null,
    "tracking_url": "https://<domain>/t/9c4f2e10-7b3a-4d61-9f28-0a1b2c3d4e5f"
  },
  "Status": true,
  "Message": null,
  "MessageDebug": null,
  "Total": 0, "Page": 0, "Records": 0
}
```

---

### 7.4 `GET /orders?external_order_id={id}`

| Query | Type | Required |
|---|---|:---:|
| `external_order_id` | `string` | ✔ |

الاستجابة مطابقة لـ `7.3`.

---

### 7.5 `POST /orders/{uuid}/cancel`

#### Request

```json
{
  "reason": "ألغى العميل الطلب",
  "reason_code": "CUSTOMER_CANCELLED"
}
```

| Field | Type | Required | Rules |
|---|---|:---:|---|
| `reason` | `string` | ✔ | `max:255` |
| `reason_code` | `string` | ✘ | `max:64` |

#### Allowed from

| Status | Result |
|---|---|
| `pending` | `200` |
| `assigned` | `200` — يُحرَّر الكابتن |
| `picked_up` | `200` — البضاعة مع الكابتن |
| `on_the_way` | `200` — البضاعة مع الكابتن |
| `delivered` \| `delivery_failed` \| `cancelled` | `409` `invalid_transition` |

---

### 7.6 `PATCH /orders/{uuid}`

تعديل **جزئي**: أرسل الحقول المتغيّرة فقط. ما تتركه يبقى كما هو، وما ترسله بقيمة `null` يُمسح —
وهما أمران مختلفان، وهذه الواجهة تفرّق بينهما. لا ترسل الطلب كاملًا لتغيير حقل واحد.

**لا توجد واجهة `DELETE`.** الإلغاء هو الحذف: هو الذي يحرّر سعة الكابتن، ويُبقي سجلّ الطلب الذي
يُحتكم إليه عند أي نزاع، ويُبقي سجلّ الإرسال مرتبطًا بشيء قائم. حذف الطلب كان سيأخذ الثلاثة معه.
فإن كان نظامكم يحذف الطلبات، اربطوا الحذف عندكم بـ `POST /orders/{uuid}/cancel` عندنا.

#### ما الذي يمكن تغييره، وإلى متى

القاعدة واحدة: **يُرفض التعديل متى صار الشيء الذي يغيّره قد نُفِّذ فعلًا على الأرض** — لا حين
يصبح مزعجًا، بل حين يصبح كذبًا.

| ما تغيّره | مسموح حتى | بعدها |
|---|---|---|
| `customer.*` و `note` | انتهاء الطلب | — |
| `pickup.*` | `assigned` | `409` `pickup_locked` |
| `items` و `payment.amount_to_collect` و `currency` و `delivery_fee` | `picked_up` | `409` `order_already_collected` |
| `dropoff.*` | `on_the_way` | `409` `order_in_transit` |
| `promised_at` | `delivered` | `409` `order_not_editable` |

ومتى صار الطلب `delivered` أو `delivery_failed` أو `cancelled` فلا يتغيّر فيه **شيء**:
`409` `order_not_editable`.

ولماذا يقف كلٌّ عند حدّه:

- **نقطة الاستلام** تُقفل عند `assigned` لأن الكابتن **اختير** بناءً على قربه من ذلك الفرع.
  تحريكها بعد ذلك لا يحرّك الكابتن، بل يجعل الإسناد خاطئًا في صمت.
- **الأصناف والمبالغ** تُقفل عند `picked_up`: البضاعة صارت بيد الكابتن، والمبلغ عُرض عليه.
- **عنوان التسليم** يُقفل عند `on_the_way` لأن أحدهم يقود إلى العنوان القديم. فإن غيّر العميل
  عنوانه بعد ذلك: **ألغوا الطلب وأرسلوا طلبًا جديدًا**. نحن لا نعيد توجيه توصيلة جارية.

#### ما لا يتغيّر أبدًا

هذه تردّ **422** وتسمّي الحقل، بدل أن تُتجاهل بصمت:

| الحقل | البديل |
|---|---|
| `payment.method` | ألغوا وأرسلوا طلبًا جديدًا. تحويل نقدي إلى مدفوع مسبقًا أثناء التوصيل يعني أن يحصّل الكابتن مبلغًا مدفوعًا أصلًا، أو ألّا يحصّل مبلغًا مستحقًا |
| `external_order_id` | لا شيء — هو المفتاح الذي يقوم عليه منع التكرار |
| `status` و `driver_uuid` | لا شيء. موضع الطلب في دورته قرارنا نحن |

#### تفصيلتان مهمّتان

**`items` استبدال لا دمج.** أرسل السلّة كاملة. القائمة الجزئية ملتبسة: لا يُعرف هل الأسطر التي
لم تُذكر حُذفت أم لم تُذكر فقط.

**لن يصلكم Webhook عن تعديلكم أنتم.** الحدث `order.updated` يُطلق على هذه الحقول، لكنه لا يُعاد
إلى العميل الذي طلب التغيير — إعادة تغييركم إليكم ضجيج تضطرون بعده إلى تصفيته.

#### Request

```json
{
  "customer": { "phone": "+966501234567" },
  "dropoff": {
    "address": "المروج، مبنى ١٢",
    "lat": 24.7520,
    "lng": 46.6580
  }
}
```

| Field | Type | Rules |
|---|---|---|
| `customer.name` | `string` | `max:255` |
| `customer.phone` | `string` | `max:30` |
| `customer.note` | `string\|null` | `max:1000` — `null` يمسحها |
| `note` | `string\|null` | `max:1000` |
| `pickup` | `object` | يُرسل كاملًا: `address` و `lat` و `lng` مطلوبة معه |
| `dropoff.address` | `string` | `max:255` |
| `dropoff.lat` / `dropoff.lng` | `float` | **الاثنان معًا أو لا شيء** — إحداثي بلا قرينه نصف نقطة، يبدو موقعًا وليس كذلك |
| `dropoff.address_ref` | `string\|null` | `max:64` |
| `payment.amount_to_collect` | `float` | `min:0` |
| `payment.reference` | `string\|null` | `max:120` |
| `currency` | `string` | `size:3` |
| `delivery_fee` | `float` | `min:0` |
| `promised_at` | `datetime\|null` | `ISO 8601` |
| `items` | `array` | `min:1` `max:50` — **السلّة كاملة** |

#### Responses

| Case | Result |
|---|---|
| نجح | `200` والطلب كاملًا، بنفس شكل أي Webhook |
| طلب ليس لكم | `404` — وليس `403`، فـ `403` يؤكّد وجوده |
| تعديل متأخّر | `409` بأحد المفاتيح الأربعة أعلاه |
| حقل غير قابل للتعديل، أو نصف إحداثي | `422` |
| طلب لا يغيّر شيئًا | `422` `nothing_to_update` |

> `422` على الطلب الفارغ مقصود: هو في الغالب عميل بنى حمولته خطأً، و«تمّ» تُخفي ذلك إلى أن
> ينتبه أحد أن التغيير لم يحدث أصلًا.

---

### 7.7 `POST /orders/{uuid}/payment`


#### Request

```json
{
  "status": "paid",
  "reference": "PAY-99213",
  "paid_at": "2026-09-18T11:10:00+03:00"
}
```

| Field | Type | Required | Rules |
|---|---|:---:|---|
| `status` | `enum` | ✔ | `paid` \| `unpaid` \| `refunded` |
| `reference` | `string` | ✘ | `max:120` |
| `paid_at` | `datetime` | ✘ | `ISO 8601` |

---

### 7.8 `POST /orders/{uuid}/rating` — 🚧 غير متاحة بعد

> **لا تبنوا على هذه الواجهة الآن.** لم تُنفَّذ، وأي نداء لها يردّ **404**.
>
> الشكل أدناه متّفق عليه ولن يتغيّر، لتبنوا عليه مرّة واحدة عند تفعيلها. سنُعلمكم عند إتاحتها.

#### Request

```json
{
  "stars": 5,
  "comment": "سريع ومهذّب",
  "rated_at": "2026-09-18T12:30:00+03:00"
}
```

| Field | Type | Required | Rules |
|---|---|:---:|---|
| `stars` | `integer` | ✔ | `min:1`, `max:5` |
| `comment` | `string` | ✘ | `max:1000` |
| `rated_at` | `datetime` | ✘ | `ISO 8601` |

**Response `202`** — تُحفظ البيانات، والاحتساب في تقارير الأداء يُفعَّل لاحقًا دون تغيير من طرفكم.

---

## 8. Webhooks

`POST` إلى الرابط الذي تزوّدوننا به.

### 8.1 Events

| Event | Trigger |
|---|---|
| `order.received` | قُبل الطلب |
| `order.assigned` | أُسند لكابتن |
| `order.picked_up` | استُلم من الفرع |
| `order.on_the_way` | في الطريق للعميل |
| `order.delivered` | سُلِّم |
| `order.delivery_failed` | تعذّر التسليم |
| `order.cancelled` | أُلغي |
| `order.updated` | تغيّر شيء ترونه دون أن يتحرّك الطلب: وقت وصول مُصحّح، أو حالة دفع تغيّرت. **التغيير الذي تُجرونه أنتم لا يُعاد إليكم** |
| `order.address_details` | أدخل الكابتن تفاصيل العنوان |
| `captain.arrived` | اقترب الكابتن من نقطة التسليم |
| `captain.location` | تحديث موقع — `opt-in` |
| `integration.ping` | اختبار يطلبه مالك المتجر من البوابة. **لا يحمل `order` ولا `sequence`** — احتملوا ذلك في المُعالِج بدل أن تفترضوا وجودهما دائمًا. يمرّ بالتوقيع والطابور والسجلّ نفسه تمامًا كأي حدث حقيقي |

### 8.2 Headers

```http
Content-Type: application/json
X-Kapitano-Event: order.on_the_way
X-Kapitano-Event-Id: 018f3c2a-9d1e-7b44-a5c6-1d2e3f405162
X-Kapitano-Delivery-Attempt: 1
X-Kapitano-Signature: t=1758182662,v1=<hmac>
```

### 8.3 Body

```json
{
  "event": "order.on_the_way",
  "event_id": "018f3c2a-9d1e-7b44-a5c6-1d2e3f405162",
  "occurred_at": "2026-09-18T11:27:15+03:00",
  "sequence": 1487,
  "order": {
    "uuid": "9c4f2e10-7b3a-4d61-9f28-0a1b2c3d4e5f",
    "external_order_id": "SO-77120",
    "order_number": "ORD-000482",
    "status": "on_the_way",
    "previous_status": "picked_up",
    "captain": {
      "name": "سعيد",
      "phone": "+966500000001",
      "vehicle_type": "motorcycle"
    },
    "eta_at": "2026-09-18T12:05:00+03:00",
    "timestamps": {
      "assigned_at": "2026-09-18T11:09:41+03:00",
      "picked_up_at": "2026-09-18T11:26:03+03:00",
      "on_the_way_at": "2026-09-18T11:27:15+03:00",
      "delivered_at": null,
      "failed_at": null,
      "cancelled_at": null
    },
    "cash": {
      "method": "cash_on_delivery",
      "amount_to_collect": "148.00",
      "amount_collected": null,
      "currency": "SAR"
    },
    "failure_reason": null
  }
}
```

### 8.4 Signature verification

> ⚠️ **مهم — الترويسة قد تحمل أكثر من `v1` واحد.**
>
> أثناء **نافذة تدوير المفاتيح** نوقّع كل إشعار بالمفتاح الجديد **والقديم معًا**، ونرسلهما في
> الترويسة نفسها:
>
> ```http
> X-Kapitano-Signature: t=1758182662,v1=<hmac-new>,v1=<hmac-old>
> ```
>
> لذلك **مرّوا على كل قيم `v1` واقبلوا إن طابقت إحداها**، ولا تقرأوا الأولى فقط. هذا هو ما يسمح
> لكم بنشر المفتاح الجديد على مهلكم دون أن تفشل إشعاراتكم لحظة الضغط على زر التدوير. الصيغة
> صيغة Stripe وهي تسمح بتكرار `v1` أصلًا.

```php
$parts = explode(',', $request->header('X-Kapitano-Signature'));

$timestamp = 0;
$received = [];

foreach ($parts as $part) {
    [$key, $value] = explode('=', trim($part), 2);

    if ($key === 't') {
        $timestamp = (int) $value;
    } elseif ($key === 'v1') {
        $received[] = $value;   // قد تتكرّر — اجمعوها كلها
    }
}

if (abs(time() - $timestamp) > 300) {
    abort(400);
}

$expected = hash_hmac('sha256', $timestamp.'.'.$request->getContent(), $webhookSecret);

$ok = false;

foreach ($received as $candidate) {
    if (hash_equals($expected, $candidate)) {
        $ok = true;
    }
}

if (! $ok) {
    abort(401);
}
```

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

| | ماذا يحدث |
|---|---|
| **الوارد** (أنتم ← نحن) | نقبل توقيعكم بالمفتاح **الجديد أو القديم** طوال **٢٤ ساعة** من لحظة التدوير |
| **الصادر** (نحن ← أنتم) | نرسل قيمتَي `v1` كما في الأعلى طوال النافذة نفسها |
| بعد النافذة | يُقبل المفتاح الجديد وحده، ويُحذف القديم |

أي أنّ التدوير **لا يقطع الخدمة**: انشروا المفتاح الجديد في أي وقت خلال الأربع والعشرين ساعة.

### 8.5 Handling rules

| Field | Rule |
|---|---|
| `order` | الطلب **كاملًا** في كل حدث. عامِل أي إشعار على أنه «استبدل نسختك» لا «طبّق تعديلًا جزئيًا» — بهذا يُصحّح الحدثُ التالي أي حدث ضائع من تلقاء نفسه |
| `sequence` | تصاعدي لكل `order`. تجاهل أي `sequence <= last_processed` |
| `event_id` | فريد. تجاهل المكرّر |
| Response | `2xx` خلال `15` ثانية. عالِج في الخلفية |

### 8.6 Retry schedule

`6` محاولات: `10s` → `60s` → `300s` → `900s` → `3600s` → `3600s`.

بعد استنفادها يُسجَّل الحدث `dropped`. الحالة تبقى متاحة عبر `GET /orders/{uuid}`.

---

## 9. Reference Values

### `status`

| Value | Next |
|---|---|
| `pending` | `assigned`, `cancelled` |
| `assigned` | `picked_up`, `cancelled` |
| `picked_up` | `on_the_way`, `cancelled` |
| `on_the_way` | `delivered`, `delivery_failed`, `cancelled` |
| `delivered` | — |
| `delivery_failed` | — |
| `cancelled` | — |

```
pending ──► assigned ──► picked_up ──► on_the_way ──┬──► delivered
   │            │             │             │       └──► delivery_failed
   └────────────┴─────────────┴─────────────┴──────────► cancelled
```

### `payment.method`

| Value |
|---|
| `cash_on_delivery` |
| `prepaid` |

### `payment.status`

| Value |
|---|
| `paid` |
| `unpaid` |
| `refunded` |

### `customer.locale`

| Value |
|---|
| `ar` |
| `en` |
