ابدأ من هنا
كل اللي السيرفر بتاعك محتاجه قبل أول حجز. كلّم الـ API من السيرفر بتاعك بس: الـ client secret والتوكنات عمرهم ما يوصلوا لمتصفح ولا لتطبيق موبايل.
ادخل بالـ client credentials
الـ API بيستخدم OAuth 2.0 client credentials (RFC 6749 §4.4). ابعت الـ client id (kp_…) والـ secret (ks_…) بـ HTTP Basic (الأفضل) أو كخانات client_id وclient_secret في الفورم، واطلب الـ scopes اللي محتاجها. التوكن Bearer صالح ١٠ دقايق؛ خد واحد جديد لما يخلص. الأخطاء ماشية على RFC 6749 §5.2 (invalid_client، invalid_scope، …).
$EZAZ_API هو عنوان الـ API الأساسي، بيوصلك مع بيانات الدخول.
جرّبه في Postman: نزّل مجموعة الشركاء (كل العمليات، بأمثلة المرجع ده). حط متغيراتها baseUrl وpartnerClientId وpartnerClientSecret (خلّي الـ secret في Postman بس، وعمره ما يتحط في workspace مشترك): بعدها المجموعة بتطلب التوكن لوحدها وتجدّده قبل ما يخلص، وبتبعت Idempotency-Key جديد مع كل إنشاء.
POST /partner/oauth/tokencurl -s "$EZAZ_API/partner/oauth/token" \
-u "$EZAZ_CLIENT_ID:$EZAZ_CLIENT_SECRET" \
-d grant_type=client_credentials \
-d "scope=venues:read availability:read reservations:read reservations:write"200 OK{
"access_token": "eyJhbGciOiJIUzI1NiJ9…",
"token_type": "Bearer",
"expires_in": 600,
"scope": "availability:read reservations:read reservations:write venues:read"
}لما إزاز يغيّر الـ secret بتاعك، القديم بيفضل شغال ٢٤ ساعة علشان تلحق تنزّل الجديد. ولو تطبيقك اتوقف، توكناته بتبطّل في ساعتها.
الـ Scopes
إزاز بيدّي تطبيقك مجموعة scopes؛ وكل توكن بيشيل اللي إنت طالبه منها. أي طلب برّه الـ scope بتاعه بيرجع 403. والعملية اللي مكتوبة تحت اتنين scopes محتاجاهم الاتنين: الـ quote بيقرا الأسعار وباقات اللاعب.
| الـ Scope | بيسمح بإيه | العمليات |
|---|---|---|
availability:read | Free slots and prices | |
passes:read | A player's packages and memberships at a venue | |
players:erase | Erase what came through you about a player | |
reservations:read | Your reservations and their reviews | |
reservations:write | Book and cancel | |
reviews:write | Review reservations you made | |
safety_reports:read | A player's safety reports | |
safety_reports:write | File safety reports and add photos | |
venues:read | Venues, courts, court details and reviews | |
webhooks:manage | Set your webhook |
حدود الطلبات
كل تطبيق ليه عدد طلبات في الدقيقة (١٢٠ إلا لو اتفقنا على غير كده)، بيتحسب للتطبيق مش للعنوان، في دقايق ثابتة. لو عدّيت الحد، الـ API بيرد بـ 429 ومعاه هيدر Retry-After بالثواني والكود PARTNER_RATE_LIMITED. استنى المدة دي وبعدين جرّب تاني.
HTTP/1.1 429 Too Many Requests
Retry-After: 17
Content-Type: application/problem+json
{ "status": 429, "code": "PARTNER_RATE_LIMITED", "detail": "Too many requests this minute." }مفاتيح الـ Idempotency
أي طلب بيحجز أو بيسجّل حاجة (POST /reservations، POST /safety-reports) محتاج هيدر Idempotency-Key: قيمة عشوائية جديدة (UUID) لكل عملية. لو النت فصل، أعد الطلب بنفس المفتاح: هيرجعلك أول نتيجة، وعمره ما هيعمل حجز تاني. استخدم مفتاح جديد لأي عملية جديدة.
POST /partner/v1/reservationscurl -s "$EZAZ_API/partner/v1/reservations" \
-H "Authorization: Bearer $EZAZ_TOKEN" \
-H "Idempotency-Key: 7d0c2f8e-5b1a-4f7e-9a63-2c4b8e1d0f95" \
-H "Content-Type: application/json" \
-H "Accept-Language: en" \
-d '{
"venueId": "3f6c1a52-8d4e-4b7a-9c2f-5e1d0a9b7c31",
"courtId": "9b2e7d14-6a3c-4f1e-8d5b-0c7a2e9f4b63",
"start": "2026-10-10T17:00:00Z",
"minutes": 90,
"customer": { "name": "Omar Hassan", "phone": "+201001234567", "phoneVerified": true },
"payment": { "method": "PREPAID_BY_PARTNER", "collected": { "amount": 120000, "currency": "EGP" } },
"externalRef": "kb_8f2c41"
}'المرجع بتاعك
ابعت الـ id بتاع الحجز عندك في externalRef (لحد ٨٠ حرف). بيرجعلك في كل رد وفي كل webhook، علشان تطابقهم مع بياناتك. ميبقاش عندك غير حجز واحد شغال (مؤكد) لكل externalRef؛ التاني بيرجع EXTERNAL_REF_IN_USE.
الدفع في كل حجز
اختار طريقة دفع واحدة لكل حجز. الأسعار بالقرش كأرقام صحيحة (120000 يعني ١٬٢٠٠٫٠٠ جنيه)؛ متستخدمش كسور عشرية أبدًا. الحجز بيسجّل price (السعر) وcharge (اللي المكان ليه في الحجز ده).
payment{ "method": "PAY_AT_VENUE" }
{ "method": "PREPAID_BY_PARTNER", "collected": { "amount": 120000, "currency": "EGP" } }
{ "method": "PACKAGE", "packageCreditId": "5a1e…" } // needs "customer": { …, "phoneVerified": true }الأخطاء
الأخطاء بصيغة RFC 9457 problem details (application/problem+json) ومعاها code ثابت. اعتمد على الكود، مش على النص. الـ detail مكتوب للناس، بالعربي أو بالإنجليزي على حسب هيدر Accept-Language بتاعك.
HTTP/1.1 409 Conflict
Content-Type: application/problem+json
{
"type": "about:blank",
"title": "Conflict",
"status": 409,
"code": "COURT_TAKEN",
"detail": "Someone just booked this court at that time. Pick another time or court."
}| الكود | HTTP | الرسالة (زي ما الـ API بيبعتها) |
|---|---|---|
VALIDATION_FAILED | 400 | في بيانات مش مظبوطة. |
MALFORMED_REQUEST | 400 | مقدرناش نقرا الطلب. |
PHONE_INVALID | 400 | اكتب رقم موبايل صحيح. |
FILE_REJECTED | 400 | الملف ده مينفعش يترفع. استخدم JPG أو PNG أو WebP في حدود الحجم المسموح. |
PARTNER_INVALID | 400 | راجع الخانة دي: {0}. |
PARTNER_WEBHOOK_URL_INVALID | 400 | الـ webhook لازم يروح على لينك https عام على البورت العادي، مش عنوان IP ولا اسم داخلي. |
PARTNER_PHONE_NOT_VERIFIED | 400 | الباقة مش هتدفع غير لما تأكد إن اللاعب أكّد الرقم ده معاك (customer.phoneVerified). |
COURT_SLOT_NOT_BOOKABLE | 400 | الميعاد ده مينفعش يتحجز: راجع مواعيد الفتح، والمدة (٦٠ أو ٩٠ أو ١٢٠ دقيقة)، واليوم. |
COURT_PAYMENT_INVALID | 400 | راجع الدفع: الدفع المقدّم محتاج المبلغ اللي حصّلته، والباقة محتاجة باقة اللاعب، والدفع في المكان مش محتاج ده ولا ده. |
COURT_REVIEW_INVALID | 400 | اختار من ١ لـ ٥ نجوم، وخلّي التعليق أو الرد في حدود ٥٠٠ حرف. |
SAFETY_REPORT_INVALID | 400 | راجع الخانة دي: {0}. اكتب اللي حصل في الملعب في حدود ٥٠٠ حرف، وفي آخر ٣٠ يوم. |
UNAUTHENTICATED | 401 | سجّل دخولك الأول. |
FORBIDDEN | 403 | معندكش صلاحية للحتة دي. |
NOT_FOUND | 404 | مش موجود. |
VENUE_NOT_FOUND | 404 | مش لاقيين المكان ده. |
COURT_NOT_FOUND | 404 | مش لاقيين الملعب ده. |
COURT_RESERVATION_NOT_FOUND | 404 | مش لاقيين حجز الملعب ده. |
COURT_PASS_NOT_FOUND | 404 | مش لاقيين الباقة أو العضوية دي. |
COURT_REVIEW_NOT_FOUND | 404 | مش لاقيين التقييم ده. |
SAFETY_REPORT_NOT_FOUND | 404 | مش لاقيين البلاغ ده. |
COURT_TAKEN | 409 | حد لسه حاجز الملعب ده في نفس الميعاد. اختار ميعاد أو ملعب تاني. |
VENUE_NOT_TAKING_BOOKINGS | 409 | المكان ده لسه مش بياخد حجوزات ملاعب على إزاز. |
EXTERNAL_REF_IN_USE | 409 | عندك حجز شغال بنفس المرجع ده. |
COURT_RESERVATION_NOT_CANCELLABLE | 409 | الحجز ده مينفعش يتلغي دلوقتي: عدّى معاد الإلغاء بتاع المكان، أو خلص، أو اتلغى قبل كده. |
COURT_RESERVATION_STATE_INVALID | 409 | الحجز ده مينفعش يتغير كده دلوقتي. |
COURT_PASS_NOT_USABLE | 409 | الباقة دي مينفعش تدفع التمرين ده: الدقايق اللي فاضلة مش كفاية، أو بتخلص قبله، أو بتاعة حد تاني. |
COURT_REVIEW_NOT_ALLOWED | 409 | الحجز ممكن يتقيّم بعد ما يتلعب، وخلال ١٤ يوم من نهايته. |
COURT_REVIEW_EXISTS | 409 | الحجز ده اتقيّم قبل كده بتقييم مختلف. |
SAFETY_REPORT_STATE_INVALID | 409 | البلاغ ده مينفعش يتغير كده دلوقتي: اتحل خلاص، أو عليه ٣ صور. |
CONCURRENT_UPDATE | 409 | حد تاني عدّل نفس الحاجة في نفس اللحظة. جرّب تاني. |
UNSUPPORTED_MEDIA_TYPE | 415 | نوع المحتوى مش مدعوم. |
PARTNER_RATE_LIMITED | 429 | طلبات كتير قوي في الدقيقة دي. استنى شوية وجرّب تاني. |
PARTNER_PASS_LOOKUPS_LIMITED | 429 | دوّرت على باقات لاعيبة كتير قوي من تطبيقك. استنى شوية وجرّب تاني. |
TOO_MANY_SAFETY_REPORTS | 429 | اللاعب ده بعت بلاغات كتير النهارده. لو الموضوع مستعجل، كلّم دعم إزاز. |
COURT_INVALID | 400 | راجع الخانة دي: {0}. |
CUSTOMER_HAS_OPEN_COMMITMENTS | 409 | العميل ده لسه عنده حجز جاي أو باقة يقدر يستخدمها. الغيها أو خلّصها الأول. |
INTERNAL_ERROR | 500 | حصلت مشكلة عندنا. جرّب تاني. |
الصفحات بالـ cursor
القوايم بتتقسم صفحات بـ cursor، مش بأرقام صفحات، علشان مفيش حاجة تتنط أو تتكرر والبيانات بتتغير. الأماكن بترجع nextCursor: ابعته في after لحد ما ميرجعش. القوايم اللي الأحدث فيها الأول (تقييمات الملعب، وبلاغات اللاعب) بتكمل بـ before وbeforeId من آخر عنصر. وحجوزاتك بتتقرا بفترة زمنية: from وto، ٣١ يوم بالكتير.
GET /partner/v1/venues?limit=50
→ { "items": [ … ], "nextCursor": "c2a9…" }
GET /partner/v1/venues?limit=50&after=c2a9…
→ { "items": [ … ] } # no nextCursor: that was the last page
GET /partner/v1/venues/{venueId}/courts/{courtId}/reviews?limit=20
→ [ …, { "id": "e41b…", "createdAt": "2026-10-02T18:00:00Z", … } ]
GET …/reviews?limit=20&before=2026-10-02T18:00:00Z&beforeId=e41b…الـ Webhooks
حدّد العنوان بتاعك بـ PUT /partner/v1/webhook (scope webhooks:manage). لازم يكون لينك https عام على البورت العادي، مش عنوان IP ولا اسم داخلي. كل مرة تحدده، الرد بيجيب signing secret جديد (whsec_…) بيظهر ساعتها بس: احفظه على السيرفر بتاعك.
الأحداث
| الحدث | بيتبعت إمتى |
|---|---|
reservation.created | حجز إنت عملته اتأكد. |
reservation.checked_in | الديسك في المكان سجّل وصول اللاعب. الحالة بتفضل CONFIRMED، وcheckedInAt فيها وقت وصوله، ومبقاش ينفع تلغي الحجز. |
reservation.cancelled | حجز اتلغى، منك أو من المكان (ومعاه cancelReason). |
reservation.no_show | المكان سجّل إن اللاعب مجاش. |
reservation.completed | التمرين خلص (بنشيّك كل ١٥ دقيقة). |
review.replied | المكان رد على تقييم لاعبك. |
safety_report.resolved | المكان حل بلاغ لاعبك بعته. |
تجاهل أي نوع حدث متعرفهوش، بس رد عليه برضه بـ 2xx علشان ميتبعتش تاني: الأنواع الجديدة بتيجي في إصدارات صغيرة، زي ما reservation.checked_in جه في 1.2.0.
شكل الرسالة
كل رسالة ليها نفس الغلاف: id (ثابت لكل حدث)، وtype، وcreatedAt، وdata. الرسايل فيها ids ومواعيد وحالات بس، وعمرها ما بتشيل رقم اللاعب.
POST /ezaz/webhooks HTTP/1.1
Content-Type: application/json
Ezaz-Signature: t=1791651600,v1=5f0c…e2
Ezaz-Event-Id: 0b8e6a1c-2f4d-4e7b-9c1a-6d3f8e2b5a70
User-Agent: Ezaz-Webhooks/1
{
"id": "0b8e6a1c-2f4d-4e7b-9c1a-6d3f8e2b5a70",
"type": "reservation.cancelled",
"createdAt": "2026-10-09T12:40:00Z",
"data": {
"reservationId": "d7f3…",
"venueId": "3f6c…",
"courtId": "9b2e…",
"start": "2026-10-10T17:00:00Z",
"end": "2026-10-10T18:30:00Z",
"status": "CANCELLED",
"payment": "PREPAID_BY_PARTNER",
"externalRef": "kb_8f2c41",
"cancelReason": "Court maintenance"
}
}اتأكد من التوقيع
Ezaz-Signature: t=TIMESTAMP,v1=SIGNATURE: الـ TIMESTAMP بثواني يونكس، والـ SIGNATURE هو HMAC-SHA256 بالـ hex لـ TIMESTAMP + "." + body بالـ signing secret بتاعك. قبل ما تثق في أي رسالة:
- استخدم الـ body الخام زي ما وصل بالظبط، قبل ما تقرا الـ JSON.
- قارن التوقيعات بمقارنة وقتها ثابت (constant time).
- ارفض أي توقيت بعيد عن ساعتك أكتر من ٥ دقايق (علشان محدش يعيد رسالة قديمة).
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 5 * 60;
/**
* rawBody: the request body exactly as received (a Buffer or string), before any JSON parsing.
* header: the Ezaz-Signature header. secret: your whsec_… signing secret.
*/
export function verifyEzazSignature(rawBody, header, secret, nowMs = Date.now()) {
const parts = Object.fromEntries(
String(header ?? "").split(",").map((part) => part.trim().split("=")),
);
const t = Number(parts.t);
if (!Number.isInteger(t) || !/^[0-9a-f]{64}$/.test(parts.v1 ?? "")) return false;
if (Math.abs(nowMs / 1000 - t) > TOLERANCE_SECONDS) return false;
const expected = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest();
return timingSafeEqual(expected, Buffer.from(parts.v1, "hex"));
}
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 5 * 60
def verify_ezaz_signature(raw_body: bytes, header: str, secret: str, now: float | None = None) -> bool:
"""raw_body: the request body exactly as received, before any JSON parsing."""
try:
parts = dict(part.strip().split("=", 1) for part in header.split(","))
t = int(parts["t"])
given = parts["v1"]
except (KeyError, ValueError):
return False
if abs((time.time() if now is None else now) - t) > TOLERANCE_SECONDS:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, given)
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.HexFormat;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
final class EzazWebhooks {
private static final long TOLERANCE_SECONDS = 5 * 60;
/** rawBody: the request body exactly as received, before any JSON parsing. */
static boolean verify(byte[] rawBody, String header, String secret, Instant now) throws Exception {
String t = null;
String v1 = null;
for (String part : header.split(",")) {
String[] kv = part.trim().split("=", 2);
if (kv.length == 2 && kv[0].equals("t")) t = kv[1];
if (kv.length == 2 && kv[0].equals("v1")) v1 = kv[1];
}
if (t == null || v1 == null) return false;
long timestamp;
byte[] given;
try {
timestamp = Long.parseLong(t);
given = HexFormat.of().parseHex(v1);
} catch (IllegalArgumentException e) {
return false;
}
if (Math.abs(now.getEpochSecond() - timestamp) > TOLERANCE_SECONDS) return false;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
mac.update((timestamp + ".").getBytes(StandardCharsets.UTF_8));
return MessageDigest.isEqual(mac.doFinal(rawBody), given);
}
}
import 'dart:convert';
import 'package:crypto/crypto.dart';
const toleranceSeconds = 5 * 60;
/// [rawBody]: the request body exactly as received, before any JSON parsing.
bool verifyEzazSignature(List<int> rawBody, String header, String secret, {DateTime? now}) {
final parts = <String, String>{};
for (final part in header.split(',')) {
final i = part.indexOf('=');
if (i > 0) parts[part.substring(0, i).trim()] = part.substring(i + 1).trim();
}
final t = int.tryParse(parts['t'] ?? '');
final given = parts['v1'];
if (t == null || given == null) return false;
final nowSeconds = (now ?? DateTime.now()).millisecondsSinceEpoch ~/ 1000;
if ((nowSeconds - t).abs() > toleranceSeconds) return false;
final expected = Hmac(sha256, utf8.encode(secret)).convert([...utf8.encode('$t.'), ...rawBody]).toString();
return _constantTimeEquals(expected, given);
}
bool _constantTimeEquals(String a, String b) {
if (a.length != b.length) return false;
var diff = 0;
for (var i = 0; i < a.length; i++) {
diff |= a.codeUnitAt(i) ^ b.codeUnitAt(i);
}
return diff == 0;
}
إعادة الإرسال
رد بـ 2xx في خلال ١٠ ثواني؛ إزاز مش بيمشي ورا أي redirect. أي رد تاني بيتعاد بعد دقيقة، و٥ دقايق، و٣٠ دقيقة، وساعتين، و٦ ساعات، و١٥ ساعة (حوالي يوم)، وبعدها بيتساب. لو تطبيقك اتوقف أو شال الـ webhook، الرسايل اللي مستنية بتقف.
1m → 5m → 30m → 2h → 6h → 15h
التكرار والترتيب
كل رسالة بتوصل مرة على الأقل: نفس الحدث ممكن يوصل مرتين، والأحداث ممكن توصل مش بالترتيب. احفظ الـ id بتاع كل حدث (موجود كمان في هيدر Ezaz-Event-Id) وتجاهل أي حدث اتعامل معاه قبل كده؛ ولو الترتيب فارق، اقرا الحجز تاني بـ GET /partner/v1/reservations/{id}.
الإصدارات
ده /partner/v1. إضافة خانات أو عمليات أو أحداث أو parameters اختيارية مش بتكسر حاجة: تجاهل أي خانة متعرفهاش. أي تغيير بيكسر بياخد إصدار جديد (/partner/v2) جنب القديم، والقديم بيفضل شغال ٦ شهور على الأقل. فيه فحص في بناء إزاز بيمنع أي تغيير بيكسر v1.