openapi: 3.1.0
info:
  title: Coldtill merchant API
  version: "1"
  summary: Accept crypto payments into your own wallets and send payouts from your server.
  description: |
    The merchant API is what your shop or backend calls. Every Coldtill server exposes it at its own address,
    for example `https://pay.example.com`, or a project's checkout domain.

    - **Authentication:** an `API-Key` header with a project API key (`ct_…`; older `kh_…` keys still work).
      `Authorization: Bearer <key>` is accepted too.
    - **Amounts** are decimal strings in responses. Request amounts may be numbers or strings.
    - **Errors** are a non-2xx status with `{"error": "message"}`, sometimes with a `code`.
    - **Rate limit:** 600 requests per minute per client IP; over it returns `429`.

  license:
    name: Coldtill EULA
    url: https://coldtill.com/eula
  contact:
    url: https://coldtill.com/docs/api
servers:
  - url: https://{host}
    variables:
      host:
        default: pay.example.com
        description: Your Coldtill server or project checkout domain
security:
  - apiKey: []
tags:
  - name: Payments
  - name: Payouts
  - name: Reference

paths:
  /api/v1/ticker:
    get:
      tags: [Reference]
      operationId: getTicker
      summary: Enabled networks and tokens with USD prices
      security: []
      responses:
        "200":
          description: One entry per enabled token
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Tick" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /api/v1/payment:
    post:
      tags: [Payments]
      operationId: createPayment
      summary: Create a payment
      description: |
        Returns a hosted checkout link to send the customer to. A customer has at most one open payment: creating
        a new one replaces the previous one (`REPLACED`). If the previous payment has a deposit that is still
        confirming, the request fails with `409` so that deposit isn't stranded; retry after it confirms.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreatePaymentRequest" }
            example: { customerID: "42", customerEmail: jane@example.com, amountInUSD: 25.00, invoiceID: INV-1001 }
      responses:
        "200":
          description: Payment created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CreatePaymentResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "409":
          description: The customer's previous payment has a deposit still confirming
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /api/v1/payment/reference/{reference_id}:
    get:
      tags: [Payments]
      operationId: getPayment
      summary: Get a payment
      description: Ship the order on `FILLED` or `OVER_FILLED`. Prefer webhooks to polling.
      parameters:
        - $ref: "#/components/parameters/ReferenceID"
      responses:
        "200":
          description: The payment and its deposits
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Payment" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/blockchain-currency/reference/{reference_id}:
    get:
      tags: [Payments]
      operationId: listPaymentCurrencies
      summary: What the customer can pay this payment with
      parameters:
        - $ref: "#/components/parameters/ReferenceID"
      responses:
        "200":
          description: Payable network and currency pairs
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/PayableCurrency" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/deposit-address/reference/{reference_id}:
    post:
      tags: [Payments]
      operationId: assignDepositAddress
      summary: Get the customer's deposit address on a network
      description: |
        Returns the customer's permanent address for that network's family (every EVM network shares one).
        Use it to build your own checkout instead of the hosted one.
      parameters:
        - $ref: "#/components/parameters/ReferenceID"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [blockchain_code]
              properties:
                blockchain_code: { $ref: "#/components/schemas/NetworkCode" }
      responses:
        "200":
          description: The deposit address
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DepositAddress" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: No deposit wallet is set up for that network yet
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/v1/withdrawal/merchant:
    post:
      tags: [Payouts]
      operationId: createPayout
      summary: Send a payout from the hot wallet
      description: |
        Through the API a payout can only go to the project's address book. Payouts over the project's limits
        wait for an admin (`pending-approval`). Retrying with the same `Idempotency-Key` and parameters returns the
        original payout; reusing a key with different parameters returns `409`.
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string, maxLength: 200 }
          example: refund-INV-1001
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreatePayoutRequest" }
            example: { blockchainCode: BASE, currencyCode: USDC, amount: "12.50", toAddress: "0xAbC…" }
      responses:
        "200":
          description: The payout, `pending` or `pending-approval`
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Payout" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: Destination not in the address book, or payouts are off for this project
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: Idempotency key reused with different parameters
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
    get:
      tags: [Payouts]
      operationId: listPayouts
      summary: List payouts created through the API
      parameters:
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 50 } }
        - { name: offset, in: query, schema: { type: integer, minimum: 0, default: 0 } }
      responses:
        "200":
          description: Newest first
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Payout" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/v1/withdrawal/{id}/merchant:
    get:
      tags: [Payouts]
      operationId: getPayout
      summary: Get a payout
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200":
          description: The payout
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Payout" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

webhooks:
  payment:
    post:
      summary: A payment changed
      description: |
        Event names are `payment.` plus the lower-cased state, sent in `X-Coldtill-Event`. Verify
        `X-Coldtill-Signature-V2` (HMAC-SHA256 of `<timestamp>.<raw body>`) before trusting the body, and decide
        from the signed `status`, not the event header. Every header is also sent under its old `X-Keyhold-` name.
      parameters:
        - $ref: "#/components/parameters/WebhookEvent"
        - $ref: "#/components/parameters/WebhookDelivery"
        - $ref: "#/components/parameters/WebhookTimestamp"
        - $ref: "#/components/parameters/WebhookSignatureV2"
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PaymentEvent" }
      responses:
        "200": { description: Any 2xx within 60 seconds acknowledges the delivery }
  payout:
    post:
      summary: A payout changed
      description: Event names are `payout.` plus the status. The body is the payout object.
      parameters:
        - $ref: "#/components/parameters/WebhookEvent"
        - $ref: "#/components/parameters/WebhookDelivery"
        - $ref: "#/components/parameters/WebhookTimestamp"
        - $ref: "#/components/parameters/WebhookSignatureV2"
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/Payout" }
      responses:
        "200": { description: Any 2xx within 60 seconds acknowledges the delivery }

components:
  securitySchemes:
    apiKey:
      type: apiKey
      in: header
      name: API-Key
      description: A project API key, created under API keys in the dashboard. Shown once, stored hashed.

  parameters:
    ReferenceID:
      name: reference_id
      in: path
      required: true
      description: The payment's `reference_id` from create payment
      schema: { type: string, format: uuid }
    WebhookEvent:
      { name: X-Coldtill-Event, in: header, required: true, schema: { type: string }, example: payment.filled }
    WebhookDelivery:
      name: X-Coldtill-Delivery
      in: header
      required: true
      description: Unique per delivery; retries reuse it
      schema: { type: string }
    WebhookTimestamp:
      name: X-Coldtill-Timestamp
      in: header
      required: true
      description: Unix seconds when this attempt was signed. Reject if more than five minutes off.
      schema: { type: string }
    WebhookSignatureV2:
      name: X-Coldtill-Signature-V2
      in: header
      required: true
      description: "`sha256=` + hex HMAC-SHA256 of `<timestamp>.<raw body>` with the endpoint secret"
      schema: { type: string }

  responses:
    BadRequest:
      description: Invalid input
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    Unauthorized:
      description: Missing or invalid API key
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    NotFound:
      description: Not found, or it belongs to another project
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    RateLimited:
      description: Too many requests
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error: { type: string }
        code: { type: string }

    Decimal:
      type: string
      pattern: '^-?[0-9]+(\.[0-9]+)?$'
      examples: ["25.00"]

    NetworkCode:
      type: string
      description: |
        `ETH`, `BASE`, `POLYGON`, `BSC`, `TRON`, `SOL` or `BTC`. In testnet mode the same codes mean Sepolia,
        Base Sepolia, Amoy, BSC testnet, Nile, Devnet and Signet.
      examples: [BASE]

    PaymentState:
      type: string
      enum: [OPEN, PARTIALLY_FILLED, FILLED, OVER_FILLED, CANCELLED, REPLACED]
      description: |
        `FILLED` is within 1% of the amount owed; `OVER_FILLED` is more than 1% above. `FILLED`, `OVER_FILLED`,
        `CANCELLED` and `REPLACED` are final.

    Tick:
      type: object
      properties:
        blockchainCode: { $ref: "#/components/schemas/NetworkCode" }
        currencyCode: { type: string, examples: [USDC] }
        tokenAddress: { type: string, description: Empty for the native coin }
        standard: { type: string, enum: [NATIVE, ERC20, SPL, TRC20] }
        walletPrecision: { type: integer, description: Token decimals }
        family: { type: string, enum: [evm, sol, tron, btc] }
        price: { type: string, description: USD price, empty if unavailable }

    CreatePaymentRequest:
      type: object
      required: [customerID, amountInUSD]
      properties:
        customerID: { type: string, description: Your ID for the customer. The same ID always gets the same deposit addresses. }
        amountInUSD:
          oneOf: [{ type: number }, { $ref: "#/components/schemas/Decimal" }]
          description: Greater than 0 and at most 10,000,000
        customerEmail: { type: string, format: email }
        invoiceID: { type: string, description: Your order reference, echoed in webhooks }
        network: { $ref: "#/components/schemas/NetworkCode", description: Restrict the checkout to one network }
        currency: { type: string, description: With `network`, restrict to one currency such as `USDC` }
        expire:
          type: string
          format: date-time
          description: At least a minute ahead. Defaults to the project's payment expiry (24 hours).

    CreatePaymentResponse:
      type: object
      properties:
        host: { type: string, format: uri, description: Where the checkout is served (the project's checkout domain if set) }
        reference_id: { type: string, format: uuid }
        url: { type: string, format: uri, description: Send the customer here }

    Payment:
      type: object
      properties:
        invoiceID: { type: string }
        customerID: { type: string }
        amountInUSD: { $ref: "#/components/schemas/Decimal" }
        filledAmountInUSD: { $ref: "#/components/schemas/Decimal" }
        pendingAmountInUSD: { $ref: "#/components/schemas/Decimal" }
        paymentState: { $ref: "#/components/schemas/PaymentState" }
        referenceID: { type: string, format: uuid }
        url: { type: string, format: uri }
        createdAt: { type: string, format: date-time }
        expiresAt: { type: string, format: date-time }
        deposits:
          type: array
          items:
            type: object
            properties:
              network: { $ref: "#/components/schemas/NetworkCode" }
              currency: { type: string }
              amount: { $ref: "#/components/schemas/Decimal" }
              amountInUSD: { $ref: "#/components/schemas/Decimal" }
              transactionHash: { type: string }
              status: { type: string, enum: [pending, confirmed, orphaned] }
              confirmations: { type: integer }
              requiredConfirmations: { type: integer }

    PayableCurrency:
      type: object
      properties:
        blockchainCode: { $ref: "#/components/schemas/NetworkCode" }
        network: { type: string, description: Network name }
        currencyCode: { type: string }
        customerAddress: { type: string, description: The customer's deposit address, if already assigned }
        tokenAddress: { type: string }
        standard: { type: string }
        walletPrecision: { type: integer }
        family: { type: string }
        confirmations: { type: integer }

    DepositAddress:
      type: object
      properties:
        id: { type: string, format: uuid }
        createdAt: { type: string, format: date-time }
        Address: { type: string }
        Family: { type: string, enum: [evm, sol, tron, btc] }
        Status: { type: string, enum: [active] }

    CreatePayoutRequest:
      type: object
      required: [blockchainCode, currencyCode, amount]
      properties:
        blockchainCode: { $ref: "#/components/schemas/NetworkCode" }
        currencyCode: { type: string, examples: [USDC] }
        amount:
          oneOf: [{ $ref: "#/components/schemas/Decimal" }, { type: number }]
          description: In the currency, with at most the token's decimals
        toAddress: { type: string, description: Must be in the project's address book }
        addressBookId: { type: string, format: uuid, description: Instead of `toAddress` }

    Payout:
      type: object
      properties:
        id: { type: string, format: uuid }
        status:
          type: string
          enum: [pending-approval, pending-otp-verification, pending, signing, initiated, sent, failed, rejected, cancelled, needs-review]
        network: { $ref: "#/components/schemas/NetworkCode" }
        currency: { type: string }
        tokenContract: { type: string }
        amount: { $ref: "#/components/schemas/Decimal" }
        amountInUSD: { $ref: "#/components/schemas/Decimal" }
        toAddress: { type: string }
        transactionHash: { type: string }
        blockNumber: { type: integer }
        approvalReason: { type: string, description: Why the payout is held }
        failureReason: { type: string }
        idempotencyKey: { type: string }
        createdBy: { type: string }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }

    PaymentEvent:
      type: object
      properties:
        customer_id: { type: string }
        invoice_id: { type: string }
        reference_id: { type: string, format: uuid }
        status: { $ref: "#/components/schemas/PaymentState" }
        amount: { $ref: "#/components/schemas/Decimal" }
        currency: { type: string, enum: [USD] }
        filled_amount: { $ref: "#/components/schemas/Decimal" }
        filled_amount_in_usd: { $ref: "#/components/schemas/Decimal" }
        timestamp: { type: integer, description: Unix seconds }
        payment_info:
          type: array
          items:
            type: object
            properties:
              source_address: { type: string }
              transaction_hash: { type: string }
              destination_address: { type: string }
              block_number: { type: integer }
              network: { $ref: "#/components/schemas/NetworkCode" }
              currency: { type: string }
              amount: { $ref: "#/components/schemas/Decimal" }
        confirmation_current: { type: integer, description: Progress events only }
        confirmation_required: { type: integer, description: Progress events only }
        conversion:
          type: array
          description: Present when the customer paid through an auto-convert swap
          items:
            type: object
            properties:
              provider: { type: string, enum: [near-intents, thorchain] }
              from_currency: { type: string }
              from_network: { type: string }
              from_amount: { $ref: "#/components/schemas/Decimal" }
              from_amount_in_usd: { $ref: "#/components/schemas/Decimal" }
              network: { $ref: "#/components/schemas/NetworkCode" }
              currency: { type: string }
              amount: { $ref: "#/components/schemas/Decimal" }
              transaction_hashes: { type: array, items: { type: string } }
