# The API contract Everything that holds for **every** Balinyaar endpoint. The per-domain files in [`domains/`](domains/index.md) assume all of this and never restate it. > Last verified: 2026-07-30 against commit `d3ec723`. Wire facts were derived mechanically from > [`openapi/swagger.v1.json`](openapi/swagger.v1.json) (2026-07-29) and read out of the code named > beside each claim. **When the JSON and this file disagree, the JSON wins and this file is wrong.** --- ## Base, versioning, routing | | | | --- | --- | | Client base URL | `NEXT_PUBLIC_API_URL` → [`client/src/config.ts`](../../client/src/config.ts) `API_URL` (required; boot fails without it) | | Local | `http://localhost:5002` — **plain HTTP**, per `launchSettings.json`. There is no local TLS and no certificate to trust | | Deployed | `https://api.balinyaar.ir` → Caddy → `balinyaar-api:8080` | | Route template | `api/v{version:apiVersion}/[controller]/[action]`, `DefaultApiVersion = 1.0` | | Segment casing | snake_case, via `SnakeCaseParameterTransformer` (`RouteTokenTransformerConvention`) | | Swagger UI | `/swagger` · ReDoc `/api-docs/{documentName}` · documents `v1` and `v1.1` | Route strings are **never hardcoded** server-side — the `[controller]`/`[action]` tokens also derive the dynamic-permission key, so renaming a handler renames its URL *and* its permission ([server/CLAUDE.md](../../server/CLAUDE.md) hard rule 4). `v1.1` is registered (`AddSwagger("v1","v1.1")`) but **empty**: all 55 controllers are `[ApiVersion("1")]` and `ApiVersionDocumentProcessor` drops every path whose URL lacks the document's version segment. ## The envelope `Baya.Application/Models/ApiResult/ApiResult.cs`, applied by `ApiResultFilterAttribute` + `base.OperationResult(result)`. | Field | Type | Notes | | --- | --- | --- | | `isSuccess` | boolean | | | `statusCode` | **integer** | `ApiResultStatusCode`: `200 400 401 403 404 406 409 422 424 500`. Not a string | | `message` | string \| null | User-safe. Defaults to the status's display name (`"Success"`, `"Bad Request Error"`, …) | | `requestId` | string \| null | `Activity.Current.TraceId` as hex — the W3C trace id, empty string if no activity | | `code` | string \| null | Optional stable machine-readable error code. **Omitted from the wire when null** | | `data` | `T` \| null | The payload. Present only on `ApiResult` | Failure responses use the same shape with `data` null (or the validation dictionary, below). **Client-side drift:** `ApiEnvelope` in [`client/src/lib/api/types.ts`](../../client/src/lib/api/types.ts) declares `isSuccess`, `statusCode`, `message`, `requestId`, `data` — but **not `code`**, even though `clientFetch` reads `body.code` at runtime and threads it into `ApiError`. Harmless today, incomplete as a type. ## Casing **JSON bodies are camelCase. URL segments are snake_case.** REQ-001 settled this; verified here mechanically — across all 339 component schemas there are **427 distinct property names, 0 containing an underscore and 0 in PascalCase**. Query parameters are the exception with no single rule: most are camelCase, and `GET /search/nurses` takes snake_case (`service_category_id`, `city_id`, `district_id`, `nurse_gender`, `min_price`, `max_price`, `page_size`) — the only endpoint that does. See [domains/search.md](domains/search.md). ## Status codes | Code | Meaning | Server source | | --- | --- | --- | | `200` | Success, payload in `data` | | | `400` | Validation / business-rule failure | `data` is `{ "": ["", …] }` (`ApiResultOfDictionaryOfStringAndListOfString`), from `ModelStateValidationAttribute` + FluentValidation | | `401` | Unauthenticated — missing, expired or unreadable token | | | `403` | Authenticated but lacks the policy/permission | | | `404` | Not found — **and a tenancy mismatch.** Deliberate: a 403 would confirm the row exists ([server/CLAUDE.md](../../server/CLAUDE.md) rule 20) | | | `406` | Not acceptable | | | `409` | Conflict — forward-only state-machine violation, duplicate, or converged idempotent replay | `OperationResult.ConflictResult` | | `422` | Entity process error | | | `424` | Failed dependency (an external rail refused) | | | `429` | Rate limited | The rate limiter, below | | `5xx` | Unexpected. Generic message; detail only in logs — **except** in Development, where the developer exception page returns a stack trace | `ExceptionHandler` | Handlers **never throw for an expected failure** — they return `OperationResult.FailureResult` / `NotFoundResult` / `ConflictResult`, which the filter maps to the codes above. ### What the client does with each [`client/src/lib/api/client.ts`](../../client/src/lib/api/client.ts): | | Behaviour | | --- | --- | | `401` | One silent refresh + retry (single-flight). On failure: clear both auth cookies, toast "session expired", `window.location.replace('/{locale}/login')`, **return `undefined` rather than throw** | | `403` | Toast + throw `ApiError(403, message, code)` | | `5xx` | Toast + throw `ApiError` | | other `4xx` | **Throw without toasting** — the calling hook owns the user-facing message | | network failure | Toast + `throw new ApiError(0, 'Network error')` | | `204` | Returns `undefined` | `serverFetch` ([`server.ts`](../../client/src/lib/api/server.ts)) never toasts and never redirects — every non-OK response throws `ApiError` and the caller decides between `notFound()`, `redirect()` and an error boundary. ## Auth ### Transport is a header, storage is a cookie The JWE access token lives in a **client-readable cookie** (`access_token`) that the client reads itself and re-sends as `Authorization: Bearer `. **No cookie crosses the wire as an auth credential.** Consequences: - The server's CORS policy sets **no** `AllowCredentials()` — see [config-matrix.md](config-matrix.md#cors). - Neither fetch layer sets `credentials: 'include'`. - A cross-site cookie policy (`SameSite`) is irrelevant to API auth; the cookies are same-origin storage. | Cookie | Options | Written by | | --- | --- | --- | | `access_token` | `path=/`, `maxAge=900` (15 min), `sameSite=lax`, `secure` | `persistAuthTokens` | | `refresh_token` | `path=/`, `maxAge=604800` (7 days), `sameSite=lax`, `secure` | `persistAuthTokens` | > **Asymmetry worth knowing:** the cookie's `maxAge` is 15 minutes but `IdentitySettings:ExpirationMinutes` > is **60**. The token stays valid for an hour; the client simply stops having it after 15 minutes, so the > next call goes out unauthenticated, gets a 401, and refreshes. It works, but the refresh cadence is set > by the cookie, not the token. ### The token is opaque It is a **JWE** — signed *and* AES-128-encrypted. The client cannot read a claim out of it and must not try. Identity, roles and profile-completeness come from `GET /api/v1/me`; a user holding more than one role commits to one with `POST /api/v1/me/select_role`. See [domains/auth.md](domains/auth.md) and [docs/rules/client/auth.md](../rules/client/auth.md). ### Refresh, rotation, reuse detection `POST /api/v1/auth/refresh` with `{ refreshToken }` returns a **new pair**; the old refresh token is retired. Presenting a retired token is treated as theft and kills the session. The client coalesces concurrent 401s into **one** refresh via a module-level in-flight promise ([`refresh.ts`](../../client/src/lib/api/refresh.ts)) and retries the original request exactly once. `/auth/refresh`, `/auth/request_otp` and `/auth/verify_otp` are excluded from the retry — a 401 there is terminal. ### Authorization model Three levels, declared per controller: | Attribute | Used by | Meaning | | --- | --- | --- | | *(none)* | 7 controllers | Anonymous — there is **no** `FallbackPolicy`, so an omitted attribute *is* the decision | | `[Authorize]` | user-facing controllers | Any authenticated caller; tenancy is then resolved from `ICurrentUser` | | `[Authorize(ConstantPolicies.DynamicPermission)]` | every `Admin*` controller + `Holidays`, `PlatformConfig`, `SupportAlerts`, `Audit`, `InternalCenters` | Dynamic permission keyed off the controller/action route | ### The anonymous surface 20 of 186 operations declare no security. This is the complete list: ``` POST /api/v1/auth/request_otp POST /api/v1/auth/verify_otp GET /api/v1/catalog/categories GET /api/v1/catalog/option_groups GET /api/v1/geo/provinces GET /api/v1/geo/cities GET /api/v1/geo/districts GET /api/v1/geo/tree GET /api/v1/nurses/{nurseId}/profile GET /api/v1/nurses/{nurseId}/trust_badge GET /api/v1/nurses/{id}/reviews GET /api/v1/nurses/{id}/review_tags GET /api/v1/nurse_variants/get/{id} GET /api/v1/search/nurses GET /api/v1/ping/get_status GET /api/v1/ping/get_status_rate_limited POST /api/v1/webhooks/payments/{provider} POST /api/v1/webhooks_bnpl/{provider} POST /api/v1/webhooks/payouts/{provider} GET /api/v1/dev/last_otp/{phone} ``` Two things follow that the REQ ledger has not caught up with: - **REQ-066/067** ask for anonymous nurse search + profile reads for guest browse and are filed *open*. Those endpoints are **already anonymous**. What is genuinely missing is the rate limit the REQ asks for (`SearchController` and `NursesController` carry no `[EnableRateLimiting]`, so they fall to the 100/min per-IP global limiter) and the privacy review of the profile payload. - `GET /api/v1/dev/last_otp/{phone}` returns any registered phone's login code. It is Development-only code, and the deployment runs as Development — **so it is live on `api.balinyaar.ir`**. Recorded in [DEPLOY.md](../../DEPLOY.md) as the deployment's largest exposure. ## Localisation The client sends `Accept-Language` (`fa` default) on **every** call, taken from the URL's locale segment. `serverFetch` reads the locale from its own `x-app-locale` request header (`HEADER_NAMES.LOCALE`, set by the Next.js middleware) and forwards it as `Accept-Language`. Reference data carrying `nameFa`/`nameEn` returns both and the client picks. ## Pagination Every unbounded list is paginated. Payload: `{ items, total, page, pageSize }` — `total`, `page` and `pageSize` are `integer/int32`, `items` is nullable. 29 operations take paging params, in three declared spellings: | Declared | Count | Endpoints | | --- | --- | --- | | `Page`, `PageSize` | 25 | the default — every `*/list`, `admin_*` worklist, `tickets`, `notifications`, … | | `page`, `pageSize` | 3 | `admin_payouts/batches/{id}` · `nurses/{id}/reviews` · `patients/{id}/care_records` | | `page`, **`page_size`** | 1 | `search/nurses` | The first two are interchangeable — ASP.NET model binding is case-insensitive, which is what REQ-010 recorded. **`page_size` is not**: it is a different name, and sending `pageSize` to `search/nurses` binds nothing and silently yields the default page size. The client's search client already sends `page_size` correctly. ## Idempotency `Idempotency-Key`, a request header. **Read on exactly two endpoints**, both via `Request.Headers["Idempotency-Key"].FirstOrDefault()`: | Endpoint | Controller | Semantics | | --- | --- | --- | | `POST /api/v1/bookings/{bookingRequestId}/payments` | `PaymentsController` | One key per payment **attempt**, reused across retries of that attempt. A new attempt takes a new key. `409` means "already in progress / already captured" — a benign convergence, not an error | | `POST /api/v1/checkout_bnpl/initiate` | `CheckoutBnplController` | Same, per BNPL attempt | Because it is read from the header rather than bound as a parameter, **it appears nowhere in swagger.** It *is* in the CORS allow-list, so the pre-flight passes. Money-path writes are idempotent by construction regardless of the header: the webhook event is upserted first and a duplicate no-ops, `bookings.booking_request_id` is `UNIQUE` so a replayed conversion cannot create a second booking, and a unique-violation on confirm is treated as idempotent success. **The DB constraint is the backstop, not the handler's `if`.** Webhooks do **not** use the header — they dedupe on the provider's `external_event_id`. ## Money on the wire | Direction | Type | Count | Rule | | --- | --- | --- | --- | | Outbound — DTOs, results | **digit string** (`"23300000"`) | 68 / 68 | Parse with the `@/utils` BigInt helpers. **Never `Number()`** | | Inbound — `*Command` / `*Request` bodies | **`integer/int64`** | 3 / 3 | `UpsertCancellationPolicyCommand.feeAmountIrr`, `CreateRefundCommand.platformFeeRefundedIrr`, `CreateRefundCommand.nursePayoutRefundedIrr` | Invariants the client must not recompute: `grossPriceIrr = balinyaarCommissionIrr + nursePayoutAmount`; VAT applies to Balinyaar's commission only, never the nurse payout; a rate change is never retroactive (the rate is snapshotted onto the row at compute time). Toman↔IRR conversion happens only inside a provider adapter. Payout dates are resolved server-side against the holiday calendar — the client never computes one. ## Dates, ids, PII - Timestamps are **UTC ISO-8601** strings. Shamsi rendering is a client concern. - `dayOfWeek` for availability uses the **Shamsi week** (0 = Saturday … 6 = Friday), not ISO Monday-start. - Entity ids are integers. Human-facing references (`referenceCode` on tickets, `invoiceNumber`) are opaque strings. - Encrypted-at-rest fields (phone, national id, IBAN, addresses, clinical notes) are returned only to authorised callers and often masked. Each domain file states masked vs. full. - **Two-stage clinical disclosure:** a booking *request* exposes only unencrypted `customerNotes` and a city/district-coarse address; encrypted care instructions become readable only after confirmation, only to the assigned nurse and admin, and are never projected into a list. - `gender` is load-bearing — it drives same-gender caregiver matching. Never defaulted, never dropped. ## Enums **Swagger carries no string enums.** Of 339 schemas exactly one has an `enum`, and it is the integer `ApiResultStatusCode`. Every status/type/code field serialises as a bare `string`, so the JSON cannot validate a vocabulary and **the domain files here are the only written record.** Each vocabulary in [`domains/`](domains/index.md) was cross-checked against the server's `Baya.Domain` code sets and the client's string-literal unions. Both sides agree on all 18 shared vocabularies at this stamp, with one exception ([domains/tickets.md](domains/tickets.md): the client's `TicketAuthorRole` carries a `system` member the server's `TicketCodes` does not define). ## Rate limits `RateLimitingServiceExtension`. Over-limit → **429**. Partitioned on the client IP as resolved by the forwarded-headers middleware, so behind Caddy each real client gets its own bucket. | Policy | Limit | Applied to | | --- | --- | --- | | *(global, implicit)* | 100 / min per IP | every endpoint that opts into nothing else | | `otp` | 5 / min | `auth/request_otp`, `auth/verify_otp` | | `auth` | 10 / min | `auth/refresh` | | `sensitive` | 20 / min | every `Admin*` controller, `checkout_bnpl`, `bookings` money actions, `booking_sessions` check-out, `nurse_bank_accounts` writes, `payments` | | `webhook` | 120 / min | the three webhook controllers, partitioned **per provider × IP** so one PSP's burst cannot starve another | | `global` (named) | 5 / 10 s | `ping/get_status_rate_limited` only — a demonstration endpoint | `app.UseCors()` runs **after** `UseRouting()` and **before** the rate limiter and authentication, so a pre-flight `OPTIONS` is answered rather than rejected as 429 or 401. ## Platform endpoints (outside every domain) Not part of any `services/` domain, and intentionally so: | Endpoint | Purpose | | --- | --- | | `GET /api/v1/ping/get_status` | Liveness smoke test (anonymous) | | `GET /api/v1/ping/get_status_rate_limited` | Demonstrates a 429 (anonymous, 5/10 s) | | `GET /healthz/live` | Process only — never touches a dependency | | `GET /healthz/ready` | + app DB, log DB (deployed only), object-storage write probe | | `GET /HealthCheck` | The aggregate, kept for existing probes | | `GET /metrics` | Prometheus scrape (OpenTelemetry is the only metrics source) | The health and metrics endpoints are **not** under `/api/v1` and carry no envelope.