API Conventions¶
All clients (Next.js web, Flutter apps, admin) consume the same REST API. JSON only, UTF-8, UTC timestamps in ISO-8601 (2026-07-05T14:30:00Z).
Base URL & Versioning¶
https://api.yam3at.com/api/v1
- URI versioning (
/api/v1).v1is stable for the life of MVP + Scale. - Additive changes (new fields, new endpoints, new enum values on output) are non-breaking and happen within
v1— clients must tolerate unknown fields and unknown enum values. - Breaking changes (removing/renaming fields, changing semantics, new required inputs) require
v2, run side-by-side withv1for a minimum 12-month deprecation window, announced viaDeprecationandSunsetresponse headers onv1. - Mobile reality check: Flutter apps can't be force-upgraded instantly; nothing ships that breaks the previous two released app versions.
Authentication¶
Authorization: Bearer <sanctum-token>
- Tokens issued by
POST /auth/login,POST /auth/otp/verify,POST /auth/social. Missing/invalid token →401 UNAUTHENTICATED; valid token, insufficient rights →403 FORBIDDEN. - Public endpoints (search, vendor profiles, categories, CMS) require no token but are rate-limited harder.
Standard Response Envelope¶
Success (single resource):
{
"data": { "id": "01J9ZK...", "...": "..." },
"meta": {}
}
Success (collection):
{
"data": [ { "...": "..." } ],
"meta": {
"pagination": { "page": 2, "per_page": 20, "total": 143, "total_pages": 8 }
},
"links": { "next": "...?page=3", "prev": "...?page=1" }
}
Error:
{
"error": {
"code": "VALIDATION_FAILED",
"message": "The given data was invalid.",
"details": {
"budget_max": ["must be greater than or equal to budget_min"]
},
"request_id": "req_01J9ZKQ8MZ"
}
}
Rules:
dataXORerror— never both.messageis a developer-facing English string; clients render their own localized user messages keyed oncode.detailsappears only forVALIDATION_FAILED(field → array of rule messages, localized perAccept-Language).request_idis always present and echoed in theX-Request-Idheader — quote it in bug reports; it joins server logs.- Resource IDs in the API are always the ULID
public_id(idin payloads). Internal integers never leak. - Money is serialized as a string with the currency's exact decimal places, plus currency:
{"amount": "150.000", "currency": "KWD"}. Never a float.
HTTP Status Usage¶
| Status | Meaning here |
|---|---|
| 200 | Read / update success |
| 201 | Resource created (body contains it; Location header set) |
| 202 | Accepted for async processing (e.g., media confirm) |
| 204 | Success, no body (deletes, mark-read) |
| 400 | Malformed request (bad JSON, bad param type) |
| 401 / 403 | Unauthenticated / unauthorized |
| 404 | Not found or not yours (no existence oracle across tenants) |
| 409 | State conflict (illegal transition, duplicate) |
| 422 | Validation failed |
| 429 | Rate limited (Retry-After header) |
| 500 / 503 | Server error / dependency down (SEARCH_UNAVAILABLE, PAYMENT_PROVIDER_UNAVAILABLE) |
Error-Code Catalog¶
Stable, SCREAMING_SNAKE, grouped by prefix. Clients map codes → localized strings.
| Code | HTTP | Meaning |
|---|---|---|
UNAUTHENTICATED |
401 | Missing/expired token |
FORBIDDEN |
403 | Role/ownership check failed |
NOT_FOUND |
404 | Unknown or unowned resource |
VALIDATION_FAILED |
422 | See details |
RATE_LIMITED |
429 | See Retry-After |
IDEMPOTENCY_KEY_REUSED |
409 | Same key, different payload |
AUTH_INVALID_CREDENTIALS |
401 | Bad email/phone + password |
AUTH_OTP_INVALID / AUTH_OTP_EXPIRED / AUTH_OTP_MAX_ATTEMPTS |
422 | OTP failures |
AUTH_INVALID_SOCIAL_TOKEN |
401 | Google/Apple token rejected |
AUTH_PHONE_UNVERIFIED / AUTH_EMAIL_UNVERIFIED |
403 | Verification gate (booking / RFQ publish) |
VENDOR_NOT_APPROVED |
403 | Vendor action before approval |
SUBSCRIPTION_REQUIRED / SUBSCRIPTION_QUOTA_EXCEEDED |
403 | Plan gate / monthly quotation quota spent |
RFQ_INVALID_STATE / QUOTATION_INVALID_STATE / BOOKING_INVALID_STATE |
409 | Illegal state-machine transition |
QUOTATION_EXPIRED |
409 | valid_until passed |
PAYMENT_PROVIDER_UNAVAILABLE |
503 | PSP session creation failed |
PAYMENT_AMOUNT_MISMATCH |
409 | Webhook/redirect amount disagreement (flagged) |
REVIEW_ALREADY_EXISTS / REVIEW_BOOKING_NOT_COMPLETED |
409 | Review gates |
MEDIA_TYPE_NOT_ALLOWED / MEDIA_TOO_LARGE |
422 | Upload policy |
SEARCH_UNAVAILABLE |
503 | Meilisearch down, degraded mode |
OTP_DELIVERY_FAILED |
502 | SMS/email provider failure |
Pagination, Filtering, Sorting¶
- Page-based for most collections:
?page=1&per_page=20(per_pagemax 50, default 20). - Cursor-based for messages and notifications (append-heavy):
?cursor=<opaque>&limit=30; responsemeta.next_cursor. - Filtering: flat query params named after fields —
?status=confirmed&city_id=...&category_id=...&price_min=100&price_max=500&rating_min=4&date_from=2026-09-01. Unknown filter params are rejected (422), not ignored — silent typos hide bugs. - Sorting:
?sort=-created_at(leading-= desc); allowed sort fields are per-endpoint allow-lists (e.g., search:rating,price_min,distance,created_at). - Sparse includes:
?include=vendor,itemsfor optional relations (per-endpoint allow-list).
Idempotency Keys (Payments & Money)¶
Required on: POST /payments, POST /subscriptions, POST /admin/refunds.
Idempotency-Key: 3f7c2e9a-4b1d-4c68-9a55-8f0d2f1a6b3e
- Client-generated UUID v4, unique per logical attempt; retries of the same attempt reuse the key.
- Server stores key + request hash + response for 24 h: same key + same payload → replay the stored response (with
Idempotency-Replayed: true); same key + different payload →409 IDEMPOTENCY_KEY_REUSED. - Independently, webhooks are idempotent by provider reference — double protection on the money path.
Rate Limits¶
Returned on every response: X-RateLimit-Limit, X-RateLimit-Remaining; on 429: Retry-After (seconds).
| Bucket | Limit |
|---|---|
| Unauthenticated (per IP) | 60/min |
| Authenticated (per user) | 240/min |
| Login attempts | 5/min per account+IP |
| OTP request | 1/60 s and 5/h per phone |
| Search | 60/min per user or IP |
| RFQ create | 10/day per customer |
| Messages | 30/min per user |
| Media pre-sign | 60/h per user |
Full abuse-control design: Security — rate limiting.
Localization¶
Accept-Language: ar (or en; default: ar)
- Affects: validation
detailsmessages, localized display fields, and errormessagestays English (developer-facing). - Bilingual entities return both languages plus a resolved display field, so clients can switch language without refetching:
{ "name": "قاعة الياسمين", "name_ar": "قاعة الياسمين", "name_en": "Al Yasmin Hall" }
- Dates are ISO-8601 UTC; clients localize formatting and calendars. Money strings keep Western digits; clients render Eastern Arabic numerals if desired.
Webhook Signing (Outbound — Scale)¶
Inbound PSP webhook verification is specified in Integrations. When Yam3at itself emits webhooks (Scale: partner/enterprise APIs), the contract is:
X-Yam3at-Signature: t=1751721000,v1=hex(hmac_sha256(secret, t + "." + raw_body))
X-Yam3at-Event: booking.confirmed
X-Yam3at-Delivery: 01J9ZKQ8MZ...
- Receivers must verify HMAC over the raw body, reject timestamp skew > 5 min, and respond 2xx within 10 s.
- Retries: 5 attempts, exponential backoff over 24 h; deliveries are idempotent by
X-Yam3at-Deliveryid. - Secrets are per-endpoint and rotatable (two active secrets during rotation).
Miscellaneous¶
- Clock skew: clients must not compute expiry locally from durations; the API always returns absolute timestamps (
expires_at). - Compression: gzip/br supported; responses > 1 KB compressed.
- CORS: allow-list of first-party origins only; the API is not a public CORS API in MVP.
- OpenAPI: the spec is generated from code annotations and published per environment; client SDK types (TS/Dart) are generated from it in CI.
Endpoint catalog with examples: endpoints.md.