# 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<T>` |

Failure responses use the same shape with `data` null (or the validation dictionary, below).

**Client-side drift:** `ApiEnvelope<T>` 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 `{ "<field>": ["<message>", …] }` (`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 <token>`. **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.
