openapi: 3.1.0
info:
  title: Scan & Pay API
  description: |
    Accept PayTo PayID payments via QR code. Three endpoints, one auth header,
    one signed webhook. Suitable for any backend in any language.

    Credentials are issued from the merchant dashboard at
    https://merchant.scanandpay.com.au under Settings → Integrations.

    **Rate limits** — requests are rate-limited per API key. When exceeded the
    API returns `429 Too Many Requests`. Back off with exponential jitter before
    retrying. The official SDKs handle this automatically.
  version: "1.0.0"
  contact:
    name: Scan & Pay
    url: https://docs.scanandpay.com.au
    email: hi@scanandpay.com.au
  license:
    name: Proprietary
servers:
  - url: https://api.scanandpay.com.au
    description: Production

security:
  - ApiKeyAuth: []

tags:
  - name: Payments
    description: Create and read payment sessions.
  - name: Health
    description: Service availability.

paths:
  /createPaymentSession:
    post:
      tags: [Payments]
      summary: Create a payment session
      description: |
        Returns a session ID, payment URL, and a base64-encoded PNG of the QR
        code customers scan to pay. Sessions expire 5 minutes after creation.

        The endpoint is **idempotent** by `(merchantId, platformOrderId)`. Reposting
        the same pair returns the existing session instead of creating a new one.
      operationId: createPaymentSession
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateSessionRequest"
            examples:
              minimal:
                summary: Minimal valid request
                value:
                  merchantId: xxxxxxxxxxxxxxxxxxxx
                  platformOrderId: "order_456"
                  amount: 19.90
                  payId: merchant@example.com.au
                  merchantName: Acme Coffee
      responses:
        "200":
          description: Session created (or existing session returned via idempotency).
          headers:
            Cache-Control:
              schema: { type: string }
              description: Always `no-store, no-cache, must-revalidate, private`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PaymentSession"
        "400":
          description: Missing required field, invalid amount, or non-AUD currency.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401":
          description: Missing or invalid `X-Scanpay-Key` header.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "403":
          description: Merchant subscription is not active.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SubscriptionErrorResponse" }
        "405":
          description: Method other than POST.
        "429":
          description: Rate limit exceeded. Retry with exponential backoff.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "500":
          description: Internal server error.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /getPaymentStatus:
    get:
      tags: [Payments]
      summary: Read the current state of a payment session
      description: |
        Poll this endpoint at ≥2 second intervals to learn when a session has been
        paid. Stop polling once `status` is terminal (`PAID`, `EXPIRED`, or
        `FAILED`). For server-to-server flows, prefer subscribing to webhooks
        instead of polling.
      operationId: getPaymentStatus
      parameters:
        - name: sessionId
          in: query
          required: true
          schema:
            type: string
            example: SP_SESS_abc123def456
      responses:
        "200":
          description: Session state.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PaymentStatusResponse"
        "400":
          description: Missing `sessionId` query parameter or malformed session ID.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401":
          description: Missing or invalid `X-Scanpay-Key` header.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "404":
          description: Session does not exist.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "405":
          description: Method other than GET.
        "429":
          description: Rate limit exceeded. Retry with exponential backoff.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /ping:
    get:
      tags: [Health]
      summary: Health check
      operationId: ping
      security: []
      responses:
        "200":
          description: Service is up.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  message: { type: string, example: pong }
                required: [success, message]

components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-Scanpay-Key
      description: |
        The API Secret issued from the merchant dashboard. Send on every request
        except `/ping`.

  schemas:
    CreateSessionRequest:
      type: object
      required: [merchantId, platformOrderId, amount, payId, merchantName]
      properties:
        merchantId:
          type: string
          description: Your Merchant ID from the dashboard.
          example: xxxxxxxxxxxxxxxxxxxx
        platformOrderId:
          type: string
          description: Your unique order reference. Used as the idempotency key.
          example: "order_456"
        amount:
          type: number
          minimum: 0.01
          description: Total in AUD (e.g. `19.90` = $19.90).
          example: 19.90
        currency:
          type: string
          enum: [AUD]
          default: AUD
          description: Only `AUD` is supported today.
        reference:
          type: string
          description: Customer-visible reference string. Defaults to `Order #{platformOrderId}`.
          example: "Order #456"
        payId:
          type: string
          description: The merchant's registered PayID (email, phone, or ABN).
          example: merchant@example.com.au
        merchantName:
          type: string
          description: Display name on the customer payment screen.
          example: Acme Coffee

    PaymentSession:
      type: object
      properties:
        success: { type: boolean, example: true }
        sessionId:
          type: string
          example: SP_SESS_abc123def456
        payUrl:
          type: string
          format: uri
          description: Hosted payment page (for fallback / sharing).
          example: https://pay.scanandpay.com.au/p/SP_SESS_abc123def456
        qrUrl:
          type: string
          description: QR code as a data URI (`data:image/png;base64,...`). Render
            directly in an `<img src>` — no further encoding needed.
        payId: { type: string, example: merchant@example.com.au }
        amount: { type: number, example: 19.90 }
        currency: { type: string, example: AUD }
        reference: { type: string }
        status:
          $ref: "#/components/schemas/SessionStatus"
        expiresAt:
          type: string
          format: date-time
          description: ISO 8601, UTC. Sessions expire 5 minutes after creation.
      required: [success, sessionId, payUrl, qrUrl, status, expiresAt]

    PaymentStatusResponse:
      type: object
      properties:
        success: { type: boolean, example: true }
        sessionId: { type: string, example: SP_SESS_abc123def456 }
        status: { $ref: "#/components/schemas/SessionStatus" }
        amount: { type: number }
        currency: { type: string, example: AUD }
        payId: { type: string }
        reference: { type: string }
        merchantName: { type: string }
        createdAt:
          type: string
          format: date-time
        paidAt:
          type: [string, "null"]
          format: date-time
          description: Set when status flips to `PAID`.
        expiresAt:
          type: string
          format: date-time
      required: [success, sessionId, status]

    SessionStatus:
      type: string
      enum: [WAITING, PAID, EXPIRED, FAILED]
      description: |
        - `WAITING` — created, customer hasn't paid yet (poll continues).
        - `PAID` — funds confirmed via NPP rail. Terminal.
        - `EXPIRED` — 5-minute window elapsed without payment. Terminal.
        - `FAILED` — bank rejected or session aborted. Terminal.

    WebhookEvent:
      type: object
      description: |
        Payload posted to your webhook URL on payment state change. The signature
        in the `X-Scanpay-Signature` header is HMAC-SHA256 of the **raw JSON
        body** with your Webhook Secret as the key, hex-encoded.

        Replay protection: reject events where `timestamp` is older than 60 seconds.
        Cache the `nonce` for 24 hours and reject duplicates.
      properties:
        order_id:
          type: string
          description: Your `platformOrderId` from the original create-session call.
          example: "order_456"
        payment_session_id:
          type: string
          example: SP_SESS_abc123def456
        status:
          type: string
          enum: [confirmed, failed, expired]
          description: |
            - `confirmed` — paid (mirrors `PAID` from getPaymentStatus).
            - `failed` — bank rejected.
            - `expired` — 5-minute window elapsed.
        amount:
          type: number
          example: 19.90
        currency:
          type: string
          example: AUD
        tx_id:
          type: string
          description: Bank transaction reference.
          example: bank_ref_123
        timestamp:
          type: integer
          description: Unix epoch seconds when the event was sent.
        nonce:
          type: string
          description: Unique per delivery. Cache for 24h to ensure idempotent processing.
          example: a1b2c3d4e5f6
      required: [order_id, payment_session_id, status, amount, currency, tx_id, timestamp, nonce]

    ErrorResponse:
      type: object
      properties:
        error:
          type: string
          description: Error code.
        message:
          type: string
          description: Human-readable detail.
      required: [error]

    SubscriptionErrorResponse:
      allOf:
        - $ref: "#/components/schemas/ErrorResponse"
        - type: object
          properties:
            reason:
              type: string
              description: Subscription status code.

  headers:
    XScanpaySignature:
      description: HMAC-SHA256 hex digest of the raw request body, signed with the
        merchant's Webhook Secret.
      schema: { type: string }
