16 KiB
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 fromopenapi/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 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 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
| 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
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. - 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
maxAgeis 15 minutes butIdentitySettings:ExpirationMinutesis 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
(
SearchControllerandNursesControllercarry 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 onapi.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.
dayOfWeekfor availability uses the Shamsi week (0 = Saturday … 6 = Friday), not ISO Monday-start.- Entity ids are integers. Human-facing references (
referenceCodeon 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
customerNotesand 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. genderis 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.