openapi: 3.0.3
info:
  title: StixPay Merchant API
  version: 1.0.0
  description: |
    Merchant checkout API — on-ramp (card) and direct acquirer flows.

    ## Authentication

    Send `X-API-Key` with your API key on every authenticated request:
    - `sdk_…` — Test / sandbox (StixPay sandbox checkout only)
    - `pk_…` — Live / production providers only

    Webhook signing secrets (`secret_…`) are created per endpoint on the merchant Webhooks page (HMAC-SHA256 of the raw body → `X-Webhook-Signature`). Do not send them as `X-API-Key`.
    Do not send a `sandbox` flag in the body — environment comes from the key prefix.

    Reseller keys also require `merchant_id` (`merchants.id` of a linked sub-merchant) in the JSON body, POST fields, or query string.

    ## Integration flow (required)

    1. `POST /api/kyc/start` — create/onboard the customer with full PII (phone, address, birthdate, city, postal, country…).
    2. `POST /api/payment/create` — payment fields only (amount, currency, email, URLs…). Phone/address/DOB are not accepted here.

    ## Hosted checkout

    `POST /api/payment/create` (alias: `POST /api/payment/`) returns `payment_id` and `redirect_url`
    to StixPay hosted pages.

servers:
  - url: https://stixpay.com
    description: Production
  - url: http://localhost
    description: Local

tags:
  - name: Health
  - name: Products
  - name: Payments
  - name: KYC
    x-displayName: Customer KYC (merchant-hosted)
  # Hidden from public docs (see DOCS_VISIBILITY in docs/index.html) — endpoints remain live.
  - name: NftCheckout
    x-displayName: NFT Checkout (Wert NFT)
    x-doc-hidden: true
  - name: StixPass
    x-displayName: StixPass (status & KYC link)
    x-doc-hidden: true
  - name: BalanceQuotes
    x-displayName: Balance & Quotes
  - name: Webhooks

paths:
  /api/health:
    get:
      tags: [Health]
      summary: Platform health & latency
      description: |
        Public status for merchant monitoring (no API key).
        Returns overall status (`operational`, `degraded`, `outage`) and per-service latency in milliseconds.
        Does not expose internal infrastructure (database, environment, etc.).
      security: []
      responses:
        '200':
          description: Platform operational or degraded
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  status: { type: string, enum: [operational, degraded, outage] }
                  checked_at: { type: string, format: date-time }
                  response_time_ms: { type: number }
                  services:
                    type: object
                    additionalProperties:
                      type: object
                      properties:
                        status: { type: string }
                        latency_ms: { type: number }
                  api_version: { type: string }
              example:
                success: true
                status: operational
                checked_at: '2026-08-19T11:07:57+00:00'
                response_time_ms: 24.5
                services:
                  checkout_api: { status: operational, latency_ms: 8.2 }
                  payment_processing: { status: operational, latency_ms: 6.1 }
                  webhooks: { status: operational, latency_ms: 4.0 }
                  fx_quotes: { status: operational, latency_ms: 3.5 }
                api_version: '1.0.0'
        '503':
          description: Platform outage (checkout unavailable)

  /api/kyc/start:
    post:
      tags: [KYC]
      summary: Start merchant-hosted Light KYC
      description: |
        **Light KYC only.** Full Verification (id) is not available via merchant API — the customer must
        complete it in their StixPay account (use `set_password_url` for new customers).

        **One call:** create a StixPay customer account (if the email is new), save profile data,
        and start Sumsub Light KYC verification.

        Send **all** profile fields below. StixPay persists the profile, forwards it to Sumsub, and starts review.
        When `country` is `US` (or `USA`, normalized to `US`), a **state** field is **required**.
        Accepted keys (same value): `customer_state`, `state`, or `state_of_residence` — ISO 3166-2
        subdivision code, 2 letters (e.g. `CA`, `NY`).

        If the email is unknown, a customer account is created automatically (`customer_created: true`)
        with wallet + profile (Test/sandbox and Live). A one-time `set_password_url` (7-day TTL) lets the
        customer access their portal. StixPay sends a welcome e-mail with that link on both Test and Live
        when mail delivery succeeds (`welcome_email_sent: true`). If the customer has not set a password yet,
        a later KYC start re-issues the link, and `POST /api/payment/create` hosted checkout offers the same
        create-password or Stix-generated-password options as classic signup.

        The customer can be linked to multiple merchants; each merchant only sees their own KYC link.

        After KYC starts, call `POST /api/payment/create` with the **same** email — payment fields only
        (no phone / address / birthdate). Identity mismatches return `422 customer_identity_mismatch`.

        **Test vs Live isolation:** identity lock and Light KYC approval are keyed by API plane
        (`sdk_` sandbox vs `pk_` production). Sandbox KYC never writes or locks the live profile.
        A customer already verified in Live can start a separate Sandbox KYC with different PII
        (same email) without `customer_identity_mismatch`.
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - customer_email
                - customer_first_name
                - customer_lastname
                - customer_phone
                - birthdate
                - customer_address
                - postal_code
                - city
                - country
                - level
              properties:
                customer_email: { type: string, format: email }
                customer_first_name: { type: string }
                customer_lastname: { type: string }
                customer_phone: { type: string, description: 'E.164 recommended (e.g. +33611223344)' }
                birthdate: { type: string, format: date, description: 'YYYY-MM-DD' }
                customer_address: { type: string }
                postal_code: { type: string }
                city: { type: string }
                country: { type: string, description: 'ISO 3166-1 alpha-2 (e.g. FR, US). `USA` is normalized to `US`.' }
                customer_state:
                  type: string
                  description: |
                    US state / subdivision — **required when country is US** (or USA).
                    2-letter code (e.g. CA, NY). Aliases: `state`, `state_of_residence` (same semantics).
                state:
                  type: string
                  description: 'Alias of `customer_state` (2-letter US state code when country is US).'
                state_of_residence:
                  type: string
                  description: 'Alias of `customer_state` (2-letter US state code when country is US).'
                level: { type: string, enum: [light], description: 'Must be light — id/full verification is customer-portal only' }
                customer_user_id: { type: integer, description: 'Optional lookup if email omitted' }
            example:
              customer_email: buyer@example.com
              customer_first_name: Jane
              customer_lastname: Doe
              country: FR
              level: light
              customer_phone: '+33611223344'
              postal_code: '75001'
              city: Paris
              customer_address: 1 rue de Rivoli
              birthdate: '1990-01-15'
            examples:
              fr:
                summary: France (default)
                value:
                  customer_email: buyer@example.com
                  customer_first_name: Jane
                  customer_lastname: Doe
                  country: FR
                  level: light
                  customer_phone: '+33611223344'
                  postal_code: '75001'
                  city: Paris
                  customer_address: 1 rue de Rivoli
                  birthdate: '1990-01-15'
              us:
                summary: United States (state required)
                value:
                  customer_email: buyer@example.com
                  customer_first_name: Jane
                  customer_lastname: Doe
                  country: US
                  customer_state: CA
                  level: light
                  customer_phone: '+14155550123'
                  postal_code: '94102'
                  city: San Francisco
                  customer_address: 1 Market St
                  birthdate: '1990-01-15'
      responses:
        '200':
          description: KYC started or already approved
        '422':
          description: Validation error or customer_identity_mismatch
        '502':
          description: Sumsub error
        '503':
          description: Sumsub not configured

  /api/kyc/status:
    get:
      tags: [KYC]
      summary: Poll KYC status (opaque)
      description: |
        Returns opaque KYC state for a customer you started via `/api/kyc/start`.
        No documents or Sumsub applicant ids are exposed.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: customer_email
          in: query
          schema: { type: string, format: email }
        - name: customer_user_id
          in: query
          schema: { type: integer }
        - name: external_user_id
          in: query
          schema: { type: string }
      responses:
        '200':
          description: Status object
        '403':
          description: KYC was not started by this merchant for this customer

  /api/product/create:
    post:
      tags: [Products]
      summary: Create NFT catalogue product
      description: |
        Register a digital license NFT product for this merchant. Returns `product_id` (unique, e.g. `lic_…`)
        to pass in `POST /api/payment/create`.

        **License fields (written into on-chain NFT metadata after mint):**
        - `license_type` — `perpetual` | `subscription` | `one_time`
        - `validity_days` — **required** for `subscription` / `one_time` (duration in days). Mint also sets `valid_from` / `valid_until` and passes them to `buyLicense`.
        - `activation_site` — **required** when `status` is `published` (site URL where the license can be activated).
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateNftCatalogProduct'
            examples:
              subscription:
                summary: Subscription (30 days)
                value:
                  name: Premium Membership - Filesharing Shop
                  description: Accès à notre plateforme de file sharing
                  fiat_amount: 19.99
                  fiat_currency: EUR
                  product_code: filesharing_premium
                  license_type: subscription
                  validity_days: 30
                  activation_site: https://example.com
              perpetual:
                summary: Perpetual license
                value:
                  name: Lifetime Access - Filesharing Shop
                  description: Accès permanent
                  fiat_amount: 49.99
                  fiat_currency: EUR
                  product_code: filesharing_lifetime
                  license_type: perpetual
                  activation_site: https://example.com
      responses:
        '201':
          description: Product created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NftCatalogProductResponse'
        '200':
          description: Product updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NftCatalogProductResponse'
        '422':
          description: Validation error (e.g. missing validity_days or activation_site)

  /api/payment/create:
    post:
      tags: [Payments]
      summary: Create payment
      description: |
        Hosted card checkout (on-ramp / direct acquirer). Returns `payment_id` and `redirect_url`
        to StixPay hosted pages.

        **Requires** a prior `POST /api/kyc/start` for the same `customer_email` (and merchant).
        Do **not** send phone, address, postal_code, city, or birthdate here — those belong on KYC start.

        **NFT product payments:** create a catalogue row with `POST /api/product/create`, then
        `POST /api/payment/create` with `product_id`. The hosted top-up shows **NFT checkout providers only** (Wert NFT).
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreatePaymentRequest'
            examples:
              card_checkout:
                summary: Card / on-ramp checkout (after KYC start)
                value:
                  amount: 19.99
                  currency: EUR
                  payment_method: credit_card
                  merchant_order_id: ORDER_123
                  customer_email: buyer@example.com
                  customer_first_name: Jane
                  customer_lastname: Doe
                  country: FR
                  store_name: Example Store
                  success_url: https://test.fr/success
                  failure_url: https://test.fr/failed
              nft_product:
                summary: NFT catalogue product (product_id)
                value:
                  product_id: lic_ab12cd34ef
                  merchant_order_id: ORDER_NFT_123
                  customer_email: buyer@example.com
                  customer_first_name: Jane
                  customer_lastname: Doe
                  country: FR
                  store_name: Filesharing Shop
                  success_url: https://shop.example/success
                  failure_url: https://shop.example/failed
      responses:
        '200':
          description: Checkout session created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreatePaymentResponse'
        '400':
          description: Validation / business error
        '403':
          description: KYB / MCC / agreement block
        '422':
          description: |
            KYC required (`error: kyc_required`) when the customer has no Light KYC approved
            on this API plane (sandbox vs live). KYC is shared across merchants for the same
            customer email. Also `error: customer_identity_mismatch` on identity mismatch.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: false }
                  error: { type: string, example: kyc_required }
                  message: { type: string }

  /api/payment/:
    post:
      tags: [Payments]
      summary: Create payment (alias)
      description: |
        Same as `/api/payment/create`. Prefer the `/create` path in new integrations.

        **NFT product payments:** use `product_id` from `POST /api/product/create` (hosted top-up, NFT providers only).
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreatePaymentRequest'
      responses:
        '200':
          description: Checkout session created

  /api/payment/{payment_id}:
    get:
      tags: [Payments]
      summary: Get payment
      security:
        - ApiKeyAuth: []
      parameters:
        - $ref: '#/components/parameters/PaymentId'
      responses:
        '200':
          description: Payment object
        '404':
          description: Not found

  /api/payment/{payment_id}/status:
    get:
      tags: [Payments]
      summary: Payment status
      security:
        - ApiKeyAuth: []
      parameters:
        - $ref: '#/components/parameters/PaymentId'
      responses:
        '200':
          description: Lightweight status
        '404':
          description: Not found

  /api/payment/{payment_id}/cancel:
    post:
      tags: [Payments]
      summary: Cancel payment
      security:
        - ApiKeyAuth: []
      parameters:
        - $ref: '#/components/parameters/PaymentId'
      responses:
        '200':
          description: Cancelled
        '404':
          description: Not found
        '422':
          description: Not cancellable

  /api/transactions:
    get:
      tags: [Payments]
      summary: List transactions
      security:
        - ApiKeyAuth: []
      parameters:
        - name: page
          in: query
          schema: { type: integer, default: 1 }
        - name: per_page
          in: query
          schema: { type: integer, default: 20 }
        - name: q
          in: query
          schema: { type: string }
          description: Search merchant_order_id, email, or payment_id
        - name: status
          in: query
          schema: { type: string }
      responses:
        '200':
          description: Paginated list

  /api/balance:
    get:
      tags: [BalanceQuotes]
      summary: Balance
      security:
        - ApiKeyAuth: []
      responses:
        '200':
          description: Available / hold / reserve in EUR

  /api/quote:
    get:
      tags: [BalanceQuotes]
      summary: Quote
      security:
        - ApiKeyAuth: []
      parameters:
        - name: amount
          in: query
          required: true
          schema: { type: number }
        - name: currency
          in: query
          required: true
          schema: { type: string }
        - name: to_currency
          in: query
          schema: { type: string, default: EUR }
      responses:
        '200':
          description: Indicative FX quote
        '422':
          description: Validation or missing rate

  /api/rates:
    get:
      tags: [BalanceQuotes]
      summary: Reference rates
      security:
        - ApiKeyAuth: []
      parameters:
        - name: from
          in: query
          schema: { type: string }
        - name: to
          in: query
          schema: { type: string }
      responses:
        '200':
          description: FX rate table

  /api/webhook/test:
    post:
      tags: [Webhooks]
      summary: Test webhook
      security:
        - ApiKeyAuth: []
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                url: { type: string, format: uri }
                event: { type: string, default: webhook.test }
            example:
              url: https://example.com/webhooks/stixpay
              event: webhook.test
      responses:
        '200':
          description: Delivered
        '422':
          description: Delivery failed

  /api/gateway/wert_nft/status.php:
    get:
      tags: [NftCheckout]
      summary: NFT checkout order status
      description: |
        Poll payment / mint after NFT checkout (`product_id` flow).
        Lookup by your `merchant_order_id` (same value sent on `POST /api/payment/create`).
        Requires API key. Replaces the legacy `wert_nft/session.php` / `license_session.php` session endpoints.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: order
          in: query
          required: true
          schema: { type: string }
          description: Your merchant_order_id
      responses:
        '200':
          description: |
            `{ success, order: { merchant_order_id, asset_type, payment_status, mint_status, nft_id, operation_id, outcome, success_url, failure_url } }`
        '401':
          description: Unauthorized
        '404':
          description: Not found

  /api/gateway/stixpass/customer_status.php:
    x-doc-hidden: true
    get:
      tags: [StixPass]
      summary: Customer StixPass status (no KYC)
      description: |
        Returns membership, wallet, licenses bought from this merchant, non-KYC badges, and benefits.
        Never exposes KYC level / Sumsub data. Customer must have prior NFT activity with this merchant.
        Rate limit: 120/hour/merchant.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: customer_user_id
          in: query
          schema: { type: integer }
        - name: email
          in: query
          schema: { type: string }
        - name: external_user_id
          in: query
          schema: { type: string, example: cust_123 }
      responses:
        '200':
          description: Status payload (`kyc` is always null)
        '403':
          description: Customer not linked to this merchant
        '404':
          description: Customer not found
        '429':
          description: Rate limit exceeded
    post:
      tags: [StixPass]
      summary: Customer StixPass status (POST)
      security:
        - ApiKeyAuth: []
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                customer_user_id: { type: integer }
                email: { type: string }
                external_user_id: { type: string }
      responses:
        '200':
          description: Status payload
        '429':
          description: Rate limit exceeded

  /api/gateway/stixpass/kyc_link.php:
    x-doc-hidden: true
    post:
      tags: [StixPass]
      summary: Sumsub WebSDK token for merchant-hosted KYC
      description: |
        Light KYC only. Issues a short-lived Sumsub access token so the merchant can run Light KYC on their site.
        Full Verification (id) must be completed by the customer in their StixPay account.
        Unknown emails are soft-created (`customer_created=true`). Rate limit: 30/hour/merchant.
        On GREEN, StixPass updates passport / KYC badges and may emit opaque `stixpass.kyc.updated` to merchants.
        Do not expose document payloads to merchants.
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [level]
              properties:
                customer_user_id: { type: integer }
                email: { type: string, format: email }
                first_name: { type: string }
                last_name: { type: string }
                level: { type: string, enum: [light], example: light }
            example:
              email: buyer@example.com
              level: light
      responses:
        '200':
          description: Token, already_approved, and customer_created flag
        '429':
          description: Rate limit exceeded
        '502':
          description: Sumsub token failed
        '503':
          description: Sumsub not configured

components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
      description: API key — `sdk_…` (sandbox) or `pk_…` (production). Rejects webhook `secret_…`.

  parameters:
    PaymentId:
      name: payment_id
      in: path
      required: true
      schema: { type: string }
      description: api_payment_requests.id returned by Create payment

  schemas:
    CreatePaymentRequest:
      type: object
      description: |
        Call `POST /api/kyc/start` first. Create payment accepts payment fields only
        (no phone / address / birthdate / postal_code / city).
        With `product_id`, `amount` and `currency` are optional (taken from the catalogue product).
      required:
        - merchant_order_id
        - customer_email
        - store_name
        - success_url
        - failure_url
      properties:
        product_id:
          type: string
          description: Catalogue id from POST /api/product/create — NFT checkout only on top-up
        amount: { type: number }
        currency: { type: string }
        payment_method:
          type: string
          description: |
            Optional checkout rail for the buyer on hosted checkout (how they pay: card, Apple Pay, bank transfer, …).
            Alias: `checkout_payment_method`. Default: `credit_card`. Independent of KYC / `provider_code`.
            Availability depends on the on-ramp provider, country/currency, and methods enabled for the merchant.
            Do not send when `product_id` is set (NFT catalogue checkout).
          enum:
            - credit_card
            - apple_pay
            - google_pay
            - open_banking
            - paypal
            - ideal
            - sofort
            - klarna_paynow
            - gbp_bank_transfer
            - ach_bank_transfer
            - payid_bank_transfer
            - npp_bank_transfer
            - interac
            - pix
            - spei
            - khipu
            - zar_bank_transfer
            - pse
            - vietqr
          example: credit_card
        checkout_payment_method:
          type: string
          description: Alias of `payment_method` (same allowed values).
          example: credit_card
        merchant_order_id: { type: string }
        customer_email: { type: string, format: email }
        customer_first_name: { type: string, description: Required unless KYC already verified (profile applied server-side) }
        customer_lastname: { type: string, description: Required unless KYC already verified }
        country: { type: string, description: ISO 3166-1 alpha-2; required unless available from verified KYC profile }
        store_name: { type: string }
        success_url: { type: string, format: uri }
        failure_url: { type: string, format: uri }
        provider_code:
          type: string
          description: Optional on-ramp lock — wert | banxa_api | banxa_hosted
        idempotency_key: { type: string }
        merchant_id:
          type: integer
          description: Required for reseller API keys

    CreatePaymentResponse:
      type: object
      properties:
        success: { type: boolean }
        payment_id: { type: string }
        redirect_url: { type: string }
        action_required: { type: string }
        merchant_order_id: { type: string }
        amount: { type: number }
        currency: { type: string }
        checkout_token: { type: string }
        expires_at: { type: string }

    CreateNftCatalogProduct:
      type: object
      required: [name, fiat_amount]
      properties:
        product_id:
          type: string
          description: Update existing catalogue row (same value as response product_id; alias public_id)
        public_id: { type: string, description: Alias of product_id for updates }
        name: { type: string, example: Premium Membership - Filesharing Shop }
        description: { type: string }
        fiat_amount: { type: number, example: 19.99 }
        fiat_currency: { type: string, default: EUR }
        product_code: { type: string, example: filesharing_premium }
        license_type:
          type: string
          enum: [perpetual, subscription, one_time]
          default: perpetual
        validity_days:
          type: integer
          minimum: 1
          example: 30
          description: >
            Required for subscription / one_time. Duration in days.
            Stored in NFT metadata (validity_days, valid_from, valid_until) and passed to buyLicense.
            Ignored / cleared for perpetual.
        activation_site:
          type: string
          format: uri
          description: >
            Required when status is published (default). Site URL where the license can be activated.
            Written on-chain as activation_site. Aliases accepted: site_url, website.
          example: https://example.com
        status:
          type: string
          enum: [published, draft, archived]
          default: published
        merchant_id:
          type: integer
          description: Required for reseller API keys

    NftCatalogProduct:
      type: object
      properties:
        product_id: { type: string, example: lic_ab12cd34ef567890 }
        public_id: { type: string, example: lic_ab12cd34ef567890 }
        product_code: { type: string, example: filesharing_premium }
        name: { type: string }
        description: { type: string }
        fiat_amount: { type: number, example: 19.99 }
        fiat_currency: { type: string, example: EUR }
        license_type:
          type: string
          enum: [perpetual, subscription, one_time]
        validity_days:
          type: integer
          nullable: true
          description: null for perpetual
        activation_site:
          type: string
          example: https://example.com
        status:
          type: string
          enum: [published, draft, archived]
        sort_order: { type: integer }

    NftCatalogProductResponse:
      type: object
      properties:
        success: { type: boolean, example: true }
        product_id: { type: string, example: lic_ab12cd34ef567890 }
        product:
          $ref: '#/components/schemas/NftCatalogProduct'
        created: { type: boolean, description: true when a new row was inserted }
