Skip to content
Ezaz Developers

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/token
curl -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.

Partner API scopes
ScopeWhat it allowsOperations
availability:readFree slots and prices
passes:readA player's packages and memberships at a venue
players:eraseErase what came through you about a player
reservations:readYour reservations and their reviews
reservations:writeBook and cancel
reviews:writeReview reservations you made
safety_reports:readA player's safety reports
safety_reports:writeFile safety reports and add photos
venues:readVenues, courts, court details and reviews
webhooks:manageSet 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/reservations
curl -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."
}
Problem codes a partner can receive
CodeHTTPDetail (as the API sends it)
VALIDATION_FAILED400Some fields are invalid.
MALFORMED_REQUEST400The request could not be read.
PHONE_INVALID400Enter a valid mobile number.
FILE_REJECTED400This file can't be uploaded. Use a JPG, PNG or WebP within the size limit.
PARTNER_INVALID400Check this field: {0}.
PARTNER_WEBHOOK_URL_INVALID400Webhooks go to a public https URL on the standard port, never an IP address or an internal name.
PARTNER_PHONE_NOT_VERIFIED400A package can pay only when you confirm the player verified this phone with you (customer.phoneVerified).
COURT_SLOT_NOT_BOOKABLE400That time can't be booked: check the opening hours, the length (60, 90 or 120 minutes) and the date.
COURT_PAYMENT_INVALID400Check the payment: prepaid needs the amount you collected, a package needs the player's package, and pay at the venue needs neither.
COURT_REVIEW_INVALID400Give 1 to 5 stars, and keep the comment or reply within 500 characters.
SAFETY_REPORT_INVALID400Check this field: {0}. Say what happened to the court in up to 500 characters, within the last 30 days.
UNAUTHENTICATED401Please sign in.
FORBIDDEN403You don't have access to this.
NOT_FOUND404Not found.
VENUE_NOT_FOUND404We couldn't find that venue.
COURT_NOT_FOUND404We couldn't find that court.
COURT_RESERVATION_NOT_FOUND404We couldn't find that court reservation.
COURT_PASS_NOT_FOUND404We couldn't find that package or membership.
COURT_REVIEW_NOT_FOUND404We couldn't find that review.
SAFETY_REPORT_NOT_FOUND404We couldn't find that report.
COURT_TAKEN409Someone just booked this court at that time. Pick another time or court.
VENUE_NOT_TAKING_BOOKINGS409This venue isn't taking court bookings on Ezaz yet.
EXTERNAL_REF_IN_USE409You already have a live reservation with this external reference.
COURT_RESERVATION_NOT_CANCELLABLE409This reservation can't be cancelled now: it's past the venue's cutoff, over, or already cancelled.
COURT_RESERVATION_STATE_INVALID409This reservation can't change that way now.
COURT_PASS_NOT_USABLE409This package can't pay for that session: not enough minutes left, it expires first, or it's someone else's.
COURT_REVIEW_NOT_ALLOWED409A reservation can be reviewed once it was played, within 14 days of its end.
COURT_REVIEW_EXISTS409This reservation was already reviewed, differently.
SAFETY_REPORT_STATE_INVALID409This report can't change that way now: it's already resolved, or it has 3 photos.
CONCURRENT_UPDATE409Someone else changed this at the same moment. Please try again.
UNSUPPORTED_MEDIA_TYPE415Unsupported content type.
PARTNER_RATE_LIMITED429Too many requests this minute. Wait and try again.
PARTNER_PASS_LOOKUPS_LIMITED429Too many player-pass lookups from your app. Wait and try again.
TOO_MANY_SAFETY_REPORTS429This player sent several reports today. If it's urgent, contact Ezaz support.
COURT_INVALID400Check this field: {0}.
CUSTOMER_HAS_OPEN_COMMITMENTS409This customer still has a booking to come or a pass they can use. Cancel or settle those first.
INTERNAL_ERROR500Something 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

Webhook events
EventWhen it is sent
reservation.createdA reservation you made is confirmed.
reservation.checked_inThe venue's desk checked the player in. The status stays CONFIRMED; checkedInAt says when, and you can no longer cancel the booking.
reservation.cancelledA reservation was cancelled, by you or by the venue (with cancelReason).
reservation.no_showThe venue marked the player as a no-show.
reservation.completedThe session ended (checked every 15 minutes).
review.repliedThe venue replied to your player's review.
safety_report.resolvedThe 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).
Signature check in Node.js
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"));
}
Signature check in Python
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)
Signature check in Java
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);
    }
}
Signature check in Dart
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.