Base URL: https://api.mahjoz.io/api/open/v1
The Open API uses OAuth2 Client Credentials flow.
Create an API client from your Mahjoz dashboard: Settings > API Integration
After creating a client, you will receive a client_id and client_secret. Save these immediately — the secret is only shown once.
POST /oauth/token
Content-Type: application/json
{
"grant_type": "client_credentials",
"client_id": "<client_id>",
"client_secret": "<client_secret>",
"scope": ""
}
Response:
{
"token_type": "Bearer",
"expires_in": 31536000,
"access_token": "eyJ0eXAiOiJKV1Q..."
}
Include the token in all requests:
Authorization: Bearer <access_token>
Requests are rate-limited per client. Default: 60 requests/minute. When exceeded, the API returns 429 Too Many Requests with a Retry-After header.
| Scope | Description |
|---|---|
orders:read |
Read orders |
orders:write |
Create orders and check availability |
customers:read |
Read customers |
customers:write |
Create customers |
items:read |
Read items (services and products) |
staff:read |
Read staff (providers) and their services |
branches:read |
Read branches |
categories:read |
Read categories |
payment_methods:read |
Read payment methods |
payment_methods:write |
Create payment methods |
transactions:read |
Read transactions |
transactions:write |
Record order payments |
* |
All scopes |
List endpoints return paginated results.
| Parameter | Default | Max | Description |
|---|---|---|---|
per_page |
25 | 100 | Items per page |
page |
1 | - | Page number |
Response format:
{
"data": [...],
"links": { "first": "...", "last": "...", "prev": null, "next": "..." },
"meta": { "current_page": 1, "last_page": 5, "per_page": 25, "total": 120 }
}
All errors follow a consistent format:
{
"error": "error_code",
"message": "Human-readable description"
}
| HTTP Code | Error Code | Description |
|---|---|---|
| 401 | authentication_failed |
Missing/invalid/expired token |
| 403 | insufficient_scope |
Token lacks required scope |
| 403 | feature_disabled |
Open API not available on current plan |
| 422 | validation_error |
Invalid request data |
| 422 | timeslot_unavailable |
Requested time slot is not available |
| 429 | - | Rate limit exceeded |
| 502 | availability_check_failed |
Availability could not be checked |
GET /orders
Scope: orders:read
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
page |
integer | Page number |
per_page |
integer | Items per page (max 100) |
from |
date | Filter orders from date (YYYY-MM-DD) |
to |
date | Filter orders to date (YYYY-MM-DD) |
status |
string | Filter by status |
Response:
{
"data": [
{
"id": "uuid",
"order_number": "ORD-001",
"status": "confirmed",
"source": "api",
"customer": { "id": "uuid", "first_name": "...", "last_name": "...", "phone": "..." },
"branch": { "id": "uuid", "name": "Main Branch" },
"items": [
{
"id": "uuid",
"name": "Haircut",
"type": "service",
"price": 100.00,
"quantity": 1,
"discount_amount": 0,
"total": 100.00
}
],
"currency": "SAR",
"total": 150.00,
"discount_amount": 0,
"created_at": "2026-02-25T10:00:00.000000Z",
"updated_at": "2026-02-25T10:00:00.000000Z"
}
]
}
GET /orders/{uuid}
Scope: orders:read
Returns order with items, transactions, and full customer/branch details. Each item includes staff_id.
POST /orders
Scope: orders:write
Note: If the order includes service items, the API validates the requested time slot against the provider's availability before creating the order. If the slot is unavailable, a
422error is returned.
Base request (unpaid order):
{
"team_id": "branch-uuid",
"customer_id": "customer-uuid",
"start": "2026-02-25T10:00:00",
"items": [
{
"id": "service-uuid",
"type": "service",
"staff_id": "staff-uuid",
"quantity": 1,
"unit_amount": 100.00,
"discount_amount": 0
},
{
"id": "product-uuid",
"type": "product",
"quantity": 2,
"unit_amount": 25.00
}
],
"note": "Optional note"
}
Base request fields:
| Field | Type | Required | Description |
|---|---|---|---|
team_id |
uuid | Yes | Branch UUID |
customer_id |
uuid | Yes | Customer UUID |
start |
datetime | Yes (if items contain a service) | ISO 8601 appointment start time |
items |
array | Yes | At least 1 item |
items.*.id |
uuid | Yes | Service/Product/Package UUID |
items.*.type |
string | Yes | service, product, or package |
items.*.quantity |
integer | Yes | Minimum 1 |
items.*.staff_id |
uuid | No | Staff member UUID |
items.*.unit_amount |
number | No | Override unit price |
items.*.discount_amount |
number | No | Discount per item |
note |
string | No | Order note |
payment |
object | No | See Inline Payment below |
Add a payment object to create a confirmed transaction in the same request — the order is marked paid atomically. Requires scope transactions:write.
The payment method is picked in this order (first match wins):
payment.payment_method_id (UUID) — explicit.payment.payment_method_type (slug) — e.g. cash, card, bank_transfer, other, third-party, voucher.Payment fields:
| Field | Type | Required | Description |
|---|---|---|---|
payment.amount |
number | Yes (if payment present) |
Must not exceed order total |
payment.payment_method_id |
uuid | No | Tenant payment method UUID (discover via GET /payment-methods) |
payment.payment_method_type |
string | No | Method type slug — resolved against the tenant's active methods |
payment.reference_number |
string | No | External transaction reference |
payment.date |
datetime | No | Payment timestamp (defaults to now) |
payment.note |
string | No | Free-form note attached to the transaction |
Example A — cash payment by type:
{ "team_id": "...", "customer_id": "...", "items": [...],
"payment": { "amount": 150.00, "payment_method_type": "cash" } }
Example B — explicit method UUID:
{ "team_id": "...", "customer_id": "...", "items": [...],
"payment": { "amount": 150.00, "payment_method_id": "f3a1...-uuid" } }
Payment-related errors:
| HTTP | Error | Condition |
|---|---|---|
| 422 | payment_method_not_found |
UUID is not a method of this tenant, or type slug has no active method |
| 422 | payment_method_type_ambiguous |
Tenant has multiple active methods of that type — use payment_method_id instead |
| 422 | payment_method_required |
payment.amount was given but no method could be resolved |
| 422 | payment_method_unavailable |
Linked integration app was uninstalled by the tenant |
If the transaction fails validation, the order is also rolled back — you can safely retry.
Response (201) with inline payment:
{
"data": {
"id": "uuid",
"order_number": "ORD-042",
"status": "confirmed",
"payment_status": "paid",
"currency": "SAR",
"total": 150.00,
"created_at": "2026-02-25T10:30:00.000000Z",
"transaction": {
"id": "txn-uuid",
"amount": "150.00",
"currency": "SAR",
"status": "confirmed",
"reference_number": null,
"date": "2026-02-25T10:30:00+00:00"
}
}
}
When no payment is sent, transaction is null and payment_status will typically be unpaid.
POST /orders/{uuid}/status
Scope: orders:write
Request:
{
"status": "completed",
"note_for_status": "finished on-site"
}
Request Fields:
| Field | Type | Required | Description |
|---|---|---|---|
status |
string | Yes | One of confirmed, in-progress, completed, canceled |
note_for_status |
string | No | Free-text reason or note attached to the transition |
Behavior:
status equals the order's current status, the call is a no-op and returns the order.canceled is rejected with 422 order_has_invoice when the order already has an invoice.canceled is rejected with 422 invalid_status_transition when the order has refunded children.order.status_changed webhook.Response: Same shape as GET /orders/{uuid} (full order detail with customer, branch, items, and transactions).
POST /availability
Scope: orders:write
Request:
{
"service_id": "service-uuid",
"date": "2026-02-25",
"staff_id": "staff-uuid",
"quantity": 1,
"visible_days": 7,
"next_availability": false
}
Request Fields:
| Field | Type | Required | Description |
|---|---|---|---|
service_id |
uuid | Yes | Service to check |
date |
date | Yes | Start date (YYYY-MM-DD) |
staff_id |
uuid | No | Specific staff member |
quantity |
integer | No | Default 1 |
visible_days |
integer | No | Days to check (1-30, default 7) |
next_availability |
boolean | No | Find next available slot if none on given date |
Response:
{
"data": {
"slots": {
"2026-02-25": ["09:00", "09:30", "10:00", "10:30"],
"2026-02-26": ["09:00", "11:00", "14:00"]
}
}
}
GET /customers
Scope: customers:read
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
page |
integer | Page number |
per_page |
integer | Items per page (max 100) |
name |
string | Filter by name |
phone |
string | Filter by phone number |
email |
string | Filter by email |
Response item fields: id, first_name, last_name, email, phone, type, city, created_at, updated_at
GET /customers/{uuid}
Scope: customers:read
Returns customer with address, note, blocked, tax_registration_number, tags.
GET /customers/phone?phone=<phone>
Scope: customers:read
| Parameter | Type | Required | Description |
|---|---|---|---|
phone |
string | Yes | Phone number in international format, with or without the leading + — +966500000001, 966500000001 and 00966500000001 all match the same customer. If you send the +, URL-encode it as %2B. Separators (spaces, dashes, dots, parentheses) are ignored on both sides of the comparison. |
Matching is done on digits only, so the stored formatting does not matter. Local
(national) formats such as 0500000001 are not resolved — send the country
code.
Response (found):
{
"data": {
"id": "uuid",
"first_name": "John",
"last_name": "Doe",
"email": "john@example.com",
"phone": "+966500000001",
"type": "individual",
"city": "Riyadh",
"address": "123 Main St",
"note": "VIP customer",
"blocked": false,
"tax_registration_number": "300000000000003",
"tags": [],
"created_at": "2026-02-25T10:00:00.000000Z",
"updated_at": "2026-02-25T10:00:00.000000Z"
}
}
Response (not found):
{
"data": null
}
POST /customers
Scope: customers:write
Request:
{
"first_name": "John",
"last_name": "Doe",
"email": "john@example.com",
"phone": "+966500000001",
"phone_country": "SA",
"type": "individual",
"city": "Riyadh",
"address": "123 Main St",
"note": "VIP customer",
"tax_registration_number": "300000000000003"
}
Request Fields:
| Field | Type | Required | Description |
|---|---|---|---|
first_name |
string | Yes | Min 2 chars |
last_name |
string | No | Min 2 chars |
email |
string | No | Valid email (RFC + DNS) |
phone |
string | No | Unique per tenant |
phone_country |
string | No | Required with phone, 2-char country code (e.g., SA) |
type |
string | No | individual or company (default: individual) |
city |
string | No | Min 3 chars |
address |
string | No | Min 3 chars |
note |
string | No | Free text |
tax_registration_number |
string | No | 15 digits |
Response (201):
{
"data": {
"id": "uuid",
"first_name": "John",
"last_name": "Doe",
"email": "john@example.com",
"phone": "+966500000001",
"type": "individual",
"city": "Riyadh",
"address": "123 Main St",
"note": "VIP customer",
"blocked": false,
"tax_registration_number": "300000000000003",
"tags": [],
"created_at": "2026-02-25T10:30:00.000000Z",
"updated_at": "2026-02-25T10:30:00.000000Z"
}
}
GET /items
Scope: items:read
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
page |
integer | Page number |
per_page |
integer | Items per page (max 100) |
type |
string | service or product |
name |
string | Filter by name |
Response:
{
"data": [
{
"id": "uuid",
"type": "service",
"sku": "SRV-001",
"name": { "ar": "قص شعر", "en": "Haircut" },
"slug": "haircut",
"description": { "ar": "...", "en": "..." },
"html_description": { "ar": "<p>...</p>", "en": "<p>...</p>" },
"status": true,
"price": 100.00,
"offer_price": 80.00,
"currency": "SAR",
"duration": 30,
"options": [
{
"id": "uuid",
"name": "Red Small",
"price": 120.00,
"offer_price": 100.00,
"duration": 45,
"is_active": true,
"values": [
{ "option_value_id": "uuid", "option_name": "Color", "value": "Red" },
{ "option_value_id": "uuid", "option_name": "Size", "value": "Small" }
]
}
],
"category": {
"id": "uuid",
"name": { "ar": "الشعر", "en": "Hair" },
"status": true
},
"branch": { "id": "uuid", "name": "Main Branch" },
"created_at": "2026-02-25T10:00:00.000000Z",
"updated_at": "2026-02-25T10:00:00.000000Z"
},
{
"id": "uuid",
"type": "product",
"sku": "PRD-001",
"name": { "ar": "شامبو", "en": "Shampoo" },
"slug": "shampoo",
"description": { "ar": "...", "en": "..." },
"html_description": { "ar": "<p>...</p>", "en": "<p>...</p>" },
"status": true,
"price": 25.00,
"offer_price": null,
"currency": "SAR",
"cost": 10.00,
"stock": 50,
"options": [
{
"id": "uuid",
"name": "500ml",
"price": 30.00,
"offer_price": 30.00,
"quantity": 20,
"is_active": true,
"values": [
{ "option_value_id": "uuid", "option_name": "Size", "value": "500ml" }
]
}
],
"category": {
"id": "uuid",
"name": { "ar": "المنتجات", "en": "Products" },
"status": true
},
"branch": { "id": "uuid", "name": "Main Branch" },
"created_at": "2026-02-25T10:00:00.000000Z",
"updated_at": "2026-02-25T10:00:00.000000Z"
}
]
}
Type-specific fields:
duration, staff (on detail view), options include durationcost, stock, options include quantityOptions: Each item can have variants (options). Each option has a name, price, offer_price, and an array of values describing which option values make up the variant (e.g., Color=Red, Size=Small).
GET /items/{uuid}
Scope: items:read
Returns item with full details. For services, includes staff array with each staff member's id, name, price, and duration.
GET /staff
Scope: staff:read
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
page |
integer | Page number |
per_page |
integer | Items per page (max 100) |
name |
string | Filter by name |
branch_id |
uuid | Filter by branch UUID |
Response:
{
"data": [
{
"id": "uuid",
"name": "Ahmed Ali",
"phone": "+966500000001",
"phone_country": "SA",
"email": "ahmed@example.com",
"image": "https://...",
"branch": { "id": "uuid", "name": "Main Branch" },
"services": [
{
"id": "uuid",
"name": "Haircut",
"price": 100.00,
"duration": 30
}
],
"created_at": "2026-02-25T10:00:00.000000Z",
"updated_at": "2026-02-25T10:00:00.000000Z"
}
]
}
GET /staff/{uuid}
Scope: staff:read
Returns staff member with branch and full services list.
GET /staff/{uuid}/working-hour
Scope: staff:read
Returns the staff member's default working hour with intervals. If the staff member has no working hour configured, returns the working hour of their branch (team) as a fallback. The source field indicates which one was returned.
Responds with 404 only when neither the staff nor their branch has a working hour.
Response:
{
"data": {
"id": "uuid",
"source": "staff",
"all_time": false,
"active": true,
"intervals": [
{
"id": "uuid",
"day": "sun",
"from": "09:00",
"to": "17:00",
"active": true
}
]
}
}
| Field | Type | Description |
|---|---|---|
source |
string | staff if staff has a custom working hour, branch if falling back to the branch (team) working hour |
intervals[].day |
string | Lowercase three-letter day code (sun, mon, tue, wed, thu, fri, sat) |
intervals[].from / intervals[].to |
string | Time of day in HH:mm format, in the tenant's timezone |
GET /branches
Scope: branches:read
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
page |
integer | Page number |
per_page |
integer | Items per page (max 100) |
Response:
{
"data": [
{
"id": "uuid",
"name": "Main Branch",
"slug": "main-branch",
"phone": "+966500000001",
"phone_country": "SA",
"address": "123 Main St, Riyadh",
"latitude": 24.7136,
"longitude": 46.6753,
"location": "in-store",
"status": true,
"created_at": "2026-02-25T10:00:00.000000Z",
"updated_at": "2026-02-25T10:00:00.000000Z"
}
]
}
GET /categories
Scope: categories:read
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
page |
integer | Page number |
per_page |
integer | Items per page (max 100) |
branch_id |
uuid | Filter by branch UUID |
Response:
{
"data": [
{
"id": "uuid",
"name": { "ar": "خدمات الشعر", "en": "Hair Services" },
"slug": "hair-services",
"description": { "ar": "جميع خدمات الشعر", "en": "All hair-related services" },
"status": true,
"sort_order": 1,
"branch": { "id": "uuid", "name": "Main Branch" },
"created_at": "2026-02-25T10:00:00.000000Z",
"updated_at": "2026-02-25T10:00:00.000000Z"
}
]
}
Use GET /payment-methods to discover which methods are available on a tenant (cash, card, bank transfer, and any installed app methods). The returned UUIDs can be passed as payment.payment_method_id on POST /orders.
POST /payment-methods still only supports creating cash and other types; other method types are managed by tenants in the Mahjoz dashboard.
GET /payment-methods
Scope: payment_methods:read
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
per_page |
integer | Items per page (default 25, max 100) |
page |
integer | Page number |
Response:
{
"data": [
{
"id": "uuid",
"name": { "ar": "نقداً", "en": "Cash" },
"slug": "on_arrival",
"type": {
"id": "uuid",
"name": "Cash",
"slug": "cash"
},
"created_at": "2026-02-25T10:00:00.000000Z",
"updated_at": "2026-02-25T10:00:00.000000Z"
}
]
}
GET /payment-methods/{id}
Scope: payment_methods:read
Returns a single payment method by UUID.
GET /transactions
Scope: transactions:read
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
status |
string | Filter by transaction status (e.g. pending, confirmed, refused) |
order_id |
uuid | Filter by related order UUID |
booking_id |
uuid | Filter by related booking UUID |
from |
date | Start of date range (created_at) |
to |
date | End of date range (created_at) |
per_page |
integer | Items per page (default 25, max 100) |
page |
integer | Page number |
Response:
{
"data": [
{
"id": "uuid",
"transaction_no": 1042,
"amount": "150.00",
"currency": "SAR",
"status": "confirmed",
"is_deposit": false,
"note": null,
"date": "2026-04-15T10:30:00+03:00",
"payment_method": {
"id": "uuid",
"name": { "en": "Cash" },
"slug": "on_arrival"
},
"order": { "id": "uuid", "order_number": 1015 },
"booking": null,
"created_at": "2026-04-15T10:30:00.000000Z",
"updated_at": "2026-04-15T10:30:00.000000Z"
}
]
}
GET /transactions/{id}
Scope: transactions:read
Returns a single transaction by UUID.
POST /payment-methods
Scope: payment_methods:write
Creates a tenant-level payment method. Only the cash and other types are supported via the Open API.
Body:
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | yes | Display name |
type |
string | yes | One of cash, other |
status |
boolean | no | Defaults to true |
Response: 201 Created with the created payment method.
POST /orders/{id}/payments
Scope: transactions:write
Records a payment (transaction) against a sale order. The amount cannot exceed the order's remaining unpaid amount and the order must not be in draft status.
Body:
| Field | Type | Required | Description |
|---|---|---|---|
amount |
number | yes | Amount to pay (cannot exceed remaining) |
payment_method_id |
uuid | yes | UUID of a tenant payment method |
note |
string | no | Optional note |
reference_number |
string | no | External reference |
date |
datetime | no | Defaults to now |
Response: 201 Created with the created transaction resource.