# Integration — the client↔server seam The two projects have **no shared build**. Everything that crosses between them is described here, as one thing. This page is the whole seam; the four files below are the detail. > Last verified: 2026-07-30 against commit `d3ec723`, `swagger.v1.json` (2026-07-29, 178 paths / 186 > operations) and the code in `client/src/lib/api/`, `client/src/services/*/`, `server/src/API/`. | File | Covers | | --- | --- | | [api-contract.md](api-contract.md) | Envelope, casing, pagination, errors, idempotency, auth, money, rate limits | | [domains/](domains/index.md) | 22 files, one per client `services/` domain — every endpoint, verdicted against the live swagger | | [openapi/](openapi/README.md) | The machine contract + how to regenerate it | | [config-matrix.md](config-matrix.md) | Every env var and appsettings key: client, server, docker, the bot | | [topology.md](topology.md) | The runtime dependency graph — 3 containers, Caddy, remote SQL, the OTP relay | --- ## Transport HTTP/JSON. The client reads one base URL — `NEXT_PUBLIC_API_URL` — and prefixes every path with `/api/v1/`. Locally that is `http://localhost:5002` (**plain HTTP**; `launchSettings.json` binds no TLS); deployed it is `https://api.balinyaar.ir`, which Caddy terminates and forwards to `balinyaar-api:8080`. **gRPC exists and the client does not use it.** `Baya.Web.Plugins.Grpc` serves exactly one service (User) over HTTP/2 on the same port. Nothing in `client/` speaks it. Treat it as an internal affordance. **Two OpenAPI documents are served, `v1` and `v1.1`.** All 55 controllers declare `[ApiVersion("1")]`, and `ApiVersionDocumentProcessor` keeps only paths whose URL contains the document's version segment — so **`v1.1` is served but contains zero paths.** `v1` is the contract. ## The envelope Every response — success *and* failure — is `ApiResult`. The payload is always under `data`. ```json { "isSuccess": true, "statusCode": 200, "message": "Success", "requestId": "0af7651916cd43dd8448eb211c80319c", "code": null, "data": { } } ``` `requestId` is the **W3C trace id** of the request (`Activity.Current.TraceId`), the same id OpenTelemetry traces on — so a support ticket maps 1:1 to a trace. `code` is an optional stable machine-readable error code (`otp_locked`, …), omitted from the wire when null. The client never unwraps centrally: `clientFetch`/`serverFetch` return the **raw envelope**, and each service calls `unwrap()` from [`client/src/lib/api/types.ts`](../../client/src/lib/api/types.ts). ## Casing — camelCase bodies, snake_case URLs Verified mechanically against the live swagger: of **427 distinct property names, 0 contain an underscore and 0 are PascalCase.** JSON bodies are camelCase. URL *segments* are snake_case, produced by the server's `SnakeCaseParameterTransformer` from `[controller]`/`[action]` tokens (`SetPrimary` → `/set_primary`). Three routes break the snake_case rule with hardcoded hyphens — `admin/partner-centers`, `admin/tickets`, `admin/reviews` — see [domains/index.md](domains/index.md#route-shape-exceptions). ## Pagination `{ items, total, page, pageSize }` (`total`/`page`/`pageSize` are int32). 29 operations are paginated. The **declared** query-param names are not uniform — 25 declare `Page`/`PageSize`, 3 declare `page`/`pageSize`, and `GET /search/nurses` declares `page`/`page_size`. Model binding is case-insensitive so the first two are interchangeable; `page_size` is a *different name* and is not. Full table in [api-contract.md](api-contract.md#pagination). ## Errors `200` · `400` validation (field errors under `data`) · `401` unauthenticated · `403` forbidden · `404` not found (**also returned for a tenancy mismatch**, deliberately, so a 403 never confirms a row exists) · `409` state-machine/idempotency conflict · `422` · `424` · `429` rate-limited · `5xx`. `clientFetch` behaviour: **401** → one silent refresh + retry, then clear cookies, toast, redirect to login (no throw); **403** and **5xx** → toast + throw `ApiError`; **other 4xx** → throw without toasting (the calling hook owns the message); **network failure** → toast + `ApiError(0)`. ## Auth **Bearer header, not cookie auth.** The JWE access token is *stored* in a cookie the client reads itself and *sent* as `Authorization: Bearer `. The server's CORS policy therefore does **not** allow credentials, and the client sends no `credentials: 'include'`. The token is opaque to the client (signed + AES-encrypted). Role and identity come from `GET /api/v1/me`; a multi-role user picks one with `POST /api/v1/me/select_role`. On a 401 the client runs one single-flight silent refresh (`POST /api/v1/auth/refresh`) — the server rotates the pair and detects reuse, so a replayed refresh token kills the session. `/auth/refresh`, `/auth/request_otp` and `/auth/verify_otp` are excluded from the retry. **20 of 186 operations are anonymous** — the OTP pair, catalog + geo reference reads, the public nurse search/profile/reviews/trust-badge reads, `ping`, the three webhooks, and Development's `GET /api/v1/dev/last_otp/{phone}`. Full list in [api-contract.md](api-contract.md#the-anonymous-surface). ## Idempotency `Idempotency-Key` is a request header. The server reads it on exactly **two** endpoints: | Endpoint | Key scope | | --- | --- | | `POST /api/v1/bookings/{bookingRequestId}/payments` | one key per payment *attempt*, reused across retries of that attempt | | `POST /api/v1/checkout_bnpl/initiate` | same, per BNPL attempt | The client also sends it on `admin_payouts` process/retry, where **the server does not read it** — see [domains/payouts.md](domains/payouts.md). Webhooks do not use the header; they dedupe on the provider's `external_event_id`. The header is allowed through CORS but is **not declared in swagger** (it is read from `Request.Headers`, not bound as a parameter). ## Money **IRR Rials, integer, no floats, anywhere.** On the wire the direction matters, and it is consistent: - **Outbound (DTOs/results): a digit string.** All 68 money properties on read models are `type: string`. Parse with the `@/utils` BigInt helpers — never `Number()`. - **Inbound (commands): `integer/int64`.** All 3 money properties on command bodies. Toman is display-only and is converted **only** inside a provider adapter at its boundary (`ICurrencyNormalizer`, `Seams:Currency:TomanToIrrMultiplier`). `gross = commission + payout` always, and VAT is on Balinyaar's commission only. See [docs/rules/server/money.md](../rules/server/money.md) and [docs/rules/client/services.md](../rules/client/services.md). ## Enums **Swagger declares no string enums** — 1 of 339 schemas has an `enum`, and it is the integer `ApiResultStatusCode`. Every status/code field is a bare `string` on the wire. The vocabulary therefore lives in the domain files here, cross-checked against the server's `Baya.Domain` code sets and the client's string-literal unions. **All 18 shared vocabularies match on both sides** as of this stamp; the one exception is noted in [domains/tickets.md](domains/tickets.md). --- ## What the server owes the client 1. The `ApiResult` envelope on every response, with `requestId` populated and `code` set on any failure the client must branch on. 2. camelCase bodies; snake_case URL segments; `{ items, total, page, pageSize }` on every list. 3. `404`, never `403`, for a row the caller does not own. 4. Money as a digit string outbound; the three-amount split guaranteed server-side. 5. A masked address and only unencrypted `customerNotes` before a booking is confirmed; full care instructions only after, only to the assigned nurse and admin. 6. `is_internal` filtered at the query layer — a non-staff caller can never read or set one. 7. Idempotent behaviour on the two keyed endpoints, and `409` (not `500`) on a converged replay. 8. Reachability: `/healthz/live`, `/healthz/ready`, and CORS origins that list the client's real origin. ## What the client owes the server 1. `Authorization: Bearer ` on every authenticated call — and nothing else for auth. No cookies cross the wire. 2. `Accept-Language` (`fa` default) on every call, from the active locale segment. 3. `Content-Type: application/json` — **except** `FormData` bodies, where the browser sets the multipart boundary itself. 4. One stable `Idempotency-Key` per payment/BNPL attempt; a new attempt gets a new key. 5. `page`/`pageSize` within the server's cap; never an unbounded list request. 6. IRR integers inbound; Toman conversion only at the UI boundary. 7. Exactly one silent refresh per 401, single-flighted, and never on the refresh/OTP endpoints. 8. No derived money. The client never computes commission, VAT, refund amounts or payout dates.