openapi: 3.1.0
info:
  title: Moja Switch Partner API
  version: 1.0.0-draft
  description: |
    B2B payment infrastructure API for cross-border corridors.
    Examples use the sandbox corridor Canada → Tanzania (`CA-TZ`, CAD → TZS).

    **Implementation status is part of the contract.** An operation or
    property marked `x-moja-status: planned` (summary prefixed `[Planned]`, or
    🔲 in its description) is published target contract that the deployed
    platform does **not** serve or emit yet. Everything unmarked is live: an
    automated check keeps this file and the deployed API in step. Requests
    with unknown fields are rejected with `400 invalid_request`, so do not
    send a planned request property. Error codes are catalogued at
    https://docs.mojaswitch.com/guides/errors/.

    **Corridors are data, not a fixed list.** What a given tenant may actually
    use is per-account entitlement: read `GET /corridors/{corridor_code}` (the
    list, `GET /corridors`, is planned) and treat the corridor
    code, its currencies and their minor-unit exponents as server-supplied.
    Naming a corridor here is an example, not a statement of what is live
    for you and not a constraint in the contract — the schemas do not enumerate corridors, and a
    client that hardcodes one has to be re-released to gain another.

    **Currencies differ in minor-unit exponent** (zero-decimal currencies
    exist). No client may assume two decimal places; see `MinorUnitAmount` and
    `GET /corridors/{corridor_code}`.

    **How FX is priced depends on your tenant's FX model, set by
    Moja Switch per tenant — see `POST /payments`.**
    - **BYO-FX.** You supply the send→receive rate and the exact
      receive (payout) amount; Moja executes that instruction and adds no FX
      margin. Responses **echo your locked `fx_rate` and `receive_amount`**.
    - **Moja Switch FX.** You pay on a single-use `quote_id` from
      `POST /quotes`: the quote is priced from the Moja Switch FX **reference
      rate** plus your tenant's own agreed margin, and the platform locks that
      price exactly. Moja Switch serves a reference rate; it is not the
      underlying FX counterparty.

    Off-production, a BYO-FX payment that omits `fx` on a corridor with a
    configured sandbox rate gets a Moja-derived rate as a dev convenience
    (see `FX` schema); that is never production pricing.

    **Collections (`kind: collection`) are sandbox-only.** See
    `POST /payments` and the `Payouts` tag. Production collections and tenant
    payouts are not offered yet.

    **Versioning.** The API is versioned by URL major (`/v1`). This contract
    is a pre-launch draft (`1.0.0-draft`) and may still change before the
    first partner goes live. After that, a breaking change is published as a
    new major version (`/v2`) with a deprecation window; it is never made
    silently to `/v1`.
  contact:
    name: Moja Switch API Support
    url: https://mojaswitch.com
servers:
  - url: https://api-sandbox.mojaswitch.com/v1
    description: Sandbox (live). Use an `sk_sandbox_*` key. Moves no real money.
  - url: https://api.mojaswitch.com/v1
    x-moja-status: planned
    description: >-
      Production — not serving yet. The production base URL and `sk_live_*`
      keys are issued to a tenant at go-live, after its readiness gates close;
      do not configure a client against this URL before then.

security:
  - bearerAuth: []

tags:
  - name: Quotes
    description: FX quotes and rate locks
  - name: Payments
    description: Payment lifecycle
  - name: Payouts
    description: |
      Tenant CAD payouts for collections. Sandbox only; production payouts are not offered yet.
      Members expose gross, fee, net and the collection `reference` for reconciliation.
  - name: Sandbox
    description: |
      Sandbox-only partner actions that move no money state (payout scenario
      configuration; webhook redelivery of an already-emitted event).
  - name: FX
    description: Indicative FX reference data. Never a customer rate.
  - name: End Users
    description: |
      End users — natural persons a tenant acts for. Every tenant with the
      `end_users:*` scopes gets this surface; there is no separate consumer API.
      `GET /end-users/{end_user_id}/limits` is planned (`x-moja-status: planned`).
  - name: Verification
    description: |
      Identity verification of an end user — session handoff, status, tier,
      the backend-authored requirement list, and the tenant's own verification
      decision.
  - name: Recipients
    description: |
      An end user's saved beneficiaries. Screened at save; usable only after a
      screening decision returns.
  - name: Corridors
    description: |
      Corridor discovery — payout methods, limits and minor-unit exponents, as
      data rather than hardcoded client knowledge. `GET /corridors/{corridor_code}`
      and `GET /account/readiness` are live; the corridor list and client
      configuration are planned.
  - name: Devices
    description: |
      🔲 **Planned — not routed.** Provider-agnostic push-notification device
      registration.
  - name: Support
    description: |
      🔲 **Contract published, not yet implemented.**

      In-app support case intake.
  - name: Account
    description: |
      Partner account bootstrap and readiness. `POST /signup` is public
      (no API key); readiness and client-config require scope `read`.

paths:
  /quotes:
    post:
      tags: [Quotes]
      operationId: createQuote
      summary: Create FX quote
      description: |
        Prices an FX quote for a corridor and returns the resulting
        `receive_amount` and all-in `fx_rate`. Pre-step before payment creation.

        **Who prices is a property of your tenant, not of the request.**
        Moja Switch holds your *FX model* in your tenant commercial
        configuration, and your *commercial terms* (your margin) **per
        corridor** — yours alone, independent of any other tenant on the same
        corridor. The two models behave differently here:

        | Your FX model | Who sets the rate | What you send | Margin |
        |---|---|---|---|
        | **Moja Switch FX** | Moja Switch: its own validated reference rate, less **your commercial margin for this corridor** | `send_amount` **or** `receive_amount`; **no `fx`** | Applied by Moja Switch, inside the rate |
        | **BYO-FX** | You: your own FX source, rate and pricing | `send_amount` and an inline `fx` | **None** added by Moja Switch |

        ### Moja Switch FX
        - **Send exactly one** of `send_amount` (what the sender pays) or
          `receive_amount` (what the recipient must get). With `receive_amount`
          the quote's `receive_amount` equals the request **exactly** and
          `send_amount` is the smallest amount that covers it; `fx_rate` is then
          the *effective* rate (receive ÷ send, floored at 8 places, never above
          the margin-implied rate).
        - **Do not send `fx`** — any `fx` key, even `{}` or `null` →
          `422 fx_not_accepted`. A Moja Switch FX tenant does not bring a rate.
        - Moja Switch reads its own reference rate. If it has no valid, fresh
          rate, the quote is refused with `503 fx_unavailable` (never priced from
          a cached or indicative rate). Otherwise it applies your margin for the quoted corridor, prices with integer arithmetic (each
          currency at its own minor-unit exponent; CAD and TZS two digits), persists the quote and returns it.
        - **Show your end user only `send_amount` and `fx_rate`.** The margin is
          already inside `fx_rate`. The response's `fx_provenance` and
          `fees.fx_markup` are for **your systems**, never for an end user;
          `disclosure.total_cost` is a **figure**, never a percentage.
        - A quote is **single-use** and valid until `expires_at`. Pay on it with
          `POST /payments` `quote_id` — see that operation. Re-quote rather than
          extending it client-side.

        ### BYO-FX
        **BYO-FX rules:**
        - **Production requires `fx`** with **both** `fx.rate` and
          `fx.receive_amount`, and so does any corridor with no sandbox rate,
          in every environment. Omitting it → `422 fx_required`.
        - `fx.receive_amount` is **authoritative** (paid verbatim); `fx.rate` is
          the **declared basis** (recorded, consistency-checked, not used to
          recompute the payout).
        - Consistency rule:
          `|receive_amount − round(send_amount × rate)| ≤ 1 minor unit`,
          else `422 fx_inconsistent`. Values that are not positive, not valid for
          the corridor, or too precise →
          `422 validation_error` with `details[].field` naming the `fx` field.
        - **Sandbox only:** when `fx` is omitted off-production on a corridor
          with a configured sandbox rate (CA-TZ), Moja derives an indicative rate as a **dev convenience** — never
          production pricing. This fallback does not exist on the production
          server.

        **Scope:** `write`.

        **Cost disclosure is part of the response, not an afterthought.** Every
        quote carries a required `disclosure` object with the total cost as a
        figure in the send currency and the rate basis behind it. A client
        cannot render a quote without it; see
        `QuoteDisclosure`.

        **No rate available → `503 fx_unavailable`** (Moja Switch FX). This
        can be an expected state, not an incident: a reference rate that is
        stale, unvalidated or outside its deviation band is refused rather than
        used. Render the no-quote state and retry later; no return time is
        given. **Never fall back to a cached or indicative rate** — a displayed
        rate is a rate a customer expects to receive.

        **Platform-priced quotes (Moja Switch FX).** A quote Moja Switch priced
        returns `pricing_mode: platform_priced`, the all-in `fx_rate`, the
        `expires_at` it is good until, a `disclosure` (the total cost as a figure
        in the send currency) and `fx_provenance` — the source rate, the provider,
        the rate timestamp, the pricing-rule version, and whether the underlying
        rate was `indicative` or `executable` — so an integrator (and an auditor)
        can answer "where did this number come from".

        This is available **only to tenants configured for Moja Switch FX**. For
        every other tenant, production still requires `fx` (`422 fx_required`) and `pricing_mode` is `client_provided`: Moja Switch does not
        price a BYO-FX client's customers and adds no margin to their rate.
        `receive_amount` is accepted **only** for a Moja Switch FX tenant.

        **Errors specific to Moja Switch FX:** `422 fx_not_accepted` (you sent
        `fx`), `422 commercial_terms_not_configured` (no commercial terms are
        configured for you on this corridor — there is no default margin; ask
        Moja Switch to configure them), `422 amount_below_minimum` (the amount delivers nothing at this
        rate), `422 transaction_limit_exceeded` / `422 amount_out_of_range` (the
        same ceilings a payment is held to), `503 fx_unavailable` (no valid,
        fresh reference rate — retry; never display a price).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateQuoteRequest'
      responses:
        '201':
          description: Quote created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Quote'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/Unprocessable'
        '503':
          $ref: '#/components/responses/FXUnavailable'

  /payments:
    post:
      tags: [Payments]
      operationId: createPayment
      summary: Create payment
      description: |
        Initiates a cross-border payment. Requires Idempotency-Key header.
        Target response time: < 30s for sync rails (mobile money B2C).

        **How the payment is priced depends on your tenant's FX model.**

        - **Moja Switch FX**: pay on a `quote_id` from `POST /quotes`
          and **omit `fx`** (an `fx` → `422 fx_not_accepted`, or
          `400 invalid_request` if it is malformed; no `quote_id` →
          `422 quote_required`). Moja Switch locks **that quote's** price exactly —
          it never re-prices. The quote must be **yours**, priced by Moja Switch FX
          **in this environment**, matching this payment, **unexpired and not
          already used**: a quote is **single-use**, consumed atomically with the
          payment, so a second payment on it → `422 quote_invalid` and an expired
          one → `422 quote_expired`. A **retry with the same `Idempotency-Key`**
          returns the original payment and never touches the quote.
        - **BYO-FX**: the partner sets the FX; Moja executes it (below). Moja
          Switch adds no margin.

        Either way the response carries `fx_provenance` — see `Payment`.

        **BYO-FX.** The partner sets the FX; Moja executes
        it. Supply the locked FX inline via the `fx` object. The response
        **echoes the partner's `fx_rate` and `receive_amount`** (their values;
        no Moja markup).
        - **Production requires inline** `fx` (with both `fx.rate` and
          `fx.receive_amount`), and so does any corridor with no sandbox
          rate, in every environment; a request without it → `422 fx_required`. A `quote_id` does not
          satisfy this.
        - `fx.receive_amount` is **authoritative** (paid verbatim); `fx.rate` is
          the **declared basis**, consistency-checked against `send_amount`:
          `|receive_amount − round(send_amount × rate)| ≤ 1 minor unit`,
          else `422 fx_inconsistent`.
        - Values must be positive, valid for the corridor and within precision
          (`422 validation_error`, `details[].field` naming the `fx` field).
        - **Sandbox only:** with `fx` omitted off-production on a corridor with
          a configured sandbox rate (CA-TZ), Moja applies a derived rate as a dev convenience — never production
          pricing.

        **Compliance is not an HTTP error here.** Screening runs after the
        payment is persisted. A block (a review decision is treated as a block),
        including a missing or invalid required `sender_kyc` attestation,
        returns **`201` with `status: FAILED`** and `failure.code:
        compliance_blocked`; a screening outage returns `201 FAILED` with
        `failure.code: compliance_error`. See `PaymentFailure`.

        **Scope:** `write`.

        **The originator: `sender`, always.** For a push collection it is the
        inline `Party`. For `funding_method: INTERAC_REQUEST` it is an
        `InteracSender` (`email`, `name`): the person the Interac request is sent
        to.

        **The beneficiary: `payout`, or a saved recipient.** A saved recipient
        is named by `recipient_id` together with `end_user_id` (both or neither,
        else `422 validation_error`). Without `payout`, the platform builds the
        payout from the screened recipient record, so the client restates none
        of it. With `payout` as well, its beneficiary phone must equal the saved
        phone (`422 recipient_phone_mismatch`). Before any collection:
        `404 end_user_not_found`, `404 recipient_not_found` (unknown, or another
        end user's), `422 recipient_not_approved` (screening not passed),
        `422 recipient_not_payable` (not a mobile-money recipient in the
        corridor's payout country).

        **The end user must be able to send.** A payment naming an end user is
        refused with `422 end_user_not_permitted` unless that end user's
        `account_status` is `created` **and** its `transact_authorization` is
        `authorized` — with or without `sender_kyc`. An attestation says who the
        sender is; it never lets a suspended, closed or unauthorized account send.
        `details[0].message` names the refusing state (for example
        `account_status is suspended`). Nothing is created, so the same
        `Idempotency-Key` may be retried once the end user is restored. An
        idempotent retry of a payment created before a suspension still returns
        that payment.

        **FX.** `fx` and `quote_id` are mutually exclusive (`422 validation_error`).

        **Tenant controls.** Moja Switch must have enabled your tenant for the
        corridor in this environment (`403 tenant_not_activated`; a contract or
        an entitlement alone is not enablement), and the payment must fit your
        tenant limit profile for the send currency (`422 tenant_limit_exceeded`,
        `details[0].message` names the limit: `per_transfer`,
        `rolling_24h_amount`, `rolling_24h_count` or `rolling_30d_amount`). A
        profile with a blank limit refuses every payment
        (`403 tenant_limits_unresolved`): a blank is never read as unlimited.
        Nothing is created on any of these, so the same `Idempotency-Key` may be
        retried once Moja Switch has changed the decision; an idempotent retry of
        a payment created earlier still returns it.

        Idempotency: a retried create with the same `Idempotency-Key` and body
        returns the original payment (at-least-once delivery, exactly-once
        effect) — the client-locked `fx_rate`/`receive_amount` never change on
        replay.

        **Idempotency namespace: `(partner_id, key)`.** `end_user_id` does not
        narrow it. A tenant that sends on behalf of many end users must keep keys
        unique across all of them (for example, derive the key from the end user
        and the client's own attempt key).

        Generate the key (UUIDv4, one per transfer attempt) and persist it
        before the first attempt, so a retry after process death reuses it.

        **`kind` (optional): `transfer` or `collection`.** Absent means
        `transfer`, and a transfer request behaves exactly as it does without
        `kind`; `"kind": "transfer"` and no `kind` are the same request for
        idempotency. `kind: collection` is **sandbox-only** today:
        outside a sandbox-serving instance it is refused with
        `403 product_not_available` before the body is validated. On a sandbox
        instance it is validated (`422 validation_error` naming each field) and
        then requires an operator-activated collection product for that tenant
        (`403 tenant_not_activated` when missing). With activation, Create returns
        `201` and the payment advances through Interac sandbox collection to
        `COLLECTED`, then Moja Switch schedules a tenant CAD payout
        (`GET /v1/payouts`). Production collections are not offered yet.
        Any other `kind` value is `422 validation_error`. A collection is not
        corridor-checked (it has no corridor; one that names `corridor` anyway
        is checked and refused unless you are entitled to it) and needs the
        `write` scope. With a production key every collection is
        `403 product_not_available`, whatever its fields. A collection's rules are conditional and
        enforced by the server, so the schema below describes a transfer;
        see `kind` for what a collection requires and forbids.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreatePaymentRequest'
            examples:
              transfer_byo_fx:
                summary: Transfer, BYO-FX (inline fx)
                value:
                  corridor: CA-TZ
                  reference: partner-tx-12345
                  send_amount: { value: '100.00', currency: CAD }
                  fx:
                    rate: '1850.25'
                    receive_amount: { value: '185025.00', currency: TZS }
                  sender:
                    type: INDIVIDUAL
                    country: CA
                    external_id: partner-cust-1001
                    first_name: Alex
                    last_name: Sender
                  sender_kyc:
                    attested: true
                    attestation_id: kyc_ref_abc123
                    attested_at: '2026-11-09T10:00:00Z'
                  payout:
                    method: MOBILE_MONEY
                    currency: TZS
                    beneficiary:
                      type: INDIVIDUAL
                      first_name: Neema
                      last_name: Recipient
                      phone: '+255712345678'
                  purpose: FAMILY_SUPPORT
              transfer_moja_switch_fx:
                summary: Transfer, Moja Switch FX (pay on a quote; no fx)
                value:
                  corridor: CA-TZ
                  reference: partner-tx-12346
                  quote_id: quo_contract
                  send_amount: { value: '100.00', currency: CAD }
                  sender:
                    type: INDIVIDUAL
                    country: CA
                    external_id: partner-cust-1001
                    first_name: Alex
                    last_name: Sender
                  sender_kyc:
                    attested: true
                    attestation_id: kyc_ref_abc123
                    attested_at: '2026-11-09T10:00:00Z'
                  payout:
                    method: MOBILE_MONEY
                    currency: TZS
                    beneficiary:
                      type: INDIVIDUAL
                      first_name: Neema
                      last_name: Recipient
                      phone: '+255712345678'
                  purpose: FAMILY_SUPPORT
              collection_sandbox:
                summary: Collection (sandbox only, kind collection)
                value:
                  kind: collection
                  reference: inv-2026-000123
                  send_amount: { value: '250.00', currency: CAD }
                  funding_method: INTERAC_REQUEST
                  sender: { email: payer@example.com, name: Jordan Payer }
                  payer_id: payer-42
                  destination_country: TZ
                  purpose: FAMILY_SUPPORT
                  sender_kyc:
                    attested: true
                    attestation_id: kyc_ref_abc123
                    attested_at: '2026-11-09T10:00:00Z'
                  sandbox: { collection_outcome: paid }
      responses:
        '201':
          description: Payment created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Payment'
        '200':
          description: Idempotent replay — original payment returned
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Payment'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/Unprocessable'
        '500':
          description: |
            Either an error envelope (`internal_error`, nothing created), or —
            when processing failed internally after the payment was persisted —
            the **Payment** itself, deliberately: it exists and has an id, so
            the partner sees which payment and its state. Normally `FAILED`
            with `failure.code: internal_error`; if recording `FAILED` also
            failed, the last persisted state (read it again with
            `GET /payments/{payment_id}`). A Payment has `id`; an envelope has
            `error`. A retry with the same `Idempotency-Key` returns the stored
            payment, never a second one.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/Payment'
                  - $ref: '#/components/schemas/Error'
        '503':
          $ref: '#/components/responses/EntitlementUnavailable'

    get:
      tags: [Payments]
      operationId: listPayments
      summary: List payments
      description: |
        Returns the authenticated partner's payments, newest first. Scoped to
        the partner: another partner's payments are never visible, and a filter
        naming one of their resources returns an empty page, not a 404.

        **Scope:** `read`.

        **Keyset pagination, not offset.** The page is ordered by
        `created_at DESC, id DESC` and walked with an opaque `cursor`. Read
        `has_more`; when it is true, pass `next_cursor` back **verbatim** as
        `cursor` on the following request. The cursor encodes the sort position
        *and* the filter set:
        - Never construct, parse or mutate a cursor. Its encoding is not part of
          this contract and will change.
        - Changing any filter while paginating invalidates the cursor →
          `422 invalid_cursor`. Start the walk again with the new filters.
        - A payment created while you paginate sorts ahead of your cursor and so
          does not appear in the remaining pages. Re-request the first page to
          see it. Keyset pagination does not skip or duplicate rows the way
          offset pagination does.
      parameters:
        - name: end_user_id
          in: query
          required: false
          description: >-
            Return only payments originated by this end user. The natural filter
            for an activity screen.
          schema:
            type: string
            pattern: '^eu_[a-zA-Z0-9]+$'
            example: eu_7Qm4xK2pLd
        - name: status
          in: query
          required: false
          description: >-
            Return only payments in these statuses. Repeat the parameter to
            match several (`?status=COMPLETED&status=FAILED`). Omit for all.
          style: form
          explode: true
          schema:
            type: array
            maxItems: 14
            items:
              $ref: '#/components/schemas/PaymentStatus'
        - name: created_after
          in: query
          required: false
          description: Only payments created at or after this instant (RFC 3339).
          schema:
            type: string
            format: date-time
        - name: created_before
          in: query
          required: false
          description: Only payments created strictly before this instant (RFC 3339).
          schema:
            type: string
            format: date-time
        - name: limit
          in: query
          required: false
          description: Page size. Defaults to 25.
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 25
        - name: cursor
          in: query
          required: false
          description: >-
            Opaque keyset cursor from a previous response's `next_cursor`. Pass
            it back unchanged; do not construct one.
          schema:
            type: string
            maxLength: 512
      responses:
        '200':
          description: A page of payments
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PaymentList'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/Unprocessable'

  /payments/{payment_id}:
    get:
      tags: [Payments]
      operationId: getPayment
      summary: Get payment
      parameters:
        - $ref: '#/components/parameters/PaymentId'
      responses:
        '200':
          description: Payment details
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Payment'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /payments/{payment_id}/cancel:
    post:
      tags: [Payments]
      operationId: cancelPayment
      summary: Cancel payment
      description: Cancels a payment before send-side collection begins. Returns 409 if cancellation is no longer allowed.
      parameters:
        - $ref: '#/components/parameters/PaymentId'
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  maxLength: 500
      responses:
        '200':
          description: Payment cancelled
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Payment'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'

  /payments/{payment_id}/receipt:
    get:
      tags: [Payments]
      operationId: getPaymentReceipt
      summary: Get payment receipt
      description: |
        ✅ **Implemented.**

        Returns the receipt for a payment whose funds have been delivered.

        `receipt_number` is `RCPT-<year>-<first 8 characters of the payment id, upper case>`,
        derived from the payment, so a re-fetch always returns the same number. `issued_at`
        is the payment's `completed_at`. `fees.platform_fee` may be zero.

        **Scope:** `read`.

        **Availability.** A receipt exists only once the payment has reached
        `COMPLETED`, `SETTLED` or `RECONCILED`. Anything earlier —
        or a `FAILED`/`CANCELLED` payment, which has no receipt —
        returns `409 receipt_not_available`. Use `GET /payments/{payment_id}`
        for in-flight state; a receipt is a record of money that moved, not a
        status view.

        **Amounts are integer minor units** (`MinorUnitAmount`), each carrying
        its own currency and exponent — `amount / 10^minor_unit_exponent` is the
        value to display. A receipt is the artifact a user screenshots and sends
        to support, so it is the last place an amount should be ambiguous by a
        factor of a hundred.

        **Masking is structural for everything except the recipient's own
        phone number.** `recipient.phone` is the destination MSISDN in full —
        the sender is shown their own saved recipient's number so they can
        verify who they are paying. Bank account numbers, document numbers and
        any credential are never returned here — there is no parameter that
        unmasks them.
      parameters:
        - $ref: '#/components/parameters/PaymentId'
      responses:
        '200':
          description: Receipt
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Receipt'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'

  /payouts:
    get:
      tags: [Payouts]
      operationId: listPayouts
      summary: List tenant CAD payouts
      description: |
        Returns the authenticated partner's tenant CAD payouts,
        newest first. Each member carries gross, fee, net and the collection
        `reference`, so you can reconcile each payout line to your own records.

        **Scope:** `read`.

        **Sandbox only:** production payouts are not offered yet.
      parameters:
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 25
      responses:
        '200':
          description: A page of payouts
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TenantPayoutList'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /payouts/{payout_id}:
    get:
      tags: [Payouts]
      operationId: getPayout
      summary: Get tenant CAD payout
      description: |
        Returns one tenant CAD payout scoped to the authenticated partner.
        Another partner's id is `404`, never a cross-tenant leak.

        **Scope:** `read`.
      parameters:
        - name: payout_id
          in: path
          required: true
          schema:
            type: string
            pattern: '^tpo_[a-zA-Z0-9]+$'
      responses:
        '200':
          description: Payout details
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TenantPayout'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /sandbox/payout-outcome:
    put:
      tags: [Sandbox]
      operationId: setSandboxPayoutOutcome
      summary: Set sandbox payout scenario for the next payout
      description: |
        Configures the tenant's next sandbox payout outcome
        (`paid` | `delayed` | `failed_before_send` | `returned`). The value is
        snapshotted onto the payout row at `CREATED`. Moves no money.

        **Scope:** `write`. Sandbox API keys only.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [payout_outcome]
              properties:
                payout_outcome:
                  type: string
                  enum: [paid, delayed, failed_before_send, returned]
      responses:
        '200':
          description: Scenario stored
          content:
            application/json:
              schema:
                type: object
                required: [payout_outcome]
                properties:
                  payout_outcome:
                    type: string
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/Unprocessable'

  /sandbox/webhooks/{event_id}/redeliver:
    post:
      tags: [Sandbox]
      operationId: redeliverSandboxWebhook
      summary: Re-deliver an already-emitted sandbox webhook
      description: |
        Re-queues an already-delivered event (same `event_id`, same body, fresh
        signature) so the tenant can exercise duplicate handling. Moves no money
        state.

        **Scope:** `write`. Sandbox API keys only.
      parameters:
        - name: event_id
          in: path
          required: true
          schema:
            type: string
      responses:
        '202':
          description: Queued for redelivery
          content:
            application/json:
              schema:
                type: object
                required: [event_id, status]
                properties:
                  event_id:
                    type: string
                  status:
                    type: string
                    enum: [queued]
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /end-users:
    post:
      tags: [End Users]
      operationId: createEndUser
      summary: Create end user
      description: |
        Registers a natural person the tenant acts for and returns their
        platform `id`. Every tenant may model end users; nothing about this
        resource is specific to any one product.

        **Scope:** `end_users:write`.

        **Creation authorises nothing.** A new end user starts at
        `account_status: created`, `verification_status: unverified`,
        `transact_authorization: not_authorized`. No money path is open until
        `transact_authorization` is `authorized`.

        **Safe to retry.** `external_id` is your own identifier for the person
        and is unique within your tenant. Re-posting the same `external_id` with
        an identical body returns the existing end user with `200` instead of
        creating a second record; re-posting it with a different body is
        `409 end_user_already_exists`. A create can therefore never fork one
        person into two records under retry.

        **Screened before creation.** When a name is supplied, the person's
        identity (name, country, phone) is screened (sanctions, AML/fraud
        rules) before the account exists. A block or review outcome is
        `422 compliance_blocked` and no account is created. A transport or
        dependency fault is `503 screening_unavailable`, and nothing is created
        either. With no name (sign-up with phone and country only)
        there is nothing to screen against: the account is created
        unscreened and **cannot be authorized to transact** until a profile
        update supplies a name that clears screening.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateEndUserRequest'
      responses:
        '201':
          description: End user created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EndUser'
        '200':
          description: Existing end user returned — same `external_id`, same body
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EndUser'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/Unprocessable'
        '503':
          $ref: '#/components/responses/ScreeningUnavailable'

  /end-users/{end_user_id}:
    get:
      tags: [End Users]
      operationId: getEndUser
      summary: Get end user
      description: |
        Returns the profile and the three independent state fields.

        **Scope:** `end_users:read`.

        This is the authoritative read for what an end user may do. A client
        that cached an earlier state, or that took a verification vendor's word
        for an outcome, is holding UI state — not authorization.
      parameters:
        - $ref: '#/components/parameters/EndUserId'
      responses:
        '200':
          description: End user
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EndUser'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

    patch:
      tags: [End Users]
      operationId: updateEndUser
      summary: Update end user profile
      description: |
        Updates profile attributes. Omitted fields are left unchanged; an
        explicit `null` is rejected with `422 validation_error` rather than
        silently clearing a field.

        **Scope:** `end_users:write`.

        **The three state fields are not writable here.** `account_status`,
        `verification_status` and `transact_authorization` are outcomes, not
        inputs: they move only through `POST /end-users/{end_user_id}/suspend`
        and through verification and compliance decisions. Sending one returns
        `422 immutable_field` naming the field. A partner cannot promote its own
        end user to `verified`, which is the point.

        Changing a name or date of birth on a `verified` end user does not
        invalidate the verification record (that record is evidence of what was
        checked and when); it may trigger re-screening, which surfaces as a
        change to `transact_authorization`, never as an edit to
        `verification_status`.

        **Screened before it is written.** A change to the first name, last
        name or phone is screened before it is saved, exactly as at creation:
        `422 compliance_blocked` or `503 screening_unavailable` leave the end
        user unchanged. A clearing result on a verified end user that is not
        yet authorized opens `transact_authorization`. A change that cannot be
        screened (no name) is saved but moves an `authorized` end user back to
        `not_authorized` until a named profile clears.
      parameters:
        - $ref: '#/components/parameters/EndUserId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateEndUserRequest'
      responses:
        '200':
          description: Updated end user
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EndUser'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/Unprocessable'
        '503':
          $ref: '#/components/responses/ScreeningUnavailable'

  /end-users/{end_user_id}/suspend:
    post:
      tags: [End Users]
      operationId: suspendEndUser
      summary: Suspend an end user and revoke their authorization to transact
      description: |
        The named shorthand for the one transition partners reach for most:
        equivalent to `POST /end-users/{end_user_id}/state-transitions` with
        `account_status: suspended`, **and** `transact_authorization: revoked`
        **only where authorization is currently `authorized`**. There is one
        state machine, not two — this route is a convenience over it, and the
        same audit record is written. Use the state-transition route for
        anything else, including restoring access.

        **Why authorization is not revoked unconditionally.** `revoked` means
        *withdrawn after having been granted*. Writing it for an end user who
        was never authorized would record a withdrawal of something never held
        — a false entry on a retained audit record, and it would destroy the
        distinction between `not_authorized` and `revoked` that makes the field
        worth reading. A `not_authorized` end user is therefore left
        `not_authorized` by this route; only `account_status` moves.

        **Scope:** `end_users:write`.

        **`verification_status` is deliberately left untouched.** Revoking
        access and falsifying an identity record are different acts, and only
        one of them is legal. The verification record is the evidence a
        regulator asks for — *who was checked, against what, when, with what
        outcome* — so it is never rewritten to express a present-day access
        decision. That is what the separate, independently revocable
        `transact_authorization` field exists for.

        `reason` and `actor` are required: a suspension with no recorded cause
        and no recorded initiator is not an audit trail.

        Idempotent: suspending an already-suspended end user returns `200` with
        the same state, not a conflict. In-flight payments are unaffected by
        this call; the block applies to new payment creation.
      parameters:
        - $ref: '#/components/parameters/EndUserId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SuspendEndUserRequest'
      responses:
        '200':
          description: End user suspended
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EndUser'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/Unprocessable'

  /end-users/{end_user_id}/due-diligence:
    post:
      tags: [End Users]
      operationId: submitEndUserDueDiligence
      summary: Submit the end user's customer due diligence (CDD) profile
      description: |
        Records the customer due diligence information the AML/ATF program
        requires before a consumer may send:
        residential address, occupation, expected activity (purpose, monthly
        frequency and value, destination countries), source of funds and a
        PEP/HIO self-declaration.

        **Scope:** `end_users:write`.

        **Versioned, never edited.** Each call records a new version; the newest
        is the current profile. Earlier versions are retained as the record of
        what was collected and when. Re-submit whenever the customer's
        information changes.

        **Accepted codes are compliance configuration.** `occupation_category`,
        `expected_activity.purpose`, `monthly_count`, `monthly_value`,
        `source_of_funds` and `pep_hio_declaration` take lower-case codes from
        the platform's compliance configuration. A code it does not accept is
        `422 validation_error` on that field. The codes in force at launch are
        listed on each property.

        **Required before sending.** `POST /payments` naming an end user with no
        profile — or with a code compliance has since retired — is
        `422 end_user_cdd_required`. The platform rates the customer from this
        profile; the rating is an internal compliance record and is not
        returned. A customer the platform cannot serve without further review is
        refused at payment creation with `422 end_user_review_required`, and a
        payment above a limit that applies to the customer with
        `422 end_user_limit_exceeded`; neither discloses a rating or a limit.
        Customer-facing wording for these refusals must not suggest a rating or
        an investigation.
      parameters:
        - $ref: '#/components/parameters/EndUserId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CddProfileRequest'
      responses:
        '201':
          description: Profile recorded — the new current profile
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CddProfile'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/Unprocessable'
    get:
      tags: [End Users]
      operationId: getEndUserDueDiligence
      summary: Read the end user's current CDD profile
      description: |
        The newest submitted profile. `404 cdd_profile_not_found` when none has
        been submitted; `404 end_user_not_found` for an id that is not one of
        your end users.

        **Scope:** `end_users:read`.
      parameters:
        - $ref: '#/components/parameters/EndUserId'
      responses:
        '200':
          description: The current CDD profile
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CddProfile'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /end-users/{end_user_id}/state-transitions:
    post:
      tags: [End Users]
      operationId: transitionEndUserState
      summary: Move an end user's account status or authorization to transact
      description: |
        The single, **bidirectional** operation over the two settable states.
        Set `account_status`, `transact_authorization`, or both in one atomic
        transition; either can move in either direction, so there is no one-way
        door and no separate "unsuspend" verb to keep in sync.

        **Scope:** `end_users:write`.

        **`verification_status` is not settable here, in either direction.**
        Restoring authorization does not and cannot mark anyone verified, just
        as revoking it does not unmark them: that field records what was
        checked, and it is evidence, not a switch. Sending it returns
        `422 immutable_field`. Restoring `transact_authorization: authorized`
        may still be refused when the prerequisites are not met —
        `422 verification_required`, or `422 identity_screening_required` when
        the end user's current name and phone have not cleared screening —
        and a refusal leaves every state exactly as it was.

        **The trail runs both ways.** `reason` and `actor` are required on every
        transition, restoration included. A revocation with a recorded cause and
        a restoration without one is a hole in the trail precisely where the
        interesting question is asked — *who let them back on, and on what
        basis*. Neither value is ever returned to a client or shown to an end
        user; they are written to the audit record.

        **Permitted moves.** `account_status`: `created` ↔ `suspended`.
        `closed` is terminal — there is no transition out of it, and attempting
        one is `409 invalid_state_transition`.
        `transact_authorization`: any of `authorized`, `not_authorized`,
        `revoked` to any other. A transition to the state an end user is already
        in is idempotent and returns `200`.
      parameters:
        - $ref: '#/components/parameters/EndUserId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EndUserStateTransitionRequest'
      responses:
        '200':
          description: Transition applied — the resulting end user
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EndUser'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/Unprocessable'

  /end-users/{end_user_id}/verifications:
    post:
      tags: [Verification]
      operationId: createVerification
      summary: Start an identity verification
      description: |
        Opens a verification and returns a **server-issued, single-use,
        expiring handoff** the client can hand to the verification flow.

        **Scope:** `verifications:write`.

        **The client never holds a vendor credential.** `handoff.reference` (and
        `handoff.url`, when the flow is hosted) are single-use and expire at
        `handoff.expires_at`. They are capabilities, not identifiers: do not log
        them, do not persist them, do not put them in a crash report or an
        analytics event, and do not embed them in a durable deep link.

        **Anything the verification flow hands back to the client is untrusted
        UI state.** It is fine to use it to move a screen along. It is never
        authority. Re-read `GET /end-users/{end_user_id}/verifications/{verification_id}`
        for the decision and tier, and `GET /end-users/{end_user_id}` for
        whether the end user may now transact. Those two reads are the only
        authoritative answers.

        Starting a second verification while one is still open returns
        `409 verification_in_progress` with the open `verification_id`.
      parameters:
        - $ref: '#/components/parameters/EndUserId'
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateVerificationRequest'
      responses:
        '201':
          description: Verification started
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Verification'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/Unprocessable'
        '503':
          description: >-
            `identity_verification_unavailable`: the identity-verification
            service is unavailable or did not return a session. Nothing is
            started; retry later.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /end-users/{end_user_id}/verifications/{verification_id}:
    get:
      tags: [Verification]
      operationId: getVerification
      summary: Get verification status
      description: |
        Returns the authoritative status, decision and tier for one
        verification. Poll this (or wait for your own notification) rather than
        trusting anything the verification flow returned to the device.

        **Scope:** `verifications:write`.

        A `declined` decision carries no reason beyond the decision itself. The
        platform does not return the rule, the vendor signal or the list that
        produced it — naming it would tell a bad actor exactly what to change.
        Where a re-attempt is possible,
        `GET /end-users/{end_user_id}/verification-requirements` says what is
        still outstanding.
      parameters:
        - $ref: '#/components/parameters/EndUserId'
        - $ref: '#/components/parameters/VerificationId'
      responses:
        '200':
          description: Verification
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Verification'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /end-users/{end_user_id}/verification-decisions:
    post:
      tags: [Verification]
      operationId: recordVerificationDecision
      summary: Record the tenant's own verification decision
      description: |
        For a tenant that runs its own identity check: records the outcome
        against the end user, with an audit-trail entry whose actor is your
        partner identity and whose reason carries `decision` and `reference`.
        Returns the updated end user.

        **Scope:** `verifications:write`.

        - `approved` — sets `verification_status: verified` and the tier
          reached, and, when the end user is `account_status: created` and
          `transact_authorization: not_authorized`, authorizes it to transact.
          This is what opens the send gate on `POST /payments`.
        - `declined` — sets `verification_status: failed`, unless already
          `verified` (a decline never revokes a verified end user; use
          `POST /end-users/{end_user_id}/state-transitions`).
        - `review` — sets `verification_status: pending`, unless already
          `verified`.
        - `expired` — moves `pending` back to `unverified`; otherwise no change.

        Repeating a decision is idempotent. `decision` outside the four values,
        or an empty `reference`, is `422 validation_error`.
      parameters:
        - $ref: '#/components/parameters/EndUserId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/VerificationDecisionRequest'
      responses:
        '200':
          description: The updated end user
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EndUser'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/Unprocessable'

  /end-users/{end_user_id}/verification-requirements:
    get:
      tags: [Verification]
      operationId: getVerificationRequirements
      summary: Get the outstanding verification requirements
      description: |
        Returns the **ordered, backend-authored** list of steps this end user
        must complete, with the per-step field validation to apply. Render the
        steps in array order.

        **Scope:** `verifications:write`.

        **No tier→limit table is ever returned, and none may be held on a
        device.** Steps carry stable `key`s, not display copy: the tenant owns
        its own wording and localisation and looks it up by `key`, so changing a
        rule here never ships a string to an app store. Eligibility is not
        computed on a phone — client-side validation of these fields is a
        courtesy to the user, and the platform re-validates everything and
        remains the only authority on what an end user may do.

        The list is a point-in-time answer (`generated_at`); re-read it after
        every submission rather than caching it across a session.
      parameters:
        - $ref: '#/components/parameters/EndUserId'
      responses:
        '200':
          description: Verification requirements
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VerificationRequirements'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /end-users/{end_user_id}/recipients:
    get:
      tags: [Recipients]
      operationId: listRecipients
      summary: List an end user's recipients
      description: |
        Returns the end user's saved beneficiaries, newest first.

        **Scope:** `recipients:read`.

        Destination MSISDNs (`Recipient.phone`) are returned in full on every
        read — the recipient is the end user's own saved contact, and the
        number is not masked.
      parameters:
        - $ref: '#/components/parameters/EndUserId'
        - name: status
          in: query
          required: false
          description: >-
            Return only recipients in these statuses. Repeat to match several.
          style: form
          explode: true
          schema:
            type: array
            maxItems: 3
            items:
              $ref: '#/components/schemas/RecipientStatus'
      responses:
        '200':
          description: Recipients
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RecipientList'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

    post:
      tags: [Recipients]
      operationId: createRecipient
      summary: Save a recipient
      description: |
        Saves a beneficiary to the end user's book and submits it for screening.

        **Scope:** `recipients:write`.

        **The beneficiary is screened at save, before it is stored.**
        Screening happens at save, not at send, so a user is not told at the
        moment of payment that their recipient is unusable. The recipient is
        stored once, with its decision: `approved`, or `rejected` (no
        explanation is given). A recipient is never usable before a decision:
        if the screening dependency does not answer, nothing is saved and the
        response is `503 screening_unavailable` — retry the save. Treat any
        `status` other than `approved` as not selectable, including
        `pending_screening`, which a recipient can still hold. Referencing a
        recipient that is not `approved` on a payment is
        `422 recipient_not_approved`, with no screening detail attached.

        **Stable beneficiary key.** The platform derives `beneficiary_key` from
        exactly three inputs — name, destination MSISDN and destination country
        — because 24-hour aggregation and velocity rules are keyed on the
        beneficiary, and a phone string alone is not a stable enough identity.
        Saving a beneficiary whose key already exists in this end user's book
        returns `409 duplicate_recipient`.
      parameters:
        - $ref: '#/components/parameters/EndUserId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateRecipientRequest'
      responses:
        '201':
          description: Recipient saved with its screening decision
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Recipient'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/Unprocessable'
        '503':
          $ref: '#/components/responses/ScreeningUnavailable'

  /end-users/{end_user_id}/recipients/{recipient_id}:
    get:
      tags: [Recipients]
      operationId: getRecipient
      summary: Get a recipient
      description: |
        Returns one saved beneficiary, with the destination MSISDN masked.

        **Scope:** `recipients:read`.
      parameters:
        - $ref: '#/components/parameters/EndUserId'
        - $ref: '#/components/parameters/RecipientId'
      responses:
        '200':
          description: Recipient
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Recipient'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

    patch:
      tags: [Recipients]
      operationId: updateRecipient
      summary: Update a recipient
      description: |
        Updates a saved beneficiary. Omitted fields are unchanged; an explicit
        `null` is `422 validation_error`.

        **Scope:** `recipients:write`.

        **Editing any component of the beneficiary key — name, MSISDN or
        country — produces a new `beneficiary_key`, resets `status` to
        `pending_screening`, and re-screens.** The new details are screened
        before they are written, so the response carries the new decision; if
        the screening dependency does not answer, nothing changes and the
        response is `503 screening_unavailable`. The recipient is never usable
        on details that have not been screened. Editing only `nickname` does
        not change the key and does not re-screen: a label is not an identity,
        and re-screening on a rename would be noise that trains people to ignore
        the state.
      parameters:
        - $ref: '#/components/parameters/EndUserId'
        - $ref: '#/components/parameters/RecipientId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateRecipientRequest'
      responses:
        '200':
          description: Updated recipient
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Recipient'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/Unprocessable'
        '503':
          $ref: '#/components/responses/ScreeningUnavailable'

    delete:
      tags: [Recipients]
      operationId: deleteRecipient
      summary: Remove a recipient
      description: |
        Removes the beneficiary from the end user's book. Idempotent: deleting
        an already-deleted recipient returns `204`.

        **Scope:** `recipients:write`.

        This removes the saved entry only. Payments already made to this
        beneficiary, and the screening record behind them, are retained — they
        are transaction and compliance records with their own retention rules,
        and a user removing an address-book entry does not and cannot delete
        them.
      parameters:
        - $ref: '#/components/parameters/EndUserId'
        - $ref: '#/components/parameters/RecipientId'
      responses:
        '204':
          description: Recipient removed
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /end-users/{end_user_id}/devices:
    post:
      tags: [Devices]
      operationId: registerDevice
      x-moja-status: planned
      summary: '[Planned] Register a device for notifications'
      description: |
        Registers a device so the platform can notify this end user.

        **Scope:** `end_users:write`.

        **Provider-agnostic by construction.** `push_token` is an opaque string;
        this contract names no push provider and carries no provider-specific
        field, so changing or adding a delivery provider is not a contract
        change. `platform` describes the device class, not a transport.

        **The token is write-only.** It is never returned by any endpoint, never
        echoed in an error, and never logged. Re-registering the same token for
        the same end user returns the existing registration (`200`) rather than
        accumulating duplicates.

        **What a notification may contain.** A delivered notification carries an
        **opaque reference only** — no name, no amount, no currency, no MSISDN,
        no corridor, no status text. A lock screen is a public surface and push
        payloads traverse a third party. The app receives the reference and
        fetches the detail over this authenticated API, which is also what keeps
        the notification honest when the underlying state has since changed.
      parameters:
        - $ref: '#/components/parameters/EndUserId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RegisterDeviceRequest'
      responses:
        '201':
          description: Device registered
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Device'
        '200':
          description: Device already registered — existing registration returned
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Device'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/Unprocessable'

  /end-users/{end_user_id}/devices/{device_id}:
    delete:
      tags: [Devices]
      operationId: deleteDevice
      x-moja-status: planned
      summary: '[Planned] Deregister a device'
      description: |
        Removes a device registration — on sign-out, on token rotation, or on
        "log out everywhere". Idempotent: `204` whether or not it was present.

        **Scope:** `end_users:write`.
      parameters:
        - $ref: '#/components/parameters/EndUserId'
        - $ref: '#/components/parameters/DeviceId'
      responses:
        '204':
          description: Device deregistered
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /end-users/{end_user_id}/limits:
    get:
      tags: [End Users]
      operationId: getEndUserLimits
      x-moja-status: planned
      summary: '[Planned] Get this end user''s remaining allowance'
      description: |
        🔲 **Contract published, not yet implemented.**

        Returns what **this end user** may still send on a corridor — not the
        corridor's maxima, which is what `GET /corridors/{corridor_code}`
        returns and is a different question. Without this, a client cannot say
        "you can send $120 more today", and cannot explain a limit denial other
        than by restating it.

        **Scope:** `end_users:read`.

        **Read it; do not reimplement it.** The remaining figure depends on
        aggregation the client cannot see — a 24-hour window across every
        payment, keyed on the end user, including payments in flight. A client
        that subtracts its own visible history will be wrong, and it will be
        wrong in the permissive direction. The platform remains the sole
        authority: a client renders these numbers and a denial, and never
        pre-decides one. Treat the response as point-in-time and re-read it
        before a send rather than caching it.

        `next_tier` names the tier that would raise these limits and points at
        the verification requirements to reach it, so a denial can tell a
        customer what to do next instead of only what they cannot do. It carries
        no limit values for that tier — those are a compliance determination and
        are returned only once the tier is actually reached.
      parameters:
        - $ref: '#/components/parameters/EndUserId'
        - name: corridor
          in: query
          required: false
          description: >-
            Limit the answer to one corridor. Omit to get every corridor this
            end user's tenant is entitled to.
          schema:
            $ref: '#/components/schemas/CorridorCode'
      responses:
        '200':
          description: Remaining allowances
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EndUserLimits'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /end-users/{end_user_id}/support-cases:
    get:
      tags: [Support]
      operationId: listSupportCases
      x-moja-status: planned
      summary: '[Planned] List an end user''s support cases'
      description: |
        🔲 **Contract published, not yet implemented.**

        Returns this end user's cases, newest first, so a customer can see what
        they have already raised. A support channel a customer can write to but
        not read from produces duplicate cases and a customer who believes they
        were ignored.

        **Scope:** `end_users:write`.
      parameters:
        - $ref: '#/components/parameters/EndUserId'
        - name: status
          in: query
          required: false
          description: Return only cases in these statuses. Repeat to match several.
          style: form
          explode: true
          schema:
            type: array
            maxItems: 4
            items:
              $ref: '#/components/schemas/SupportCaseStatus'
      responses:
        '200':
          description: Support cases
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SupportCaseList'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

    post:
      tags: [Support]
      operationId: createSupportCase
      x-moja-status: planned
      summary: '[Planned] Open a support case'
      description: |
        Creates a durable support case for an end user and returns its
        reference. Intended for in-app intake, and durable so that a case
        survives the app session it was raised in.

        **Scope:** `end_users:write`.

        `message` is stored verbatim as part of the case record. Do not place
        credentials, card numbers, document images or API keys in it, and do not
        prompt a user for them in the surface that feeds it — a support case is
        read by humans and retained.
      parameters:
        - $ref: '#/components/parameters/EndUserId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateSupportCaseRequest'
      responses:
        '201':
          description: Support case opened
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SupportCase'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/Unprocessable'

  /signup:
    post:
      tags: [Account]
      operationId: selfServeSignup
      summary: Self-serve sandbox partner signup
      security: []
      description: |
        Provisions instant **sandbox** access for a new partner and returns a
        one-time `sk_sandbox_*` API key. No compliance step — sandbox moves no
        real money. The new partner account has `status: SANDBOX` and is not
        ready for production. This operation can never issue a production key
        or a live-capable account.

        **Public by design.** There is no API key before signup, so this
        operation needs none. It is rate-limited per client IP address.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SelfServeSignupRequest'
      responses:
        '201':
          description: Sandbox partner provisioned; API key secret shown once
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SelfServeSignupResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '429':
          $ref: '#/components/responses/RateLimited'
        '502':
          description: Signup is temporarily unavailable; retry later

  /account/readiness:
    get:
      tags: [Account]
      operationId: getAccountReadiness
      summary: This partner's own production go-live readiness
      description: |
        The partner's composite **production** go-live projection — the same rows
        the money-path gate reasons from, so `can_send_live` here always matches
        whether a live payment would actually clear the gate.

        **Tenant-scoped by construction.** The partner is taken from the
        authenticated API key, never from a path or body field.

        **Corridor capability is evaluated for `CA-TZ` today** — the
        `capability` requirement is met when the partner holds an
        enabled entitlement to `CA-TZ`. 🔲 **Planned:** a corridor-agnostic
        default (any enabled catalogue corridor) and the `corridor` query
        parameter to ask about one corridor; today the parameter is ignored.

        `requirements[].blocking` marks the enforced dimensions — Compliance,
        Funding, corridor capability — whose failure prevents go-live. Treasury
        and commercial are carried for transparency and never block.

        If readiness cannot be read, the answer is `500 internal_error`, never
        an account reported as ready by mistake. Sandbox traffic is not gated, because it moves
        no real money.

        No corridor entitlement is required to call it: it reports your
        entitlement rather than using one.

        **Scope:** `read`.
      parameters:
        - name: corridor
          in: query
          required: false
          x-moja-status: planned
          description: >-
            🔲 Planned — ignored today (capability is evaluated for `CA-TZ`).
            Will ask about one corridor rather than any.
          schema:
            $ref: '#/components/schemas/CorridorCode'
      responses:
        '200':
          description: Go-live readiness
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AccountReadiness'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '500':
          description: Readiness could not be read (`internal_error`). Your account is not reported as ready; retry later.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /client-config:
    get:
      tags: [Corridors]
      operationId: getClientConfig
      x-moja-status: planned
      summary: '[Planned] Operating posture for a client application'
      description: |
        What a client application must know before it lets anyone move money:
        whether this build is still supported, and whether money operations are
        currently enabled at all.

        **Not end-user scoped, and answerable before anyone signs in.** A client
        below the minimum version must be able to learn that on launch, and a
        money-operations stop must reach a client whose user never authenticates.

        **This is the client half of the compliance kill switch.** The platform
        can already refuse a payment; without this, a stop leaves every deployed
        app still offering to send money and discovering the refusal one request
        at a time. `money_operations_enabled: false` means the client SHALL hide
        or disable every send affordance while **leaving read and support
        surfaces reachable** — a customer whose transfer is in flight still needs
        to see it and still needs to reach support, and cutting them off is worse
        than the condition that triggered the stop.

        Clients SHOULD read this on launch and on foreground, and SHALL NOT cache
        it past `max_age_seconds`. It is deliberately cheap to serve.

        🔲 **Planned — not implemented.** Calling it today returns `404`. The
        "before anyone signs in" wording refers to end-user sign-in in a client
        app: the call itself still requires a partner API key with scope
        `read`.

        **Scope:** `read`.
      responses:
        '200':
          description: Client operating posture
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ClientConfig'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /corridors:
    get:
      tags: [Corridors]
      operationId: listCorridors
      x-moja-status: planned
      summary: '[Planned] List the corridors this partner may use'
      description: |
        Returns the corridors the authenticated partner is entitled to, as data.

        **Scope:** `read`.

        **Discover, do not hardcode.** A client that ships a corridor list, a
        currency list or a decimal assumption has to be re-released to gain a
        corridor; one that renders this response does not. The returned set is
        per-partner entitlement, so two partners can legitimately see different
        lists, and a corridor absent here is refused on the money routes with
        `403 corridor_not_entitled`.

        The transacting routes type `corridor` as the same pattern-constrained
        string (see the `Corridor` schema), so this endpoint — not the type — is
        what says which corridors a tenant may actually use. Treat
        `CorridorCode` as an opaque server-supplied string, so that widening the
        set is an entitlement change rather than a client release.
      responses:
        '200':
          description: Corridors available to this partner
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CorridorList'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /corridors/{corridor_code}:
    get:
      tags: [Corridors]
      operationId: getCorridor
      summary: Get corridor detail
      description: |
        Returns what a client needs to build and validate a payment for one
        enabled catalogue corridor: payout methods, the limits in force, and the
        **minor-unit exponent of both currencies**. Not entitlement-scoped (an
        unknown or disabled corridor is `404 corridor_not_found`); per-method
        beneficiary field specs are planned.

        **Scope:** `read`.

        **The minor-unit exponent is load-bearing — never assume two decimals.**
        `send_currency.minor_unit_exponent` and
        `receive_currency.minor_unit_exponent` give the number of fractional
        digits that currency has. It is `2` for CAD and TZS; it is `0` for a
        zero-decimal currency, where `1500` means one thousand five hundred
        units and `15.00` is not a valid amount at all. Formatting,
        rounding, input masks, "cents" arithmetic and any equality check must
        read the exponent. A client that hardcodes two decimals will be wrong by
        a factor of one hundred the first time it meets a zero-decimal currency,
        and it will be wrong quietly.

        **Limits are server-supplied and this response is the only place to read
        them.** Do not cache them past a session, do not re-implement them, and
        do not use them to decide anything — render them, and let the platform
        deny. The platform is the sole authority on limits and its denial is the
        answer, not a second opinion.
      parameters:
        - $ref: '#/components/parameters/CorridorCode'
      responses:
        '200':
          description: Corridor detail
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CorridorDetail'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /fx/reference:
    get:
      tags: [FX]
      operationId: getFXReference
      summary: Get the indicative FX reference rate for a corridor
      description: |
        The validated **indicative** market reference rate for an enabled
        corridor, priced as its send→receive pair: receive-currency
        units per one send-currency unit, a decimal string with at most 8
        fractional digits. **Never a customer rate**: no spread, no quote, no
        lock — do not show it as the rate a customer will get. To price a
        payment, use `POST /quotes`.

        **Scope:** `read`. You must also be entitled to `corridor` (`403 corridor_not_entitled` otherwise;
        `503 entitlement_unavailable` if the entitlement cannot be checked).

        If there is no valid, fresh rate, the answer is `503 fx_unavailable`;
        a stale or older rate is never returned instead.
      parameters:
        - name: corridor
          in: query
          required: true
          schema:
            $ref: '#/components/schemas/CorridorCode'
      responses:
        '200':
          description: Indicative reference rate
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FXReference'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '503':
          $ref: '#/components/responses/FXUnavailable'

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: API Key
      description: |
        Partner API key (`sk_live_*` or `sk_sandbox_*`).

        **Scopes are deny-by-default.** A key that lacks the scope a route
        requires gets `403 insufficient_scope`; a route that is not mapped to a
        scope is denied outright. Every operation below names the scope it
        requires in its description (an HTTP-bearer security scheme cannot carry
        scopes structurally the way an OAuth2 one can, so they are documented
        rather than machine-declared).

        | Scope | Grants |
        |---|---|
        | `read` | `GET /payments/{payment_id}`, `GET /payments`, `GET /payments/{payment_id}/receipt`, `GET /payouts`, `GET /payouts/{payout_id}`, `GET /fx/reference`, `GET /corridors/{corridor_code}`, `GET /account/readiness`; and, when they ship, `GET /corridors` and `GET /client-config` (planned) |
        | `write` | `POST /quotes`, `POST /payments`; sandbox only: `PUT /sandbox/payout-outcome`, `POST /sandbox/webhooks/{event_id}/redeliver` |
        | `cancel` | `POST /payments/{payment_id}/cancel` |
        | `end_users:read` | Read an end user and their due-diligence profile (and their devices, planned) |
        | `end_users:write` | Create, update and suspend an end user; submit due diligence; move account state (and, planned: register and delete devices, open a support case) |
        | `verifications:write` | Start a verification, read its status, and read the requirement list |
        | `recipients:read` | List and read recipients |
        | `recipients:write` | Create, update and delete recipients |

        The `end_users:*`, `verifications:*` and `recipients:*` scopes are new
        and are **not** granted implicitly. A key issued before they existed
        carries none of them, and every end-user route returns
        `403 insufficient_scope` until the scope is granted to that key.

  parameters:
    PaymentId:
      name: payment_id
      in: path
      required: true
      schema:
        type: string
        pattern: '^pay_[a-zA-Z0-9]+$'
        example: pay_abc123def456
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: >-
        UUIDv4, one per payment attempt, persisted by the client BEFORE the
        first attempt and reused on every retry of that attempt. Must be a
        UUID (else `400 invalid_request`). The key is namespaced
        `(partner_id, key)`; `end_user_id` does not narrow it. See the
        operation description for the replay rules.
      schema:
        type: string
        format: uuid
        example: 550e8400-e29b-41d4-a716-446655440000
    EndUserId:
      name: end_user_id
      in: path
      required: true
      schema:
        type: string
        pattern: '^eu_[a-zA-Z0-9]+$'
        example: eu_7Qm4xK2pLd
    VerificationId:
      name: verification_id
      in: path
      required: true
      schema:
        type: string
        pattern: '^ver_[a-zA-Z0-9]+$'
        example: ver_3Hb8tR1sVw
    RecipientId:
      name: recipient_id
      in: path
      required: true
      schema:
        type: string
        pattern: '^rcp_[a-zA-Z0-9]+$'
        example: rcp_9Zd5nP6yTc
    DeviceId:
      name: device_id
      in: path
      required: true
      schema:
        type: string
        pattern: '^dev_[a-zA-Z0-9]+$'
        example: dev_2Fk7wQ4mNb
    CorridorCode:
      name: corridor_code
      in: path
      required: true
      description: >-
        Corridor code as returned by `GET /corridors`. Treat it as an opaque
        server-supplied string, not a value your client enumerates.
      schema:
        $ref: '#/components/schemas/CorridorCode'

  schemas:
    Money:
      type: object
      description: >-
        Decimal-string amount, used by the original partner operations
        (`POST /quotes`, `POST /payments`, `GET /payments/{payment_id}`). Its
        `value` pattern caps the world at two decimal places, which is why the
        newer end-user operations use `MinorUnitAmount` instead. Unchanged here:
        swapping the representation on an existing operation would be a
        breaking `/v1` change.
      required: [value, currency]
      properties:
        value:
          type: string
          pattern: '^\d+(\.\d{1,2})?$'
          example: '100.00'
        currency:
          type: string
          pattern: '^[A-Z]{3}$'
          example: CAD

    MinorUnitAmount:
      type: object
      description: |
        An amount in **integer minor units**, with the currency and its exponent
        carried alongside it. Used by the end-user operations.

        **Why not a decimal string.** A decimal string makes an impossible
        amount representable. `Money` accepts `1500.50` in a zero-decimal
        currency — and there is no such amount, because that currency has no
        minor unit at all. A client that sends
        it, a service that stores it and a reviewer reading it will each assume
        someone else rejected it. This shape makes that unrepresentable rather
        than merely discouraged: `amount` is an integer, so there is nowhere to
        put the fractional part.

        **How to read it.** `amount` is in minor units and
        `minor_unit_exponent` says how many fractional digits the currency has,
        so the major-unit value is `amount / 10^minor_unit_exponent`:
        - `{"amount": 46256250, "currency": "TZS", "minor_unit_exponent": 2}` is
          **462,562.50 shillings** — not 46,256,250.
        - `{"amount": 10000, "currency": "CAD", "minor_unit_exponent": 2}` is
          **100.00 dollars**.

        **The exponent is server-supplied and is never assumed.** Read it from
        the amount you were given; do not hardcode `2`, and do not infer it from
        the currency code with a table of your own that will drift. It is
        repeated on every amount deliberately, so that an amount is
        self-describing even when it is logged, forwarded or stored away from
        the corridor metadata it came from.

        **A client must never compute, scale, convert or re-derive an amount.**
        Render the server's `amount` with the server's `minor_unit_exponent`,
        and nothing else. Specifically, do not multiply a minor-unit integer by
        a rate, do not convert between currencies, do not re-scale an amount
        whose exponent differs from the one you expected, and do not
        recalculate a total from parts. Ask the server for the figure you want
        to show.

        This is not stylistic. Rate arithmetic that ignores the exponent is
        correct only while the two currencies happen to share one — and it fails
        by a **factor of 100** the first time CAD (exponent 2) meets a
        zero-decimal currency (exponent 0), silently, producing an amount that is plausible on screen
        and wrong by two orders of magnitude. Do not convert amounts yourself.
        Cross-currency arithmetic is the server's job, and the server returns
        both sides of it.

        The original partner operations still use `Money` (a decimal string);
        moving them to this shape would be a breaking change and will follow
        the versioning rules in the API description.
      required: [amount, currency, minor_unit_exponent]
      properties:
        amount:
          type: integer
          format: int64
          minimum: 0
          description: >-
            The value in MINOR units of `currency` — cents for CAD and
            TZS; whole units for a zero-decimal currency. Never a major-unit value, and never fractional.
          example: 10000
        currency:
          type: string
          pattern: '^[A-Z]{3}$'
          description: ISO 4217 alphabetic code.
          example: CAD
        minor_unit_exponent:
          type: integer
          minimum: 0
          maximum: 4
          description: >-
            Number of fractional digits this currency has. `2` for CAD and TZS,
            `0` for a zero-decimal currency. Server-supplied; a
            client reads it rather than assuming it.
          example: 2

    FX:
      type: object
      description: |
        Client-provided FX (BYO-FX). The partner submits
        **both** fields; Moja executes the instruction and does not compute a
        customer-facing rate or apply an FX markup in production.

        - `receive_amount` is **AUTHORITATIVE for execution** — the exact integer
          minor-unit amount paid to the recipient, executed verbatim. Moja does
          **not** recompute the payout from `rate`.
        - `rate` is the **DECLARED BASIS** — recorded for audit and
          consistency-checked, but never used to recompute the payout.

        **Consistency rule:** the two must agree within one minor
        unit of the receive currency —
        `|receive_amount − round(send_amount × rate)| ≤ 1 minor unit`.
        A larger divergence → `422 fx_inconsistent`. Values that are not
        positive, not valid for the corridor, or too precise →
        `422 validation_error`.

        **Both fields are REQUIRED inline in production**, and in every environment on
        a corridor with no sandbox rate (a `quote_id` does not satisfy the FX
        requirement). Otherwise, off-production, the enclosing
        request MAY omit the whole `fx` object, in which case Moja derives an
        indicative rate as a **sandbox/dev convenience only** — never production
        pricing.
      required: [rate, receive_amount]
      properties:
        rate:
          type: string
          pattern: '^\d+(\.\d{1,8})?$'
          description: >-
            DECLARED BASIS. Client-supplied send→receive rate: receive units per
            1 send unit. Positive decimal string, up to 8 fractional digits.
            Recorded and consistency-checked, not used to recompute the payout.
          example: '1850.25'
        receive_amount:
          allOf:
            - $ref: '#/components/schemas/Money'
          description: >-
            AUTHORITATIVE for execution. The exact payout amount the partner
            instructs, in the receive currency (e.g. TZS); paid to the recipient
            verbatim.
        rate_id:
          type: string
          maxLength: 128
          description: >-
            OPTIONAL. The partner's OWN identifier for the FX rate they declare —
            their treasury/FX-provider rate id or deal id. Opaque, carry-through
            METADATA for audit + reconciliation only: it is recorded and echoed
            back but NEVER affects the locked rate, `receive_amount`, execution,
            the consistency check, or any pricing decision. Optional in both
            sandbox and production; its absence is always valid. When present it
            must be a printable string of at most 128 characters.
          example: dealer-quote-7f3a91

    PaymentFXProvenance:
      type: object
      description: |
        **FX provenance: which FX model priced this payment, and what
        Moja Switch applied.** Present whenever the tenant has an FX model
        configured. It answers two questions from a single record:

        1. *Was the FX Moja Switch's (internal) or the client's own (external)?* —
           `model`.
        2. *What margin did Moja Switch apply?* — `margin_bps` / `margin_minor` for
           Moja Switch FX; **nothing** for BYO-FX, because Moja Switch adds no
           margin to a client's own FX.

        For `MOJA_SWITCH_FX` Moja Switch itself obtained the reference rate and
        applied the tenant's commercial margin for the corridor; **every figure comes from the quote
        Moja Switch priced, not from the client**. For `BYO_FX` the client owns its
        FX lifecycle and only `model` is recorded — the margin, reference and rate
        fields are absent by construction (and forbidden by a database constraint).

        ⛔ **For your systems and Moja staff only — never render to an end user.**
        The end user is shown the amount to send and the all-in rate; the margin
        percentage is never disclosed to them.
      required: [model]
      properties:
        model:
          type: string
          enum: [MOJA_SWITCH_FX, BYO_FX]
          description: >-
            `MOJA_SWITCH_FX` = internal (Moja Switch priced it). `BYO_FX` =
            external (the client priced it).
        reference_rate:
          type: string
          description: >-
            The reference rate Moja Switch priced from (`MOJA_SWITCH_FX` only).
            Receive units per 1 send unit.
          example: '2754.41434878'
        margin_bps:
          type: integer
          description: >-
            The commercial margin Moja Switch applied, in basis points off the
            reference rate (200 = 2.00%) (`MOJA_SWITCH_FX` only).
          example: 200
        margin_minor:
          type: integer
          format: int64
          description: >-
            That margin as an **amount**, in the send currency's minor units,
            rounded **up** (never understated): what the sender paid for the
            conversion beyond the reference value of what the recipient received
            (`MOJA_SWITCH_FX` only). Includes the sub-unit remainder lost to
            rounding the receive amount down, so it can exceed the percentage of
            the send by a cent.
          example: 201
        margin_currency:
          type: string
          description: The currency of `margin_minor` (the send currency) (`MOJA_SWITCH_FX` only).
          example: CAD
        customer_rate:
          type: string
          description: >-
            The all-in rate the customer was quoted (reference less margin; the
            effective rate for a receive-anchored quote) (`MOJA_SWITCH_FX` only).
          example: '2699.3260618'
        reference:
          type: object
          description: >-
            Identifies the reference rate Moja Switch priced from
            (`MOJA_SWITCH_FX` only).
          required: [provider, rate_type, as_of]
          properties:
            provider:
              type: string
              example: currencyapi
            rate_type:
              type: string
              example: indicative
            as_of:
              type: string
              format: date-time
              description: When the reference rate was observed.

    CreateQuoteRequest:
      type: object
      required: [corridor, receive_currency, payout_method]
      description: >-
        Send **exactly one** of `send_amount` (what the sender pays, in the
        corridor's send currency) or `receive_amount` (what the recipient must
        get). `send_amount` is required unless `receive_amount` is sent;
        `receive_amount` is accepted only for a tenant on **Moja Switch FX**.
      properties:
        corridor:
          $ref: '#/components/schemas/Corridor'
        send_amount:
          $ref: '#/components/schemas/Money'
        receive_amount:
          allOf:
            - $ref: '#/components/schemas/Money'
          description: >-
            **Moja Switch FX only.** What the recipient must get, in the
            corridor's receive currency (whole units for a zero-decimal
            currency). The quote delivers exactly this amount and prices the
            smallest `send_amount` that covers it. Mutually exclusive with
            `send_amount`.
        receive_currency:
          type: string
          pattern: '^[A-Z]{3}$'
          description: >-
            ISO 4217 payout currency. **Was a closed enum (`TZS`) and is now a
            pattern-constrained code**, for the same reason `Corridor` is: the
            currency follows the corridor, and a corridor set that moves cannot
            have its currencies frozen in the contract. Read the valid value for
            a corridor from `GET /corridors/{corridor_code}`
            (`receive_currency.currency`) rather than assuming it, and read
            `minor_unit_exponent` with it — never assume two decimals.
          example: TZS
        payout_method:
          $ref: '#/components/schemas/PayoutMethod'
        end_user_id:
          type: string
          description: >-
            Accepted so a client can send the same identity on the quote and the
            payment; **not used to price** today (a quote is not
            end-user-specific).
          example: eu_7Qm4xK2pLd
        fx:
          allOf:
            - $ref: '#/components/schemas/FX'
          description: >-
            Client-provided FX (**BYO-FX** tenants only). REQUIRED in production for
            a BYO-FX tenant (else `422 fx_required`). **A Moja Switch FX tenant
            must omit it entirely** — an `fx` is `422 fx_not_accepted` (and a
            malformed one, `{}` or `null`, is `400 invalid_request`):
            Moja Switch prices that tenant's quotes.

    Quote:
      type: object
      required: [id, corridor, send_amount, receive_amount, fx_rate, fees, disclosure, expires_at, created_at]
      properties:
        id:
          type: string
          example: quote_xyz789
        corridor:
          $ref: '#/components/schemas/Corridor'
        send_amount:
          $ref: '#/components/schemas/Money'
        receive_amount:
          allOf:
            - $ref: '#/components/schemas/Money'
          description: >-
            The locked payout amount. Echoes the partner's `fx.receive_amount`
            when supplied, otherwise derived from `send_amount` × the partner's
            `fx.rate` (BYO-FX).
        fx_rate:
          type: string
          description: >-
            The partner's locked send→receive rate, echoed back (receive units
            per 1 send unit). Not a Moja-computed or marked-up rate. Off-production,
            when the partner omitted `fx`, this is the Moja-derived sandbox/dev
            rate (never production pricing).
          example: '1850.25'
        fees:
          $ref: '#/components/schemas/QuoteFees'
        pricing_mode:
          allOf:
            - $ref: '#/components/schemas/PricingMode'
          description: >-
            Who priced this quote. Absent on quotes issued before this field
            existed; treat an absent value as `client_provided`.
        fx_provenance:
          allOf:
            - $ref: '#/components/schemas/FXProvenance'
          description: >-
            Where the rate came from. Present only when `pricing_mode` is
            `platform_priced` — under BYO-FX the provenance of the rate is the
            partner's own record, not ours.
        disclosure:
          allOf:
            - $ref: '#/components/schemas/QuoteDisclosure'
          description: >-
            **Required.** The cost disclosure for this quote. Required so that a
            client cannot render a quote without it.
        expires_at:
          type: string
          format: date-time
          description: >-
            When the locked rate stops being valid. A `platform_priced` rate
            MUST NOT be displayed to an end customer after this instant;
            re-quote instead of extending it client-side.
        created_at:
          type: string
          format: date-time

    QuoteDisclosure:
      type: object
      description: |
        ✅ **Implemented for Moja Switch FX quotes** (`pricing_mode:
        platform_priced`). A BYO-FX quote (`client_provided`) does not
        yet carry it.

        What this quote costs the sender, as figures, in the send currency.

        **Why this is `required` and not optional.** A consumer-protection
        disclosure that a client can omit is a disclosure that a client will
        omit — under deadline, on the screen where it matters most. Making the
        field required moves "did anyone remember the total cost" from a review
        question to a decode error. `total_cost` is the figure a pre-commitment
        screen must show; **prose is not a cost disclosure**, and "no transfer
        fees" is not one either.

        **What a consumer may be shown** of a figure derived from an FX spread
        is still under review and may be narrowed. For a tenant on **Moja Switch FX**, Moja Switch prices the
        quote and the disclosure carries the real basis: `RATE_INCLUDES_MARKUP` with
        `fx_margin` when the tenant's margin for the corridor is positive,
        `SOURCE_RATE_NO_MARKUP` with `fx_margin` absent when it is zero. For
        BYO-FX tenants Moja computes no customer-facing rate.
      required: [total_cost, rate_basis]
      properties:
        total_cost:
          allOf:
            - $ref: '#/components/schemas/MinorUnitAmount'
          description: >-
            **Everything the sender pays beyond the amount delivered**, in the
            SEND currency — platform fees plus any FX margin, as a single
            figure. This is the number a pre-commitment screen renders. It is
            never a range and never a percentage.
        rate_basis:
          type: string
          description: |
            How the rate shown relates to the rate underneath it, so that a
            client can pick disclosure copy without reasoning about pricing:
            - `SOURCE_RATE_NO_MARKUP` — no FX margin is applied; the entire cost
              is in fees and is in `total_cost`. The only value reachable today.
            - `RATE_INCLUDES_MARKUP` — part of the cost sits **in the rate**.
              A client showing this MUST pair any "no fees" statement, on the
              same surface, with the statement that the cost is in the rate.

            Treat an unrecognised value as `RATE_INCLUDES_MARKUP` — the
            conservative reading, which forces the fuller disclosure rather than
            suppressing it.
          enum:
            - SOURCE_RATE_NO_MARKUP
            - RATE_INCLUDES_MARKUP
        fees_total:
          allOf:
            - $ref: '#/components/schemas/MinorUnitAmount'
          description: >-
            The fee component of `total_cost`, in the send currency. Broken out
            so a client can itemise fees without subtracting figures itself.
        fx_margin:
          allOf:
            - $ref: '#/components/schemas/MinorUnitAmount'
          description: >-
            The FX-margin component of `total_cost`, in the send currency.
            Present only when `rate_basis` is `RATE_INCLUDES_MARKUP`; a zero
            margin is expressed by `SOURCE_RATE_NO_MARKUP` with this field
            absent, not by a zero here.

    PricingMode:
      type: string
      description: >-
        Who set the rate on a quote. `client_provided` is the BYO-FX default:
        the partner supplied `fx` and Moja executed it.
        `platform_priced` means Moja Switch priced the quote — **Moja Switch FX**
        — available only to tenants configured for it; a BYO-FX tenant
        never receives one. Treat an unrecognised value
        as `client_provided` and do not display a rate you cannot attribute.
      enum:
        - client_provided
        - platform_priced

    FXProvenance:
      type: object
      description: >-
        The audit trail behind a `platform_priced` rate: what the platform
        started from, who supplied it, when, and under which pricing rule. It
        exists so that an integrator — or an auditor, or a support agent
        answering "why was my rate this" — can reconstruct the number without
        asking us. Returned only when `pricing_mode` is `platform_priced`.
      required: [source_rate, provider, rate_timestamp, rule_version, rate_type]
      properties:
        source_rate:
          type: string
          pattern: '^\d+(\.\d{1,8})?$'
          description: >-
            The underlying market/source rate the platform started from, before
            any pricing rule was applied. Receive units per 1 send unit.
          example: '1850.25'
        provider:
          type: string
          maxLength: 64
          description: >-
            ⛔ **Integrator-only — never render to an end user.** Identifier of
            the rate source: an opaque, stable string, not a display name and
            not a URL. Naming the counterparty behind a rate on a consumer
            surface discloses a commercial relationship that is not the
            customer's, and it is the same class of leak as naming a rail. Use
            it for reconciliation and audit.
          example: rate_source_a
        rate_timestamp:
          type: string
          format: date-time
          description: >-
            When the source rate was observed (RFC 3339, UTC). This is the age
            of the rate, which is not the same as when the quote was created.
        rule_version:
          type: string
          maxLength: 64
          description: >-
            Version of the pricing rule applied to the source rate. Changes
            whenever the rule changes, so two quotes are only comparable when
            their `rule_version` matches.
          example: pricing-2026-09-01
        rate_type:
          type: string
          description: >-
            Whether the source rate was `indicative` (a reference observation,
            not dealable) or `executable` (published by a counterparty as
            dealable). An `indicative` source is not a commitment that the rate
            can be obtained, and must not be presented to an end customer as if
            it were.
          enum:
            - indicative
            - executable

    QuoteFees:
      type: object
      description: |
        Quote cost breakdown, always denominated in the SEND currency (what the
        quote costs the sender). Both fields are always present and may be zero
        amounts. `platform_fee` is the flat platform fee (may be zero). `fx_markup`
        is the margin inside `fx_rate` on a **Moja Switch FX** quote;
        it is zero on a **BYO-FX** quote, where the partner owns the
        customer-facing rate and Moja adds no spread. For your systems, not for
        an end user.
      required: [platform_fee, fx_markup]
      properties:
        platform_fee:
          $ref: '#/components/schemas/Money'
        fx_markup:
          $ref: '#/components/schemas/Money'

    CreatePaymentRequest:
      type: object
      description: |
        **Originator.** `sender` is always required. For a push collection it is
        the inline `Party`; for `funding_method: INTERAC_REQUEST` it is an
        `InteracSender` (`email` and `name`, the person the Interac request is
        addressed to).

        **Beneficiary — `payout`, or a saved recipient.** `payout` is the inline
        instruction. A saved recipient is named by `recipient_id` together with
        `end_user_id` (both or neither, else `422 validation_error`). With a saved
        recipient and no `payout`, the platform builds the payout from the screened
        record (method, beneficiary, wallet network), so nothing is restated. If
        `payout` is also sent, its beneficiary phone must equal the recipient's
        saved phone (`422 recipient_phone_mismatch`).

        **FX.** `fx` (client-provided) and `quote_id` (a Moja quote) are mutually
        exclusive: supplying both is `422 validation_error`. A corridor with no
        sandbox rate always requires inline `fx` with both `rate` and
        `receive_amount`.

        Saved-recipient failures, all before any collection: `404
        end_user_not_found`, `404 recipient_not_found` (unknown, or another end
        user's), `422 recipient_not_approved` (screening not passed),
        `422 recipient_not_payable` (not a mobile-money recipient in the
        corridor's payout country), `422 end_user_not_permitted` (the end user
        is not `account_status: created` with `transact_authorization:
        authorized`), `422 sender_velocity_exceeded` (the payment would take
        the end user to a compliance limit on payments in a rolling window;
        nothing is created), `422 end_user_cdd_required` (the end user has no
        current due diligence profile), `422 end_user_review_required` (the end
        user cannot send until a compliance review is complete),
        `422 end_user_limit_exceeded` (above a limit that applies to the end
        user; not disclosed).

        **Kind.** `kind` is optional; absent is `transfer`, and everything
        above describes a transfer. `kind: collection` is validated on the
        server only (a `oneOf` with a defaulted discriminator would break
        generated clients), so the `required` list below is a transfer's.
        Collections are **sandbox-only** and need an operator-activated
        collection product for your tenant; see `kind`.
      required: [corridor, reference, send_amount, sender, purpose]
      anyOf:
        - title: Inline payout instruction
          required: [payout]
        - title: Saved recipient
          required: [recipient_id]
      dependentRequired:
        recipient_id: [end_user_id]
        end_user_id: [recipient_id]
      properties:
        kind:
          type: string
          enum: [transfer, collection]
          default: transfer
          description: >-
            What the payment does. Omit it, or send `transfer`, for a transfer:
            the request is the same either way. `collection` collects
            CAD from a payer for the tenant by Interac request. It is
            **sandbox-only**: outside sandbox it is `403 product_not_available`
            (checked before the body is validated); in sandbox, after
            validation, a tenant without an operator-activated collection
            product gets `403 tenant_not_activated` (nothing is created), and
            an activated tenant gets `201`. A collection **requires** `reference`, `send_amount`
            (CAD), `funding_method: INTERAC_REQUEST`, `sender` (an
            `InteracSender`: the payer), `payer_id`, `destination_country`,
            `purpose` and `sender_kyc`; **may** carry `quote_id`, `metadata`
            and, in sandbox, `sandbox`; and **forbids** `corridor`, `payout`,
            `recipient_id`, `end_user_id` and `fx`. Each violation is
            `422 validation_error` naming the field. A transfer must not carry
            `payer_id`, `destination_country` or `sandbox`
            (`400 invalid_request`).
        payer_id:
          type: string
          minLength: 1
          maxLength: 128
          description: >-
            `kind: collection` only. The tenant's own stable identifier for the
            payer: printable, no leading or trailing spaces. This is the KYC
            attestation subject for a collection — `InteracSender`
            has no `external_id`; do not put a partner user id on `sender`.
          example: payer-42
        destination_country:
          type: string
          pattern: '^[A-Z]{2}$'
          description: >-
            `kind: collection` only. ISO 3166-1 alpha-2 country the tenant will
            send the collected funds on to (outside Moja Switch).
          example: TZ
        sandbox:
          type: object
          additionalProperties: false
          description: >-
            `kind: collection` only, sandbox only. The simulated outcome of the
            collection.
          required: [collection_outcome]
          properties:
            collection_outcome:
              type: string
              enum: [paid, expired, amount_mismatch, reverse_after_collect]
        corridor:
          $ref: '#/components/schemas/Corridor'
        reference:
          type: string
          description: Partner's unique reference for this payment
          maxLength: 128
          example: partner-tx-12345
        quote_id:
          type: string
          description: >-
            **Moja Switch FX tenants: required.** The `id` of a quote from
            `POST /quotes`, single-use and unexpired; see the operation description.
            **BYO-FX / unconfigured tenants:** an optional reference to a prior
            sandbox quote; it does **not** satisfy the production FX requirement
            (a `quote_id`-only production request → `422 fx_required`). Mutually
            exclusive with `fx` (`422 validation_error`).
        fx:
          allOf:
            - $ref: '#/components/schemas/FX'
          description: >-
            Client-provided FX (**BYO-FX tenants only**). Mutually exclusive with
            `quote_id` (`422 validation_error`). REQUIRED inline in production and
            on a corridor with no sandbox rate (`422 fx_required` if absent). Omit only in sandbox CA-TZ to
            use the Moja-derived dev rate. **A Moja Switch FX tenant must omit it**
            (`422 fx_not_accepted`).
        send_amount:
          $ref: '#/components/schemas/Money'
        payout:
          allOf:
            - $ref: '#/components/schemas/PayoutInstruction'
          description: >-
            The beneficiary and payout method, inline. Required unless a saved
            recipient (`recipient_id` + `end_user_id`) is supplied. When both
            are sent, `payout.beneficiary.phone` must equal the recipient's
            saved phone (`422 recipient_phone_mismatch`).
        funding_method:
          allOf:
            - $ref: '#/components/schemas/FundingMethod'
          description: >-
            How the sender intends to pay. **For `kind: collection` it is
            required and must be `INTERAC_REQUEST`.** For a transfer,
            `INTERAC_REQUEST` is implemented for a withdrawn corridor only, so
            it is not available on CA-TZ or any other enabled corridor today.
            Where a transfer corridor offers it, it is for a
            saved recipient (`recipient_id`), with `sender` an
            `InteracSender`. The platform asks the sender for the full send
            amount and the payment waits at `COLLECTING` until the rail reports
            the funds deposited. Another corridor →
            `422 funding_method_unsupported`; an unknown value →
            `422 validation_error`. Omit for the corridor's push collection.
        recipient_id:
          type: string
          pattern: '^rcp_[a-zA-Z0-9]+$'
          description: >-
            A screened recipient from this end user's book. Requires
            `end_user_id`, and the recipient must belong to that end user (else
            `404 recipient_not_found`). It must be `approved`
            (`422 recipient_not_approved` otherwise), checked before any
            collection.
          example: rcp_9Zd5nP6yTc
        sender:
          oneOf:
            - $ref: '#/components/schemas/Party'
            - $ref: '#/components/schemas/InteracSender'
          description: >-
            The originator. A `Party` for push collection; an `InteracSender`
            when `funding_method` is `INTERAC_REQUEST`.
        end_user_id:
          type: string
          pattern: '^eu_[a-zA-Z0-9]+$'
          description: >-
            The end user whose saved recipient is being paid. Required with, and
            only with, `recipient_id` (`404 end_user_not_found` if unknown). It
            does not replace `sender`. The end user must be able to send:
            `account_status: created` and `transact_authorization: authorized`,
            else `422 end_user_not_permitted`, whatever `sender_kyc` says.
          example: eu_7Qm4xK2pLd
        sender_kyc:
          $ref: '#/components/schemas/SenderKYCAttestation'
        purpose:
          $ref: '#/components/schemas/PaymentPurpose'
        metadata:
          type: object
          additionalProperties:
            type: string
          maxProperties: 20

    InteracSender:
      type: object
      description: >-
        The sender of an `INTERAC_REQUEST` payment: the person the Interac
        request is addressed to. No other fields are accepted — in particular
        not `external_id`. For `kind: collection`, the KYC-attested payer
        identity is top-level `payer_id`, not a field on this object.
      required: [email, name]
      additionalProperties: false
      properties:
        email:
          type: string
          format: email
          description: A plain email address (no display name).
          example: sender@example.com
        name:
          type: string
          maxLength: 140
          example: Contract Sender
        type:
          type: string
          enum: [INDIVIDUAL, BUSINESS]
          default: INDIVIDUAL
        country:
          type: string
          pattern: '^[A-Z]{2}$'
          default: CA

    Payment:
      type: object
      description: |
        A payment: a transfer (the default) or, in sandbox, a collection
        (`kind: collection` on create).

        **`corridor` and `payout` describe a transfer and are not required.**
        A collection has neither. Today a collection's response still carries
        both keys with placeholder values (`corridor: ""`, and a `payout` with
        an empty `method`, `beneficiary: null` and a `currency` that does not
        describe the collection). Ignore both on a collection, and do not
        branch on their presence: the payment does not yet carry `kind`, so
        track which of your payments are collections from your own create
        request (`reference`, or the `id` returned at create).
      required: [id, status, reference, send_amount, created_at, updated_at]
      properties:
        id:
          type: string
          example: pay_abc123def456
        status:
          $ref: '#/components/schemas/PaymentStatus'
        corridor:
          $ref: '#/components/schemas/Corridor'
        reference:
          type: string
        send_amount:
          $ref: '#/components/schemas/Money'
        receive_amount:
          allOf:
            - $ref: '#/components/schemas/Money'
          description: >-
            The locked payout amount, echoing the partner's client-provided FX
            (BYO-FX). Present once FX is locked.
        fx_rate:
          type: string
          description: >-
            The partner's locked send→receive rate, echoed back (not Moja-computed
            or marked up). Present once FX is locked.
        fx_rate_id:
          type: string
          maxLength: 128
          description: >-
            The partner's declared-rate identifier (`fx.rate_id`), echoed back for
            reconciliation. Present only when the partner supplied it at create.
            Pure metadata — it never affected how the payment was priced or
            executed.
          example: dealer-quote-7f3a91
        quote_id:
          type: string
          description: >-
            The quote this payment was priced on (for a Moja Switch FX tenant, the
            single-use quote it paid on), for reconciliation. Absent when the
            payment carried no quote.
          example: quo_contract
        fx_provenance:
          $ref: '#/components/schemas/PaymentFXProvenance'
        payout:
          $ref: '#/components/schemas/PayoutInstruction'
        sender:
          $ref: '#/components/schemas/Party'
        end_user_id:
          type: string
          pattern: '^eu_[a-zA-Z0-9]+$'
          description: >-
            The end user this payment was originated for. Present only when the
            payment was created with `end_user_id`; absent for payments created
            with an inline `sender`. It is the filter key for
            `GET /payments?end_user_id=`.
          example: eu_7Qm4xK2pLd
        recipient_id:
          type: string
          pattern: '^rcp_[a-zA-Z0-9]+$'
          description: >-
            The saved recipient this payment was sent to. Present only when the
            payment was created with `recipient_id`. `payout` is still returned
            in full, resolved from that recipient — the response shape does not
            change with the request shape.
          example: rcp_9Zd5nP6yTc
        purpose:
          $ref: '#/components/schemas/PaymentPurpose'
        rails:
          type: object
          description: >-
            ⛔ **Integrator-only — never render to an end user.** The rail
            identities behind a payment. A rail name tells a customer which
            provider handled their money, which is not theirs to know, changes
            without notice, and turns a routing decision into a support
            conversation. Use it for reconciliation and diagnostics; do not pass
            it to a consumer surface, a push payload or a receipt.
          properties:
            send:
              type: string
              example: ca_psp_example
            receive:
              type: string
              example: tz_mobile_money_example
        failure:
          $ref: '#/components/schemas/PaymentFailure'
        funding_method:
          allOf:
            - $ref: '#/components/schemas/FundingMethod'
          description: >-
            How the sender is funding this payment, as requested at create.
            Present for `INTERAC_REQUEST` payments.
        funding_instructions:
          allOf:
            - $ref: '#/components/schemas/FundingInstructions'
          description: >-
            What the sender must do to pay. Present for `INTERAC_REQUEST`
            payments; `reference` is the Interac reference once the request is
            made.
        funds_taken:
          type: boolean
          description: |
            Present for `INTERAC_REQUEST` payments. **Not reliable on a
            `kind: collection` today:** it reads `false` even after the payer's
            funds were collected; use `GET /payouts` for a collection.
            **Whether the sender's money left their account.** Consumer-safe and the only correct basis for telling
            someone what happened to their funds:
            - `false` — nothing was collected. "Your money was not taken."
            - `true` — funds were collected. If the payment did not complete,
              they are being returned; read `return_state` for where that is.

            A terminal status alone cannot answer this question, which is why
            this field exists. **Do not derive it from `failure.stage`** — that
            field names an internal stage, it is integrator-only, and inferring
            consumer copy from it is precisely the leak this contract forbids.
          example: false
        return_state:
          allOf:
            - $ref: '#/components/schemas/ReturnState'
          x-moja-status: planned
          description: >-
            🔲 Planned — not emitted. Whether collected funds are on their way
            back to the sender. Absent or `NONE` when nothing was collected or
            nothing is being returned.
        taking_longer_than_usual:
          type: boolean
          x-moja-status: planned
          description: |
            🔲 Planned — not emitted. **An attention signal, deliberately with no
            cause attached.** `true` means this payment has been in progress
            longer than it should be and warrants telling the customer so — and
            it means nothing else.

            It is a boolean rather than a reason code on purpose: a boolean
            cannot leak which internal state, rail, counterparty or compliance
            step produced it, so a client cannot accidentally build copy that
            does. The platform derives it from dwell time against the expected
            duration of whatever stage the payment is in; the stage itself is
            not exposed and will not be.

            Pair it with a support entry point. Do not present it as a failure,
            do not promise a completion time, and do not ask the customer to do
            anything — there is nothing for them to do.
          example: false
        cancellable:
          type: boolean
          x-moja-status: planned
          description: >-
            🔲 Planned — not emitted. Whether `POST /payments/{payment_id}/cancel`
            would be accepted **right now**. Read this rather than inferring
            eligibility from `status`: the rule is a platform decision that has
            already changed once, and a client that re-implements it will offer
            a cancel button that fails, or hide one that would have worked. It
            is a point-in-time answer — a cancel may still return `409` if the
            payment advances between the read and the call, which a client must
            handle rather than treat as a bug.
          example: true
        metadata:
          type: object
          additionalProperties:
            type: string
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        completed_at:
          type: string
          format: date-time

    PaymentFailure:
      type: object
      description: >-
        Why a payment failed, for the integrator. ⛔ **`message` and `stage` are
        integrator-only and must never reach an end user.** `stage` names an
        internal processing stage — including `compliance` — and telling a
        customer their payment failed at the compliance stage both discloses a
        control and, where the control is a sanctions or fraud rule, may be
        unlawful to say. Use `funds_taken` and `return_state` to tell a customer
        what happened to their money, and a generic did-not-complete message for
        the rest.
      properties:
        code:
          type: string
          description: >-
            Stable failure code. Safe to branch on; not safe to display
            verbatim.
          example: payout_failed
        message:
          type: string
          description: >-
            ⛔ **Integrator-only.** Diagnostic text for logs and support tooling.
            Never render it to an end user and never place it in a notification.
        stage:
          type: string
          description: >-
            ⛔ **Integrator-only.** The internal stage that failed. Never
            rendered, never mapped to consumer copy, never used to infer whether
            funds were taken — `funds_taken` answers that question directly.
            New values may be added; treat an unrecognised value as
            `internal`.
          enum: [compliance, fx, routing, collection, settlement, compensation, internal]

    FundingMethod:
      type: string
      description: |
        How the **sender** pays us. The only value accepted today is
        `INTERAC_REQUEST`. It is required on a `kind: collection` (sandbox).
        On a transfer it is implemented for a withdrawn corridor only and so
        refused (`422 funding_method_unsupported`) on CA-TZ and every other
        enabled corridor today; any other value is refused. Omit `funding_method` for the default push/card
        collection. `EFT_PAD` and `BILL_PAYMENT` are planned, as is per-corridor
        publication in `GET /corridors/{corridor_code}` (`funding_methods`).

        A pattern-constrained server-supplied code rather than an enum, so that
        enabling a funding method is not a client release. Treat an
        unrecognised value as one you cannot render.

        **This is consumer-facing by necessity, and that is not a rail leak.**
        A funding method names the payment scheme the customer themselves uses
        in their own bank; they cannot pay without being told which one. It does
        **not** name the PSP that processes it, and no field here ever will —
        that remains integrator-only, like `rails`.
      pattern: '^[A-Z][A-Z0-9_]{1,31}$'
      example: INTERAC_REQUEST

    FundingInstructions:
      type: object
      description: |
        What the sender must do to fund this payment. Present on
        `INTERAC_REQUEST` payments once instructions exist; absent when the
        method needs no action from the customer. Today it carries `method`,
        `reference` and `request_link`; the other properties are planned.

        **Render what is present and never fabricate what is not.** The fields
        that apply depend on `method`, so a client keys off `method` and shows
        the ones it carries — it does not invent a reference, guess a deadline,
        or display a placeholder where a value is missing. A wrong reference on
        a bank transfer is money that arrives unattributable.
      required: [method]
      properties:
        method:
          $ref: '#/components/schemas/FundingMethod'
        reference:
          type: string
          maxLength: 64
          description: >-
            The reference the sender must quote so their funds are matched to
            this payment. Show it verbatim and make it copyable; do not
            reformat, re-case or add spacing.
          example: MP-0000-0000-0001
        pay_by:
          type: string
          format: date-time
          x-moja-status: planned
          description: >-
            🔲 Planned — not emitted. When the instruction expires (RFC 3339). After this the payment may
            no longer be funded and must be re-created. Render the deadline;
            do not silently extend it.
        request_link:
          type: string
          format: uri
          description: >-
            HTTPS link that takes the sender into their own bank's payment flow,
            where the method uses one. Single-purpose and tied to this payment —
            treat it as sensitive, do not log it and do not share it beyond the
            sender.
        bill_payment_number:
          type: string
          maxLength: 64
          x-moja-status: planned
          description: >-
            🔲 Planned — not emitted. The account/reference number to enter as the bill payee reference,
            where the method is a bill payment.
        account_masked:
          type: string
          maxLength: 64
          x-moja-status: planned
          description: >-
            🔲 Planned — not emitted. The sender's own pre-authorised debit account, masked to its last
            digits, so they can confirm which account will be debited. The full
            account number is never returned.
          example: '****4321'

    ReturnState:
      type: string
      description: |
        🔲 Planned — used only by the planned `Payment.return_state`. Where
        collected funds are on their way back to the sender.

        - `NONE` — nothing is being returned. Also the correct value when
          nothing was ever collected.
        - `RETURN_IN_PROGRESS` — a reversal has **durably committed** and the
          funds are on their way back.
        - `RETURNED` — the return has completed.

        **`RETURN_IN_PROGRESS` is set only on durable commit of the reversal,
        never on intent.** Not when a failure is detected, not when a refund is
        decided, not when a reversal is dispatched — only once the REVERSAL
        journal entry exists. This is the whole reason the field is separate
        from `status`: "your money is coming back" is a promise, and a promise
        made from an intention that later fails is worse than saying nothing.
        A client MUST NOT show a refund message on any other basis.

        Treat an unrecognised value as `NONE` — say nothing rather than promise
        something.
      enum:
        - NONE
        - RETURN_IN_PROGRESS
        - RETURNED

    PayoutInstruction:
      type: object
      required: [method, currency, beneficiary]
      properties:
        method:
          $ref: '#/components/schemas/PayoutMethod'
        currency:
          type: string
          pattern: '^[A-Z]{3}$'
          description: >-
            ISO 4217 payout currency, a pattern-constrained code rather than a
            fixed list, so new corridors need no client release. Must match the corridor's payout currency from
            `GET /corridors/{corridor_code}`, else `422 validation_error`.
          example: TZS
        beneficiary:
          $ref: '#/components/schemas/Beneficiary'

    Beneficiary:
      type: object
      required: [type]
      properties:
        type:
          type: string
          enum: [INDIVIDUAL, BUSINESS]
        first_name:
          type: string
        last_name:
          type: string
        business_name:
          type: string
        phone:
          type: string
          description: E.164 format for mobile money
          example: '+255712345678'
        bank_account:
          $ref: '#/components/schemas/BankAccount'

    BankAccount:
      type: object
      description: Phase 1b — bank payout rail
      properties:
        bank_code:
          type: string
        account_number:
          type: string
        account_name:
          type: string

    SenderKYCAttestation:
      type: object
      description: |
        Partner-supplied sender KYC attestation (the partner-attestation
        model: the partner attests sender KYC is complete rather than Moja
        Switch re-running KYC).

        **Optional at the API boundary, but effectively REQUIRED when the
        corridor requires attestation**, which is the default. When required and the attestation is absent, not
        marked `attested`, missing `attestation_id`/`attested_at`, or stale
        (older than the configured max age or dated in the future), the payment
        is rejected at the compliance step: the payment is created and returned
        `201` with `status: FAILED` and `failure.code: compliance_blocked`
        (not an HTTP 422). A supplied attestation is ALWAYS fully
        validated regardless of the required flag: if you send one, it must be
        valid.

        The identity the attestation covers is not repeated here. For a
        transfer, it is `sender.external_id`, name, and country (`CA`). For a
        `kind: collection`, it is top-level `payer_id` plus the Interac
        payer's name and country on `sender` (`InteracSender` has no
        `external_id`).
      required: [attested]
      properties:
        attested:
          type: boolean
          description: Partner's assertion that sender KYC is complete. Must be true to pass compliance.
          example: true
        attestation_id:
          type: string
          description: Partner's KYC reference, for audit traceability. Required for the attestation to be accepted.
          example: kyc_ref_abc123
        level:
          type: string
          description: Partner's KYC tier. Free-form today.
          example: FULL
        attested_at:
          type: string
          format: date-time
          description: >-
            When the partner completed KYC (RFC 3339). Required for the
            attestation to be accepted. Rejected if in the future or older than
            the configured maximum age.

    Party:
      type: object
      required: [type, country]
      properties:
        type:
          type: string
          enum: [INDIVIDUAL, BUSINESS]
        first_name:
          type: string
        last_name:
          type: string
        business_name:
          type: string
        country:
          type: string
          pattern: '^[A-Z]{2}$'
        external_id:
          type: string
          description: Partner's user/customer ID

    Corridor:
      allOf:
        - $ref: '#/components/schemas/CorridorCode'
      description: |
        Corridor on the transacting routes. **This was a closed enum
        (`CA-TZ`) and is now the same pattern-constrained string as
        `CorridorCode`.**

        It had to change. A closed enum in the contract is the same defect as a
        hardcoded corridor in a client: the corridor set is per-tenant
        entitlement data, it moves, and an enum makes every move a contract
        change and a client release. Published as an enum, a quote on any newer corridor
        was a *schema violation* rather than an entitlement question — which is not
        something the contract should have an opinion about.

        **Entitlement is the authority** on what a given tenant may actually
        use (`GET /corridors`, which will list it, is planned). A corridor you
        are not entitled to is `403 corridor_not_entitled`; a syntactically valid
        code that does not exist is `422 unsupported_corridor`. Treat the value
        as an opaque server-supplied string: do not enumerate it, do not switch
        on it beyond what discovery told you, and do not ship a build that
        breaks when the set changes.

    PayoutMethod:
      type: string
      enum: [MOBILE_MONEY, BANK_TRANSFER]

    PaymentPurpose:
      type: string
      enum:
        - FAMILY_SUPPORT
        - SCHOOL_FEES
        - UTILITY_BILL
        - MERCHANT_PAYMENT
        - OTHER

    PaymentStatus:
      type: string
      description: >-
        Partner-facing payment lifecycle status. PROCESSING is a safe generic
        "in progress" value: the platform presents any internal-only substate
        (e.g. a compensation state) that is not one of the enumerated lifecycle
        stages as PROCESSING, so a partner never observes a raw internal state.
        For a transfer, PROCESSING is non-terminal and transient: a subsequent
        poll or webhook will report a terminal status.
        Clients should treat any unrecognised value as a non-terminal state.

        **Collection statuses are published but not emitted yet.**
        `CANCEL_REQUESTED`, `COLLECTED`, `PAYOUT_SCHEDULED`, `PAID_OUT` and
        `REVERSED` belong to the `kind: collection` lifecycle.
        The platform records them, but the partner status mapping does not pass
        them through yet; each reads `PROCESSING`. A collection reports
        `CREATED`, `VALIDATING`, `COMPLIANCE_CHECK`, `COLLECTING`, `FAILED` and
        `CANCELLED` verbatim. A collection that is paid, scheduled into a
        payout, paid out or reversed reads `PROCESSING` and today never reaches
        a later terminal status. Reconcile collected funds from `GET /payouts`
        (each payout member carries the collection `reference`). Accept all
        five values now so that enabling them is not a client release.
      enum:
        - CREATED
        - VALIDATING
        - COMPLIANCE_CHECK
        - FX_LOCKED
        - ROUTING
        - COLLECTING
        - CANCEL_REQUESTED
        - COLLECTED
        - PAYOUT_SCHEDULED
        - PAID_OUT
        - REVERSED
        - PAYOUT_PENDING
        - PAYOUT_SENT
        - PROCESSING
        - SETTLED
        - COMPLETED
        - RECONCILED
        - FAILED
        - CANCELLED

    PaymentList:
      type: object
      description: >-
        One keyset-paginated page of payments. Read `has_more`; when it is true,
        send `next_cursor` back unchanged as `cursor`.
      required: [data, has_more]
      properties:
        data:
          type: array
          description: Payments, ordered `created_at DESC, id DESC`.
          items:
            $ref: '#/components/schemas/Payment'
        has_more:
          type: boolean
          description: >-
            Whether a further page exists. The only correct pagination
            predicate — an empty `data` array is not the terminator, and neither
            is a short page.
        next_cursor:
          type: string
          maxLength: 512
          description: >-
            Opaque cursor for the next page. Present only when `has_more` is
            true. Its encoding is not part of this contract: pass it back
            verbatim, never construct or decode one.
          example: eyJjIjoiMjAyNi0wOS0xM1QxMDoxNTowMFoiLCJpIjoicGF5X2FiYzEyM2RlZjQ1NiJ9

    TenantPayout:
      type: object
      description: A CAD payout of collected funds to your tenant, created and run by Moja Switch.
      required: [id, status, currency, cycle, attempt, net_amount_minor, created_at, updated_at, members]
      properties:
        id:
          type: string
          pattern: '^tpo_[a-zA-Z0-9]+$'
        status:
          type: string
          enum: [CREATED, SENT, PAID, FAILED, RETURNED, OUTCOME_UNKNOWN]
        currency:
          type: string
          enum: [CAD]
        cycle:
          type: string
        attempt:
          type: integer
          minimum: 1
        net_amount_minor:
          type: integer
          format: int64
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        members:
          type: array
          items:
            $ref: '#/components/schemas/TenantPayoutMember'

    TenantPayoutMember:
      type: object
      required: [collection_id, reference, gross_amount_minor, fee_amount_minor, net_amount_minor, active]
      properties:
        collection_id:
          type: string
        reference:
          type: string
          description: Tenant's collection reference for reconciliation.
        gross_amount_minor:
          type: integer
          format: int64
        fee_amount_minor:
          type: integer
          format: int64
        net_amount_minor:
          type: integer
          format: int64
        active:
          type: boolean

    TenantPayoutList:
      type: object
      required: [data, has_more]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/TenantPayout'
        has_more:
          type: boolean

    Receipt:
      type: object
      description: >-
        A record of a completed transfer, suitable for display, download or
        sharing by an end user. Contains no unmasked destination number, no
        account or document number, and no internal identifier.
      required: [payment_id, receipt_number, issued_at, status, corridor, send_amount, recipient]
      properties:
        payment_id:
          type: string
          example: pay_abc123def456
        receipt_number:
          type: string
          description: >-
            Human-quotable reference for this receipt. Stable for the life of
            the payment — a re-fetch returns the same number, so a user reading
            it to support and a user reading it from a saved PDF agree.
          example: RCPT-2026-0001234
        issued_at:
          type: string
          format: date-time
        status:
          allOf:
            - $ref: '#/components/schemas/PaymentStatus'
          description: >-
            The payment status at issue time. A receipt is only issued from
            `COMPLETED`, `SETTLED` or `RECONCILED`.
        corridor:
          $ref: '#/components/schemas/CorridorCode'
        reference:
          type: string
          description: The partner's own reference for the payment, echoed back.
        end_user_id:
          type: string
          pattern: '^eu_[a-zA-Z0-9]+$'
          description: Present when the payment was originated by an end user.
          example: eu_7Qm4xK2pLd
        send_amount:
          allOf:
            - $ref: '#/components/schemas/MinorUnitAmount'
          description: >-
            What the sender paid, in integer minor units of the send currency.
        receive_amount:
          allOf:
            - $ref: '#/components/schemas/MinorUnitAmount'
          description: >-
            The amount delivered, in integer minor units of the receive
            currency, with that currency's exponent carried alongside it. A
            zero-decimal receive currency therefore cannot arrive here with
            cents attached.
        fx_rate:
          type: string
          description: The rate applied to this payment, echoed back.
          example: '1850.25'
        fees:
          $ref: '#/components/schemas/ReceiptFees'
        sender_name:
          type: string
          description: >-
            Display name of the originator, as held by the platform. Present
            only where the tenant supplied one.
          example: Test Sender
        recipient:
          type: object
          description: The beneficiary.
          required: [display_name, phone, country]
          properties:
            display_name:
              type: string
              example: Sample Recipient
            phone:
              type: string
              description: E.164 destination MSISDN, in full.
              example: '+255712345678'
            country:
              type: string
              pattern: '^[A-Z]{2}$'
              example: TZ
        completed_at:
          type: string
          format: date-time

    ReceiptFees:
      type: object
      description: >-
        What the transfer cost the sender, denominated in the SEND currency.
        The same breakdown as `QuoteFees` — platform fees only, no FX
        markup under BYO-FX — expressed in integer minor units. It is
        a separate schema rather than a reuse of `QuoteFees` because `QuoteFees`
        is part of the existing `POST /quotes` response and changing its
        representation would be a breaking `/v1` change.
      required: [platform_fee]
      properties:
        platform_fee:
          allOf:
            - $ref: '#/components/schemas/MinorUnitAmount'
          description: >-
            The flat platform fee. Always present; may be zero.

    AccountStatus:
      type: string
      description: >-
        Lifecycle of the end-user record itself. Independent of verification and
        of authorization to transact — see `EndUser` for why the three are
        separate. Treat an unrecognised value as not permitting money movement.
      enum:
        - created
        - suspended
        - closed

    VerificationStatus:
      type: string
      description: >-
        The outcome of identity verification, and **only** that. It records what
        was checked and how it resolved. It is never edited to express an access
        decision: to stop an end user transacting, revoke
        `transact_authorization`. Treat an unrecognised value as unverified.
      enum:
        - unverified
        - pending
        - verified
        - failed

    TransactAuthorization:
      type: string
      description: >-
        Whether this end user may currently move money. This is the field a send
        affordance renders from, and the only one. Independently revocable at
        any time by the platform, without any change to `verification_status`.
        `not_authorized` means never granted (or prerequisites outstanding);
        `revoked` means withdrawn after having been granted. Neither value
        carries a reason — the platform does not disclose why a compliance
        decision was taken. Treat an unrecognised value as not authorized.

        Revocation is not a one-way door: authorization is restored through the
        same `POST /end-users/{end_user_id}/state-transitions` route, with its
        own recorded reason and actor, and **without** touching
        `verification_status` in either direction.
      enum:
        - authorized
        - not_authorized
        - revoked

    EndUser:
      type: object
      description: |
        A natural person a tenant acts for. Not a partner user: this is the
        tenant's customer, not an employee of the tenant with a console login.

        **Three independent state fields, on purpose.**

        | Field | Answers | Moved by |
        |---|---|---|
        | `account_status` | Does this record exist and is it usable | Tenant or platform |
        | `verification_status` | What was checked about this person, and how it resolved | The verification outcome, and nothing else |
        | `transact_authorization` | May this person move money **right now** | Platform, independently revocable at any time |

        **Why they are not one flag.** A verified customer who later becomes a
        sanctions hit has to be stopped immediately — and the only honest way to
        stop them is to revoke `transact_authorization`, because the remedy must
        never be to falsify the verification record. That record is the evidence
        a regulator asks for: who was checked, against what, when, with what
        outcome. Rewriting it to mean "blocked today" destroys the evidence and
        answers the wrong question. Conversely, restoring access must not
        require re-verifying someone who was already verified.

        Collapsed into a single flag, these states also produce the opposite
        failure: a partially verified person passing an is-ok check and reaching
        a money path. So: **render the send affordance from
        `transact_authorization` alone**, show verification progress from
        `verification_status`, and never infer one from the other.

        `account_status` and `transact_authorization` move — in both directions
        — through `POST /end-users/{end_user_id}/state-transitions`, which
        requires a reason and an actor on every move so that a restoration is as
        attributable as a revocation. `verification_status` moves only as a
        verification outcome and is not writable by any route.
      required: [id, external_id, account_status, verification_status, transact_authorization, created_at, updated_at]
      properties:
        id:
          type: string
          pattern: '^eu_[a-zA-Z0-9]+$'
          example: eu_7Qm4xK2pLd
        external_id:
          type: string
          maxLength: 128
          description: >-
            The tenant's own identifier for this person. Unique within the
            tenant, and the key that makes `POST /end-users` safe to retry.
          example: tenant-user-000123
        first_name:
          type: string
          maxLength: 100
          example: Test
        last_name:
          type: string
          maxLength: 100
          example: Sender
        date_of_birth:
          type: string
          format: date
          description: ISO 8601 date. Used by verification and screening.
          example: '1990-01-01'
        country:
          type: string
          pattern: '^[A-Z]{2}$'
          description: ISO 3166-1 alpha-2 country of residence.
          example: CA
        phone:
          type: string
          description: E.164. The person's own contact number, not a destination.
          example: '+14165550000'
        email:
          type: string
          format: email
          example: test.sender@example.com
        account_status:
          $ref: '#/components/schemas/AccountStatus'
        account_status_updated_at:
          type: string
          format: date-time
          description: >-
            When `account_status` last moved. The reason and actor behind the
            move are on the audit record and are deliberately not returned here.
        verification_status:
          $ref: '#/components/schemas/VerificationStatus'
        verification_tier:
          type: string
          maxLength: 64
          description: >-
            The tier reached, as a server-defined opaque identifier. Present
            only once a verification has resolved. It is not a limit, and it
            does not map to one on the client: limits come from
            `GET /corridors/{corridor_code}` and are enforced by the platform.
          example: tier_1
        transact_authorization:
          $ref: '#/components/schemas/TransactAuthorization'
        transact_authorization_updated_at:
          type: string
          format: date-time
          description: >-
            When `transact_authorization` last moved, in either direction. The
            reason and actor are on the audit record and are deliberately not
            returned — a revocation reason is a compliance detail, and this
            field is read by clients that render to end users.
        metadata:
          type: object
          additionalProperties:
            type: string
          maxProperties: 20
          description: >-
            Tenant key/value metadata, echoed back. Never place credentials,
            document numbers or card data here.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    CreateEndUserRequest:
      type: object
      required: [external_id, country]
      properties:
        external_id:
          type: string
          maxLength: 128
          description: >-
            Your own identifier for this person. Unique within your tenant;
            re-posting it with an identical body returns the existing end user
            rather than creating a second one.
          example: tenant-user-000123
        first_name:
          type: string
          maxLength: 100
          example: Test
        last_name:
          type: string
          maxLength: 100
          example: Sender
        date_of_birth:
          type: string
          format: date
          example: '1990-01-01'
        country:
          type: string
          pattern: '^[A-Z]{2}$'
          description: ISO 3166-1 alpha-2 country of residence.
          example: CA
        phone:
          type: string
          description: E.164.
          example: '+14165550000'
        email:
          type: string
          format: email
          example: test.sender@example.com
        metadata:
          type: object
          additionalProperties:
            type: string
          maxProperties: 20

    UpdateEndUserRequest:
      type: object
      description: >-
        Profile fields only. `account_status`, `verification_status` and
        `transact_authorization` are not accepted here and return
        `422 immutable_field`. At least one property must be present.
      minProperties: 1
      properties:
        first_name:
          type: string
          maxLength: 100
        last_name:
          type: string
          maxLength: 100
        date_of_birth:
          type: string
          format: date
        phone:
          type: string
          description: E.164.
        email:
          type: string
          format: email
        metadata:
          type: object
          additionalProperties:
            type: string
          maxProperties: 20

    CddAddress:
      type: object
      description: The customer's residential address. Personal information.
      required: [line1, city, country]
      additionalProperties: false
      properties:
        line1:
          type: string
          minLength: 1
          maxLength: 200
        line2:
          type: string
          maxLength: 200
        city:
          type: string
          minLength: 1
          maxLength: 100
        region:
          type: string
          maxLength: 100
          description: Province, state or region.
        postal_code:
          type: string
          maxLength: 20
        country:
          type: string
          pattern: '^[A-Z]{2}$'
          description: ISO 3166-1 alpha-2.

    CddExpectedActivity:
      type: object
      description: >-
        What the customer expects to do. Actual activity is
        compared against it.
      required: [purpose, monthly_count, monthly_value, destination_countries]
      additionalProperties: false
      properties:
        purpose:
          type: string
          pattern: '^[a-z0-9_]{1,64}$'
          description: >-
            Purpose of the relationship. At launch: family_support,
            personal_remittance, education, medical_expenses, household_support,
            gifts, personal_obligations, other.
        monthly_count:
          type: string
          pattern: '^[a-z0-9_]{1,64}$'
          description: 'Expected transfers per month. At launch: up_to_4, 5_to_10, 11_to_30, over_30.'
        monthly_value:
          type: string
          pattern: '^[a-z0-9_]{1,64}$'
          description: >-
            Expected value per month. At launch: up_to_cad_1000,
            cad_1000_to_3000, cad_3000_to_10000, over_cad_10000.
        destination_countries:
          type: array
          minItems: 1
          maxItems: 20
          items:
            type: string
            pattern: '^[A-Z]{2}$'
          description: Distinct countries the customer expects to send to (ISO 3166-1 alpha-2).

    CddProfileRequest:
      type: object
      required: [residential_address, occupation, occupation_category, expected_activity, source_of_funds, pep_hio_declaration, actor]
      additionalProperties: false
      properties:
        residential_address:
          $ref: '#/components/schemas/CddAddress'
        occupation:
          type: string
          minLength: 1
          maxLength: 100
          description: The customer's occupation in their own words (e.g. "Registered nurse"). Personal information.
        occupation_category:
          type: string
          pattern: '^[a-z0-9_]{1,64}$'
          description: >-
            At launch: employed, self_employed, business_owner, retired,
            student, homemaker, unemployed, cash_intensive_business,
            money_services, other.
        expected_activity:
          $ref: '#/components/schemas/CddExpectedActivity'
        source_of_funds:
          type: string
          pattern: '^[a-z0-9_]{1,64}$'
          description: >-
            Where the money sent comes from. At launch: employment_income,
            pension_or_benefits, savings, business_income, investments,
            sale_of_assets, inheritance, gift, loan, other.
        pep_hio_declaration:
          type: string
          pattern: '^[a-z0-9_]{1,64}$'
          description: >-
            The customer's politically exposed person / head of international
            organization self-declaration. At launch: none, domestic_pep,
            foreign_pep, hio, family_or_close_associate. Screening is separate
            and is not replaced by this declaration.
        actor:
          $ref: '#/components/schemas/Actor'

    CddProfile:
      type: object
      description: >-
        The current customer due diligence profile. Carries no risk rating: the
        rating is an internal compliance record.
      required: [end_user_id, residential_address, occupation, occupation_category, expected_activity, source_of_funds, pep_hio_declaration, submitted_at]
      properties:
        end_user_id:
          type: string
        residential_address:
          $ref: '#/components/schemas/CddAddress'
        occupation:
          type: string
        occupation_category:
          type: string
        expected_activity:
          $ref: '#/components/schemas/CddExpectedActivity'
        source_of_funds:
          type: string
        pep_hio_declaration:
          type: string
        submitted_at:
          type: string
          format: date-time

    SuspendEndUserRequest:
      type: object
      description: >-
        Equivalent to a state transition setting `account_status: suspended` and
        `transact_authorization: revoked`. `reason` and `actor` are required for
        the same reason they are required on the general transition route.
      required: [reason, actor]
      properties:
        reason:
          type: string
          minLength: 1
          maxLength: 500
          description: >-
            Why this is being done, for the audit record. Recorded on the
            platform and never returned to any client or shown to an end user,
            so do not write it as end-user-facing copy — and do not put
            credentials, document numbers or case detail in it.
        actor:
          $ref: '#/components/schemas/Actor'

    Actor:
      type: object
      description: >-
        Who initiated a state change, recorded on the audit record so that a
        transition can be attributed months later. Platform-initiated
        transitions are recorded with a platform actor that a partner cannot
        supply, so a partner can never attribute its own action to us.
      required: [type, id]
      properties:
        type:
          type: string
          description: >-
            `PARTNER_USER` for a human acting in the partner's own console or
            support tool; `PARTNER_SYSTEM` for an automated rule of the
            partner's; `PLATFORM` for a Moja-initiated transition such as a
            compliance revocation.

            **`PLATFORM` is never accepted on an inbound request** — a partner
            supplying it is rejected (`422 validation_error`, field
            `actor.type`). It exists so that
            a transition read back months later distinguishes an action the
            partner took from one we took, and a partner can never attribute
            its own action to us.
          enum:
            - PARTNER_USER
            - PARTNER_SYSTEM
            - PLATFORM
        id:
          type: string
          maxLength: 128
          description: >-
            The partner's own stable identifier for that user or system.
            **Use an opaque internal identifier, not an email address, phone
            number or personal name** — this is retained on an audit record with
            its own retention period, and it is the wrong place to accumulate
            personal data.
          example: partner-agent-0042

    EndUserStateTransitionRequest:
      type: object
      description: >-
        At least one of `account_status` or `transact_authorization` must be
        present; supply both to move them atomically. `verification_status` is
        not accepted in either direction (`422 immutable_field`).
      required: [reason, actor]
      anyOf:
        - required: [account_status]
          title: Move account status
        - required: [transact_authorization]
          title: Move authorization to transact
      properties:
        account_status:
          allOf:
            - $ref: '#/components/schemas/AccountStatus'
          description: >-
            Target account status. `created` ↔ `suspended` in both directions;
            `closed` is terminal and cannot be left
            (`409 invalid_state_transition`).
        transact_authorization:
          allOf:
            - $ref: '#/components/schemas/TransactAuthorization'
          description: >-
            Target authorization. Movable in both directions at any time. Moving
            it to `authorized` never alters `verification_status` and may be
            refused when prerequisites are unmet (`422 verification_required`,
            `422 identity_screening_required`), in which case nothing changes.
        reason:
          type: string
          minLength: 1
          maxLength: 500
          description: >-
            Why, for the audit record — **required on restorations as well as
            revocations**. Never returned to any client and never shown to an
            end user.
        actor:
          $ref: '#/components/schemas/Actor'

    VerificationSessionStatus:
      type: string
      description: >-
        Lifecycle of one verification attempt. Distinct from `decision`, which
        is the outcome: a session can be `completed` with a `declined` decision.
        Treat an unrecognised value as not yet resolved.
      enum:
        - created
        - in_progress
        - completed
        - expired
        - cancelled

    VerificationDecision:
      type: string
      description: >-
        The outcome of a resolved verification. `review` means a human decision
        is outstanding and is **not** an approval — it grants nothing. Absent
        while the session is unresolved. Treat an unrecognised value as not
        approved.
      enum:
        - approved
        - declined
        - review

    Verification:
      type: object
      required: [id, end_user_id, status, created_at]
      properties:
        id:
          type: string
          pattern: '^ver_[a-zA-Z0-9]+$'
          example: ver_3Hb8tR1sVw
        end_user_id:
          type: string
          pattern: '^eu_[a-zA-Z0-9]+$'
          example: eu_7Qm4xK2pLd
        status:
          $ref: '#/components/schemas/VerificationSessionStatus'
        decision:
          allOf:
            - $ref: '#/components/schemas/VerificationDecision'
          description: >-
            Present once the session resolves. A `declined` decision carries no
            reason: the platform does not return the rule or signal behind it.
        tier:
          type: string
          maxLength: 64
          description: >-
            Tier reached, as a server-defined opaque identifier. Never map it to
            a limit on the client.
          example: tier_1
        handoff:
          type: object
          description: >-
            The single-use, expiring handoff into the verification flow.
            Returned at creation only. It is a capability, not an identifier:
            never log it, persist it, put it in a crash report or analytics
            event, or embed it in a durable deep link. The client holds no
            vendor credential at any point, and anything the flow hands back to
            the client is untrusted UI state — the authoritative result is this
            resource plus `GET /end-users/{end_user_id}`.
          required: [reference, expires_at]
          properties:
            reference:
              type: string
              maxLength: 512
              description: Opaque single-use reference. Consumed on first use.
              example: vfs_2b7f4c9e1a3d5f60
            url:
              type: string
              format: uri
              description: >-
                HTTPS entry point for a hosted flow, when the flow is hosted.
                Single-use and expiring on the same terms as `reference`.
              example: https://verify.example.com/s/2b7f4c9e1a3d5f60
            expires_at:
              type: string
              format: date-time
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        completed_at:
          type: string
          format: date-time

    CreateVerificationRequest:
      type: object
      properties:
        requested_tier:
          type: string
          maxLength: 64
          description: >-
            The tier you are asking for, as returned by
            `GET /end-users/{end_user_id}/verification-requirements`. Advisory:
            the platform decides what is required and what is granted. Omit to
            let the platform choose the next tier.
          example: tier_1
        metadata:
          type: object
          additionalProperties:
            type: string
          maxProperties: 20

    VerificationDecisionRequest:
      type: object
      required: [decision, reference]
      properties:
        decision:
          type: string
          enum: [approved, declined, review, expired]
        reference:
          type: string
          minLength: 1
          description: >-
            Your reference for the check (your case or inquiry id), recorded in
            the audit trail.
          example: kyc_case_8812

    FXReference:
      type: object
      required: [corridor, rate, rate_type, provider, as_of]
      properties:
        corridor:
          $ref: '#/components/schemas/CorridorCode'
        rate:
          type: string
          pattern: '^\d+(\.\d{1,8})?$'
          description: Receive-currency units per one send-currency unit.
          example: '2712.5'
        rate_type:
          type: string
          enum: [indicative]
        provider:
          type: string
          description: The reference-data provider.
        as_of:
          type: string
          format: date-time
          description: When the provider observed the rate.

    VerificationRequirements:
      type: object
      description: >-
        The ordered list of steps outstanding for this end user, authored by the
        platform and rendered by the client. It contains no limit amounts and no
        tier→limit mapping, because eligibility is never computed on a device.
      required: [end_user_id, verification_status, steps, generated_at]
      properties:
        end_user_id:
          type: string
          pattern: '^eu_[a-zA-Z0-9]+$'
          example: eu_7Qm4xK2pLd
        verification_status:
          $ref: '#/components/schemas/VerificationStatus'
        current_tier:
          type: string
          maxLength: 64
          description: Tier reached so far, if any. Opaque identifier.
          example: tier_0
        target_tier:
          type: string
          maxLength: 64
          description: The tier these steps would reach. Opaque identifier.
          example: tier_1
        steps:
          type: array
          description: >-
            **Ordered.** Render in array order; the order is a platform
            decision, not a display preference.
          items:
            $ref: '#/components/schemas/VerificationRequirementStep'
        generated_at:
          type: string
          format: date-time
          description: >-
            When this answer was computed. It is point-in-time — re-read after
            each submission rather than caching it across a session.

    VerificationRequirementStep:
      type: object
      description: >-
        One step. `key` is stable and is what the client keys its own copy and
        localisation off — this contract deliberately ships no display strings,
        so changing a requirement here never forces an app-store release for
        wording.
      required: [key, type, status, required]
      properties:
        key:
          type: string
          maxLength: 64
          description: Stable machine key for this step.
          example: government_id
        type:
          type: string
          description: >-
            The kind of interaction the step needs, so a client can pick a
            renderer without special-casing every `key`.
          enum:
            - document
            - selfie
            - fields
            - address
            - attestation
        status:
          type: string
          enum:
            - pending
            - submitted
            - satisfied
            - failed
        required:
          type: boolean
          description: >-
            Whether the step must be completed to reach `target_tier`. An
            optional step never blocks.
        remediation:
          type: string
          maxLength: 300
          description: >-
            Consumer-safe text explaining what is needed, when a step returns to
            `pending` or lands on `failed` and can be re-attempted. Platform-set
            so the wording is not invented per client.

            This is what makes "more information needed" actionable: without it a
            client can say something is wrong but not what, which produces a
            customer who resubmits the same evidence. Example shape: *"A clearer
            photo of the back of your driver's licence."*

            It SHALL NOT disclose a screening decision, a risk reason, a vendor
            name, or anything about why a decision went the way it did — only
            what the customer should do next. Absent when there is nothing
            actionable to say.
          example: A clearer photo of the back of your driver's licence.
        fields:
          type: array
          x-moja-status: planned
          description: >-
            🔲 Planned — not emitted. Field-level validation for a `fields`-type step. Apply it for the
            user's benefit; the platform revalidates everything regardless.
          items:
            $ref: '#/components/schemas/VerificationFieldSpec'

    VerificationFieldSpec:
      type: object
      required: [name, type, required]
      properties:
        name:
          type: string
          maxLength: 64
          example: postal_code
        type:
          type: string
          enum:
            - string
            - date
            - number
            - enum
            - country
        required:
          type: boolean
        pattern:
          type: string
          description: ECMA-262 regular expression the value must match, when constrained.
        max_length:
          type: integer
          minimum: 1
        options:
          type: array
          description: Permitted values for an `enum` field.
          items:
            type: string

    RecipientStatus:
      type: string
      description: |
        Screening state of a saved beneficiary. **Only `approved` may be
        selected for a transfer** — treat every other value, including any
        unrecognised one, as not selectable.

        - `pending_screening` — no decision yet on the current details. Not
          selectable. Save and edit screen synchronously, so a new recipient
          is not normally returned in this state.
        - `approved` — screened and selectable.
        - `rejected` — screening declined. Terminal for that beneficiary key,
          and it carries no explanation.
        - `unverified` — 🔲 planned; nothing sets it today. Will mean **the
          rail rejected the beneficiary details** on a transfer attempt. The
          recipient is kept, not
          deleted: the customer entered it, it is probably a typo in one digit,
          and deleting their data to express "we could not reach this number"
          loses the thing they need in order to correct it. Not selectable until
          corrected — a `PATCH` that changes the MSISDN, name or country
          produces a new beneficiary key and re-screens.

        `unverified` is deliberately distinct from `rejected`: one means "these
        details did not resolve at the network", the other means "this
        beneficiary did not pass screening". They have different remedies and
        must not share a label.
      enum:
        - pending_screening
        - approved
        - rejected
        - unverified

    WalletNetwork:
      type: string
      description: >-
        A mobile-money network code, validated against this pattern only.
        Publication per corridor (`GET /corridors/{corridor_code}`
        `wallet_networks`) is planned. A
        pattern-constrained server-supplied string rather than an enum, for the
        same reason corridors are: networks are added per corridor, and freezing
        them in the contract makes every addition a client release. Do not
        enumerate these client-side and do not infer one from an MSISDN prefix —
        prefixes are reassigned between networks, and a wrong guess sends money
        to the wrong rail.
      pattern: '^[A-Z][A-Z0-9_]{1,31}$'
      example: NETWORK_A

    NameCheckResult:
      type: string
      description: |
        🔲 Planned — used only by the planned `Recipient.name_check`. The outcome of checking the beneficiary name
        against the name registered on the destination wallet.

        - `MATCH` — the registered name matches what was entered.
        - `CLOSE_MATCH` — a near match. A client should ask the customer to
          confirm before sending; it must not auto-accept it.
        - `NO_MATCH` — the names differ. Not a block on its own, but the
          customer must be told before they commit, because this is the last
          point at which a misdirected transfer is cheap to stop.
        - `UNAVAILABLE` — the network did not answer, or the corridor does not
          support the check. **Not a pass.** Say the check could not be
          performed rather than implying it succeeded.

        Treat an unrecognised value as `UNAVAILABLE`.
      enum:
        - MATCH
        - CLOSE_MATCH
        - NO_MATCH
        - UNAVAILABLE

    NameCheck:
      type: object
      description: >-
        🔲 Planned — not emitted. Result of the destination-wallet name check.
        Distinct from screening: `status` says whether this beneficiary is
        permitted, this says whether the details reach the person the sender
        meant. A beneficiary key plus a screening decision is not a name check,
        and neither substitutes for the other.
      required: [result]
      properties:
        result:
          $ref: '#/components/schemas/NameCheckResult'
        checked_at:
          type: string
          format: date-time
          description: >-
            When the check ran. A name check ages: a result from weeks ago is
            evidence about a number that may since have been recycled.

    Recipient:
      type: object
      required: [id, end_user_id, status, beneficiary_key, display_name, country, phone, created_at, updated_at]
      properties:
        relationship:
          $ref: '#/components/schemas/BeneficiaryRelationship'
        id:
          type: string
          pattern: '^rcp_[a-zA-Z0-9]+$'
          example: rcp_9Zd5nP6yTc
        end_user_id:
          type: string
          pattern: '^eu_[a-zA-Z0-9]+$'
          example: eu_7Qm4xK2pLd
        status:
          $ref: '#/components/schemas/RecipientStatus'
        beneficiary_key:
          type: string
          maxLength: 128
          description: >-
            Stable identity of the beneficiary, derived by the platform from
            exactly three inputs — name, destination MSISDN and destination
            country. Velocity and 24-hour aggregation are keyed on it, which is
            why it cannot be the phone string alone. Stable across edits that
            touch none of those three; a change to any of them yields a new key
            and a re-screen.
          example: bk_5f4a2c8e9d1b7306
        display_name:
          type: string
          maxLength: 200
          description: The beneficiary's name as it will be used for payout.
          example: Sample Recipient
        nickname:
          type: string
          maxLength: 60
          description: >-
            The end user's own label for this recipient. Not part of the
            beneficiary key, and editing it does not re-screen.
          example: Home
        type:
          type: string
          enum: [INDIVIDUAL, BUSINESS]
        country:
          type: string
          pattern: '^[A-Z]{2}$'
          description: ISO 3166-1 alpha-2 destination country.
          example: TZ
        phone:
          type: string
          description: E.164 destination MSISDN, in full.
          example: '+255712345678'
        payout_method:
          $ref: '#/components/schemas/PayoutMethod'
        wallet_network:
          allOf:
            - $ref: '#/components/schemas/WalletNetwork'
          description: >-
            The mobile-money network this beneficiary is on, as supplied at
            save. Present only when supplied; never guessed from the MSISDN
            prefix.
        name_check:
          allOf:
            - $ref: '#/components/schemas/NameCheck'
          x-moja-status: planned
          description: >-
            🔲 Planned — not emitted. Whether the beneficiary name matches the name
            registered on the destination wallet. Absent when no check has run
            yet — absent is not a pass.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    RecipientList:
      type: object
      required: [data]
      properties:
        data:
          type: array
          description: Recipients, newest first.
          items:
            $ref: '#/components/schemas/Recipient'

    CreateRecipientRequest:
      type: object
      required: [type, country, phone, payout_method]
      properties:
        relationship:
          $ref: '#/components/schemas/BeneficiaryRelationship'
        type:
          type: string
          enum: [INDIVIDUAL, BUSINESS]
        first_name:
          type: string
          maxLength: 100
          description: Required for an `INDIVIDUAL`.
          example: Sample
        last_name:
          type: string
          maxLength: 100
          description: Required for an `INDIVIDUAL`.
          example: Recipient
        business_name:
          type: string
          maxLength: 200
          description: Required for a `BUSINESS`.
        country:
          type: string
          pattern: '^[A-Z]{2}$'
          description: >-
            ISO 3166-1 alpha-2 destination country. Part of the beneficiary key.
          example: TZ
        phone:
          type: string
          description: >-
            Destination MSISDN in E.164. Part of the beneficiary key. Reads
            return it in full (`Recipient.phone`).
          example: '+255700000001'
        payout_method:
          $ref: '#/components/schemas/PayoutMethod'
        wallet_network:
          allOf:
            - $ref: '#/components/schemas/WalletNetwork'
          description: >-
            Optional. Validated against the `WalletNetwork` pattern only
            (else `422 validation_error`); not yet checked against a corridor's
            network list.
        nickname:
          type: string
          maxLength: 60
          description: The end user's own label. Not part of the beneficiary key.
          example: Home

    UpdateRecipientRequest:
      type: object
      description: >-
        At least one property must be present. Changing `first_name`,
        `last_name`, `business_name`, `phone` or `country` changes the
        beneficiary key and therefore re-screens: the new details are screened
        before they are written, and the recipient is never usable on details
        without a decision. Changing only `nickname` does neither.
      minProperties: 1
      properties:
        first_name:
          type: string
          maxLength: 100
        last_name:
          type: string
          maxLength: 100
        business_name:
          type: string
          maxLength: 200
        country:
          type: string
          pattern: '^[A-Z]{2}$'
        phone:
          type: string
          description: Destination MSISDN in E.164.
        payout_method:
          $ref: '#/components/schemas/PayoutMethod'
        wallet_network:
          allOf:
            - $ref: '#/components/schemas/WalletNetwork'
          description: >-
            Not part of the beneficiary key, so changing it does not re-screen.
        relationship:
          $ref: '#/components/schemas/BeneficiaryRelationship'
        nickname:
          type: string
          maxLength: 60

    CorridorCode:
      type: string
      description: >-
        A corridor identifier, `<send ISO 3166-1 alpha-2>-<receive ISO 3166-1
        alpha-2>`. **Treat it as an opaque server-supplied string**: the set is
        data (`GET /corridors`) and per-partner entitlement, not a constant your
        client enumerates, so a client that renders whatever discovery returns
        gains a corridor without a release. The `Corridor` schema used on the
        transacting routes is now this same shape — the two agree, and neither
        is a closed set.
      pattern: '^[A-Z]{2}-[A-Z]{2}$'
      example: CA-TZ

    CurrencyUnit:
      type: object
      description: >-
        A currency and how many fractional digits it actually has. **Read
        `minor_unit_exponent` — never assume two.** It is `2` for CAD and TZS
        and `0` for a zero-decimal currency, where `1500` is one thousand five
        hundred units and `15.00` is not a valid amount.
        Formatting, input masks, rounding and minor-unit arithmetic all depend
        on it; hardcoding `2` is wrong by a factor of one hundred against a
        zero-decimal currency, and wrong silently.

        This describes the corridor's currencies, which is what a client needs
        before it has any amount in hand — to build an input mask, to label a
        field, to validate what the user typed. Amounts themselves carry their
        own exponent (`MinorUnitAmount`) so that an amount stays self-describing
        away from this metadata.
      required: [currency, minor_unit_exponent]
      properties:
        currency:
          type: string
          pattern: '^[A-Z]{3}$'
          example: TZS
        minor_unit_exponent:
          type: integer
          minimum: 0
          maximum: 4
          description: >-
            Number of fractional digits this currency has (ISO 4217 minor unit).
            `2` for CAD and TZS, `0` for a zero-decimal currency.
          example: 2

    CorridorSummary:
      type: object
      required: [code, send_currency, receive_currency, payout_methods]
      properties:
        code:
          $ref: '#/components/schemas/CorridorCode'
        send_country:
          type: string
          pattern: '^[A-Z]{2}$'
          example: CA
        receive_country:
          type: string
          pattern: '^[A-Z]{2}$'
          example: TZ
        send_currency:
          $ref: '#/components/schemas/CurrencyUnit'
        receive_currency:
          $ref: '#/components/schemas/CurrencyUnit'
        payout_methods:
          type: array
          description: Payout methods available on this corridor for this partner.
          items:
            $ref: '#/components/schemas/PayoutMethod'
        funding_methods:
          type: array
          x-moja-status: planned
          description: >-
            🔲 Planned — not emitted. How a sender may fund a payment on this
            corridor, in the order they should be offered. Read this rather than
            hardcoding a payment-method screen.
          items:
            $ref: '#/components/schemas/FundingMethod'
        wallet_networks:
          type: array
          x-moja-status: planned
          description: >-
            🔲 Planned — not emitted. Mobile-money networks reachable on this
            corridor, for `Recipient.wallet_network`. Server-supplied so that
            adding a network is not a client release.
          items:
            $ref: '#/components/schemas/WalletNetwork'

    CorridorList:
      type: object
      description: >-
        The corridors this partner is entitled to. Not a catalogue of everything
        the platform could do — two partners may legitimately see different
        lists, and a corridor absent here is refused on the money routes
        (`403 corridor_not_entitled`). 🔲 Planned with `GET /corridors`.
      required: [data]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/CorridorSummary'

    CorridorDetail:
      allOf:
        - $ref: '#/components/schemas/CorridorSummary'
        - type: object
          properties:
            payout_method_details:
              type: array
              description: >-
                Per-method requirements: the beneficiary fields a payout of that
                method needs, and their validation. Present when the corridor
                catalogue carries them (CA-KE publishes the phone pattern and
                the mobile-money networks). Absent when it does not. Render and
                validate from this rather than hardcoding a form per corridor.
              items:
                $ref: '#/components/schemas/CorridorPayoutMethod'
            limits:
              $ref: '#/components/schemas/CorridorLimits'
            fx_margin_bps:
              type: integer
              minimum: 0
              maximum: 1000
              deprecated: true
              description: >-
                **Deprecated — do not use.** Returned **only to one
                legacy tenant** (whose older client applied it itself) and absent
                for every other partner: it is that tenant's commercial arrangement with
                Moja Switch, not a corridor price. Moja Switch never prices from it: a tenant on
                **Moja Switch FX** is priced by Moja Switch from its own reference
                rate and the tenant's margin for the corridor (see `POST /quotes`); a
                **BYO-FX** tenant owns its FX and pays no Moja margin. It will be
                removed once no client reads it.
              example: 200

    CorridorPayoutMethod:
      type: object
      required: [method, required_fields]
      properties:
        method:
          $ref: '#/components/schemas/PayoutMethod'
        required_fields:
          type: array
          description: >-
            Beneficiary fields this method requires, with their validation.
            Render and validate from this rather than hardcoding a form per
            corridor.
          items:
            $ref: '#/components/schemas/VerificationFieldSpec'

    CorridorLimits:
      type: object
      description: >-
        The limits in force for this partner on this corridor, in the send
        currency. **Values are server-supplied and are published here to be
        rendered, not to be evaluated.** The platform is the sole authority: a
        client must not pre-decide a denial from these numbers, must not cache
        them beyond a session, and must treat the platform's rejection as the
        answer. Amounts are absent when no limit of that kind applies — absent
        means "no limit expressed here", never "no limit".

        Amounts are integer minor units (`MinorUnitAmount`), so a limit on a
        zero-decimal currency cannot be published with cents in it — which
        matters more here than almost anywhere, because a limit that is wrong by
        a factor of a hundred either blocks every payment or none.
      properties:
        per_payment_min:
          $ref: '#/components/schemas/MinorUnitAmount'
        per_payment_max:
          $ref: '#/components/schemas/MinorUnitAmount'
        per_day_max:
          $ref: '#/components/schemas/MinorUnitAmount'
        per_month_max:
          $ref: '#/components/schemas/MinorUnitAmount'

    DevicePlatform:
      type: string
      description: >-
        Device class. Deliberately **not** a push provider: this contract names
        no notification vendor, so the platform can change or add delivery
        providers without a contract change.
      enum:
        - IOS
        - ANDROID
        - WEB

    RegisterDeviceRequest:
      type: object
      required: [platform, push_token]
      properties:
        platform:
          $ref: '#/components/schemas/DevicePlatform'
        push_token:
          type: string
          maxLength: 4096
          description: >-
            Opaque provider token for this device. **Write-only**: never
            returned by any endpoint, never echoed in an error, never logged.
          example: opaque-device-token-000000
        device_label:
          type: string
          maxLength: 60
          description: >-
            Optional end-user-visible label for a device list. Must not contain
            personal data beyond what the user typed.

    Device:
      type: object
      required: [id, platform, created_at]
      properties:
        id:
          type: string
          pattern: '^dev_[a-zA-Z0-9]+$'
          example: dev_2Fk7wQ4mNb
        platform:
          $ref: '#/components/schemas/DevicePlatform'
        device_label:
          type: string
          maxLength: 60
        created_at:
          type: string
          format: date-time
        last_seen_at:
          type: string
          format: date-time

    SupportCaseCategory:
      type: string
      enum:
        - PAYMENT_ISSUE
        - RECIPIENT_ISSUE
        - VERIFICATION_ISSUE
        - ACCOUNT_ISSUE
        - OTHER

    SupportCaseStatus:
      type: string
      description: Treat an unrecognised value as open.
      enum:
        - open
        - in_progress
        - resolved
        - closed

    CreateSupportCaseRequest:
      type: object
      required: [category, message]
      properties:
        category:
          $ref: '#/components/schemas/SupportCaseCategory'
        message:
          type: string
          maxLength: 2000
          description: >-
            What the end user reported, stored verbatim on a durable case record
            read by humans. Never place credentials, card numbers, document
            images or API keys in it.
        payment_id:
          type: string
          pattern: '^pay_[a-zA-Z0-9]+$'
          description: The payment the case is about, when there is one.
          example: pay_abc123def456
        contact_email:
          type: string
          format: email
          description: >-
            Reply address, when it differs from the end user's profile email.
          example: test.sender@example.com

    SupportCase:
      type: object
      required: [id, end_user_id, case_number, category, status, created_at]
      properties:
        id:
          type: string
          pattern: '^sup_[a-zA-Z0-9]+$'
          example: sup_4Ln8vC3hJk
        end_user_id:
          type: string
          pattern: '^eu_[a-zA-Z0-9]+$'
          example: eu_7Qm4xK2pLd
        case_number:
          type: string
          description: >-
            Human-quotable reference to give the end user. Stable for the life
            of the case.
          example: CASE-2026-0004567
        category:
          $ref: '#/components/schemas/SupportCaseCategory'
        status:
          $ref: '#/components/schemas/SupportCaseStatus'
        payment_id:
          type: string
          pattern: '^pay_[a-zA-Z0-9]+$'
          example: pay_abc123def456
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    AccountReadiness:
      type: object
      description: >-
        Composite **production** go-live readiness, derived from the same rows
        the money-path gate reasons from — so `can_send_live` is not an estimate.
      required: [environment, readiness, can_send_live, requirements]
      properties:
        environment:
          type: string
          description: The environment this projection describes.
        readiness:
          type: string
          enum: [ready, not_ready]
        can_send_live:
          type: boolean
          description: >-
            The readiness fold **and** corridor capability. True only when a live
            payment would actually clear the gate.
        requirements:
          type: array
          items:
            $ref: '#/components/schemas/ReadinessRequirement'

    ReadinessRequirement:
      type: object
      description: One go-live requirement, with its own blocking status.
      required: [key, status, blocking, message]
      properties:
        key:
          type: string
          description: Stable machine key for the dimension.
          example: compliance
        status:
          type: string
          description: Partner-facing status for this requirement.
        blocking:
          type: boolean
          description: >-
            Whether failing this prevents go-live. Compliance, Funding and
            corridor capability block; treasury and commercial are carried for
            transparency and never block.
        reason:
          type: string
          description: >-
            Typed, non-leaky code for why this is outstanding. Never free text
            and never a compliance detail.
          example: capability_not_enabled
        message:
          type: string
          description: Partner-facing text. Corridor-neutral.
          example: Live sending is not yet enabled on any corridor.
        action_url:
          type: string
          format: uri
          description: >-
            A self-serve next step where one genuinely exists. **Never
            fabricated** — absent when there is nothing the partner can do alone.

    BeneficiaryRelationship:
      type: string
      description: >-
        The sender's stated relationship to this beneficiary.

        **Platform data, not client state.** It is a record-keeping element for a
        remittance — a sender-beneficiary relationship is the kind of thing an
        examiner asks about, and it also feeds risk: a first payment to a
        newly-added beneficiary described as `OTHER` is a different signal from
        one to a spouse. Holding it in a client or a BFF would put a
        record-keeping element outside the retained record.

        Sender-stated and **never verified** — it is what the customer said, not
        a fact the platform has established, and nothing may be inferred from it
        beyond that. Optional, because compelling an answer produces a worse one.
      enum:
        - SELF
        - SPOUSE
        - PARENT
        - CHILD
        - SIBLING
        - OTHER_FAMILY
        - FRIEND
        - BUSINESS
        - OTHER

    SelfServeSignupRequest:
      type: object
      required: [name]
      properties:
        name:
          type: string
          minLength: 1
          description: Display name for the new sandbox partner
          example: Acme Remit
        contact_email:
          type: string
          format: email
          description: Optional contact email for the partner
    SelfServeSignupResponse:
      type: object
      required: [id, name, status, corridor_entitlements, created_at, updated_at, api_key]
      properties:
        id:
          type: string
          description: Partner id (`partner_<slug>`)
        name:
          type: string
        status:
          type: string
          description: Always `SANDBOX` for self-serve signup
          example: SANDBOX
        contact_email:
          type: string
          format: email
        corridor_entitlements:
          type: array
          items:
            type: object
            required: [corridor, enabled]
            properties:
              corridor:
                type: string
              enabled:
                type: boolean
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        api_key:
          type: object
          required: [id, environment, key_prefix, secret]
          properties:
            id:
              type: string
            environment:
              type: string
              description: Always `sandbox`
              example: sandbox
            key_prefix:
              type: string
              description: Non-secret display prefix
            secret:
              type: string
              description: >
                One-time `sk_sandbox_*` secret. Shown once; only its hash is
                stored. Treat as a credential — never log it.
    ClientConfig:
      type: object
      description: >-
        Operating posture for a client application. Read on launch and on
        foreground; never cached past `max_age_seconds`.
      required: [money_operations_enabled, max_age_seconds]
      properties:
        money_operations_enabled:
          type: boolean
          description: >-
            When `false`, the client SHALL disable every send affordance and
            SHALL keep read and support surfaces reachable. A customer with a
            transfer in flight still needs to see it and still needs to reach
            support.
        money_operations_message:
          type: string
          maxLength: 300
          description: >-
            Consumer-safe text to show when money operations are disabled. Set
            by the platform so the wording is not invented per client. It
            SHALL NOT name a rail, a provider, a compliance stage, or a cause.
          example: Sending is temporarily unavailable. You can still view your transfers and contact support.
        minimum_version:
          type: string
          description: >-
            The lowest client version permitted to move money, as the client's
            own version string. A client below it SHALL disable money operations
            and prompt the customer to update, independently of
            `money_operations_enabled`.
          example: 1.4.0
        upgrade_url:
          type: string
          format: uri
          description: Where to send a customer whose build is below `minimum_version`.
        max_age_seconds:
          type: integer
          minimum: 0
          description: >-
            How long this answer may be cached. A short value is the point: this
            is the control that has to arrive quickly when it changes.
          example: 300

    SupportCaseList:
      type: object
      description: >-
        Every support case this end user has raised, newest first. Unpaginated:
        the set is small by construction, and a customer who cannot see all of
        their own open cases cannot tell whether the one they care about was
        received.
      required: [data]
      properties:
        data:
          type: array
          description: Cases, ordered `created_at DESC, id DESC`.
          items:
            $ref: '#/components/schemas/SupportCase'

    EndUserLimits:
      type: object
      description: >-
        What this end user may still send, per corridor, as of `as_of`.

        **Server-computed and authoritative.** A client renders these and never
        evaluates, caches as authoritative, or pre-empts a limit locally — the
        platform is the only authority on whether a payment is permitted, and a
        client-side check that disagrees would either block a payment the
        platform would have allowed and audited, or imply one it would refuse.

        `remaining` is the figure a customer can act on ("you can send $120.00
        more today"). It is **not** the corridor maximum, which is in
        `GET /corridors/{corridor_code}` and is the same for everyone.
      required: [as_of, corridors]
      properties:
        as_of:
          type: string
          format: date-time
          description: >-
            When these figures were computed. They move as the end user
            transacts and as rolling windows age out, so read them immediately
            before a send rather than caching them.
        corridors:
          type: array
          description: One entry per corridor this end user's tenant is entitled to.
          items:
            $ref: '#/components/schemas/EndUserCorridorLimits'
        next_tier:
          $ref: '#/components/schemas/NextTier'

    EndUserCorridorLimits:
      type: object
      description: >-
        Remaining allowances for one corridor. Each `*_remaining` is what is
        left of the corresponding ceiling; a zero means the next payment of any
        size is refused on that dimension.
      required: [corridor, currency]
      properties:
        corridor:
          $ref: '#/components/schemas/CorridorCode'
        currency:
          type: string
          pattern: '^[A-Z]{3}$'
          description: The send currency these allowances are denominated in.
          example: CAD
        per_payment_max:
          allOf:
            - $ref: '#/components/schemas/MinorUnitAmount'
          description: The largest single payment currently permitted.
        per_day_remaining:
          allOf:
            - $ref: '#/components/schemas/MinorUnitAmount'
          description: Remaining in the rolling 24-hour window.
        per_month_remaining:
          allOf:
            - $ref: '#/components/schemas/MinorUnitAmount'
          description: Remaining in the rolling 30-day window.
        payment_count_remaining:
          type: integer
          minimum: 0
          description: >-
            Remaining payments in the rolling 24-hour window, where a count
            limit applies. A volume allowance can be intact while this is zero.

    NextTier:
      type: object
      description: >-
        The tier that would raise these limits, and how to reach it — so a
        denial can tell a customer what to do next rather than only what they
        cannot do.

        **It carries no limit values.** Those are a compliance determination and
        are returned only once the tier is actually reached; publishing them
        early would let a client present an allowance nobody has approved.
      required: [tier]
      properties:
        tier:
          type: string
          description: Opaque tier identifier. Not for display.
          example: tier_2
        display_name:
          type: string
          description: Consumer-safe name for the tier, if the tenant sets one.
          example: Verified
        verification_requirements_url:
          type: string
          format: uri
          description: >-
            Relative path to `GET /end-users/{end_user_id}/verification-requirements`
            for the steps that reach this tier.

    Error:
      type: object
      description: |
        The error envelope, returned by every error on every operation,
        `request_id` included. Codes are catalogued at
        https://docs.mojaswitch.com/guides/errors/; branch on `code`, never on
        `message`.
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              description: Stable error code. Branch on it.
            message:
              type: string
            payment_id:
              type: string
              description: Present when the error concerns a specific payment.
            request_id:
              type: string
              description: >-
                Identifies the request (also the `X-Request-Id` header). Quote it
                to support: it ties a report to our logs.
            details:
              type: array
              items:
                type: object
                properties:
                  field:
                    type: string
                  message:
                    type: string
            end_user_id:
              type: string
              pattern: '^eu_[a-zA-Z0-9]+$'
              x-moja-status: planned
              description: >-
                🔲 Planned — not emitted. Would be present when the error concerns a
                specific end user.
            next_available_at:
              type: string
              format: date-time
              x-moja-status: planned
              description: >-
                🔲 Planned — not emitted. When an unavailable capability is expected
                back (RFC 3339).

  responses:
    BadRequest:
      description: Invalid request
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    RateLimited:
      description: |
        Too many requests for the current rate-limit window. The response has
        `error.code` `rate_limit_exceeded` and a `Retry-After` header.
      headers:
        Retry-After:
          description: Seconds until the caller may retry
          schema:
            type: integer
            minimum: 1
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Unauthorized:
      description: Missing or invalid credentials
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Forbidden:
      description: >-
        Partner disabled, corridor not entitled, insufficient scope, tenant not
        enabled, or account not ready (`error.code` distinguishes them).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    NotFound:
      description: Resource not found
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Conflict:
      description: State conflict (e.g. cancel after collection)
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Unprocessable:
      description: Validation failed
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    ScreeningUnavailable:
      description: |
        The compliance screening dependency did not answer (transport error
        or fault), so the request was refused. `error.code` is
        `screening_unavailable`. No screening decision was reached, and nothing
        was written: for `POST /end-users` no account was created, for
        `POST` (save) a recipient no recipient was saved, and for `PATCH` of an
        end user or a recipient nothing changed. Retry once the dependency
        recovers.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    FXUnavailable:
      description: |
        `error.code` is `fx_unavailable`. Moja Switch has no validated, fresh
        reference rate for the corridor (it is stale, unvalidated, outside its
        tolerance band, or no source is configured), so it does not price from
        it. No return time is given; retry later.

        Render the no-quote state. **Do not fall back to a cached, indicative or
        last-known rate** — that is how a customer is quoted a number nobody can
        execute.

        `error.code` may instead be `entitlement_unavailable`: we could not
        confirm your access to the corridor, so nothing was priced. Retry
        later.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    EntitlementUnavailable:
      description: |
        `error.code` is `entitlement_unavailable`. We could not confirm your
        access to the corridor, so nothing was created. This is not a decision
        about your access: a corridor you are not entitled to is
        `403 corridor_not_entitled`. Retry later, with
        the same `Idempotency-Key` for a payment.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
