openapi: 3.1.0
info:
  title: RMO Network API
  version: "0.2.0"
  summary: Public payments API for the RMO Network — card authorizations, payment codes, and webhook subscriptions.
  description: |
    The RMO Network is a closed-loop payment network. We are both the
    acquirer (we onboard merchants) and the issuer (we issue cards and
    payment codes). The public API surface at `api.rmous.org/v1/*` is
    what merchants, processors, and developers call to authorize,
    capture, reverse, and refund transactions.

    ## Authentication

    Every mutating request requires three headers:

    | Header | Purpose |
    |---|---|
    | `X-API-Key: pk_live_…` / `pk_test_…` | Public tenant identifier |
    | `Authorization: Bearer sk_…` | Tenant secret (bcrypt-compared inside the DB, never echoed) |
    | `Idempotency-Key: <opaque>` | Required on writes — replays are returned, not re-executed |

    Optionally (per-client setting `RequireSignature`):

    | Header | Purpose |
    |---|---|
    | `X-Signature: sha256=<hex>` | HMAC-SHA256 of `METHOD\nPATH\nTIMESTAMP\nBODY` using the bearer secret |
    | `X-Timestamp: <unix>` | Rejected if drift exceeds 300s |

    ## PCI posture

    The host serving `/v1/*` has no AWS Payment Cryptography permissions
    and never sees a raw PAN. Cards are addressed exclusively by opaque
    17-character `panToken` values. PINs for payment codes are bcrypt-
    hashed in the database — the plaintext is only seen at create time
    and at redemption.

    ## Webhook signatures

    When the worker delivers an outbound event, it signs the body with
    the subscription's signing secret:

    ```
    X-RMO-Signature: sha256=<hex>     # HMAC-SHA256 of timestamp + "." + body
    X-RMO-Timestamp: <unix>
    X-RMO-Event:     authorization.approved
    X-RMO-Delivery:  <delivery RecordId>
    Idempotency-Key: <event-type>:<source idempotency key>
    ```

    Verify the signature and the timestamp drift to reject replays.
  contact:
    name: developer.rmous.org
    url: https://developer.rmous.org

servers:
  - url: https://api.rmous.org
    description: Production
  - url: https://api.rmous.org
    description: Sandbox (use `pk_test_…` API keys)

security:
  - apiKey: []
    bearerAuth: []

components:
  securitySchemes:
    apiKey:
      type: apiKey
      in: header
      name: X-API-Key
      description: Tenant public identifier. `pk_live_…` for production or `pk_test_…` for sandbox.
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: opaque
      description: Tenant secret. Stored bcrypt-hashed; compared inside the DB via `pgcrypto.crypt()`.

  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: Opaque key (≤ 80 chars) that scopes the request for replay-detection.
      schema:
        type: string
        maxLength: 80
    XSignature:
      name: X-Signature
      in: header
      required: false
      description: Required when the calling client has `RequireSignature` enabled.
      schema:
        type: string
        pattern: '^sha256=[0-9a-fA-F]{64}$'
    XTimestamp:
      name: X-Timestamp
      in: header
      required: false
      description: Unix epoch seconds. Required when signing is enabled; drift > 300s is rejected.
      schema:
        type: integer
        format: int64

  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              description: Stable machine identifier. ISO-8583 numeric codes (`14`, `54`, `61`, `62`, `65`, `75`) appear on card/code declines.
              example: "54"
            message:
              type: string
              example: Card expired
            field:
              type: string
              nullable: true
            details:
              type: object
              additionalProperties: true

    AuthorizationRequest:
      type: object
      required: [panToken, amount]
      properties:
        panToken:
          type: string
          minLength: 17
          maxLength: 17
          description: Opaque card token (`Issuing.Tokens.RecordId`). The PAN itself never traverses this API.
        amount:
          type: number
          format: double
          minimum: 0.01
          example: 12.99
        currency:
          type: string
          description: "`Core.Currencies.RecordId`. Defaults to USD."
        channel:
          type: string
          description: "`Issuing.Channels.RecordId` — POS / ECOMM / etc."
        mcc:
          type: string
          pattern: '^\\d{4}$'
        merchant:
          type: string
          description: "`Merchant.Accounts.RecordId`."
        merchantNameRaw:
          type: string
          maxLength: 64
          description: Statement-style name the merchant sent.
        avsResult:
          type: string
          enum: [Y, N, A, Z, U]
        cvvResult:
          type: string
          enum: [M, N, P, U, X]
        mode:
          type: string
          enum: ["01", "02", "03", "04", "05"]
          description: "`01` card-present · `02` CNP · `03` recurring · `04` token · `05` MOTO"
        metadata:
          type: object
          additionalProperties: true

    AuthorizationApproved:
      type: object
      properties:
        authorizationId: { type: string, description: 7-digit human ID }
        recordId:        { type: string, description: 17-char RecordId }
        status:          { type: string, enum: [approved] }
        amount:          { type: number, format: double }
        approvalCode:    { type: string, pattern: '^\\d{6}$' }
        expires:         { type: string, format: date-time }

    AuthorizationDeclined:
      type: object
      properties:
        authorizationId: { type: string, nullable: true }
        recordId:        { type: string, nullable: true }
        status:          { type: string, enum: [declined] }
        amount:          { type: number, format: double }
        reasonCode:      { type: string }
        reasonMessage:   { type: string }
        details:
          type: object
          additionalProperties: true

    AuthorizationRead:
      type: object
      properties:
        authorizationId: { type: string }
        recordId:        { type: string }
        card:            { type: string }
        amount:          { type: number, format: double }
        captured:        { type: number, format: double }
        reversed:        { type: number, format: double }
        currency:        { type: string }
        channel:         { type: string, nullable: true }
        mcc:             { type: string, nullable: true }
        mode:            { type: string, nullable: true }
        merchant:        { type: string, nullable: true }
        merchantNameRaw: { type: string, nullable: true }
        approvalCode:    { type: string, nullable: true }
        responseCode:    { type: string, nullable: true }
        avsResult:       { type: string, nullable: true }
        cvvResult:       { type: string, nullable: true }
        status:          { type: string, description: e.g. "Authorization Approved" }
        reasonCode:      { type: string, nullable: true }
        reasonTitle:     { type: string, nullable: true }
        expires:         { type: string, format: date-time, nullable: true }
        acknowledged:    { type: string, format: date-time, nullable: true }
        settled:         { type: string, format: date-time, nullable: true }
        created:         { type: string, format: date-time, nullable: true }

    CaptureRequest:
      type: object
      description: Omit `amount` to capture the full remaining hold.
      properties:
        amount:
          type: number
          format: double
          minimum: 0.01

    CaptureResponse:
      type: object
      properties:
        authorizationId:    { type: string }
        recordId:           { type: string }
        status:             { type: string, enum: [charged, partial] }
        amountCaptured:     { type: number, format: double }
        settled:            { type: string, format: date-time, nullable: true }
        acknowledged:       { type: string, format: date-time, nullable: true }
        idempotencyKey:     { type: string }

    ReverseRequest:
      type: object
      properties:
        amount:
          type: number
          format: double
          minimum: 0.01
          description: Defaults to full remaining hold or full captured amount.
        reason:
          type: string
          description: Issuing.Reasons.Code (default `RV02` customer void).
          default: RV02

    ReverseResponse:
      type: object
      properties:
        authorizationId:    { type: string }
        recordId:           { type: string }
        status:             { type: string, enum: [reversed, partial-reversed] }
        amountReversed:     { type: number, format: double }
        settled:            { type: string, format: date-time, nullable: true }
        reasonCode:         { type: string }
        idempotencyKey:     { type: string }

    IncrementRequest:
      type: object
      required: [amount]
      properties:
        amount:    { type: number, format: double, minimum: 0.01 }
        metadata:  { type: object, additionalProperties: true }

    IncrementResponse:
      type: object
      properties:
        authorizationId:     { type: string }
        recordId:            { type: string }
        parentAuthorization: { type: string }
        status:              { type: string, enum: [approved] }
        amount:              { type: number, format: double }
        approvalCode:        { type: string }
        expires:             { type: string, format: date-time }

    PaymentCodeCreateRequest:
      type: object
      required: [member]
      properties:
        member:            { type: string, description: "`Relationship.Accounts.RecordId`" }
        currency:          { type: string }
        account:           { type: string }
        merchant:          { type: string }
        maxAmount:         { type: number, format: double }
        expiryMinutes:     { type: integer, default: 10, minimum: 1 }
        singleUse:         { type: boolean, default: true }
        allowRecurring:    { type: boolean, default: false }
        recurringInterval: { type: string, enum: [daily, weekly, monthly] }
        recurringLimit:    { type: integer }
        recurringThru:     { type: string, format: date }
        lockoutThreshold:  { type: integer, default: 5 }
        displayHint:       { type: string, maxLength: 64 }
        metadata:          { type: object, additionalProperties: true }

    PaymentCodeCreated:
      type: object
      properties:
        recordId:   { type: string }
        code:       { type: string, description: 10-char Crockford alphabet }
        pin:        { type: string, description: 7-char Crockford alphabet — returned ONCE only }
        expiresAt:  { type: string, format: date-time }
        status:     { type: string, enum: [Code Active] }

    PaymentCodeAuthorizeRequest:
      type: object
      required: [code, pin, amount, merchant]
      properties:
        code:                { type: string, minLength: 10, maxLength: 10 }
        pin:                 { type: string, minLength: 7,  maxLength: 7 }
        amount:              { type: number, format: double, minimum: 0.01 }
        merchant:            { type: string }
        currency:            { type: string }
        deviceFingerprint:   { type: string }
        metadata:            { type: object, additionalProperties: true }

    PaymentCodeAuthorizeResponse:
      type: object
      properties:
        status:           { type: string, enum: [approved, declined] }
        authorizationId:  { type: string, nullable: true }
        recordId:         { type: string, nullable: true }
        amount:           { type: number, format: double }
        member:           { type: string, nullable: true }
        account:          { type: string, nullable: true }
        expires:          { type: string, format: date-time, nullable: true }
        reasonCode:       { type: string, nullable: true }
        reasonMessage:    { type: string, nullable: true }

    WebhookSubscriptionCreate:
      type: object
      required: [url, events]
      properties:
        url:
          type: string
          format: uri
          maxLength: 2048
          example: https://merchant.example.com/webhooks
        events:
          type: array
          minItems: 1
          items:
            type: string
            enum:
              - authorization.approved
              - authorization.declined
              - authorization.captured
              - authorization.reversed
        description:
          type: string
          maxLength: 256

    WebhookSubscriptionUpdate:
      type: object
      properties:
        url:         { type: string, format: uri, maxLength: 2048 }
        events:
          type: array
          minItems: 1
          items:
            type: string
            enum:
              - authorization.approved
              - authorization.declined
              - authorization.captured
              - authorization.reversed
        description: { type: string, maxLength: 256 }
        status:      { type: string, enum: [active, paused] }

    WebhookSubscription:
      type: object
      properties:
        id:           { type: string }
        url:          { type: string, format: uri }
        events:       { type: array, items: { type: string } }
        description:  { type: string, nullable: true }
        secret:       { type: string, description: Only present on create + rotate-secret. }
        secretLast4:  { type: string, minLength: 4, maxLength: 4 }
        status:       { type: string, enum: [active, paused, revoked] }
        failureCount: { type: integer }
        lastDelivery: { type: string, format: date-time, nullable: true }
        created:      { type: string, format: date-time }
        updated:      { type: string, format: date-time, nullable: true }

    WebhookDelivery:
      type: object
      properties:
        id:            { type: string }
        event:         { type: string }
        url:           { type: string, format: uri }
        attempt:       { type: integer }
        status:        { type: string, description: e.g. "Webhook Delivered" }
        responseCode:  { type: integer, nullable: true }
        error:         { type: string, nullable: true }
        scheduledAt:   { type: string, format: date-time, nullable: true }
        deliveredAt:   { type: string, format: date-time, nullable: true }
        nextRetryAt:   { type: string, format: date-time, nullable: true }
        created:       { type: string, format: date-time, nullable: true }

    Me:
      type: object
      properties:
        id:               { type: string }
        title:            { type: string }
        slug:             { type: string }
        environment:      { type: string, enum: [live, test] }
        apiKey:           { type: string }
        secretLast4:      { type: string, minLength: 4, maxLength: 4 }
        rateLimitTier:    { type: string }
        requireSignature: { type: boolean }
        status:           { type: string }
        created:          { type: string, format: date-time, nullable: true }
        lastUsed:         { type: string, format: date-time, nullable: true }

paths:
  /v1/health:
    get:
      summary: Liveness probe — unauthenticated.
      security: []
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:  { type: string, enum: [ok] }
                  service: { type: string }
                  version: { type: string }

  /v1/me:
    get:
      summary: Get the calling tenant's identity & limits.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Me' }

  /v1/authorizations:
    post:
      summary: Submit a card authorization.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/XSignature'
        - $ref: '#/components/parameters/XTimestamp'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AuthorizationRequest' }
      responses:
        "201":
          description: Approved
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AuthorizationApproved' }
        "200":
          description: Declined (or idempotent replay of a prior request).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/AuthorizationDeclined'
                  - $ref: '#/components/schemas/AuthorizationApproved'
        "400":
          description: Validation error
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
        "401":
          description: Auth failed
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
        "422":
          description: Invalid `panToken`
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }

  /v1/authorizations/{id}:
    get:
      summary: Read an authorization by `Id` (7-digit) or `RecordId` (17-char).
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AuthorizationRead' }
        "404":
          description: Not found
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }

  /v1/authorizations/{id}/capture:
    post:
      summary: Capture (full or partial). Omitting `amount` captures the remainder.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CaptureRequest' }
      responses:
        "200":
          description: Capture applied
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CaptureResponse' }
        "409":
          description: Auth not in a capturable state
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }

  /v1/authorizations/{id}/reverse:
    post:
      summary: Void (pre-capture) or refund (post-capture).
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ReverseRequest' }
      responses:
        "200":
          description: Reverse applied
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ReverseResponse' }
        "409":
          description: Auth not in a reversible state
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }

  /v1/authorizations/{id}/increment:
    post:
      summary: Incremental authorization — hotels / gas / ride-share.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/IncrementRequest' }
      responses:
        "201":
          description: Incremental approved
          content:
            application/json:
              schema: { $ref: '#/components/schemas/IncrementResponse' }
        "409":
          description: Parent auth not in `Authorization Approved` state
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }

  /v1/payment-codes:
    post:
      summary: Mint a payment code + PIN on behalf of a member.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PaymentCodeCreateRequest' }
      responses:
        "201":
          description: Created. `pin` is returned ONCE.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PaymentCodeCreated' }

  /v1/payment-codes/authorize:
    post:
      summary: Merchant redeems `code + pin + amount`.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PaymentCodeAuthorizeRequest' }
      responses:
        "201":
          description: Approved
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PaymentCodeAuthorizeResponse' }
        "200":
          description: Declined
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PaymentCodeAuthorizeResponse' }

  /v1/payment-codes/{code}:
    get:
      summary: Read a payment-code's state. PIN is never returned.
      parameters:
        - in: path
          name: code
          required: true
          schema: { type: string, minLength: 10, maxLength: 10 }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true

  /v1/payment-codes/{code}/revoke:
    post:
      summary: Revoke a payment code.
      parameters:
        - in: path
          name: code
          required: true
          schema: { type: string }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason: { type: string }
      responses:
        "200":
          description: Revoked
          content:
            application/json:
              schema:
                type: object
                properties:
                  revoked:  { type: boolean }
                  recordId: { type: string }

  /v1/webhook-subscriptions:
    post:
      summary: Create a webhook subscription. `secret` is returned ONCE.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookSubscriptionCreate' }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookSubscription' }
        "200":
          description: Idempotent replay — `secret` will not be returned.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookSubscription' }
    get:
      summary: List webhook subscriptions for the calling tenant.
      parameters:
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
        - in: query
          name: starting_after
          schema: { type: string }
        - in: query
          name: status
          schema: { type: string, enum: [active, paused, revoked] }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/WebhookSubscription' }
                  hasMore: { type: boolean }

  /v1/webhook-subscriptions/{id}:
    get:
      summary: Read one webhook subscription.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookSubscription' }
        "404":
          description: Not found
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    patch:
      summary: Update url / events / description / status.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookSubscriptionUpdate' }
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookSubscription' }
    delete:
      summary: Soft revoke. The worker stops delivering new events.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Revoked
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:      { type: string }
                  revoked: { type: boolean }

  /v1/webhook-subscriptions/{id}/rotate-secret:
    post:
      summary: Mint a new signing secret. Returned ONCE in the response.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Rotated. `secret` is the new signing key.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookSubscription' }

  /v1/webhook-subscriptions/{id}/deliveries:
    get:
      summary: Recent delivery attempts (debug visibility).
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
        - in: query
          name: starting_after
          schema: { type: string }
        - in: query
          name: event
          schema: { type: string }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/WebhookDelivery' }
                  hasMore: { type: boolean }
