Files
baya-monorepo/docs/integration/api-contract.md
T
2026-07-30 12:49:46 +03:30

16 KiB
Raw Blame History

The API contract

Everything that holds for every Balinyaar endpoint. The per-domain files in domains/ 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 (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_URLclient/src/config.ts API_URL (required; boot fails without it)
Local http://localhost:5002plain 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 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 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.

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 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:

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) never toasts and never redirects — every non-OK response throws ApiError and the caller decides between notFound(), redirect() and an error boundary.

Auth

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.
  • 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 and docs/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) 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 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/ 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: 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.