From dfe88a148a0e390e002784826f056b365de08bed Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Thu, 20 Aug 2026 18:08:37 +0330 Subject: [PATCH] docs(api): add OpenAPI v1 interface contract --- docs/openapi.yaml | 1737 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1737 insertions(+) create mode 100644 docs/openapi.yaml diff --git a/docs/openapi.yaml b/docs/openapi.yaml new file mode 100644 index 0000000..975464d --- /dev/null +++ b/docs/openapi.yaml @@ -0,0 +1,1737 @@ +openapi: 3.1.0 +info: + title: ZarinPal Merchant Analytics API + version: 0.1.0-draft + summary: Contract-first API for authenticated merchant analytics. + description: | + Draft MVP interface. This document is the API source of truth; generated + frontend types must come from this contract after approval. + + Merchant scope is always derived from the authenticated server-side session. + No operation accepts a merchant identifier as authorization. All timestamps + are ISO 8601. Money values are integer Rial, and decimal rates are strings. + license: + name: Proprietary +servers: + - url: /api/v1 + description: Same-origin API base +tags: + - name: Authentication + - name: Reporting periods + - name: Analytics runs + - name: Analytics details +security: + - merchantSession: [] +paths: + /auth/sessions: + post: + tags: [Authentication] + operationId: createAuthSession + summary: Authenticate a demo merchant user + description: | + Creates a revocable server-side session. Expected credential failures are + externally indistinguishable. The response sets the host-only + `__Host-zpa_session` cookie; the raw token is never present in JSON. + security: [] + parameters: + - $ref: '#/components/parameters/OriginHeader' + - $ref: '#/components/parameters/RequestIdHeader' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LoginRequest' + responses: + '201': + description: Authenticated session created. + headers: + Set-Cookie: + description: Secure opaque session cookie; value is intentionally omitted from examples. + schema: + type: string + example: __Host-zpa_session=; Path=/; Secure; HttpOnly; SameSite=Lax + X-Request-ID: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/AuthSession' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthenticated' + '413': + $ref: '#/components/responses/RequestTooLarge' + '415': + $ref: '#/components/responses/UnsupportedMediaType' + '422': + $ref: '#/components/responses/ValidationFailed' + '429': + $ref: '#/components/responses/RateLimited' + '500': + $ref: '#/components/responses/InternalError' + '503': + $ref: '#/components/responses/DependencyUnavailable' + + /auth/session: + get: + tags: [Authentication] + operationId: getAuthSession + summary: Get the current session summary + parameters: + - $ref: '#/components/parameters/RequestIdHeader' + responses: + '200': + description: Current authenticated merchant-user session. + headers: + Cache-Control: + $ref: '#/components/headers/PrivateNoStore' + X-Request-ID: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/AuthSession' + '401': + $ref: '#/components/responses/Unauthenticated' + '500': + $ref: '#/components/responses/InternalError' + '503': + $ref: '#/components/responses/DependencyUnavailable' + delete: + tags: [Authentication] + operationId: deleteAuthSession + summary: Revoke the current session + description: Revokes server-side state before expiring the browser cookie. + parameters: + - $ref: '#/components/parameters/CsrfTokenHeader' + - $ref: '#/components/parameters/OriginHeader' + - $ref: '#/components/parameters/RequestIdHeader' + responses: + '204': + description: Current authenticated session revoked. + headers: + Set-Cookie: + description: Expired session cookie. + schema: + type: string + example: __Host-zpa_session=; Path=/; Max-Age=0; Secure; HttpOnly; SameSite=Lax + X-Request-ID: + $ref: '#/components/headers/RequestId' + '401': + $ref: '#/components/responses/Unauthenticated' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/RateLimited' + '500': + $ref: '#/components/responses/InternalError' + '503': + $ref: '#/components/responses/DependencyUnavailable' + + /analytics/periods: + get: + tags: [Reporting periods] + operationId: listAnalyticsPeriods + summary: List approved reporting periods + description: | + Returns allowlisted periods with server-calculated UTC and Tehran-local + half-open boundaries. Clients start runs with a returned `periodId` and + never submit arbitrary timestamps. + parameters: + - name: periodType + in: query + required: false + description: Optional allowlisted period type. + schema: + $ref: '#/components/schemas/PeriodType' + - $ref: '#/components/parameters/RequestIdHeader' + responses: + '200': + description: Approved period choices and default selection. + headers: + Cache-Control: + $ref: '#/components/headers/PrivateNoStore' + X-Request-ID: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/PeriodList' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthenticated' + '422': + $ref: '#/components/responses/ValidationFailed' + '429': + $ref: '#/components/responses/RateLimited' + '500': + $ref: '#/components/responses/InternalError' + '503': + $ref: '#/components/responses/DependencyUnavailable' + + /analytics/runs: + post: + tags: [Analytics runs] + operationId: createAnalyticsRun + summary: Create or return the current analytics run + description: | + Idempotently resolves the current merchant/source/cohort/metric revision + and starts only missing sections. Equivalent concurrent starts deduplicate. + parameters: + - $ref: '#/components/parameters/IdempotencyKeyHeader' + - $ref: '#/components/parameters/CsrfTokenHeader' + - $ref: '#/components/parameters/OriginHeader' + - $ref: '#/components/parameters/RequestIdHeader' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateAnalyticsRunRequest' + responses: + '200': + description: Existing current run returned with no new work required. + headers: + Cache-Control: + $ref: '#/components/headers/PrivateNoStore' + Idempotency-Replayed: + description: Whether this response replayed a prior intent result. + schema: + type: boolean + X-Request-ID: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/AnalyticsRun' + '202': + description: Current run accepted and one or more missing sections are queued/calculating. + headers: + Cache-Control: + $ref: '#/components/headers/PrivateNoStore' + Idempotency-Replayed: + description: Whether this response replayed a prior intent result. + schema: + type: boolean + Location: + description: Relative URL of the run snapshot. + schema: + type: string + format: uri-reference + X-Request-ID: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/AnalyticsRun' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthenticated' + '403': + $ref: '#/components/responses/Forbidden' + '413': + $ref: '#/components/responses/RequestTooLarge' + '415': + $ref: '#/components/responses/UnsupportedMediaType' + '422': + $ref: '#/components/responses/ValidationFailed' + '429': + $ref: '#/components/responses/RateLimited' + '500': + $ref: '#/components/responses/InternalError' + '503': + $ref: '#/components/responses/AnalyticsUnavailable' + + /analytics/runs/{runId}: + parameters: + - $ref: '#/components/parameters/RunIdPath' + get: + tags: [Analytics runs] + operationId: getAnalyticsRun + summary: Get the current run snapshot + parameters: + - $ref: '#/components/parameters/RequestIdHeader' + responses: + '200': + description: Current state of every run section. + headers: + Cache-Control: + $ref: '#/components/headers/PrivateNoStore' + X-Request-ID: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/AnalyticsRun' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthenticated' + '404': + $ref: '#/components/responses/ResourceNotFound' + '429': + $ref: '#/components/responses/RateLimited' + '500': + $ref: '#/components/responses/InternalError' + '503': + $ref: '#/components/responses/AnalyticsUnavailable' + + /analytics/runs/{runId}/events: + parameters: + - $ref: '#/components/parameters/RunIdPath' + get: + tags: [Analytics runs] + operationId: streamAnalyticsRunEvents + summary: Stream partial analytics results + description: | + Authenticated SSE stream. Events are retained for one hour. A retained + `Last-Event-ID` replays only later events. Missing, invalid, unauthorized, + or expired ids reset safely by emitting `run.snapshot` with + `recovery=RESET_TO_SNAPSHOT`, then continuing from new events. + + Event types are `run.snapshot`, `section.started`, `section.ready`, + `section.failed`, `section.suppressed`, `run.completed`, and `heartbeat`. + Each event has an opaque `id` and one-line JSON `data` matching one of the + schemas named by `AnalyticsSseEvent`. + parameters: + - name: Last-Event-ID + in: header + required: false + description: Opaque last processed event id; must not contain NUL, LF, or CR. + schema: + type: string + minLength: 1 + maxLength: 256 + pattern: '^[^\u0000\u000A\u000D]+$' + - $ref: '#/components/parameters/RequestIdHeader' + responses: + '200': + description: UTF-8 event stream. A named heartbeat is emitted every 15 seconds. + headers: + Cache-Control: + description: Prevents transformation or storage of the live event stream. + schema: + type: string + const: no-cache, no-transform + X-Accel-Buffering: + description: Deployment hint disabling Nginx response buffering. + schema: + type: string + const: 'no' + X-Request-ID: + $ref: '#/components/headers/RequestId' + content: + text/event-stream: + schema: + type: string + contentMediaType: text/event-stream + examples: + sectionReady: + summary: One ready-section event + value: |- + id: evt_01K2M7ZZZZZZZZZZZZZZZZZZZZ + event: section.ready + data: {"eventId":"evt_01K2M7ZZZZZZZZZZZZZZZZZZZZ","runId":"run_01K2M7YYYYYYYYYYYYYYYYYYYY","occurredAt":"2026-08-20T09:30:00Z","sectionId":"overview","section":{"sectionId":"overview","state":"READY","calculatedAt":"2026-08-20T09:30:00Z","data":{}}} + + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthenticated' + '404': + $ref: '#/components/responses/ResourceNotFound' + '429': + $ref: '#/components/responses/ConnectionLimited' + '500': + $ref: '#/components/responses/InternalError' + '503': + $ref: '#/components/responses/AnalyticsUnavailable' + + /analytics/runs/{runId}/sections/{sectionId}/retry-attempts: + parameters: + - $ref: '#/components/parameters/RunIdPath' + - $ref: '#/components/parameters/SectionIdPath' + post: + tags: [Analytics runs] + operationId: createAnalyticsSectionRetryAttempt + summary: Retry one exhausted failed section + description: Does not clear or restart ready sibling sections. + parameters: + - $ref: '#/components/parameters/IdempotencyKeyHeader' + - $ref: '#/components/parameters/CsrfTokenHeader' + - $ref: '#/components/parameters/OriginHeader' + - $ref: '#/components/parameters/RequestIdHeader' + responses: + '202': + description: Retry accepted or equivalent current retry returned. + headers: + Cache-Control: + $ref: '#/components/headers/PrivateNoStore' + Location: + description: Relative run snapshot URL. + schema: + type: string + format: uri-reference + X-Request-ID: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/AnalyticsRun' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthenticated' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/ResourceNotFound' + '409': + $ref: '#/components/responses/Conflict' + '422': + $ref: '#/components/responses/ValidationFailed' + '429': + $ref: '#/components/responses/RateLimited' + '500': + $ref: '#/components/responses/InternalError' + '503': + $ref: '#/components/responses/AnalyticsUnavailable' + + /analytics/session-details: + get: + tags: [Analytics details] + operationId: listAnalyticsSessionDetails + summary: List authorized merchant-owned session evidence + description: | + Cursor-paginated detail rows for one approved period and view. Stable + ordering is `createdAt DESC, sessionId DESC`. The server binds cursors to + merchant, filters, and ordering. Cross-tenant and absent ids are not + distinguishable. + parameters: + - $ref: '#/components/parameters/PeriodIdQuery' + - $ref: '#/components/parameters/DetailViewQuery' + - $ref: '#/components/parameters/FailureStageQuery' + - $ref: '#/components/parameters/TerminalIdQuery' + - $ref: '#/components/parameters/CursorQuery' + - $ref: '#/components/parameters/LimitQuery' + - $ref: '#/components/parameters/RequestIdHeader' + responses: + '200': + description: Authorized detail page. + headers: + Cache-Control: + $ref: '#/components/headers/PrivateNoStore' + X-Request-ID: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/SessionDetailPage' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthenticated' + '404': + $ref: '#/components/responses/ResourceNotFound' + '422': + $ref: '#/components/responses/ValidationFailed' + '429': + $ref: '#/components/responses/RateLimited' + '500': + $ref: '#/components/responses/InternalError' + '503': + $ref: '#/components/responses/AnalyticsUnavailable' + + /analytics/session-details.csv: + get: + tags: [Analytics details] + operationId: exportAnalyticsSessionDetailsCsv + summary: Export the authorized detail view as CSV + description: | + Uses the same tenant scope, approved period, filters, and allowlisted + projection as `listAnalyticsSessionDetails`. Cursor and page limit do not + apply. Output is UTF-8 with a BOM, an ASCII snake_case header row, comma + separators, CRLF records, and RFC 4180 quoting. + + Stable columns are: + `session_id`, `terminal_id`, `session_status`, `amount_rial`, + `fee_amount_rial`, `created_at`, `expires_at`, `attempt_count`, + `latest_attempt_status`, `latest_attempt_at`, `verified_at`, `settled_at`, + `failure_stage`. + + Columns unavailable to the chosen authorized view are empty. Payer tokens, + credentials, peer data, internal ids, and any field unavailable in the JSON + detail view are forbidden. Potential spreadsheet formulas in textual cells + are prefixed with an apostrophe before CSV quoting. + parameters: + - $ref: '#/components/parameters/PeriodIdQuery' + - $ref: '#/components/parameters/DetailViewQuery' + - $ref: '#/components/parameters/FailureStageQuery' + - $ref: '#/components/parameters/TerminalIdQuery' + - $ref: '#/components/parameters/RequestIdHeader' + responses: + '200': + description: Complete authorized CSV export. + headers: + Cache-Control: + $ref: '#/components/headers/PrivateNoStore' + Content-Disposition: + description: | + Attachment with ASCII fallback first and UTF-8 `filename*` second. + Filename contains only server-generated view, period id, and time. + schema: + type: string + example: attachment; filename="unverified_paid-jm-1405-05-20260820T093000Z.csv"; filename*=UTF-8''unverified_paid-jm-1405-05-20260820T093000Z.csv + X-Content-Type-Options: + description: Prevents content sniffing. + schema: + type: string + const: nosniff + X-Request-ID: + $ref: '#/components/headers/RequestId' + content: + text/csv; charset=utf-8: + schema: + type: string + contentMediaType: text/csv + example: "\uFEFFsession_id,terminal_id,session_status,amount_rial,fee_amount_rial,created_at,expires_at,attempt_count,latest_attempt_status,latest_attempt_at,verified_at,settled_at,failure_stage\r\nses_01K2M8,term_0042,Paid,1500000,2000,2026-06-01T08:00:00Z,2026-06-01T08:15:00Z,1,Paid,2026-06-01T08:02:00Z,,,\r\n" + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthenticated' + '404': + $ref: '#/components/responses/ResourceNotFound' + '413': + $ref: '#/components/responses/ExportTooLarge' + '422': + $ref: '#/components/responses/ValidationFailed' + '429': + $ref: '#/components/responses/RateLimited' + '500': + $ref: '#/components/responses/InternalError' + '503': + $ref: '#/components/responses/AnalyticsUnavailable' + '504': + $ref: '#/components/responses/ExportTimeout' + +components: + securitySchemes: + merchantSession: + type: apiKey + in: cookie + name: __Host-zpa_session + description: | + Opaque server-side session token. The browser receives it only as a + Secure, HttpOnly, SameSite=Lax, host-only cookie with Path=/. + + parameters: + RunIdPath: + name: runId + in: path + required: true + description: Opaque analytics run identifier; possession does not authorize access. + schema: + $ref: '#/components/schemas/RunId' + SectionIdPath: + name: sectionId + in: path + required: true + schema: + $ref: '#/components/schemas/SectionId' + IdempotencyKeyHeader: + name: Idempotency-Key + in: header + required: true + description: Client-generated intent key reused unchanged for retries of one intent. + schema: + type: string + minLength: 8 + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + example: 95f6ec2d-26e8-4983-afd4-8a6d4434726b + CsrfTokenHeader: + name: X-CSRF-Token + in: header + required: true + description: CSRF token for the current authenticated session; never log this value. + schema: + type: string + minLength: 32 + maxLength: 256 + OriginHeader: + name: Origin + in: header + required: true + description: Strict same-origin evidence validated with Fetch Metadata where available. + schema: + type: string + format: uri + RequestIdHeader: + name: X-Request-ID + in: header + required: false + description: Optional safe correlation id. The server may replace invalid values. + schema: + type: string + minLength: 8 + maxLength: 128 + pattern: '^[A-Za-z0-9._:-]+$' + PeriodIdQuery: + name: periodId + in: query + required: true + description: Stable id returned by `listAnalyticsPeriods`. + schema: + $ref: '#/components/schemas/PeriodId' + DetailViewQuery: + name: view + in: query + required: true + schema: + $ref: '#/components/schemas/DetailView' + FailureStageQuery: + name: failureStage + in: query + required: false + description: Valid only when `view=FAILED`. + schema: + $ref: '#/components/schemas/FailureStage' + TerminalIdQuery: + name: terminalId + in: query + required: false + description: Opaque terminal reference resolved only inside the authenticated merchant. + schema: + type: string + minLength: 1 + maxLength: 128 + pattern: '^[A-Za-z0-9._:-]+$' + CursorQuery: + name: cursor + in: query + required: false + description: Opaque integrity-protected cursor bound to tenant, view, filters, and ordering. + schema: + type: string + minLength: 16 + maxLength: 2048 + LimitQuery: + name: limit + in: query + required: false + description: Page size; defaults to 25 and cannot exceed 100. + schema: + type: integer + minimum: 1 + maximum: 100 + default: 25 + + headers: + RequestId: + description: Server correlation id for this request. + schema: + type: string + minLength: 8 + maxLength: 128 + PrivateNoStore: + description: Prevents storage of authenticated merchant data. + schema: + type: string + const: private, no-store + RetryAfter: + description: Seconds until retry is permitted. + schema: + type: integer + minimum: 1 + + responses: + BadRequest: + description: Request syntax, cursor, event id, or parameter combination is invalid. + headers: + Cache-Control: + $ref: '#/components/headers/PrivateNoStore' + X-Request-ID: + $ref: '#/components/headers/RequestId' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + Unauthenticated: + description: The current session is missing, malformed, expired, or revoked. + headers: + Cache-Control: + $ref: '#/components/headers/PrivateNoStore' + X-Request-ID: + $ref: '#/components/headers/RequestId' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + example: + type: https://api.zarinpal.example/problems/unauthenticated + title: Authentication required + status: 401 + detail: Sign in to continue. + instance: /api/v1/analytics/runs + code: UNAUTHENTICATED + requestId: req_01K2M8A0000000000000000000 + Forbidden: + description: The authenticated request is not permitted or failed CSRF/origin checks. + headers: + Cache-Control: + $ref: '#/components/headers/PrivateNoStore' + X-Request-ID: + $ref: '#/components/headers/RequestId' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + ResourceNotFound: + description: Resource is absent or is outside the authenticated merchant boundary. + headers: + Cache-Control: + $ref: '#/components/headers/PrivateNoStore' + X-Request-ID: + $ref: '#/components/headers/RequestId' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + example: + type: https://api.zarinpal.example/problems/resource-not-found + title: Resource not found + status: 404 + detail: The requested resource is unavailable. + instance: /api/v1/analytics/runs/run_unknown + code: RESOURCE_NOT_FOUND + requestId: req_01K2M8A0000000000000000000 + Conflict: + description: Current resource state does not permit the requested operation. + headers: + Cache-Control: + $ref: '#/components/headers/PrivateNoStore' + X-Request-ID: + $ref: '#/components/headers/RequestId' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + RequestTooLarge: + description: Request exceeds the 16 KiB JSON body limit. + headers: + Cache-Control: + $ref: '#/components/headers/PrivateNoStore' + X-Request-ID: + $ref: '#/components/headers/RequestId' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + ExportTooLarge: + description: Export exceeds 100,000 rows or 50 MiB estimated uncompressed output. + headers: + Cache-Control: + $ref: '#/components/headers/PrivateNoStore' + X-Request-ID: + $ref: '#/components/headers/RequestId' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + example: + type: https://api.zarinpal.example/problems/export-too-large + title: Export is too large + status: 413 + detail: Narrow the approved filters and try again. + instance: /api/v1/analytics/session-details.csv + code: EXPORT_TOO_LARGE + requestId: req_01K2M8A0000000000000000000 + UnsupportedMediaType: + description: Request media type is not supported. + headers: + Cache-Control: + $ref: '#/components/headers/PrivateNoStore' + X-Request-ID: + $ref: '#/components/headers/RequestId' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + ValidationFailed: + description: Request is well-formed but violates the interface contract. + headers: + Cache-Control: + $ref: '#/components/headers/PrivateNoStore' + X-Request-ID: + $ref: '#/components/headers/RequestId' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + example: + type: https://api.zarinpal.example/problems/validation-error + title: Request validation failed + status: 422 + detail: One or more request values are invalid. + instance: /api/v1/analytics/runs + code: VALIDATION_ERROR + requestId: req_01K2M8A0000000000000000000 + errors: + - field: periodId + reason: UNKNOWN_PERIOD + RateLimited: + description: Authenticated-user or merchant rate limit reached before work began. + headers: + Cache-Control: + $ref: '#/components/headers/PrivateNoStore' + Retry-After: + $ref: '#/components/headers/RetryAfter' + X-Request-ID: + $ref: '#/components/headers/RequestId' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + ConnectionLimited: + description: SSE concurrency limit reached for the user or merchant. + headers: + Cache-Control: + $ref: '#/components/headers/PrivateNoStore' + Retry-After: + $ref: '#/components/headers/RetryAfter' + X-Request-ID: + $ref: '#/components/headers/RequestId' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + InternalError: + description: Unexpected server failure; implementation details are not exposed. + headers: + Cache-Control: + $ref: '#/components/headers/PrivateNoStore' + X-Request-ID: + $ref: '#/components/headers/RequestId' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + DependencyUnavailable: + description: Required authoritative dependency is unavailable; protected requests fail closed. + headers: + Cache-Control: + $ref: '#/components/headers/PrivateNoStore' + Retry-After: + $ref: '#/components/headers/RetryAfter' + X-Request-ID: + $ref: '#/components/headers/RequestId' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + AnalyticsUnavailable: + description: Analytics dependency is temporarily unavailable. + headers: + Cache-Control: + $ref: '#/components/headers/PrivateNoStore' + Retry-After: + $ref: '#/components/headers/RetryAfter' + X-Request-ID: + $ref: '#/components/headers/RequestId' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + ExportTimeout: + description: Export did not complete within the 60-second deadline before streaming began. + headers: + Cache-Control: + $ref: '#/components/headers/PrivateNoStore' + Retry-After: + $ref: '#/components/headers/RetryAfter' + X-Request-ID: + $ref: '#/components/headers/RequestId' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + + schemas: + LoginRequest: + type: object + additionalProperties: false + required: [username, password] + properties: + username: + type: string + minLength: 1 + maxLength: 64 + pattern: '^[A-Za-z0-9-]+$' + example: merchant-0001 + password: + type: string + minLength: 12 + maxLength: 256 + writeOnly: true + + AuthSession: + type: object + additionalProperties: false + required: + - user + - merchant + - createdAt + - idleExpiresAt + - absoluteExpiresAt + - csrfToken + properties: + user: + $ref: '#/components/schemas/MerchantUserSummary' + merchant: + $ref: '#/components/schemas/MerchantSummary' + createdAt: + type: string + format: date-time + idleExpiresAt: + type: string + format: date-time + absoluteExpiresAt: + type: string + format: date-time + csrfToken: + type: string + description: Session-bound CSRF token. It is not an authentication token and must not be logged. + minLength: 32 + maxLength: 256 + + MerchantUserSummary: + type: object + additionalProperties: false + required: [userId, username] + properties: + userId: + type: string + minLength: 8 + maxLength: 128 + username: + type: string + example: merchant-0001 + + MerchantSummary: + type: object + additionalProperties: false + required: [merchantId, displayName, profileIsSynthetic] + properties: + merchantId: + type: string + description: Opaque API identifier, never accepted as authorization input. + minLength: 8 + maxLength: 128 + displayName: + type: string + minLength: 1 + maxLength: 200 + profileIsSynthetic: + type: boolean + + PeriodType: + type: string + enum: [DAY, WEEK, JALALI_MONTH, PERSIAN_SEASON] + + PeriodId: + type: string + minLength: 4 + maxLength: 64 + pattern: '^[a-z0-9-]+$' + example: jm-1405-05 + + ReportingPeriod: + type: object + additionalProperties: false + required: + - periodId + - periodType + - startAt + - endAt + - localStartAt + - localEndAt + - timezone + - jalaliLabelFa + - isComplete + - isDefault + properties: + periodId: + $ref: '#/components/schemas/PeriodId' + periodType: + $ref: '#/components/schemas/PeriodType' + startAt: + type: string + format: date-time + description: Inclusive UTC boundary. + example: '2026-07-23T20:30:00Z' + endAt: + type: string + format: date-time + description: Exclusive UTC boundary. + example: '2026-08-22T20:30:00Z' + localStartAt: + type: string + format: date-time + description: Inclusive Asia/Tehran civil boundary with explicit offset. + example: '2026-07-24T00:00:00+03:30' + localEndAt: + type: string + format: date-time + description: Exclusive Asia/Tehran civil boundary with explicit offset. + example: '2026-08-23T00:00:00+03:30' + timezone: + type: string + const: Asia/Tehran + jalaliLabelFa: + type: string + minLength: 1 + maxLength: 100 + example: مرداد ۱۴۰۵ + isComplete: + type: boolean + isDefault: + type: boolean + + PeriodList: + type: object + additionalProperties: false + required: [defaultPeriodId, periods] + properties: + defaultPeriodId: + $ref: '#/components/schemas/PeriodId' + periods: + type: array + maxItems: 1000 + items: + $ref: '#/components/schemas/ReportingPeriod' + + CreateAnalyticsRunRequest: + type: object + additionalProperties: false + required: [periodId] + properties: + periodId: + $ref: '#/components/schemas/PeriodId' + + RunId: + type: string + minLength: 12 + maxLength: 128 + pattern: '^run_[A-Za-z0-9]+$' + example: run_01K2M7YYYYYYYYYYYYYYYYYYYY + + SectionId: + type: string + enum: [overview, payment_performance, fees, peer_comparison] + + AnalyticsRunState: + type: string + enum: [IN_PROGRESS, COMPLETE, COMPLETE_WITH_FAILURES] + + SectionState: + type: string + enum: [QUEUED, CALCULATING, READY, FAILED, SUPPRESSED] + + AnalyticsRun: + type: object + additionalProperties: false + required: + - runId + - state + - period + - merchantSourceRevision + - cohortRevision + - metricDefinitionVersion + - createdAt + - updatedAt + - eventsUrl + - sections + properties: + runId: + $ref: '#/components/schemas/RunId' + state: + $ref: '#/components/schemas/AnalyticsRunState' + period: + $ref: '#/components/schemas/ReportingPeriod' + merchantSourceRevision: + type: string + pattern: '^[0-9]+$' + description: Decimal string to avoid client integer-width loss. + cohortRevision: + type: string + pattern: '^[0-9]+$' + description: Decimal string to avoid client integer-width loss. + metricDefinitionVersion: + type: string + const: 0.4.0 + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + eventsUrl: + type: string + format: uri-reference + sections: + type: object + additionalProperties: false + required: [overview, paymentPerformance, fees, peerComparison] + properties: + overview: + $ref: '#/components/schemas/OverviewSection' + paymentPerformance: + $ref: '#/components/schemas/PaymentPerformanceSection' + fees: + $ref: '#/components/schemas/FeesSection' + peerComparison: + $ref: '#/components/schemas/PeerComparisonSection' + + QueuedSection: + type: object + additionalProperties: false + required: [state] + properties: + state: + type: string + const: QUEUED + queuedAt: + type: [string, 'null'] + format: date-time + + CalculatingSection: + type: object + additionalProperties: false + required: [state, startedAt] + properties: + state: + type: string + const: CALCULATING + startedAt: + type: string + format: date-time + + FailedSection: + type: object + additionalProperties: false + required: [state, failedAt, errorCode, canRetry] + properties: + state: + type: string + const: FAILED + failedAt: + type: string + format: date-time + errorCode: + type: string + enum: [CALCULATION_FAILED, CALCULATION_TIMEOUT, SOURCE_UNAVAILABLE, UNSUPPORTED_METRIC_VERSION] + canRetry: + type: boolean + retryAttemptsUrl: + type: [string, 'null'] + format: uri-reference + + ReadySectionMetadata: + type: object + additionalProperties: false + required: [calculatedAt, sourceRevision, metricDefinitionVersion] + properties: + calculatedAt: + type: string + format: date-time + sourceRevision: + type: string + pattern: '^[0-9]+$' + metricDefinitionVersion: + type: string + const: 0.4.0 + + IntegerMetric: + type: object + additionalProperties: false + required: [metricId, value, unit] + properties: + metricId: + type: string + pattern: '^[a-z0-9_]+$' + value: + type: integer + minimum: 0 + unit: + type: string + enum: [SESSIONS, RIAL] + + DecimalMetric: + type: object + additionalProperties: false + required: [metricId, value, unit, numerator, denominator] + properties: + metricId: + type: string + pattern: '^[a-z0-9_]+$' + value: + type: [string, 'null'] + pattern: '^(0|[1-9][0-9]*)(\.[0-9]{1,6})?$' + description: Null only when the denominator is zero or source is unavailable. + unit: + type: string + enum: [PERCENT, RATIO] + numerator: + type: integer + minimum: 0 + denominator: + type: integer + minimum: 0 + + TimingDistribution: + type: object + additionalProperties: false + required: [metricId, p50Ms, p95Ms, sampleSize, eligibleSampleSize, coveragePercent] + properties: + metricId: + type: string + enum: [init_time_ms, verify_time_ms] + p50Ms: + type: [integer, 'null'] + minimum: 0 + p95Ms: + type: [integer, 'null'] + minimum: 0 + sampleSize: + type: integer + minimum: 0 + eligibleSampleSize: + type: integer + minimum: 0 + coveragePercent: + type: [string, 'null'] + pattern: '^(100|[0-9]{1,2})(\.[0-9]{1,6})?$' + + PreviousPeriodComparison: + type: object + additionalProperties: false + required: [state, baselinePeriod] + properties: + state: + type: string + enum: [AVAILABLE, UNAVAILABLE] + baselinePeriod: + $ref: '#/components/schemas/ReportingPeriod' + absoluteChange: + type: [string, 'null'] + pattern: '^-?(0|[1-9][0-9]*)(\.[0-9]{1,6})?$' + relativeChangePercent: + type: [string, 'null'] + pattern: '^-?(0|[1-9][0-9]*)(\.[0-9]{1,6})?$' + unavailableReason: + type: [string, 'null'] + enum: [EMPTY_BASELINE, ZERO_BASELINE, SOURCE_UNAVAILABLE, null] + isActionableInsightEligible: + type: boolean + + OverviewData: + type: object + additionalProperties: false + required: + - bankPaidSessions + - bankPaidAmountRial + - paymentSuccessRate + - verificationCompletionRate + - comparisons + properties: + bankPaidSessions: + allOf: + - $ref: '#/components/schemas/IntegerMetric' + example: {metricId: bank_paid_sessions, value: 420, unit: SESSIONS} + bankPaidAmountRial: + allOf: + - $ref: '#/components/schemas/IntegerMetric' + example: {metricId: bank_paid_amount_rial, value: 680000000, unit: RIAL} + paymentSuccessRate: + $ref: '#/components/schemas/DecimalMetric' + verificationCompletionRate: + $ref: '#/components/schemas/DecimalMetric' + comparisons: + type: object + additionalProperties: + $ref: '#/components/schemas/PreviousPeriodComparison' + propertyNames: + enum: [bank_paid_sessions, bank_paid_amount_rial, payment_success_rate, verification_completion_rate] + + PaymentPerformanceData: + type: object + additionalProperties: false + required: + - bankPaidSessions + - verifiedSessions + - unverifiedPaidSessions + - failedSessions + - reversedSessions + - noAttemptSessions + - reachedBankSessions + - retriedSessions + - retryRecoveryRate + - initTiming + - verifyTiming + properties: + bankPaidSessions: {$ref: '#/components/schemas/IntegerMetric'} + verifiedSessions: {$ref: '#/components/schemas/IntegerMetric'} + unverifiedPaidSessions: {$ref: '#/components/schemas/IntegerMetric'} + failedSessions: {$ref: '#/components/schemas/IntegerMetric'} + reversedSessions: {$ref: '#/components/schemas/IntegerMetric'} + noAttemptSessions: {$ref: '#/components/schemas/IntegerMetric'} + reachedBankSessions: {$ref: '#/components/schemas/IntegerMetric'} + retriedSessions: {$ref: '#/components/schemas/IntegerMetric'} + retryRecoveryRate: {$ref: '#/components/schemas/DecimalMetric'} + initTiming: {$ref: '#/components/schemas/TimingDistribution'} + verifyTiming: {$ref: '#/components/schemas/TimingDistribution'} + + FeesData: + type: object + additionalProperties: false + required: [paidFeeAmountRial, bankPaidAmountRial, paidFeeRate, comparison] + properties: + paidFeeAmountRial: {$ref: '#/components/schemas/IntegerMetric'} + bankPaidAmountRial: {$ref: '#/components/schemas/IntegerMetric'} + paidFeeRate: {$ref: '#/components/schemas/DecimalMetric'} + comparison: {$ref: '#/components/schemas/PreviousPeriodComparison'} + + PeerMetric: + type: object + additionalProperties: false + required: [metricId, cohortMedian, merchantPerformancePercentile, direction] + properties: + metricId: + type: string + enum: + - payment_success_rate + - verification_completion_rate + - retry_recovery_rate + - init_time_ms_p50 + - init_time_ms_p95 + - verify_time_ms_p50 + - verify_time_ms_p95 + - paid_fee_rate + cohortMedian: + type: string + pattern: '^(0|[1-9][0-9]*)(\.[0-9]{1,6})?$' + merchantPerformancePercentile: + type: string + pattern: '^(100|[0-9]{1,2})(\.[0-9]{1,6})?$' + description: Higher always means better; lower-is-better metrics are direction-adjusted. + direction: + type: string + enum: [HIGHER_IS_BETTER, LOWER_IS_BETTER] + + PeerComparisonData: + type: object + additionalProperties: false + required: [eligiblePeerCount, combinedPeerSessionCount, metrics] + properties: + eligiblePeerCount: + type: integer + minimum: 10 + combinedPeerSessionCount: + type: integer + minimum: 1000 + metrics: + type: array + minItems: 8 + maxItems: 8 + items: + $ref: '#/components/schemas/PeerMetric' + + OverviewReadySection: + type: object + additionalProperties: false + required: [sectionId, state, metadata, data] + properties: + sectionId: {type: string, const: overview} + state: {type: string, const: READY} + metadata: {$ref: '#/components/schemas/ReadySectionMetadata'} + data: {$ref: '#/components/schemas/OverviewData'} + + PaymentPerformanceReadySection: + type: object + additionalProperties: false + required: [sectionId, state, metadata, data] + properties: + sectionId: {type: string, const: payment_performance} + state: {type: string, const: READY} + metadata: {$ref: '#/components/schemas/ReadySectionMetadata'} + data: {$ref: '#/components/schemas/PaymentPerformanceData'} + + FeesReadySection: + type: object + additionalProperties: false + required: [sectionId, state, metadata, data] + properties: + sectionId: {type: string, const: fees} + state: {type: string, const: READY} + metadata: {$ref: '#/components/schemas/ReadySectionMetadata'} + data: {$ref: '#/components/schemas/FeesData'} + + PeerComparisonReadySection: + type: object + additionalProperties: false + required: [sectionId, state, metadata, data] + properties: + sectionId: {type: string, const: peer_comparison} + state: {type: string, const: READY} + metadata: {$ref: '#/components/schemas/ReadySectionMetadata'} + data: {$ref: '#/components/schemas/PeerComparisonData'} + + PeerComparisonSuppressedSection: + type: object + additionalProperties: false + required: + - sectionId + - state + - metadata + - reason + - eligiblePeerCount + - combinedPeerSessionCount + - requiredPeerCount + - requiredPeerSessionCount + properties: + sectionId: {type: string, const: peer_comparison} + state: {type: string, const: SUPPRESSED} + metadata: {$ref: '#/components/schemas/ReadySectionMetadata'} + reason: + type: string + const: INSUFFICIENT_COHORT + eligiblePeerCount: + type: integer + minimum: 0 + combinedPeerSessionCount: + type: integer + minimum: 0 + requiredPeerCount: + type: integer + const: 10 + requiredPeerSessionCount: + type: integer + const: 1000 + + OverviewSection: + oneOf: + - $ref: '#/components/schemas/QueuedSection' + - $ref: '#/components/schemas/CalculatingSection' + - $ref: '#/components/schemas/FailedSection' + - $ref: '#/components/schemas/OverviewReadySection' + PaymentPerformanceSection: + oneOf: + - $ref: '#/components/schemas/QueuedSection' + - $ref: '#/components/schemas/CalculatingSection' + - $ref: '#/components/schemas/FailedSection' + - $ref: '#/components/schemas/PaymentPerformanceReadySection' + FeesSection: + oneOf: + - $ref: '#/components/schemas/QueuedSection' + - $ref: '#/components/schemas/CalculatingSection' + - $ref: '#/components/schemas/FailedSection' + - $ref: '#/components/schemas/FeesReadySection' + PeerComparisonSection: + oneOf: + - $ref: '#/components/schemas/QueuedSection' + - $ref: '#/components/schemas/CalculatingSection' + - $ref: '#/components/schemas/FailedSection' + - $ref: '#/components/schemas/PeerComparisonReadySection' + - $ref: '#/components/schemas/PeerComparisonSuppressedSection' + + DetailView: + type: string + enum: [UNVERIFIED_PAID, FAILED, RETRIED] + FailureStage: + type: string + enum: [NO_ATTEMPT, REACHED_BANK, OTHER_OBSERVED] + SessionStatus: + type: string + enum: [Paid, Verified, Failed, Reversed] + AttemptStatus: + type: string + enum: [InBank, Paid, Verified, Failed, Reversed] + + AttemptSummary: + type: object + additionalProperties: false + required: [sequence, status] + properties: + sequence: + type: integer + minimum: 1 + maximum: 135 + status: + $ref: '#/components/schemas/AttemptStatus' + createdAt: + type: [string, 'null'] + format: date-time + initTimeMs: + type: [integer, 'null'] + minimum: 0 + verifyTimeMs: + type: [integer, 'null'] + minimum: 0 + + SessionDetail: + type: object + additionalProperties: false + required: + - sessionId + - terminalId + - sessionStatus + - amountRial + - feeAmountRial + - createdAt + - attemptCount + - attempts + properties: + sessionId: + type: string + description: Opaque merchant-local API id, not the internal database key. + minLength: 8 + maxLength: 128 + terminalId: + type: string + minLength: 1 + maxLength: 128 + sessionStatus: + $ref: '#/components/schemas/SessionStatus' + amountRial: + type: integer + minimum: 0 + feeAmountRial: + type: integer + minimum: 0 + createdAt: + type: string + format: date-time + expiresAt: + type: [string, 'null'] + format: date-time + verifiedAt: + type: [string, 'null'] + format: date-time + settledAt: + type: [string, 'null'] + format: date-time + attemptCount: + type: integer + minimum: 0 + failureStage: + oneOf: + - $ref: '#/components/schemas/FailureStage' + - type: 'null' + attempts: + type: array + maxItems: 135 + description: Bounded ordered attempt summary; payer tokens and response codes are absent. + items: + $ref: '#/components/schemas/AttemptSummary' + + PageInfo: + type: object + additionalProperties: false + required: [limit, hasNextPage, nextCursor] + properties: + limit: + type: integer + minimum: 1 + maximum: 100 + hasNextPage: + type: boolean + nextCursor: + type: [string, 'null'] + minLength: 16 + maxLength: 2048 + + SessionDetailPage: + type: object + additionalProperties: false + required: [period, view, appliedFilters, items, pageInfo] + properties: + period: + $ref: '#/components/schemas/ReportingPeriod' + view: + $ref: '#/components/schemas/DetailView' + appliedFilters: + type: object + additionalProperties: false + properties: + terminalId: + type: [string, 'null'] + failureStage: + oneOf: + - $ref: '#/components/schemas/FailureStage' + - type: 'null' + items: + type: array + maxItems: 100 + items: + $ref: '#/components/schemas/SessionDetail' + pageInfo: + $ref: '#/components/schemas/PageInfo' + + EventId: + type: string + minLength: 12 + maxLength: 128 + pattern: '^evt_[A-Za-z0-9]+$' + + SseEventBase: + type: object + required: [eventId, runId, occurredAt] + properties: + eventId: {$ref: '#/components/schemas/EventId'} + runId: {$ref: '#/components/schemas/RunId'} + occurredAt: {type: string, format: date-time} + + RunSnapshotEvent: + unevaluatedProperties: false + allOf: + - $ref: '#/components/schemas/SseEventBase' + - type: object + required: [recovery, run] + properties: + recovery: + type: string + enum: [INITIAL, REPLAYED, RESET_TO_SNAPSHOT] + run: {$ref: '#/components/schemas/AnalyticsRun'} + SectionStartedEvent: + unevaluatedProperties: false + allOf: + - $ref: '#/components/schemas/SseEventBase' + - type: object + required: [sectionId, startedAt] + properties: + sectionId: {$ref: '#/components/schemas/SectionId'} + startedAt: {type: string, format: date-time} + SectionReadyEvent: + unevaluatedProperties: false + allOf: + - $ref: '#/components/schemas/SseEventBase' + - type: object + required: [sectionId, section] + properties: + sectionId: {$ref: '#/components/schemas/SectionId'} + section: + oneOf: + - $ref: '#/components/schemas/OverviewReadySection' + - $ref: '#/components/schemas/PaymentPerformanceReadySection' + - $ref: '#/components/schemas/FeesReadySection' + - $ref: '#/components/schemas/PeerComparisonReadySection' + SectionFailedEvent: + unevaluatedProperties: false + allOf: + - $ref: '#/components/schemas/SseEventBase' + - type: object + required: [sectionId, section] + properties: + sectionId: {$ref: '#/components/schemas/SectionId'} + section: {$ref: '#/components/schemas/FailedSection'} + SectionSuppressedEvent: + unevaluatedProperties: false + allOf: + - $ref: '#/components/schemas/SseEventBase' + - type: object + required: [sectionId, section] + properties: + sectionId: {type: string, const: peer_comparison} + section: {$ref: '#/components/schemas/PeerComparisonSuppressedSection'} + RunCompletedEvent: + unevaluatedProperties: false + allOf: + - $ref: '#/components/schemas/SseEventBase' + - type: object + required: [run] + properties: + run: {$ref: '#/components/schemas/AnalyticsRun'} + HeartbeatEvent: + unevaluatedProperties: false + allOf: + - $ref: '#/components/schemas/SseEventBase' + - type: object + required: [serverTime] + properties: + serverTime: {type: string, format: date-time} + AnalyticsSseEvent: + description: JSON payload carried in the SSE `data` field; event name selects the schema. + oneOf: + - $ref: '#/components/schemas/RunSnapshotEvent' + - $ref: '#/components/schemas/SectionStartedEvent' + - $ref: '#/components/schemas/SectionReadyEvent' + - $ref: '#/components/schemas/SectionFailedEvent' + - $ref: '#/components/schemas/SectionSuppressedEvent' + - $ref: '#/components/schemas/RunCompletedEvent' + - $ref: '#/components/schemas/HeartbeatEvent' + + FieldError: + type: object + additionalProperties: false + required: [field, reason] + properties: + field: + type: string + minLength: 1 + maxLength: 100 + description: Allowlisted public field name; never a database path. + reason: + type: string + minLength: 3 + maxLength: 100 + pattern: '^[A-Z0-9_]+$' + + Problem: + type: object + description: RFC 9457 Problem Details with stable application extensions. + additionalProperties: false + required: [type, title, status, detail, instance, code, requestId] + properties: + type: + type: string + format: uri + description: Stable problem type URI; production base URI is pending approval. + title: + type: string + minLength: 1 + maxLength: 200 + status: + type: integer + minimum: 400 + maximum: 599 + description: Must equal the actual HTTP response status. + detail: + type: string + minLength: 1 + maxLength: 1000 + description: Safe occurrence-specific guidance; clients must not parse it. + instance: + type: string + format: uri-reference + code: + type: string + minLength: 3 + maxLength: 100 + pattern: '^[A-Z0-9_]+$' + description: Stable application error code used by clients. + requestId: + type: string + minLength: 8 + maxLength: 128 + errors: + type: array + maxItems: 50 + items: + $ref: '#/components/schemas/FieldError' + snapshotUrl: + type: string + format: uri-reference + description: Optional safe recovery link for run-related problems.