# 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 <token>`. 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 <token>` 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.
