openapi: 3.0.3
info:
  title: Twin Bridge API
  version: 1.2.0
  description: |
    **Twin Bridge** is a B2B API bridge connecting international crypto providers
    (e.g. Guardarian) with licensed crypto VASPs (DantePay and others).

    Twin Bridge routes information between providers and VASPs — it never holds
    or moves funds. Payments are processed by the licensed VASP via ELQR.

    **Jurisdiction:** United States (Delaware Inc.)

    ---

    ## Integration kit

    - **Postman collection:** [twin-bridge.postman_collection.json](./twin-bridge.postman_collection.json) — auto-signs every request via pre-request script
    - **HMAC signing samples:** [samples/hmac-sign.go](./samples/hmac-sign.go) · [samples/hmac-sign.js](./samples/hmac-sign.js) · [samples/hmac-sign.php](./samples/hmac-sign.php)
    - **Webhook receiver samples:** [samples/webhook-receiver.go](./samples/webhook-receiver.go) · [samples/webhook-receiver.js](./samples/webhook-receiver.js) · [samples/webhook-receiver.php](./samples/webhook-receiver.php)
    - **VASP-side contract (for VASPs):** [openapi-vasp.yaml](./openapi-vasp.yaml)
    - **Readiness checklist:** `docs/INTEGRATION_READINESS.md` in repo

    ---

    ## Implementation Status

    Each endpoint is marked with its current availability:

    | Badge | Meaning |
    |-------|---------|
    | ✅ **Implemented** | Available now in production and sandbox |
    | 🚀 **Phase 2** | Planned, in development — do not use in production yet |
    | ⚠️ **Partial** | Core functionality works; some fields or modes are Phase 2 |

    Endpoints marked 🚀 are documented here for forward-compatibility planning.
    Their schemas may change before release.

    ---

    ## Base URL

    | Environment | Base URL |
    |-------------|----------|
    | Production  | `https://api.twinbridge.kg` |
    | Sandbox     | `https://api.twinbridge.kg` + header `X-Sandbox: true` |

    ## Authentication

    All B2B endpoints require **HMAC-SHA256** request signing.

    **Required headers for authenticated requests:**

    | Header | Description |
    |--------|-------------|
    | `X-API-Key` | Your partner API key |
    | `X-Signature` | HMAC-SHA256 signature (see below) |
    | `X-Timestamp` | Unix timestamp (seconds). Must be within ±60s of server time |
    | `Idempotency-Key` | UUID v4. **Required for all POST requests** |

    ### Signature Algorithm

    ```
    body_hash    = SHA256(request_body_bytes)  // hex string, lowercase
    message      = timestamp + HTTP_METHOD + URL_PATH + body_hash
    X-Signature  = HMAC-SHA256(api_secret, message)  // hex string, lowercase
    ```

    **Example (Python):**
    ```python
    import hmac, hashlib, time, json

    api_key    = "your_api_key"
    api_secret = "your_api_secret"
    timestamp  = str(int(time.time()))
    method     = "POST"
    path       = "/v1/transactions"
    body       = json.dumps({"fiat_amount": "5000", "fiat_currency": "KGS",
                             "crypto_currency": "USDT", "direction": "ON_RAMP",
                             "flow": "KGS_FLOW"}).encode()

    body_hash  = hashlib.sha256(body).hexdigest()
    message    = timestamp + method + path + body_hash
    signature  = hmac.new(api_secret.encode(), message.encode(),
                          hashlib.sha256).hexdigest()
    ```

    ## Rate Limiting

    **100 requests per minute** per API key (token bucket).
    When exceeded, the API returns `429 Too Many Requests`.

    ## Idempotency

    All `POST` requests **must** include an `Idempotency-Key` header (UUID v4).
    Retrying with the same key returns the original response — no duplicate
    transactions are created.

    ## Sandbox Mode

    Send header `X-Sandbox: true` to use mock adapters.
    Transactions flow through the full state machine but no real payments are made.

    ## Partner Integration Flow

    Twin Bridge uses a **3-step flow** for all transactions:

    1. **Exchange** — Call `POST /v1/transactions` to get `tx_id`, exchange rate, and QR payment data
    2. **Payment** — Present the QR code to the end user for fiat payment
    3. **Confirmation** — Twin Bridge automatically confirms payment and delivers crypto (ON_RAMP) or fiat (OFF_RAMP)

    ## Smart Routing

    Twin Bridge **automatically selects the best VASP** for each transaction based on
    current rates, liquidity, and availability. If the primary VASP is unavailable,
    the request is routed to the next best option — no configuration needed.

    ## ON_RAMP vs OFF_RAMP

    | | ON_RAMP | OFF_RAMP |
    |---|---|---|
    | **User flow** | Pays fiat (KGS via QR) | Sends crypto |
    | **Receives** | Crypto (USDT/USDC) | Fiat (KGS to card) |
    | **Required field** | `crypto_address`, `network` | `recipient_card` |
    | **Use case** | Buy crypto with cash | Cash out crypto to card |

  contact:
    name: Twin Bridge Integration Support
    email: api@twinbridge.kg
  license:
    name: Proprietary
    url: https://twinbridge.kg

servers:
  - url: http://localhost:3000
    description: "🖥 Local Demo (docker compose up -d)"
  - url: https://api.twinbridge.kg
    description: "🌐 Production"
  - url: https://api.twinbridge.kg
    description: "🧪 Sandbox (add X-Sandbox: true header)"

tags:
  - name: Public
    description: "✅ Implemented — no authentication required"
  - name: Transactions
    description: "✅ Implemented — transaction lifecycle (requires HMAC auth)"
  - name: Webhooks
    description: "✅ Implemented — outbound events sent from Twin Bridge to your endpoint"
  - name: Currencies
    description: "🚀 Phase 2 — currency pairs and payment method discovery"

paths:
  /v1/health:
    get:
      tags: [Public]
      x-status: implemented
      summary: "✅ System health check"
      description: |
        > ✅ **Implemented** — available now

        Returns the current health status of the Twin Bridge system.
        Use this endpoint for uptime monitoring and readiness probes.
      operationId: getHealth
      responses:
        '200':
          description: System status
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HealthResponse'
              examples:
                healthy:
                  summary: All systems operational
                  value:
                    status: "ok"
                    db: "ok"
                    redis: "ok"
                    vasp: "ok"
                degraded:
                  summary: Database issue
                  value:
                    status: "degraded"
                    db: "error"
                    redis: "ok"
                    vasp: "ok"

  /v1/currencies:
    get:
      tags: [Currencies]
      x-status: planned
      summary: "🚀 List supported currency pairs (Phase 2)"
      description: |
        > 🚀 **Phase 2** — not yet implemented. Schema may change before release.

        Returns a list of all supported fiat/crypto pairs including limits,
        available networks, and on-ramp/off-ramp availability.
        No authentication required.
      operationId: getCurrencies
      responses:
        '200':
          description: List of supported currency pairs
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CurrenciesResponse'
              example:
                currencies:
                  - fiat: "KGS"
                    crypto: "USDT"
                    networks: ["TRC-20", "ERC-20"]
                    min_fiat_amount: "500.00"
                    max_fiat_amount: "500000.00"
                    min_crypto_amount: "5.00"
                    max_crypto_amount: "5000.00"
                    on_ramp: true
                    off_ramp: true
                    status: "active"
                  - fiat: "KGS"
                    crypto: "USDC"
                    networks: ["TRC-20", "ERC-20"]
                    min_fiat_amount: "500.00"
                    max_fiat_amount: "500000.00"
                    min_crypto_amount: "5.00"
                    max_crypto_amount: "5000.00"
                    on_ramp: true
                    off_ramp: false
                    status: "active"

  /v1/payment-methods:
    get:
      tags: [Currencies]
      x-status: planned
      summary: "🚀 List payment methods (Phase 2)"
      description: |
        > 🚀 **Phase 2** — not yet implemented. Schema may change before release.

        Returns all supported fiat payment and payout methods with limits,
        supported currencies, and direction (ON_RAMP / OFF_RAMP).
        No authentication required.
      operationId: getPaymentMethods
      responses:
        '200':
          description: List of payment methods
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PaymentMethodsResponse'
              example:
                payment_methods:
                  - id: "elqr"
                    name: "ELQR"
                    type: "qr_code"
                    currencies: ["KGS"]
                    direction: ["ON_RAMP", "OFF_RAMP"]
                    min_amount: "100.00"
                    max_amount: "500000.00"
                    processing_time: "instant"
                    status: "active"
                  - id: "bank_transfer"
                    name: "Bank Transfer"
                    type: "bank_transfer"
                    currencies: ["KGS"]
                    direction: ["ON_RAMP", "OFF_RAMP"]
                    min_amount: "500.00"
                    max_amount: "500000.00"
                    processing_time: "1-24 hours"
                    status: "inactive"

  /v1/quote:
    get:
      tags: [Public]
      x-status: implemented
      summary: "✅ Get live exchange quote"
      description: |
        > ✅ **Implemented** — available now (replaces the legacy `/v1/rates` path)

        Returns a real-time exchange quote for a fiat/crypto pair.
        Includes the locked rate, calculated crypto amount, fee, and expiry time.
        No authentication required.

        **Note:** Use this endpoint before creating a transaction to show the user
        the exact rate they will receive.
      operationId: getQuote
      parameters:
        - name: fiat_currency
          in: query
          required: true
          schema: { type: string, example: KGS }
        - name: crypto_currency
          in: query
          required: true
          schema: { type: string, example: USDT }
        - name: fiat_amount
          in: query
          required: true
          schema: { type: string, example: "5000" }
        - name: direction
          in: query
          required: true
          schema: { type: string, enum: [ON_RAMP, OFF_RAMP], example: ON_RAMP }
        - $ref: '#/components/parameters/XSandbox'
      responses:
        '200':
          description: Live quote
          content:
            application/json:
              schema:
                type: object
                properties:
                  fiat_amount: { type: string, example: "5000" }
                  fiat_currency: { type: string, example: KGS }
                  crypto_currency: { type: string, example: USDT }
                  crypto_amount: { type: string, example: "56.49" }
                  rate: { type: string, example: "88.5", description: "KGS per 1 USDT" }
                  direction: { type: string, example: ON_RAMP }
                  vasp: { type: string, example: mock }
                  fee: { type: string, example: "0.01" }
                  rate_expires_at: { type: string, format: date-time }
                  alternatives: { type: array, items: { type: object } }
              example:
                fiat_amount: "5000"
                fiat_currency: KGS
                crypto_currency: USDT
                crypto_amount: "56.4972"
                rate: "88.5"
                direction: ON_RAMP
                vasp: mock
                fee: "0.01"
                rate_expires_at: "2026-04-14T12:00:00Z"
                alternatives: []
        '400':
          $ref: '#/components/responses/BadRequest'

  /v1/rates:
    get:
      tags: [Public]
      x-status: deprecated
      summary: "⚠️ [Deprecated] Get exchange rate — use /v1/quote instead"
      description: |
        > ⚠️ **Deprecated** — this path is kept for backwards compatibility.
        > Use `GET /v1/quote` instead, which returns richer data (crypto_amount, fee, expiry).

        Returns the current exchange rate for a currency pair.
        Rates are cached and updated in real-time from connected VASPs.
        No authentication required.
      operationId: getRates
      parameters:
        - name: pair
          in: query
          description: Currency pair in `FIAT/CRYPTO` format
          required: false
          schema:
            type: string
            default: KGS/USDT
            example: KGS/USDT
            enum:
              - KGS/USDT
              - KGS/USDC
      responses:
        '200':
          description: Current rate for the requested pair
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RateResponse'
              example:
                pair: "KGS/USDT"
                rate: "88.42"
                spread: "0.50"
                min_amount: "500.00"
                max_amount: "500000.00"
                available_networks: ["TRC-20", "ERC-20"]
                updated_at: "2026-03-31T11:00:00Z"
        '503':
          description: Rate unavailable (VASP connectivity issue)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: "rate unavailable for pair: KGS/USDT"
                code: "RATE_UNAVAILABLE"

  /v1/transactions:
    post:
      tags: [Transactions]
      x-status: implemented
      summary: "✅ Create a transaction"
      description: |
        > ✅ **Implemented** — ON_RAMP (KGS → USDT) available now
        > ⚠️ OFF_RAMP (USDT → KGS) is **Phase 2**

        Creates a new on-ramp or off-ramp transaction.

        **Implemented fields:** `direction`, `fiat_amount`, `fiat_currency`,
        `crypto_currency`, `flow`, `wallet_address`, `kyc_share_token`

        **Phase 2 fields (ignored for now):** `network`, `callback_url`, `metadata`,
        `recipient_card`, `sumsub_applicant_id`, `payment_method`

        **Flow:**
        1. Call `POST /v1/transactions` → receive `tx_id` and QR payment data
        2. Present QR to end user for fiat payment via ELQR
        3. Twin Bridge monitors and advances the transaction status automatically
        4. Your webhook receives status updates at each stage

        **Idempotency:** Always provide a unique `Idempotency-Key`.
        Retrying with the same key returns the existing transaction.

        > Only partners of type `PROVIDER` can create transactions.
      operationId: createTransaction
      security:
        - ApiKeyAuth: []
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/XApiKey'
        - $ref: '#/components/parameters/XSignature'
        - $ref: '#/components/parameters/XTimestamp'
        - $ref: '#/components/parameters/XSandbox'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateTransactionRequest'
            examples:
              on_ramp_kgs_usdt:
                summary: KGS → USDT on-ramp (TRC-20)
                value:
                  direction: "ON_RAMP"
                  fiat_amount: "5000.00"
                  fiat_currency: "KGS"
                  crypto_currency: "USDT"
                  network: "TRC-20"
                  crypto_address: "TRx7KzjN4GqLhU3k9pF2mW5vB8dY6cE1n"
                  payment_method: "elqr"
                  flow: "KGS_FLOW"
                  callback_url: "https://yourapp.com/webhooks/twinbridge"
                  metadata:
                    order_id: "ORD-2026-001"
                    user_id: "usr_12345"
              on_ramp_with_kyc:
                summary: KGS → USDT with KYC passthrough
                value:
                  direction: "ON_RAMP"
                  fiat_amount: "50000.00"
                  fiat_currency: "KGS"
                  crypto_currency: "USDT"
                  network: "TRC-20"
                  crypto_address: "TRx7KzjN4GqLhU3k9pF2mW5vB8dY6cE1n"
                  payment_method: "elqr"
                  flow: "KGS_FLOW"
                  kyc_share_token: "ssn_applicant_abc123xyz"
                  sumsub_applicant_id: "6234a2f8b3c4d5e6f7a8b9c0"
              off_ramp_usdt_kgs:
                summary: USDT → KGS off-ramp (via ELQR)
                value:
                  direction: "OFF_RAMP"
                  fiat_amount: "5000.00"
                  fiat_currency: "KGS"
                  crypto_currency: "USDT"
                  network: "TRC-20"
                  payment_method: "elqr"
                  flow: "KGS_FLOW"
                  metadata:
                    order_id: "ORD-2026-002"
      responses:
        '201':
          description: |
            Transaction created and moved to `PENDING_PAYMENT`.
            For ON_RAMP: the `payment` object contains the ELQR QR code
            the end customer scans to pay KGS.
            For OFF_RAMP: no deposit address is returned — the Provider
            holds the customer's USDT custody and debits internally;
            `recipient_kgs_wallet` / `recipient_phone` from the request
            are echoed back so the Provider can confirm the payout target.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TransactionResponse'
              example:
                tx_id: "550e8400-e29b-41d4-a716-446655440000"
                status: "PENDING_PAYMENT"
                direction: "ON_RAMP"
                fiat_amount: "5000.00"
                fiat_currency: "KGS"
                crypto_currency: "USDT"
                network: "TRC-20"
                crypto_address: "TRx7KzjN4GqLhU3k9pF2mW5vB8dY6cE1n"
                rate: "88.42"
                vasp_id: "dantepay-01"
                estimated_completion: "2026-04-03T12:05:00Z"
                fee:
                  vasp_fee: "25.00"
                  vasp_fee_currency: "KGS"
                  network_fee: "1.00"
                  network_fee_currency: "USDT"
                  total_fee_fiat: "113.42"
                  total_fee_currency: "KGS"
                payment:
                  qr_data: "00020101021226580014kg.elqr.payment..."
                  qr_url: "https://elqr.kg/qr/abc123"
                  expires_at: "2026-03-31T11:30:00Z"
                  amount: "5000.00"
                  currency: "KGS"
                created_at: "2026-03-31T11:00:00Z"
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                validation_error:
                  value:
                    error: "fiat_amount must be greater than 0"
                    code: "VALIDATION_ERROR"
                idempotency_missing:
                  value:
                    error: "Idempotency-Key header is required for POST requests"
                    code: "IDEMPOTENCY_MISSING"
                network_missing:
                  value:
                    error: "network is required for ON_RAMP transactions"
                    code: "VALIDATION_ERROR"
                invalid_address:
                  value:
                    error: "crypto_address does not match TRC-20 format"
                    code: "INVALID_ADDRESS"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: Forbidden — only PROVIDER partners can create transactions
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: "only providers can create transactions"
                code: "FORBIDDEN"
        '422':
          description: Business logic error (VASP unavailable, unsupported pair, etc.)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          $ref: '#/components/responses/RateLimitExceeded'

    get:
      tags: [Transactions]
      x-status: implemented
      summary: "✅ List transactions"
      description: |
        > ✅ **Implemented** — available now

        Returns a paginated list of transactions belonging to the authenticated partner.
        Supports filtering by status and date range.
      operationId: listTransactions
      security:
        - ApiKeyAuth: []
      parameters:
        - $ref: '#/components/parameters/XApiKey'
        - $ref: '#/components/parameters/XSignature'
        - $ref: '#/components/parameters/XTimestamp'
        - name: page
          in: query
          description: Page number (1-based)
          schema:
            type: integer
            minimum: 1
            default: 1
            example: 1
        - name: limit
          in: query
          description: Items per page (max 100)
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
            example: 20
        - name: status
          in: query
          description: Filter by transaction status
          schema:
            $ref: '#/components/schemas/TransactionStatus'
        - name: from
          in: query
          description: Start of date range (RFC3339)
          schema:
            type: string
            format: date-time
            example: "2026-03-01T00:00:00Z"
        - name: to
          in: query
          description: End of date range (RFC3339)
          schema:
            type: string
            format: date-time
            example: "2026-03-31T23:59:59Z"
      responses:
        '200':
          description: Paginated list of transactions
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ListTransactionsResponse'
              example:
                data:
                  - tx_id: "550e8400-e29b-41d4-a716-446655440000"
                    status: "COMPLETED"
                    direction: "ON_RAMP"
                    fiat_amount: "5000.00"
                    fiat_currency: "KGS"
                    crypto_amount: "56.55"
                    crypto_currency: "USDT"
                    network: "TRC-20"
                    rate: "88.42"
                    crypto_tx_hash: "a1b2c3d4e5f67890abcdef1234567890abcdef1234567890abcdef1234567890"
                    created_at: "2026-03-31T10:00:00Z"
                    paid_at: "2026-03-31T10:05:00Z"
                    completed_at: "2026-03-31T10:08:00Z"
                total: 42
                page: 1
                limit: 20
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimitExceeded'

  /v1/transactions/{id}:
    get:
      tags: [Transactions]
      x-status: implemented
      summary: "✅ Get transaction details"
      description: |
        > ✅ **Implemented** — available now

        Returns full details of a single transaction.

        **Tenant isolation:** You can only retrieve transactions that belong to your
        API key. Requesting another partner's transaction returns `404`.
      operationId: getTransaction
      security:
        - ApiKeyAuth: []
      parameters:
        - $ref: '#/components/parameters/TransactionId'
        - $ref: '#/components/parameters/XApiKey'
        - $ref: '#/components/parameters/XSignature'
        - $ref: '#/components/parameters/XTimestamp'
      responses:
        '200':
          description: Transaction details
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TransactionResponse'
        '400':
          description: Invalid transaction ID format
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: "invalid transaction id"
                code: "INVALID_ID"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimitExceeded'

  /v1/transactions/{id}/retry:
    post:
      tags: [Transactions]
      x-status: implemented
      summary: "✅ Retry QR code (regenerate expired QR)"
      description: |
        > ✅ **Implemented** — available now

        Regenerates the payment QR code for a transaction whose QR has expired.
        Only transactions in `CANCELLED` status with `cancel_reason: "qr_expired"` can be retried.
        Up to 3 retries allowed per transaction.

        **Idempotent:** safe to retry with the same `Idempotency-Key`.
      operationId: retryTransaction
      security:
        - ApiKeyAuth: []
      parameters:
        - $ref: '#/components/parameters/TransactionId'
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/XApiKey'
        - $ref: '#/components/parameters/XSignature'
        - $ref: '#/components/parameters/XTimestamp'
      responses:
        '200':
          description: New QR generated, transaction back in PENDING_PAYMENT
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TransactionResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Max retries exceeded or transaction not in retryable state
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /v1/transactions/{id}/cancel:
    post:
      tags: [Transactions]
      x-status: implemented
      summary: "✅ Cancel a transaction"
      description: |
        > ✅ **Implemented** — available now

        Cancels a transaction. Only transactions in `PENDING_PAYMENT` status can be
        cancelled. Attempting to cancel a transaction in any other state returns an error.

        **Idempotent:** safe to retry with the same `Idempotency-Key`.
      operationId: cancelTransaction
      security:
        - ApiKeyAuth: []
      parameters:
        - $ref: '#/components/parameters/TransactionId'
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/XApiKey'
        - $ref: '#/components/parameters/XSignature'
        - $ref: '#/components/parameters/XTimestamp'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CancelRequest'
            example:
              reason: "Customer requested cancellation"
      responses:
        '200':
          description: Transaction cancelled successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TransactionResponse'
              example:
                tx_id: "550e8400-e29b-41d4-a716-446655440000"
                status: "CANCELLED"
                direction: "ON_RAMP"
                fiat_amount: "5000.00"
                fiat_currency: "KGS"
                crypto_currency: "USDT"
                created_at: "2026-03-31T10:00:00Z"
        '400':
          description: Invalid transaction ID
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: Cannot cancel — transaction is not in a cancellable state
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: "cannot cancel transaction in status COMPLETED"
                code: "CANCEL_FAILED"
        '429':
          $ref: '#/components/responses/RateLimitExceeded'

  /webhooks/your-endpoint:
    post:
      tags: [Webhooks]
      x-status: implemented
      summary: "✅ Webhook events (outbound from Twin Bridge to your endpoint)"
      description: |
        > ✅ **Implemented** — `payment.completed` and `payment.cancelled` fire now
        > 🚀 **Phase 2** events: `payment.qr_scanned`, `payment.partial`, `payment.kyc_*`

        Twin Bridge sends webhook events to your registered callback URL when
        transaction status changes.

        **This path represents YOUR endpoint** — configure it in your integration
        settings. Twin Bridge will `POST` signed JSON payloads to it.

        **Currently firing events:** `payment.created`, `payment.completed`, `payment.cancelled`, `payment.failed`

        **Phase 2 events (not yet firing):** `payment.qr_scanned`, `payment.qr_expired`,
        `payment.partial`, `payment.late`, `kyc.verified`, `kyc.rejected`

        ## Webhook Security

        Every webhook request is signed with HMAC-SHA256:

        ```
        X-TwinBridge-Signature: HMAC-SHA256(webhook_secret, timestamp + "." + body)
        X-TwinBridge-Timestamp: <unix_timestamp>
        ```

        **Verify on your side:**
        ```python
        import hmac, hashlib

        expected = hmac.new(
            webhook_secret.encode(),
            (timestamp + "." + raw_body).encode(),
            hashlib.sha256
        ).hexdigest()

        assert hmac.compare_digest(expected, received_signature)
        ```

        ## Retry Policy

        If your endpoint returns a non-2xx response, Twin Bridge retries:

        | Attempt | Delay |
        |---------|-------|
        | 1 | immediate |
        | 2 | 1 second |
        | 3 | 5 seconds |
        | 4 | 30 seconds |
        | 5 | 5 minutes |

        After 5 failed attempts, an alert is triggered and the event is logged.

        ## Response

        Your endpoint **must return HTTP 200** to acknowledge receipt.
        Any other status code triggers a retry.

      operationId: webhookReceiver
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEvent'
            examples:
              payment_created:
                summary: payment.created
                value:
                  event_type: "payment.created"
                  tx_id: "550e8400-e29b-41d4-a716-446655440000"
                  status: "PENDING_PAYMENT"
                  timestamp: 1743415200
                  payload:
                    fiat_amount: "5000.00"
                    fiat_currency: "KGS"
                    crypto_currency: "USDT"
                    network: "TRC-20"
                    qr_expires_at: "2026-03-31T11:30:00Z"

              payment_qr_scanned:
                summary: payment.qr_scanned
                value:
                  event_type: "payment.qr_scanned"
                  tx_id: "550e8400-e29b-41d4-a716-446655440000"
                  status: "PAID"
                  timestamp: 1743415500

              payment_qr_expired:
                summary: payment.qr_expired — QR not scanned in time
                value:
                  event_type: "payment.qr_expired"
                  tx_id: "550e8400-e29b-41d4-a716-446655440000"
                  status: "FAILED"
                  timestamp: 1743417000
                  payload:
                    reason: "qr_expired"

              payment_completed_onramp:
                summary: payment.completed — ON_RAMP crypto delivered
                value:
                  event_type: "payment.completed"
                  tx_id: "550e8400-e29b-41d4-a716-446655440000"
                  status: "COMPLETED"
                  timestamp: 1743415800
                  payload:
                    crypto_amount: "56.55"
                    crypto_currency: "USDT"
                    crypto_tx_hash: "a1b2c3d4e5f67890abcdef1234567890abcdef1234567890abcdef1234567890"
                    network: "TRC-20"
                    rate: "88.42"
                    fee:
                      vasp_fee: "25.00"
                      network_fee: "1.00"
                    metadata:
                      order_id: "ORD-2026-001"
                      user_id: "usr_12345"

              payment_completed_offramp:
                summary: payment.completed — OFF_RAMP fiat paid out
                value:
                  event_type: "payment.completed"
                  tx_id: "660e9500-f30c-52e5-b827-557766551111"
                  status: "COMPLETED"
                  timestamp: 1743416000
                  payload:
                    fiat_amount: "5000.00"
                    fiat_currency: "KGS"
                    payout_method: "elqr"
                    payout_reference: "REF123456"
                    rate: "88.42"
                    metadata: {}

              payment_cancelled:
                summary: payment.cancelled
                value:
                  event_type: "payment.cancelled"
                  tx_id: "550e8400-e29b-41d4-a716-446655440000"
                  status: "CANCELLED"
                  timestamp: 1743415650

              kyc_verified:
                summary: payment.kyc_verified
                value:
                  event_type: "payment.kyc_verified"
                  tx_id: "550e8400-e29b-41d4-a716-446655440000"
                  status: "PROCESSING"
                  timestamp: 1743415700

              kyc_rejected:
                summary: payment.kyc_rejected
                value:
                  event_type: "payment.kyc_rejected"
                  tx_id: "550e8400-e29b-41d4-a716-446655440000"
                  status: "CANCELLED"
                  timestamp: 1743415700
                  payload:
                    reason: "document_mismatch"

              payment_partial:
                summary: payment.partial — partial payment received
                value:
                  event_type: "payment.partial"
                  tx_id: "550e8400-e29b-41d4-a716-446655440000"
                  status: "DISPUTED"
                  timestamp: 1743415700
                  payload:
                    paid_amount: "2500.00"
                    expected_amount: "5000.00"
                    fiat_currency: "KGS"

              payment_late:
                summary: payment.late — payment received after QR expiry
                value:
                  event_type: "payment.late"
                  tx_id: "550e8400-e29b-41d4-a716-446655440000"
                  status: "DISPUTED"
                  timestamp: 1743417500
                  payload:
                    paid_at: "2026-03-31T12:00:00Z"
                    expired_at: "2026-03-31T11:30:00Z"

              kyc_verified_event:
                summary: kyc.verified — KYC passed
                value:
                  event_type: "kyc.verified"
                  tx_id: "550e8400-e29b-41d4-a716-446655440000"
                  status: "PROCESSING"
                  timestamp: 1743415700

              kyc_rejected_event:
                summary: kyc.rejected — KYC rejected
                value:
                  event_type: "kyc.rejected"
                  tx_id: "550e8400-e29b-41d4-a716-446655440000"
                  status: "CANCELLED"
                  timestamp: 1743415700
                  payload:
                    reason: "document_mismatch"

      parameters:
        - name: X-TwinBridge-Signature
          in: header
          required: true
          description: HMAC-SHA256(webhook_secret, timestamp + "." + raw_body)
          schema:
            type: string
            example: "a3f8c21d4e..."
        - name: X-TwinBridge-Timestamp
          in: header
          required: true
          description: Unix timestamp of the event
          schema:
            type: string
            example: "1743415200"
      responses:
        '200':
          description: Webhook acknowledged

components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
      description: |
        Your Twin Bridge partner API key.
        Must be combined with `X-Signature` and `X-Timestamp` headers.

  parameters:
    XApiKey:
      name: X-API-Key
      in: header
      required: true
      description: Partner API key
      schema:
        type: string
        example: "tb_live_abc123xyz"

    XSignature:
      name: X-Signature
      in: header
      required: true
      description: |
        HMAC-SHA256 signature.
        `HMAC-SHA256(api_secret, timestamp + method + path + sha256(body))`
      schema:
        type: string
        example: "a3f8c21d4e9b7c2a..."

    XTimestamp:
      name: X-Timestamp
      in: header
      required: true
      description: Unix timestamp in seconds. Must be within ±60s of server time.
      schema:
        type: string
        example: "1743415200"

    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: UUID v4. Required for all POST requests. Retrying with same key returns original response.
      schema:
        type: string
        format: uuid
        example: "f47ac10b-58cc-4372-a567-0e02b2c3d479"

    XSandbox:
      name: X-Sandbox
      in: header
      required: false
      description: Set to `true` to use sandbox / mock adapters. No real payments are made.
      schema:
        type: string
        enum: ["true", "false"]
        example: "true"

    TransactionId:
      name: id
      in: path
      required: true
      description: Transaction UUID
      schema:
        type: string
        format: uuid
        example: "550e8400-e29b-41d4-a716-446655440000"

  schemas:
    TransactionStatus:
      type: string
      description: |
        Current status of the transaction.

        **Normal flow:**
        ```
        INITIATED → PENDING_PAYMENT → PAID → PROCESSING → COMPLETED
        ```

        **Cancellation:**
        ```
        PENDING_PAYMENT → CANCELLED
        ```
      enum:
        - INITIATED
        - PENDING_PAYMENT
        - PAID
        - PROCESSING
        - COMPLETED
        - FAILED
        - CANCELLED
      example: PENDING_PAYMENT

    CurrencyPair:
      type: object
      description: Supported fiat/crypto currency pair with limits and availability
      properties:
        fiat:
          type: string
          description: ISO 4217 fiat currency code
          example: "KGS"
        crypto:
          type: string
          description: Cryptocurrency ticker
          example: "USDT"
        networks:
          type: array
          items:
            type: string
          description: Supported blockchain networks for this pair
          example: ["TRC-20", "ERC-20"]
        min_fiat_amount:
          type: string
          description: Minimum fiat amount (decimal string)
          example: "500.00"
        max_fiat_amount:
          type: string
          description: Maximum fiat amount (decimal string)
          example: "500000.00"
        min_crypto_amount:
          type: string
          description: Minimum crypto amount (decimal string)
          example: "5.00"
        max_crypto_amount:
          type: string
          description: Maximum crypto amount (decimal string)
          example: "5000.00"
        on_ramp:
          type: boolean
          description: Whether ON_RAMP (fiat → crypto) is supported for this pair
          example: true
        off_ramp:
          type: boolean
          description: Whether OFF_RAMP (crypto → fiat) is supported for this pair
          example: true
        status:
          type: string
          enum: [active, inactive, maintenance]
          description: Current availability status of this pair
          example: "active"

    CurrenciesResponse:
      type: object
      properties:
        currencies:
          type: array
          items:
            $ref: '#/components/schemas/CurrencyPair'

    PaymentMethod:
      type: object
      description: Fiat payment or payout method
      properties:
        id:
          type: string
          description: Machine-readable payment method identifier
          enum: [elqr, bank_transfer]
          example: "elqr"
        name:
          type: string
          description: Human-readable payment method name
          example: "ELQR"
        type:
          type: string
          description: Technical type of the payment method
          enum: [qr_code, bank_transfer]
          example: "qr_code"
        currencies:
          type: array
          items:
            type: string
          description: Supported fiat currencies
          example: ["KGS"]
        direction:
          type: array
          items:
            type: string
            enum: [ON_RAMP, OFF_RAMP]
          description: Supported directions for this payment method
          example: ["ON_RAMP"]
        min_amount:
          type: string
          description: Minimum amount in fiat (decimal string)
          example: "100.00"
        max_amount:
          type: string
          description: Maximum amount in fiat (decimal string)
          example: "500000.00"
        processing_time:
          type: string
          description: Expected processing time description
          example: "instant"
        status:
          type: string
          enum: [active, inactive, maintenance]
          example: "active"

    PaymentMethodsResponse:
      type: object
      properties:
        payment_methods:
          type: array
          items:
            $ref: '#/components/schemas/PaymentMethod'

    CreateTransactionRequest:
      type: object
      required:
        - direction
        - fiat_amount
        - fiat_currency
        - crypto_currency
        - flow
      properties:
        direction:
          type: string
          description: |
            Transaction direction.
            - `ON_RAMP` — fiat in, crypto out (user pays KGS, receives USDT)
            - `OFF_RAMP` — crypto in, fiat out (user sends USDT, receives KGS to card)
          enum: [ON_RAMP, OFF_RAMP]
          example: ON_RAMP
        fiat_amount:
          type: string
          description: |
            Fiat amount as a decimal string. Must be positive.
            Maximum: `9999999999.99999999`.
          example: "5000.00"
        fiat_currency:
          type: string
          description: |
            ISO 4217 currency code for fiat.
            Phase 1: `KGS` only. Phase 2: `KZT`, `UZS`.
          enum: [KGS, KZT, UZS]
          example: KGS
        crypto_currency:
          type: string
          description: |
            Cryptocurrency ticker.
            Phase 1: `USDT`, `USDC`. Phase 2: `BTC`, `ETH`, `TON`.
          enum: [USDT, USDC, BTC, ETH, TON]
          example: USDT
        network:
          type: string
          description: |
            Blockchain network for crypto delivery or receipt.
            **Required for ON_RAMP.** Optional for OFF_RAMP (defaults to TRC-20 for USDT/USDC).

            Network availability by cryptocurrency:
            - `USDT`: TRC-20, ERC-20
            - `USDC`: TRC-20, ERC-20
            - `TON`: TON
            - `BTC`: (Phase 2)
            - `ETH`: ERC-20 (Phase 2)
          enum: ["TRC-20", "ERC-20", "TON"]
          example: "TRC-20"
        crypto_address:
          type: string
          description: |
            Crypto wallet address for ON_RAMP crypto delivery.
            **Required for ON_RAMP direction.**

            Address format by network:
            - `TRC-20`: 34 characters, starts with `T`, base58. Example: `TRx7KzjN4GqLhU3k9pF2mW5vB8dY6cE1n`
            - `ERC-20`: 42 characters, starts with `0x`, hex. Example: `0xAbCd1234...`
            - `TON`: 48 characters, base64url. Example: `EQBv...`
          example: "TRx7KzjN4GqLhU3k9pF2mW5vB8dY6cE1n"
        payment_method:
          type: string
          description: |
            Fiat payment or payout method.
            - `elqr` — ELQR QR payment. Default. Available for both ON_RAMP and OFF_RAMP.
            - `bank_transfer` — Bank wire transfer. Phase 2.
          enum: [elqr, bank_transfer]
          default: "elqr"
          example: "elqr"
        flow:
          type: string
          description: |
            Internal routing flow.
            - `KGS_FLOW` — routes to KGS VASPs (DantePay). Default.
            - `RUB_FLOW` — routes to RUB VASPs (Phase 2, not available yet)
          enum: [KGS_FLOW, RUB_FLOW]
          example: KGS_FLOW
        kyc_share_token:
          type: string
          description: |
            Sumsub applicant token for KYC passthrough.

            This is the Sumsub `applicantId` or share token issued by your KYC flow.
            Format: `ssn_XXXX` (share token) or UUID (applicant ID).
            Example: `ssn_applicant_abc123xyz`

            Twin Bridge forwards this token to the VASP as-is for identity verification.
            The VASP performs KYC checks directly with Sumsub.

            **When required:** transactions above regulatory KYC thresholds
            (currently 30,000 KGS per single transaction or 100,000 KGS cumulative per month).
            For amounts below threshold, this field is optional but recommended.

            KYC result is delivered via `payment.kyc_verified` or `payment.kyc_rejected` webhook.
          example: "ssn_applicant_abc123xyz"
        sumsub_applicant_id:
          type: string
          description: |
            Sumsub applicant ID for direct KYC lookup.
            Optional complement to `kyc_share_token`.
            Use when you store the applicant ID separately from the share token
            and want to allow the VASP to perform direct API lookup via Sumsub.
          example: "6234a2f8b3c4d5e6f7a8b9c0"
        callback_url:
          type: string
          format: uri
          description: |
            Per-transaction webhook URL override.
            If provided, Twin Bridge sends all webhook events for this transaction
            to this URL instead of your default partner webhook URL.
            Must be a valid HTTPS URL.
          example: "https://yourapp.com/webhooks/twinbridge/tx-specific"
        metadata:
          type: object
          description: |
            Arbitrary JSON object for partner-side tracking (max 1 KB).
            This object is stored as-is and echoed back in all webhook events
            for this transaction. Use it for order IDs, user IDs, or any
            internal reference data.
          additionalProperties: true
          example:
            order_id: "ORD-2026-001"
            user_id: "usr_12345"

    FeeInfo:
      type: object
      description: Fee breakdown for a transaction
      properties:
        vasp_fee:
          type: string
          description: VASP service fee amount (decimal string)
          example: "25.00"
        vasp_fee_currency:
          type: string
          description: Currency of the VASP fee
          example: "KGS"
        network_fee:
          type: string
          description: Blockchain network fee amount (decimal string)
          example: "1.00"
        network_fee_currency:
          type: string
          description: Currency of the network fee (usually the crypto currency)
          example: "USDT"
        total_fee_fiat:
          type: string
          description: Total fee converted to fiat equivalent (decimal string)
          example: "113.42"
        total_fee_currency:
          type: string
          description: Currency for the total fiat fee
          example: "KGS"

    TransactionResponse:
      type: object
      properties:
        tx_id:
          type: string
          format: uuid
          description: Unique Twin Bridge transaction identifier
          example: "550e8400-e29b-41d4-a716-446655440000"
        status:
          $ref: '#/components/schemas/TransactionStatus'
        direction:
          type: string
          enum: [ON_RAMP, OFF_RAMP]
          example: ON_RAMP
        fiat_amount:
          type: string
          description: Fiat amount (decimal string)
          example: "5000.00"
        fiat_currency:
          type: string
          example: KGS
        crypto_amount:
          type: string
          description: Actual crypto amount delivered (set after COMPLETED)
          example: "56.55"
        crypto_currency:
          type: string
          example: USDT
        network:
          type: string
          description: Blockchain network used for this transaction
          example: "TRC-20"
        crypto_address:
          type: string
          description: Crypto wallet address echoed back from the create request (ON_RAMP)
          example: "TRx7KzjN4GqLhU3k9pF2mW5vB8dY6cE1n"
        recipient_kgs_wallet:
          type: string
          description: |
            OFF_RAMP only: KGS wallet / phone number where the VASP will
            pay the customer out via ELQR. Echoed back from the request.
          example: "+996700123456"
        recipient_phone:
          type: string
          description: OFF_RAMP only — phone number target for an ELQR payout. Mutually exclusive with `recipient_kgs_wallet`.
          example: "+996700123456"
        rate:
          type: string
          description: Locked exchange rate at time of transaction creation
          example: "88.42"
        vasp_id:
          type: string
          description: Identifier of the VASP that processed this transaction (for transparency)
          example: "dantepay-01"
        fee:
          $ref: '#/components/schemas/FeeInfo'
        estimated_completion:
          type: string
          format: date-time
          description: Estimated completion time (ISO 8601 UTC). May change as transaction progresses.
          example: "2026-04-03T12:05:00Z"
        payment:
          $ref: '#/components/schemas/PaymentInfo'
        failure_reason:
          type: string
          description: Human-readable failure reason (set when status is FAILED or CANCELLED)
          example: "qr_expired"
        created_at:
          type: string
          format: date-time
          example: "2026-03-31T10:00:00Z"
        paid_at:
          type: string
          format: date-time
          description: Timestamp when fiat payment was confirmed
          example: "2026-03-31T10:05:00Z"
        completed_at:
          type: string
          format: date-time
          description: Timestamp when crypto was delivered (ON_RAMP) or fiat was paid out (OFF_RAMP)
          example: "2026-03-31T10:08:00Z"

    PaymentInfo:
      type: object
      description: QR payment data. Present when status is `PENDING_PAYMENT`.
      properties:
        qr_data:
          type: string
          description: |
            Raw QR code data string. Render this as a QR image for the end user.
            Format follows Kyrgyz national payment QR standard (EMVCo-compatible).
          example: "00020101021226580014kg.elqr.payment..."
        qr_url:
          type: string
          format: uri
          description: Optional direct payment link (deep-link to banking app)
          example: "https://elqr.kg/qr/abc123"
        expires_at:
          type: string
          format: date-time
          description: QR code expiry time. After this, a `payment.qr_expired` webhook is sent.
          example: "2026-03-31T11:30:00Z"
        amount:
          type: string
          description: Amount to be paid by end user
          example: "5000.00"
        currency:
          type: string
          description: Currency of the payment
          example: KGS

    ListTransactionsResponse:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/TransactionResponse'
        total:
          type: integer
          description: Total number of transactions matching the filter
          example: 42
        page:
          type: integer
          example: 1
        limit:
          type: integer
          example: 20

    CancelRequest:
      type: object
      properties:
        reason:
          type: string
          description: Optional human-readable reason for cancellation
          example: "Customer requested cancellation"

    RateResponse:
      type: object
      properties:
        pair:
          type: string
          description: Currency pair (FIAT/CRYPTO)
          example: KGS/USDT
        rate:
          type: string
          description: Current exchange rate (1 CRYPTO = rate FIAT)
          example: "88.42"
        spread:
          type: string
          description: VASP spread applied to the base rate (decimal string)
          example: "0.50"
        min_amount:
          type: string
          description: Minimum fiat amount allowed for this pair (decimal string)
          example: "500.00"
        max_amount:
          type: string
          description: Maximum fiat amount allowed for this pair (decimal string)
          example: "500000.00"
        available_networks:
          type: array
          items:
            type: string
          description: Blockchain networks currently available for this pair
          example: ["TRC-20", "ERC-20"]
        updated_at:
          type: string
          format: date-time
          description: When this rate was last refreshed
          example: "2026-03-31T11:00:00Z"

    HealthResponse:
      type: object
      properties:
        status:
          type: string
          enum: [ok, degraded]
          example: ok
        db:
          type: string
          enum: [ok, error]
          example: ok
        redis:
          type: string
          enum: [ok, error]
          example: ok
        vasp:
          type: string
          enum: [ok, error]
          example: ok

    ErrorResponse:
      type: object
      properties:
        error:
          type: string
          description: Human-readable error message
          example: "invalid transaction id"
        code:
          type: string
          description: Machine-readable error code
          example: INVALID_ID
        tx_id:
          type: string
          format: uuid
          description: Transaction ID (when relevant)
          example: "550e8400-e29b-41d4-a716-446655440000"

    WebhookEvent:
      type: object
      required: [event_type, tx_id, status, timestamp]
      properties:
        event_type:
          type: string
          description: Event type
          enum:
            - payment.created
            - payment.qr_scanned
            - payment.qr_expired
            - payment.completed
            - payment.cancelled
            - payment.partial
            - payment.late
            - payment.kyc_verified
            - payment.kyc_rejected
            - kyc.verified
            - kyc.rejected
          example: payment.completed
        tx_id:
          type: string
          format: uuid
          description: Twin Bridge transaction ID
          example: "550e8400-e29b-41d4-a716-446655440000"
        status:
          $ref: '#/components/schemas/TransactionStatus'
        timestamp:
          type: integer
          format: int64
          description: Unix timestamp of the event
          example: 1743415200
        payload:
          type: object
          description: |
            Event-specific data. Content varies by event_type.

            **payment.completed (ON_RAMP):**
            - `crypto_amount`, `crypto_currency`, `crypto_tx_hash`, `network`, `rate`, `fee`, `metadata`

            **payment.completed (OFF_RAMP):**
            - `fiat_amount`, `fiat_currency`, `payout_method`, `payout_reference`, `rate`, `metadata`
          additionalProperties: true

  responses:
    Unauthorized:
      description: Authentication failed
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          examples:
            missing_headers:
              summary: Missing authentication headers
              value:
                error: "missing authentication headers"
                code: "AUTH_MISSING"
            invalid_signature:
              summary: Signature mismatch
              value:
                error: "invalid signature"
                code: "AUTH_INVALID_SIGNATURE"
            expired_timestamp:
              summary: Timestamp too old
              value:
                error: "invalid or expired timestamp"
                code: "AUTH_TIMESTAMP"

    NotFound:
      description: Transaction not found (or belongs to another partner)
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error: "transaction not found"
            code: "NOT_FOUND"

    RateLimitExceeded:
      description: Rate limit exceeded — 100 requests/minute per API key
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error: "rate limit exceeded: 100 requests per minute"
            code: "RATE_LIMITED"
      headers:
        Retry-After:
          description: Seconds to wait before retrying
          schema:
            type: integer
            example: 30
