openapi: 3.1.0
info:
  title: Inopay API
  description: |
    Public REST API for the Inopay platform — portable Ed25519 KYC,
    routed orders to BRVM/BVMAC SGIs, HMAC-signed webhooks, and a
    chained Merkle audit trail.

    All endpoints live under /v1/. Authentication uses OAuth 2.0
    client credentials. Mutating POST requests require an
    Idempotency-Key header. Pagination is cursor-based.

    Spec is hand-curated and CI-tested against the production
    server. Compatible with openapi-generator v7+, Postman, Insomnia.
  version: 1.4.0
  contact:
    name: Inopay Partner Engineering
    email: partner@getinopay.com
    url: https://www.getinopay.com/developers
  license:
    name: Inopay API Terms
    url: https://www.getinopay.com/legal/terms

servers:
  - url: https://api.getinopay.com/v1
    description: Production
  - url: https://sandbox.getinopay.com/v1
    description: Sandbox (synthetic data)

security:
  - bearerAuth: []

tags:
  - name: auth
    description: OAuth 2.0 token exchange
  - name: kyc
    description: Portable Ed25519 KYC attestations
  - name: sgi
    description: SGI partners directory
  - name: orders
    description: Order routing to SGIs
  - name: webhooks
    description: Webhook endpoint registration
  - name: audit
    description: Merkle snapshots and audit trail

paths:
  /auth/token:
    post:
      tags: [auth]
      summary: Exchange client credentials for an access token
      operationId: getAccessToken
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TokenRequest'
            examples:
              default:
                value:
                  grant_type: client_credentials
                  client_id: ino_client_8XK9R2
                  client_secret: sk_live_4bX9_redacted_PqW2
                  scope: kyc:read orders:write audit:read
      responses:
        '200':
          description: Token issued
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TokenResponse'
              examples:
                default:
                  value:
                    access_token: eyJhbGciOiJFZERTQSIsImtpZCI6IjIwMjYtMDQifQ...
                    token_type: Bearer
                    expires_in: 900
                    scope: kyc:read orders:write audit:read
        '401':
          $ref: '#/components/responses/Unauthorized'
        '422':
          $ref: '#/components/responses/ValidationError'

  /kyc/attestations:
    post:
      tags: [kyc]
      summary: Create an Ed25519-signed KYC attestation
      operationId: createKycAttestation
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/KycAttestationRequest'
      responses:
        '201':
          description: Attestation created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/KycAttestation'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'

  /kyc/attestations/{id}:
    get:
      tags: [kyc]
      summary: Fetch a KYC attestation by id
      operationId: getKycAttestation
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, example: att_4XK9RZ }
      responses:
        '200':
          description: Attestation found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/KycAttestation'
              examples:
                default:
                  value:
                    id: att_4XK9RZ
                    version: '1.0'
                    subject_id: ino_sub_8a2f9c1d
                    level: KYC2
                    issued_at: '2026-04-25T08:00:00Z'
                    expires_at: '2027-04-25T08:00:00Z'
                    issuer: inopay
                    verifications:
                      - type: id_doc
                        verified_at: '2026-04-25T07:58:11Z'
                        method: OCR+MRZ
                      - type: selfie
                        verified_at: '2026-04-25T07:58:32Z'
                        method: passive-liveness
                    jurisdictions: [UEMOA]
                    signature: MEUCIQDk9HVx_redacted_QqL2
                    key_id: staging-2026-04
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /sgi/partners:
    get:
      tags: [sgi]
      summary: List approved SGI partners (public)
      operationId: listSgiPartners
      security: []
      parameters:
        - in: query
          name: cursor
          schema: { type: string }
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
      responses:
        '200':
          description: Partner list
          headers:
            X-Cursor-Next:
              schema: { type: string }
              description: Cursor for the next page (empty when done)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/SgiPartner'

  /orders:
    post:
      tags: [orders]
      summary: Submit an order routed to an approved SGI
      operationId: createOrder
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/OrderRequest'
            examples:
              default:
                value:
                  rcpt_to: sgi_partner_001
                  kyc_attestation_id: att_4XK9RZ
                  instrument: SNTS.BRVM
                  side: buy
                  qty: 10
                  limit_price_cents: 1250000
      responses:
        '201':
          description: Order accepted
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Order'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'

  /orders/{id}:
    get:
      tags: [orders]
      summary: Fetch an order by id
      operationId: getOrder
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, example: ord_9Pk2X }
      responses:
        '200':
          description: Order found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Order'
        '404':
          $ref: '#/components/responses/NotFound'

  /webhooks/endpoints:
    post:
      tags: [webhooks]
      summary: Register a webhook endpoint
      operationId: createWebhookEndpoint
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEndpointRequest'
      responses:
        '201':
          description: Endpoint registered
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookEndpoint'

  /audit/snapshots/{date}:
    get:
      tags: [audit]
      summary: Fetch the daily Merkle snapshot
      operationId: getAuditSnapshot
      parameters:
        - in: path
          name: date
          required: true
          schema: { type: string, format: date, example: '2026-04-25' }
      responses:
        '200':
          description: Snapshot found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuditSnapshot'
        '404':
          $ref: '#/components/responses/NotFound'

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: Short-lived (15 min) Bearer token from /v1/auth/token
    oauth2ClientCredentials:
      type: oauth2
      flows:
        clientCredentials:
          tokenUrl: https://api.getinopay.com/v1/auth/token
          scopes:
            kyc:read: Read KYC attestations
            kyc:write: Create or revoke KYC attestations
            orders:read: Read orders and fills
            orders:write: Submit and cancel orders
            audit:read: Read audit snapshots and Merkle proofs
            webhooks:manage: Register and rotate webhook endpoints

  parameters:
    IdempotencyKey:
      in: header
      name: Idempotency-Key
      required: true
      schema: { type: string, format: uuid }
      description: |
        UUID v4. The response is cached 24h: a retry with the same key
        returns the original response (same HTTP status, same body).

  responses:
    Unauthorized:
      description: Invalid or expired access token
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            code: unauthorized
            message: Access token expired
            request_id: req_a3f2b9c1
    Forbidden:
      description: Token lacks the required scope
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            code: forbidden
            message: 'Missing scope: orders:write'
            request_id: req_a3f2b9c2
    NotFound:
      description: Resource not found
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            code: not_found
            message: Order ord_unknown not found
            request_id: req_a3f2b9c3
    ValidationError:
      description: Request body or parameters failed validation
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            code: validation_error
            message: qty must be a positive integer
            details:
              - field: qty
                rule: min
                value: 0
            request_id: req_a3f2b9c4
    RateLimited:
      description: Rate limit exceeded
      headers:
        Retry-After:
          schema: { type: integer }
        X-RateLimit-Limit:
          schema: { type: integer }
        X-RateLimit-Remaining:
          schema: { type: integer }
        X-RateLimit-Reset:
          schema: { type: integer, description: 'Unix timestamp' }
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            code: rate_limit_exceeded
            message: Rate limit reached. Retry in 27 seconds.
            request_id: req_a3f2b9c1

  schemas:
    Error:
      type: object
      required: [code, message, request_id]
      properties:
        code:
          type: string
          example: validation_error
        message:
          type: string
          example: qty must be a positive integer
        details:
          type: array
          items:
            type: object
            properties:
              field: { type: string }
              rule: { type: string }
              value: {}
        request_id:
          type: string
          example: req_a3f2b9c4

    TokenRequest:
      type: object
      required: [grant_type, client_id, client_secret]
      properties:
        grant_type:
          type: string
          enum: [client_credentials]
        client_id:
          type: string
          example: ino_client_8XK9R2
        client_secret:
          type: string
          example: sk_live_4bX9_redacted_PqW2
        scope:
          type: string
          example: kyc:read orders:write

    TokenResponse:
      type: object
      required: [access_token, token_type, expires_in]
      properties:
        access_token: { type: string }
        token_type: { type: string, enum: [Bearer] }
        expires_in: { type: integer, example: 900 }
        scope: { type: string }

    KycVerification:
      type: object
      required: [type, verified_at]
      properties:
        type:
          type: string
          enum: [id_doc, address, selfie, liveness, source_of_funds, pep]
        verified_at: { type: string, format: date-time }
        method: { type: string }

    KycAttestationRequest:
      type: object
      required: [subject_id, level, jurisdictions, verifications]
      properties:
        subject_id: { type: string, example: ino_sub_8a2f9c1d }
        level:
          type: string
          enum: [KYC1, KYC2, KYC3]
        jurisdictions:
          type: array
          items:
            type: string
            enum: [UEMOA, CEMAC, GHANA]
        verifications:
          type: array
          items:
            $ref: '#/components/schemas/KycVerification'

    KycAttestation:
      type: object
      required:
        - id
        - version
        - subject_id
        - level
        - issued_at
        - expires_at
        - issuer
        - verifications
        - jurisdictions
        - signature
        - key_id
      properties:
        id: { type: string, example: att_4XK9RZ }
        version: { type: string, const: '1.0' }
        subject_id: { type: string }
        level:
          type: string
          enum: [KYC1, KYC2, KYC3]
        issued_at: { type: string, format: date-time }
        expires_at: { type: string, format: date-time }
        issuer: { type: string, const: inopay }
        verifications:
          type: array
          items:
            $ref: '#/components/schemas/KycVerification'
        jurisdictions:
          type: array
          items:
            type: string
            enum: [UEMOA, CEMAC, GHANA]
        signature:
          type: string
          description: Ed25519 signature, base64url, over the RFC 8785 canonical form
        key_id:
          type: string
          example: staging-2026-04

    SgiPartner:
      type: object
      required: [id, name, jurisdiction, capabilities]
      properties:
        id: { type: string, example: sgi_partner_001 }
        name: { type: string, example: Equis Capital }
        jurisdiction:
          type: string
          enum: [UEMOA, CEMAC, GHANA]
        capabilities:
          type: array
          items:
            type: string
            enum: [equities, bonds, opcvm, reit, money_market, junior_board]
        approved_since: { type: string, format: date }

    OrderRequest:
      type: object
      required:
        - rcpt_to
        - kyc_attestation_id
        - instrument
        - side
        - qty
      properties:
        rcpt_to:
          type: string
          example: sgi_partner_001
          description: SGI partner id (recipient of the routed order)
        kyc_attestation_id:
          type: string
          example: att_4XK9RZ
        instrument:
          type: string
          example: SNTS.BRVM
        side:
          type: string
          enum: [buy, sell]
        qty:
          type: integer
          minimum: 1
        limit_price_cents:
          type: integer
          minimum: 1
          description: Optional. Omit for market order.
        client_metadata:
          type: object
          additionalProperties: true

    Order:
      type: object
      required: [id, status, rcpt_to, instrument, side, qty, created_at]
      properties:
        id: { type: string, example: ord_9Pk2X }
        status:
          type: string
          enum: [created, routed, partially_filled, filled, failed, cancelled]
        rcpt_to: { type: string }
        kyc_attestation_id: { type: string }
        instrument: { type: string }
        side:
          type: string
          enum: [buy, sell]
        qty: { type: integer }
        limit_price_cents: { type: integer, nullable: true }
        filled_qty: { type: integer, default: 0 }
        average_fill_price_cents: { type: integer, nullable: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    WebhookEndpointRequest:
      type: object
      required: [url, events]
      properties:
        url:
          type: string
          format: uri
          example: https://your-app.example.com/webhooks/inopay
        events:
          type: array
          items:
            type: string
            enum:
              - order.created
              - order.routed
              - order.executed
              - order.failed
              - kyc.attested
              - kyc.revoked
              - audit.snapshot
              - webhook.test
        description: { type: string }

    WebhookEndpoint:
      allOf:
        - $ref: '#/components/schemas/WebhookEndpointRequest'
        - type: object
          required: [id, secret, created_at]
          properties:
            id: { type: string, example: ep_4Xk9R }
            secret:
              type: string
              description: Webhook signing secret (only returned on creation)
            created_at: { type: string, format: date-time }

    AuditSnapshot:
      type: object
      required: [date, merkle_root, count, anchored_at]
      properties:
        date: { type: string, format: date, example: '2026-04-25' }
        merkle_root:
          type: string
          description: SHA-256 hex of the day's Merkle root
          example: 4f5a9b2c8e1d3f6a7b9c0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b
        count:
          type: integer
          description: Number of orders included in the tree
          example: 18472
        anchored_at:
          type: string
          format: date-time
          example: '2026-04-26T00:05:00Z'
        anchors:
          type: array
          items:
            type: object
            properties:
              chain: { type: string, example: bitcoin }
              tx_hash: { type: string }
              block_height: { type: integer }
