openapi: 3.1.0
info:
  title: Transxact API
  version: 2026-08-16
  description: >
    The Transxact payment-intent API. Additive-only, dated versioning: pin the
    version with the `Transxact-Version: YYYY-MM-DD` request header (ADR-0006).
    Omitting the header pins to the current default (2026-08-16). A malformed or
    future date is rejected with `invalid_request` / `invalid_version`; any past
    or current date is accepted because the contract is additive-only.

    Authentication is `Authorization: Bearer <key>`. A **secret key** (`sk_live_…`
    or `sk_test_…`) has full access; a **publishable key** (`pk_live_…` or
    `pk_test_…`) may only create and drive payment intents (the checkout uses
    one) and is forbidden everywhere else (`permission_error` /
    `publishable_key_forbidden`). Rate limits apply per key on payment-intent
    creation: secret keys 10,000 requests/min, publishable keys 120
    requests/min; over the limit returns `rate_limit_error` / `rate_limited`
    with HTTP 429.

    Test mode is fully simulated (ADR-0005): outcomes are forced by magic
    values, no real money moves and no external rail is called. Live mode
    calls the real rails. An intent's mode is set by the authenticating key.

    The complete error-code catalog is documented on the Error schema, and
    every webhook event payload is documented under `webhooks`.
servers:
  - url: https://api.transxact.io
paths:
  /payment-intents:
    post:
      summary: Create a payment intent
      operationId: createPaymentIntent
      parameters:
        - $ref: "#/components/parameters/TransxactVersion"
        - $ref: "#/components/parameters/IdempotencyKey"
      description: >
        The intent's mode is set by the authenticating key (test vs live). In
        live mode a `mpaisa` redirect intent starts the real M-PAiSA rail and
        returns a redirect to Vodafone's hosted page; test mode returns a
        redirect to the aggregator's own simulate page.

        **Idempotency.** A retried request with the same `Idempotency-Key`
        (same merchant) returns the original intent with HTTP 200 instead of
        201, deduplicating network retries. Keys are honored for 24 hours.

        **Magic values (test mode).** The outcome of the simulated payment is
        forced by the `reference` or `amount`:
        | Input | Outcome |
        | --- | --- |
        | `reference: "test_success"` or amount `1` | succeeded |
        | `reference: "test_fail"` or amount `2` | failed |
        | `reference: "test_pending"` | processing, then succeeds on the next simulate |
        | anything else | succeeded |
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreatePaymentIntent" }
      responses:
        "201":
          description: Intent created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaymentIntentResponse" }
        "200":
          description: Idempotent replay of a prior creation with the same Idempotency-Key; the original intent is returned.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaymentIntentResponse" }
        "400":
          description: Invalid request.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401":
          description: Missing or invalid API key.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "403":
          description: Publishable key used where a secret key is required.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          description: Rate limit exceeded.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /payment-intents/{id}:
    get:
      summary: Retrieve a payment intent
      operationId: getPaymentIntent
      parameters:
        - $ref: "#/components/parameters/TransxactVersion"
        - in: path
          name: id
          required: true
          schema: { type: string }
          description: The intent id.
      security:
        - bearerAuth: []
      responses:
        "200":
          description: The intent.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaymentIntentResponse" }
        "401":
          description: Missing or invalid API key.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          description: Intent not found.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /payment-intents/{id}/confirm:
    post:
      summary: Drive a collect_otp intent (MyCash)
      operationId: confirmPaymentIntent
      parameters:
        - $ref: "#/components/parameters/TransxactVersion"
        - in: path
          name: id
          required: true
          schema: { type: string }
      description: >
        First send `mobile` (maps to sendOTP), then send `otp` (maps to
        approvePayment). In test mode any OTP approves except the magic
        `000000`, which fails the intent with a `payment.failed` event carrying
        `failure_reason: "otp_incorrect"`; the magic mobile `+6790000000` is
        rejected with `payment_error` / `invalid_mobile` (HTTP 422). In live
        mode this drives the real MyCash paymentRequest → sendOTP →
        approvePayment flow.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: Send `mobile` first, then `otp`. Either alone is valid; the second call must come after the first.
              properties:
                mobile: { type: string, description: "The customer's mobile number in E.164 form, e.g. +6797000000." }
                otp: { type: string, description: "The OTP the customer received. In test mode any OTP approves except magic `000000`." }
      responses:
        "200":
          description: The updated intent.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaymentIntentResponse" }
        "400":
          description: Invalid request (e.g. OTP not expected yet, mobile already collected).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401":
          description: Missing or invalid API key.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          description: Intent not found.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422":
          description: The rail rejected the request (e.g. magic mobile, or a live MyCash error).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /simulate/{id}:
    post:
      summary: Complete a test-mode redirect (test mode only)
      operationId: simulatePayment
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      description: >
        Simulates the shopper completing a redirect rail in test mode. The
        outcome is forced by the intent's magic values (set at creation):
        `reference: "test_success"` or amount `1` succeeds; `reference:
        "test_fail"` or amount `2` fails; `reference: "test_pending"`
        transitions to `processing` on the first simulate and succeeds on the
        next. Fails with `invalid_request` if the intent is not test-mode
        (`simulate_test_only`) or not a redirect intent (`not_redirect`).
      responses:
        "200":
          description: The updated intent.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaymentIntentResponse" }
        "400":
          description: Invalid request (test-mode-only or redirect-only).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          description: Intent not found.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /webhook-endpoints:
    post:
      summary: Register a webhook endpoint
      operationId: createWebhookEndpoint
      parameters:
        - $ref: "#/components/parameters/TransxactVersion"
      description: >
        Creates an endpoint in the `unverified` state and emits a
        `webhook.verified` event to it. The endpoint becomes `active` only once
        it answers that event with a 2xx (see `webhooks.webhook.verified`). The
        signing `secret` is returned exactly once — store it.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url: { type: string, description: "An https:// URL to receive webhook events." }
      responses:
        "201":
          description: Endpoint created; the signing secret is returned once.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookEndpointCreateResponse" }
        "400":
          description: Invalid request (e.g. not an https URL).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401":
          description: Missing or invalid API key.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
    get:
      summary: List webhook endpoints
      operationId: listWebhookEndpoints
      parameters:
        - $ref: "#/components/parameters/TransxactVersion"
      security:
        - bearerAuth: []
      responses:
        "200":
          description: The merchant's endpoints.
          content:
            application/json:
              schema:
                type: object
                properties:
                  endpoints:
                    type: array
                    items: { $ref: "#/components/schemas/WebhookEndpoint" }

  /webhook-endpoints/{id}:
    delete:
      summary: Delete a webhook endpoint
      operationId: deleteWebhookEndpoint
      parameters:
        - $ref: "#/components/parameters/TransxactVersion"
        - in: path
          name: id
          required: true
          schema: { type: string }
      security:
        - bearerAuth: []
      responses:
        "200":
          description: Endpoint deleted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookEndpoint" }
        "404":
          description: Endpoint not found.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /webhook-endpoints/{id}/rotate:
    post:
      summary: Rotate a webhook endpoint secret
      operationId: rotateWebhookEndpointSecret
      parameters:
        - $ref: "#/components/parameters/TransxactVersion"
        - in: path
          name: id
          required: true
          schema: { type: string }
      security:
        - bearerAuth: []
      responses:
        "200":
          description: New signing secret (returned once).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookEndpointRotateResponse" }
        "404":
          description: Endpoint not found.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /payment-links:
    post:
      summary: Create a payment link
      operationId: createPaymentLink
      parameters:
        - $ref: "#/components/parameters/TransxactVersion"
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [type]
              properties:
                type:
                  type: string
                  enum: [single_use, reusable]
                amount:
                  type: integer
                  description: Required for single_use links; fixed amount in FJD cents.
                reference:
                  type: string
                suggested_amount:
                  type: integer
                  description: Optional hint for reusable links, in FJD cents.
      responses:
        "201":
          description: Link created; `url` is the hosted checkout page.
          content:
            application/json:
              schema:
                type: object
                properties:
                  link: { $ref: "#/components/schemas/PaymentLink" }
                  url: { type: string }
        "400":
          description: Invalid request (e.g. missing amount for single_use).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401":
          description: Missing or invalid API key.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
    get:
      summary: List payment links
      operationId: listPaymentLinks
      parameters:
        - $ref: "#/components/parameters/TransxactVersion"
      security:
        - bearerAuth: []
      responses:
        "200":
          description: The merchant's links.
          content:
            application/json:
              schema:
                type: object
                properties:
                  links:
                    type: array
                    items: { $ref: "#/components/schemas/PaymentLink" }

  /pay/{linkId}:
    get:
      summary: Hosted checkout page for a payment link (shopper-facing)
      operationId: openPaymentLink
      parameters:
        - in: path
          name: linkId
          required: true
          schema: { type: string }
      responses:
        "200":
          description: HTML checkout page.
          content:
            text/html:
              schema: { type: string }
        "404":
          description: Link not found or (for single-use) already used.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api-keys:
    post:
      summary: Create an API key
      operationId: createApiKey
      description: >
        Creates a secret or publishable key in test or live mode. The
        `plaintext` value is returned exactly once on creation (and again on
        rotation). Secret keys are stored hashed; publishable keys are
        retrievable.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [type, mode]
              properties:
                type: { type: string, enum: [secret, publishable] }
                mode: { type: string, enum: [test, live] }
      responses:
        "201":
          description: Key created; plaintext returned once.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiKeyResponse" }
        "400":
          description: Invalid request (e.g. invalid type or mode).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401":
          description: Missing or invalid credential.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
    get:
      summary: List API keys
      operationId: listApiKeys
      security:
        - bearerAuth: []
      responses:
        "200":
          description: The merchant's keys.
          content:
            application/json:
              schema:
                type: object
                properties:
                  keys:
                    type: array
                    items: { $ref: "#/components/schemas/ApiKey" }

  /api-keys/{id}/rotate:
    post:
      summary: Rotate an API key
      operationId: rotateApiKey
      description: Creates a new key of the same type/mode and revokes the old one; the new plaintext is returned once.
      security:
        - bearerAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        "200":
          description: New key created and old revoked.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiKeyResponse" }
        "400":
          description: Invalid request (e.g. key already revoked).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          description: Key not found.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api-keys/{id}/revoke:
    post:
      summary: Revoke an API key
      operationId: revokeApiKey
      security:
        - bearerAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Key revoked.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiKey" }
        "404":
          description: Key not found.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

webhooks:
  payment.processing:
    post:
      summary: A payment intent entered `processing`.
      description: >
        Emitted when a redirect intent transitions from `requires_action` to
        `processing` (the shopper has been handed to the rail or confirmed a
        redirect). MyCash intents never emit this event — MyCash is
        server-to-server and emits only the terminal event.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PaymentIntentEvent" }
      responses:
        "2xx":
          description: Any 2xx acknowledges delivery.
  payment.succeeded:
    post:
      summary: A payment intent succeeded.
      description: >
        Emitted when a payment is captured (ADR-0002). The intent is terminal
        and the ledger has recorded the capture.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PaymentIntentEvent" }
      responses:
        "2xx":
          description: Any 2xx acknowledges delivery.
  payment.failed:
    post:
      summary: A payment intent failed.
      description: >
        Emitted when a payment fails. A `failure_reason` in `data` explains the
        terminal cause: `otp_incorrect` (MyCash magic OTP `000000`), `cancelled`
        (M-PAiSA rCode 111), `rail_declined`, or a MyCash rail error code.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PaymentIntentEvent" }
      responses:
        "2xx":
          description: Any 2xx acknowledges delivery.
  webhook.verified:
    post:
      summary: Webhook endpoint verification probe.
      description: >
        The first event sent to a newly registered endpoint. Answering it with
        a 2xx marks the endpoint `active`; otherwise it stays `unverified`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/WebhookEnvelope"
                - type: object
                  properties:
                    data:
                      type: object
                      required: [object, id]
                      properties:
                        object: { type: string, enum: [webhook_endpoint] }
                        id: { type: string }
      responses:
        "2xx":
          description: Any 2xx marks the endpoint active.
  disbursement.sent:
    post:
      summary: A disbursement was sent to the sub-merchant's bank account.
      description: "Emitted on the daily T+1 payout run when a disbursement leaves (ADR-0002)."
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DisbursementEvent" }
      responses:
        "2xx":
          description: Any 2xx acknowledges delivery.
  disbursement.failed:
    post:
      summary: A disbursement bounced.
      description: >
        Emitted when the bank rejects a disbursement. Funds are reversed back
        into the sub-merchant payable; the merchant is notified to update bank
        details and the payout retries.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DisbursementEvent" }
      responses:
        "2xx":
          description: Any 2xx acknowledges delivery.

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: >
        A secret key (`sk_…`) for server-side access, or a publishable key
        (`pk_…`) for creating/driving intents only. Keys are scoped to a mode
        (`sk_test_…` / `pk_test_…` for test; `sk_live_…` / `pk_live_…` for
        live). Test keys never touch real rails; live keys do.
  parameters:
    TransxactVersion:
      in: header
      name: Transxact-Version
      required: false
      schema: { type: string, example: "2026-08-16" }
      description: >
        The API version (a date, ADR-0006). Defaults to the current version;
        additive-only, so any past or current date is accepted and a future or
        malformed date is rejected (`invalid_request` / `invalid_version`).
    IdempotencyKey:
      in: header
      name: Idempotency-Key
      required: false
      schema: { type: string }
      description: >
        Dedupes retries on creation. A duplicate key within 24 hours returns the
        original intent with HTTP 200 instead of 201.
    TransxactSignature:
      in: header
      name: Transxact-Signature
      required: true
      schema: { type: string, example: "v1=1a2b3c4d…" }
      description: >
        Sent on every webhook delivery. The signature is
        `HMAC-SHA256("<timestamp>.<raw-body>")` with the endpoint's signing
        secret, hex-encoded. Verify it before trusting the payload.
    TransxactTimestamp:
      in: header
      name: Transxact-Timestamp
      required: true
      schema: { type: integer }
      description: >
        The Unix seconds at which the delivery was signed. Reject any delivery
        whose timestamp is more than 5 minutes old (replay protection).
    TransxactEventId:
      in: header
      name: X-Transxact-Event-Id
      required: true
      schema: { type: string, example: "evt_…" }
      description: >
        The event id, also present in the body as `event_id`. Deduplicate
        deliveries on it: retries re-send the same id.
  schemas:
    CreatePaymentIntent:
      type: object
      required: [amount]
      properties:
        amount:
          type: integer
          description: "Amount in FJD cents. Magic values in test mode: `1` succeeds, `2` fails."
        currency:
          type: string
          default: FJD
        reference:
          type: string
          description: >
            Merchant order id. In test mode the magic values `test_success`,
            `test_fail`, and `test_pending` force the simulated outcome.
        return_url:
          type: string
          description: https:// URL to return to after a redirect rail.
        payment_method:
          type: string
          enum: [mpaisa, mycash, card]
          default: mpaisa
          description: >
            Determines the interaction pattern: mycash returns a
            collect_otp next_action; the others return redirect.
    NextAction:
      type: object
      required: [type]
      properties:
        type:
          type: string
          enum: [redirect, collect_otp]
        url:
          type: string
          description: Present only for redirect actions.
    PaymentIntent:
      type: object
      properties:
        id: { type: string }
        amount: { type: integer }
        currency: { type: string }
        reference: { type: string, nullable: true }
        return_url: { type: string, nullable: true }
        mode: { type: string, enum: [test, live] }
        status:
          type: string
          enum: [requires_action, processing, succeeded, failed, refunded]
        next_action: { $ref: "#/components/schemas/NextAction", nullable: true }
        created_at: { type: integer }
    PaymentIntentResponse:
      type: object
      properties:
        intent: { $ref: "#/components/schemas/PaymentIntent" }
    WebhookEndpoint:
      type: object
      properties:
        id: { type: string }
        url: { type: string }
        status: { type: string, enum: [unverified, active] }
        created_at: { type: integer }
    WebhookEndpointCreateResponse:
      type: object
      properties:
        endpoint: { $ref: "#/components/schemas/WebhookEndpoint" }
        secret: { type: string, description: "The signing secret, returned exactly once." }
    WebhookEndpointRotateResponse:
      type: object
      properties:
        endpoint: { $ref: "#/components/schemas/WebhookEndpoint" }
        secret: { type: string, description: "The new signing secret, returned exactly once." }
    PaymentLink:
      type: object
      properties:
        id: { type: string }
        merchant_id: { type: string }
        type: { type: string, enum: [single_use, reusable] }
        amount: { type: integer, nullable: true }
        reference: { type: string, nullable: true }
        suggested_amount: { type: integer, nullable: true }
        used_at: { type: integer, nullable: true }
        created_at: { type: integer }
    ApiKey:
      type: object
      properties:
        id: { type: string }
        type: { type: string, enum: [secret, publishable] }
        mode: { type: string, enum: [test, live] }
        prefix: { type: string, example: "sk_test_" }
        created_at: { type: integer }
        revoked_at: { type: integer, nullable: true }
        value:
          type: string
          description: Present only for publishable keys (secret keys are stored hashed).
    ApiKeyResponse:
      type: object
      properties:
        key: { $ref: "#/components/schemas/ApiKey" }
        plaintext:
          type: string
          description: The full key value. Returned exactly once (on create or rotate).
    WebhookEnvelope:
      type: object
      required: [event_id, type, data, created]
      properties:
        event_id: { type: string, description: "The event id; also sent in the X-Transxact-Event-Id header. Deduplicate deliveries on it." }
        type: { type: string, description: "The event type; matches the webhook name." }
        data: { type: object, description: "The event payload; shape depends on the event type." }
        created: { type: integer, description: "Unix seconds at which the event was created." }
      description: >
        The envelope of every webhook delivery. Payloads are **additive and
        unversioned** (ADR-0013): fields are only ever added, and receivers must
        not reject unknown fields. Deliveries are retried up to 5 times with
        exponential backoff; verify the `Transxact-Signature` header and reject
        stale timestamps.
    PaymentIntentEvent:
      allOf:
        - $ref: "#/components/schemas/WebhookEnvelope"
        - type: object
          properties:
            type: { type: string, enum: [payment.processing, payment.succeeded, payment.failed] }
            data:
              type: object
              required: [object, id, status, amount, currency]
              properties:
                object: { type: string, enum: [payment_intent] }
                id: { type: string }
                status: { type: string, enum: [processing, succeeded, failed] }
                amount: { type: integer, description: "FJD cents." }
                currency: { type: string }
                reference: { type: string, nullable: true }
                failure_reason: { type: string, description: "Present on payment.failed: otp_incorrect, cancelled, rail_declined, or a rail error code." }
                customer_phone: { type: string, description: "Present on M-PAiSA success (poll result)." }
                mycash_reference: { type: string, description: "Present on MyCash success." }
                mycash_transaction_id: { type: string, description: "Present on MyCash success." }
    DisbursementEvent:
      allOf:
        - $ref: "#/components/schemas/WebhookEnvelope"
        - type: object
          properties:
            type: { type: string, enum: [disbursement.sent, disbursement.failed] }
            data:
              type: object
              required: [object, id, amount, cycle]
              properties:
                object: { type: string, enum: [disbursement] }
                id: { type: string }
                amount: { type: integer, description: "FJD cents." }
                cycle: { type: string, description: "The settlement date (YYYY-MM-DD) the disbursement covers." }
                status: { type: string, enum: [sent, failed] }
                reason: { type: string, description: "Present on disbursement.failed: bounced." }
    Error:
      type: object
      properties:
        error:
          type: object
          required: [type, code, message]
          properties:
            type:
              type: string
              enum:
                - invalid_request
                - authentication_error
                - permission_error
                - idempotency_error
                - rate_limit_error
                - api_error
                - payment_error
              description: The error class, from a small closed set. Map it to an HTTP status as documented.
            code:
              type: string
              description: >
                A specific, stable, machine-readable code. The complete catalog
                (all live and future codes are additive):

                **invalid_request (400, 404, 409)** — invalid_json, not_found,
                invalid_version, invalid_amount, invalid_currency,
                invalid_return_url, invalid_reference, invalid_type,
                invalid_mobile, invalid_confirm, invalid_key_type, invalid_mode,
                invalid_bank_account, invalid_track, invalid_legal_name,
                invalid_tin, invalid_rail, unsupported_rail, invalid_report_type,
                simulate_test_only, not_redirect, not_collect_otp,
                intent_already_confirmed, intent_not_processing, link_not_found,
                link_used, link_mismatch (403), key_not_found (404),
                key_revoked, endpoint_not_found (404), intent_not_found (404),
                exception_not_found (404), alert_not_found (404),
                no_submission, already_decided, already_escalated,
                already_resolved, registration_doc_required,
                beneficial_owner_required, mobile_taken (409).

                **authentication_error (401)** — missing_auth, invalid_key,
                otp_invalid, invalid_credential, merchant_not_found.

                **permission_error (403)** — publishable_key_forbidden, and
                staff RBAC role denials.

                **rate_limit_error (429)** — rate_limited.

                **payment_error (400, 422, 502)** — invalid_mobile (422),
                token_verification_failed, mycash_unreachable, mycash_no_request_id,
                and live rail rejection codes.

                **api_error (500, 502, 503)** — internal_error,
                mpaisa_not_configured (503), mycash_not_configured (503),
                mpaisa_session_missing, mycash_session_missing.

                The parenthesized statuses are the HTTP codes returned for each.
                New codes are added over time; a client must treat an unknown
                code as a generic error of its `type`, never fail on an unseen
                code.
            message:
              type: string
              description: A human-readable explanation, safe to show to an end user. May change between versions; do not branch on it.