Getting started
Everything a partner server needs before the first booking. Call the API only from your server: the client secret and tokens must never reach a browser or a mobile app.
Sign in with client credentials
The API uses OAuth 2.0 client credentials (RFC 6749 §4.4). Send your client id (kp_…) and secret (ks_…) with HTTP Basic (preferred) or as client_id and client_secret form fields, and ask for the scopes you need. The token is a Bearer token valid for 10 minutes; get a new one when it expires. Errors follow RFC 6749 §5.2 (invalid_client, invalid_scope, …).
$EZAZ_API is the API's base URL, given to you with your credentials.
Try it in Postman: download the partner collection (every operation, with the examples of this reference). Set its variables baseUrl, partnerClientId and partnerClientSecret (keep the secret in Postman, never in a shared workspace): the collection then asks for a token and renews it before it expires, and sends a fresh Idempotency-Key with every create.
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"
}When Ezaz rotates your secret, the old one keeps working for 24 hours so you can deploy the new one. If your app is suspended, its tokens stop working at once.
Scopes
Ezaz grants your app a set of scopes; each token carries the ones you ask for. A call outside its scope answers 403. An operation listed under two scopes needs both: the quote reads prices and a player's passes.
| Scope | What it allows | Operations |
|---|---|---|
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 |
Rate limits
Each app has a budget of requests per minute (120 unless agreed otherwise), counted per app, not per address, in fixed one-minute windows. Over budget, the API answers 429 with a Retry-After header in seconds and the code PARTNER_RATE_LIMITED. Wait that long before retrying.
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 keys
Every call that books or files something (POST /reservations, POST /safety-reports) needs an Idempotency-Key header: a fresh random value (a UUID) per action. If the network fails, retry with the same key: you get the first result back, never a second booking. Use a new key for a new action.
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"
}'Your own reference
Send your own id for the booking in externalRef (up to 80 characters). It comes back in every response and webhook, so you can match them to your records. You can have at most one live (confirmed) reservation per externalRef; a second one is EXTERNAL_REF_IN_USE.
Payment on each booking
Pick one payment method per booking. Prices are integer piasters (120000 is EGP 1,200.00); never use floating point. The reservation records its price (list) and its charge (what the venue is owed for it).
payment{ "method": "PAY_AT_VENUE" }
{ "method": "PREPAID_BY_PARTNER", "collected": { "amount": 120000, "currency": "EGP" } }
{ "method": "PACKAGE", "packageCreditId": "5a1e…" } // needs "customer": { …, "phoneVerified": true }Errors
Errors are RFC 9457 problem details (application/problem+json) with a stable code. Branch on the code, never on the text. The detail is written for people, in Arabic or English according to your Accept-Language header.
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."
}| Code | HTTP | Detail (as the API sends it) |
|---|---|---|
VALIDATION_FAILED | 400 | Some fields are invalid. |
MALFORMED_REQUEST | 400 | The request could not be read. |
PHONE_INVALID | 400 | Enter a valid mobile number. |
FILE_REJECTED | 400 | This file can't be uploaded. Use a JPG, PNG or WebP within the size limit. |
PARTNER_INVALID | 400 | Check this field: {0}. |
PARTNER_WEBHOOK_URL_INVALID | 400 | Webhooks go to a public https URL on the standard port, never an IP address or an internal name. |
PARTNER_PHONE_NOT_VERIFIED | 400 | A package can pay only when you confirm the player verified this phone with you (customer.phoneVerified). |
COURT_SLOT_NOT_BOOKABLE | 400 | That time can't be booked: check the opening hours, the length (60, 90 or 120 minutes) and the date. |
COURT_PAYMENT_INVALID | 400 | Check the payment: prepaid needs the amount you collected, a package needs the player's package, and pay at the venue needs neither. |
COURT_REVIEW_INVALID | 400 | Give 1 to 5 stars, and keep the comment or reply within 500 characters. |
SAFETY_REPORT_INVALID | 400 | Check this field: {0}. Say what happened to the court in up to 500 characters, within the last 30 days. |
UNAUTHENTICATED | 401 | Please sign in. |
FORBIDDEN | 403 | You don't have access to this. |
NOT_FOUND | 404 | Not found. |
VENUE_NOT_FOUND | 404 | We couldn't find that venue. |
COURT_NOT_FOUND | 404 | We couldn't find that court. |
COURT_RESERVATION_NOT_FOUND | 404 | We couldn't find that court reservation. |
COURT_PASS_NOT_FOUND | 404 | We couldn't find that package or membership. |
COURT_REVIEW_NOT_FOUND | 404 | We couldn't find that review. |
SAFETY_REPORT_NOT_FOUND | 404 | We couldn't find that report. |
COURT_TAKEN | 409 | Someone just booked this court at that time. Pick another time or court. |
VENUE_NOT_TAKING_BOOKINGS | 409 | This venue isn't taking court bookings on Ezaz yet. |
EXTERNAL_REF_IN_USE | 409 | You already have a live reservation with this external reference. |
COURT_RESERVATION_NOT_CANCELLABLE | 409 | This reservation can't be cancelled now: it's past the venue's cutoff, over, or already cancelled. |
COURT_RESERVATION_STATE_INVALID | 409 | This reservation can't change that way now. |
COURT_PASS_NOT_USABLE | 409 | This package can't pay for that session: not enough minutes left, it expires first, or it's someone else's. |
COURT_REVIEW_NOT_ALLOWED | 409 | A reservation can be reviewed once it was played, within 14 days of its end. |
COURT_REVIEW_EXISTS | 409 | This reservation was already reviewed, differently. |
SAFETY_REPORT_STATE_INVALID | 409 | This report can't change that way now: it's already resolved, or it has 3 photos. |
CONCURRENT_UPDATE | 409 | Someone else changed this at the same moment. Please try again. |
UNSUPPORTED_MEDIA_TYPE | 415 | Unsupported content type. |
PARTNER_RATE_LIMITED | 429 | Too many requests this minute. Wait and try again. |
PARTNER_PASS_LOOKUPS_LIMITED | 429 | Too many player-pass lookups from your app. Wait and try again. |
TOO_MANY_SAFETY_REPORTS | 429 | This player sent several reports today. If it's urgent, contact Ezaz support. |
COURT_INVALID | 400 | Check this field: {0}. |
CUSTOMER_HAS_OPEN_COMMITMENTS | 409 | This customer still has a booking to come or a pass they can use. Cancel or settle those first. |
INTERNAL_ERROR | 500 | Something went wrong on our side. Please try again. |
Pagination with cursors
Lists are paged with cursors, never page numbers, so nothing is skipped or repeated while data changes. Venues answer nextCursor: pass it as after until it is missing. Newest-first lists (court reviews, a player's safety reports) continue with before and beforeId from the last item. Your reservations are read by time window: from and to, at most 31 days.
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
Set your endpoint with PUT /partner/v1/webhook (scope webhooks:manage). It must be a public https URL on the standard port, never an IP address or an internal name. Each time you set it, the answer carries a new signing secret (whsec_…), shown only then: store it on your server.
Events
| Event | When it is sent |
|---|---|
reservation.created | A reservation you made is confirmed. |
reservation.checked_in | The venue's desk checked the player in. The status stays CONFIRMED; checkedInAt says when, and you can no longer cancel the booking. |
reservation.cancelled | A reservation was cancelled, by you or by the venue (with cancelReason). |
reservation.no_show | The venue marked the player as a no-show. |
reservation.completed | The session ended (checked every 15 minutes). |
review.replied | The venue replied to your player's review. |
safety_report.resolved | The venue resolved a report your player filed. |
Ignore event types you don't know, and still answer them with 2xx so they aren't retried: new types come in minor versions, as reservation.checked_in did in 1.2.0.
What a delivery looks like
Every delivery has the same envelope: id (stable per event), type, createdAt and data. Payloads carry ids, times and statuses, never the player's phone.
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"
}
}Verify the signature
Ezaz-Signature: t=TIMESTAMP,v1=SIGNATURE: TIMESTAMP is in Unix seconds and SIGNATURE is the hex HMAC-SHA256 of TIMESTAMP + "." + body with your signing secret. Before trusting a delivery:
- Use the raw body exactly as received, before parsing JSON.
- Compare signatures in constant time.
- Reject a timestamp more than 5 minutes from your clock (replays).
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;
}
Retries
Answer 2xx within 10 seconds; Ezaz follows no redirects. Anything else is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 15 hours (about a day), then given up. If your app is suspended or removes its webhook, pending deliveries stop.
1m → 5m → 30m → 2h → 6h → 15h
Duplicates and order
Delivery is at least once: the same event can arrive twice, and events can arrive out of order. Store each event id (also in the Ezaz-Event-Id header) and ignore one you already handled; when order matters, read the reservation again with GET /partner/v1/reservations/{id}.
Versioning
This is /partner/v1. Adding fields, operations, events or optional parameters is not breaking: ignore fields you don't know. A breaking change gets a new major version (/partner/v2) next to the old one, which keeps running for at least 6 months. A check in Ezaz's build blocks a breaking change to v1.