openapi: 3.1.0
info:
  title: Coinwaka Pay API
  version: "2026-06-01"
  description: >
    Accept Coinwaka Balance, M-Pesa, card, PayPal, and on-chain stablecoin
    payments with one integration. Create a payment intent, send the customer to
    its hosted checkout, and confirm with a signed webhook. Coinwaka handles the
    rails, quotes, and settlement.


    This document is the source of truth for the public /v1 surface. Test mode
    uses a `cwk_test_…` key and never moves real money; live mode uses a
    `cwk_live_…` key — the key's prefix selects the environment, so the same
    base URL serves both.


    Every response includes a `Coinwaka-Request-Id` header. Authenticated
    responses also carry `Coinwaka-Environment` and the `Coinwaka-RateLimit-*`
    headers, and the top-level JSON body carries `request_id`, `environment`,
    and `livemode`. Quote your `request_id` when contacting support.
  contact:
    name: Coinwaka Developer Support
    url: https://developers.coinwaka.com
  license:
    name: Proprietary
    identifier: LicenseRef-Coinwaka-Proprietary
servers:
  - url: https://api.coinwaka.com/v1
    description: >
      Single base URL for both environments. A `cwk_test_…` key runs in
      sandbox; a `cwk_live_…` key runs in live. The response's
      `Coinwaka-Environment` header and body `environment` confirm which one
      handled the request.
security:
  - BearerKey: []
tags:
  - name: Auth
    description: Verify a key's identity, scopes, and environment.
  - name: Rates and quotes
    description: Reference prices and short-lived locked quotes.
  - name: Payment intents
    description: Create and manage one expected payment each.
  - name: Payment links
    description: Reusable checkout URLs that mint a payment intent per open.
  - name: Refunds
    description: Refund a paid payment and track its review status.
  - name: Balances and settlements
    description: Settlement balances, per-payment settlements, and reports.
  - name: Webhooks
    description: Subscribe to signed events and inspect deliveries.
  - name: Customers
    description: Store customer records to reuse across payments.
paths:
  /auth/verify:
    get:
      tags: [Auth]
      summary: Verify an API key
      description: >
        Authenticate a key without moving anything. The safe way to test a live
        key: confirms identity, scopes, IP allowance, and environment without
        creating a real payment. Any valid key is accepted, regardless of scope.
      operationId: verifyAuth
      x-coinwaka-scopes: []
      responses:
        "200":
          description: Key is valid.
          headers:
            Coinwaka-Request-Id: { $ref: "#/components/headers/CoinwakaRequestId" }
            Coinwaka-Environment: { $ref: "#/components/headers/CoinwakaEnvironment" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/AuthVerify"
                  - $ref: "#/components/schemas/Envelope"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalServerError" }
  /rates:
    get:
      tags: [Rates and quotes]
      summary: Reference rates
      description: Supported settlement assets and live reference prices. Indicative only, not a locked quote.
      operationId: getRates
      x-coinwaka-scopes: [payments:read]
      responses:
        "200":
          description: Rates.
          headers:
            Coinwaka-Request-Id: { $ref: "#/components/headers/CoinwakaRequestId" }
            Coinwaka-Environment: { $ref: "#/components/headers/CoinwakaEnvironment" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Rates"
                  - $ref: "#/components/schemas/Envelope"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalServerError" }
  /quotes:
    post:
      tags: [Rates and quotes]
      summary: Lock a quote
      description: Lock a fiat to settlement-asset quote for ~10 minutes.
      operationId: createQuote
      x-coinwaka-scopes: [payments:read]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [source_currency, target_asset, amount]
              properties:
                source_currency: { type: string, example: KES }
                target_asset: { type: string, example: USDT }
                amount:
                  type: string
                  description: Decimal amount in the currency unit, not minor units. Never send a floating-point number.
                  example: "2500"
                side:
                  type: string
                  enum: [customer_pays, merchant_receives]
                  default: customer_pays
      responses:
        "200":
          description: A locked quote.
          headers:
            Coinwaka-Request-Id: { $ref: "#/components/headers/CoinwakaRequestId" }
            Coinwaka-Environment: { $ref: "#/components/headers/CoinwakaEnvironment" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Quote"
                  - $ref: "#/components/schemas/Envelope"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalServerError" }
  /payment-intents:
    post:
      tags: [Payment intents]
      summary: Create a payment intent
      description: >
        One expected payment. Price in fiat; the customer pays by any allowed
        method; you receive the settlement asset in your Coinwaka wallet.
        Idempotent on the `Idempotency-Key` header.
      operationId: createPaymentIntent
      x-coinwaka-scopes: [payments:write]
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount, currency]
              properties:
                amount:
                  type: string
                  description: Decimal amount in the currency unit, not minor units. Never send a floating-point number.
                  example: "2500"
                currency: { type: string, example: KES }
                settlement_currency:
                  type: string
                  enum: [USDT, USDC, BTC, ETH, SOL]
                  default: USDT
                description: { type: string }
                merchant_reference: { type: string, example: ORDER-10045 }
                payment_methods:
                  type: array
                  items:
                    type: string
                    enum: [coinwaka_balance, mpesa, card, paypal, external_wallet]
                customer:
                  type: object
                  properties:
                    email: { type: string, format: email }
                    phone: { type: string, example: "+254700000001" }
                metadata:
                  type: object
                  additionalProperties: true
                expires_in_minutes: { type: integer, default: 30, maximum: 1440 }
      responses:
        "201":
          description: Created.
          headers:
            Coinwaka-Request-Id: { $ref: "#/components/headers/CoinwakaRequestId" }
            Coinwaka-Environment: { $ref: "#/components/headers/CoinwakaEnvironment" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaymentIntent"
                  - $ref: "#/components/schemas/Envelope"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalServerError" }
    get:
      tags: [Payment intents]
      summary: List payment intents
      description: Newest first, scoped to the key's environment. Cursor pagination.
      operationId: listPaymentIntents
      x-coinwaka-scopes: [payments:read]
      parameters:
        - { name: limit, in: query, schema: { type: integer, default: 20, maximum: 100 } }
        - { name: starting_after, in: query, schema: { type: string } }
        - { name: status, in: query, schema: { type: string } }
      responses:
        "200":
          description: A page of payment intents.
          headers:
            Coinwaka-Request-Id: { $ref: "#/components/headers/CoinwakaRequestId" }
            Coinwaka-Environment: { $ref: "#/components/headers/CoinwakaEnvironment" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListBase"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/PaymentIntent" }
                  - $ref: "#/components/schemas/Envelope"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalServerError" }
  /payment-intents/{id}:
    get:
      tags: [Payment intents]
      summary: Retrieve a payment intent
      operationId: getPaymentIntent
      x-coinwaka-scopes: [payments:read]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The payment intent.
          headers:
            Coinwaka-Request-Id: { $ref: "#/components/headers/CoinwakaRequestId" }
            Coinwaka-Environment: { $ref: "#/components/headers/CoinwakaEnvironment" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaymentIntent"
                  - $ref: "#/components/schemas/Envelope"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalServerError" }
  /payment-intents/{id}/cancel:
    post:
      tags: [Payment intents]
      summary: Cancel a payment intent
      description: Only while the intent is still awaiting payment.
      operationId: cancelPaymentIntent
      x-coinwaka-scopes: [payments:write]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The cancelled intent.
          headers:
            Coinwaka-Request-Id: { $ref: "#/components/headers/CoinwakaRequestId" }
            Coinwaka-Environment: { $ref: "#/components/headers/CoinwakaEnvironment" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaymentIntent"
                  - $ref: "#/components/schemas/Envelope"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalServerError" }
  /payment-intents/{id}/refund-request:
    post:
      tags: [Refunds]
      summary: Request a refund (legacy alias)
      description: >
        Legacy alias of `POST /refunds`, kept for backward compatibility with
        its original response shape. New integrations should use the `/refunds`
        resource. Provider-rail refunds go to manual review; Coinwaka Balance
        payments must be refunded by the owner in the dashboard
        (transaction-PIN protected).
      operationId: requestRefund
      x-coinwaka-scopes: [refunds:write]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                reason: { type: string }
      responses:
        "200":
          description: The refund request.
          headers:
            Coinwaka-Request-Id: { $ref: "#/components/headers/CoinwakaRequestId" }
            Coinwaka-Environment: { $ref: "#/components/headers/CoinwakaEnvironment" }
          content:
            application/json:
              schema:
                allOf:
                  - type: object
                    required: [object, payment_intent, status]
                    properties:
                      object: { type: string, enum: [refund_request] }
                      payment_intent: { type: string }
                      status: { type: string, example: requested }
                      sandbox: { type: boolean }
                  - $ref: "#/components/schemas/Envelope"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalServerError" }
  /refunds:
    post:
      tags: [Refunds]
      summary: Create a refund
      description: >
        Refund a paid payment in full. Provider-rail (M-Pesa, card, PayPal)
        refunds are queued for review (`requested`); sandbox refunds resolve
        instantly (`succeeded`). Coinwaka Balance payments are refunded by the
        owner in the dashboard, not via the API. A payment can have at most one
        refund: a repeat after success returns 409, so this is retry-safe but
        not idempotent. Partial refunds are not supported.
      operationId: createRefund
      x-coinwaka-scopes: [refunds:write]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [payment_intent]
              properties:
                payment_intent: { type: string, example: pi_live_abc123 }
                reason: { type: string }
                amount:
                  type: string
                  description: >
                    Optional. Must equal the full captured amount if sent;
                    partial refunds are rejected. Omit to refund in full.
      responses:
        "201":
          description: The refund.
          headers:
            Coinwaka-Request-Id: { $ref: "#/components/headers/CoinwakaRequestId" }
            Coinwaka-Environment: { $ref: "#/components/headers/CoinwakaEnvironment" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Refund"
                  - $ref: "#/components/schemas/Envelope"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalServerError" }
    get:
      tags: [Refunds]
      summary: List refunds
      description: Newest first, scoped to the key's environment. Cursor pagination.
      operationId: listRefunds
      x-coinwaka-scopes: [refunds:read]
      parameters:
        - { name: limit, in: query, schema: { type: integer, default: 20, maximum: 100 } }
        - { name: starting_after, in: query, schema: { type: string } }
      responses:
        "200":
          description: A page of refunds.
          headers:
            Coinwaka-Request-Id: { $ref: "#/components/headers/CoinwakaRequestId" }
            Coinwaka-Environment: { $ref: "#/components/headers/CoinwakaEnvironment" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListBase"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Refund" }
                  - $ref: "#/components/schemas/Envelope"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalServerError" }
  /refunds/{id}:
    get:
      tags: [Refunds]
      summary: Retrieve a refund
      operationId: getRefund
      x-coinwaka-scopes: [refunds:read]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The refund.
          headers:
            Coinwaka-Request-Id: { $ref: "#/components/headers/CoinwakaRequestId" }
            Coinwaka-Environment: { $ref: "#/components/headers/CoinwakaEnvironment" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Refund"
                  - $ref: "#/components/schemas/Envelope"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalServerError" }
  /payment-links:
    post:
      tags: [Payment links]
      summary: Create a payment link
      description: A reusable URL; each open mints a fresh payment intent.
      operationId: createPaymentLink
      x-coinwaka-scopes: [payment:links:write, checkout:write]
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [title, currency]
              properties:
                title: { type: string }
                amount:
                  type: string
                  description: Decimal amount in the currency unit. Omit for a customer-entered amount.
                currency: { type: string, example: KES }
                settlement_currency: { type: string, default: USDT }
                max_payments: { type: integer }
                expires_at: { type: string, format: date-time }
      responses:
        "201":
          description: The payment link.
          headers:
            Coinwaka-Request-Id: { $ref: "#/components/headers/CoinwakaRequestId" }
            Coinwaka-Environment: { $ref: "#/components/headers/CoinwakaEnvironment" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaymentLink"
                  - $ref: "#/components/schemas/Envelope"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalServerError" }
  /payment-links/{id}:
    get:
      tags: [Payment links]
      summary: Retrieve a payment link
      operationId: getPaymentLink
      x-coinwaka-scopes: [payment:links:read, payments:read]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The payment link.
          headers:
            Coinwaka-Request-Id: { $ref: "#/components/headers/CoinwakaRequestId" }
            Coinwaka-Environment: { $ref: "#/components/headers/CoinwakaEnvironment" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaymentLink"
                  - $ref: "#/components/schemas/Envelope"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalServerError" }
  /balances:
    get:
      tags: [Balances and settlements]
      summary: List balances
      description: Settlement balances per asset. A test key returns sandbox balances.
      operationId: listBalances
      x-coinwaka-scopes: [balances:read]
      responses:
        "200":
          description: Balances.
          headers:
            Coinwaka-Request-Id: { $ref: "#/components/headers/CoinwakaRequestId" }
            Coinwaka-Environment: { $ref: "#/components/headers/CoinwakaEnvironment" }
          content:
            application/json:
              schema:
                allOf:
                  - type: object
                    required: [object, data]
                    properties:
                      object: { type: string, enum: [list] }
                      data:
                        type: array
                        items:
                          type: object
                          required: [asset, total, available, on_hold]
                          properties:
                            asset: { type: string, example: USDT }
                            total: { type: string }
                            available: { type: string }
                            on_hold: { type: string }
                  - $ref: "#/components/schemas/Envelope"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalServerError" }
  /settlements:
    get:
      tags: [Balances and settlements]
      summary: List settlements
      description: One record per paid payment, with its settlement asset and availability.
      operationId: listSettlements
      x-coinwaka-scopes: [settlements:read]
      parameters:
        - { name: limit, in: query, schema: { type: integer, default: 25, maximum: 100 } }
        - { name: starting_after, in: query, schema: { type: string } }
      responses:
        "200":
          description: A page of settlements.
          headers:
            Coinwaka-Request-Id: { $ref: "#/components/headers/CoinwakaRequestId" }
            Coinwaka-Environment: { $ref: "#/components/headers/CoinwakaEnvironment" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListBase"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          type: object
                          required: [id, object, payment_intent, asset, status]
                          properties:
                            id: { type: string }
                            object: { type: string, enum: [settlement] }
                            payment_intent: { type: string }
                            asset: { type: string }
                            amount: { type: [string, "null"] }
                            status: { type: string, enum: [available, pending, frozen] }
                            available_at: { type: [string, "null"], format: date-time }
                            settled_at: { type: string, format: date-time }
                  - $ref: "#/components/schemas/Envelope"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalServerError" }
  /reports/payments:
    get:
      tags: [Balances and settlements]
      summary: Payments report
      description: Paid payments in a date window. JSON, or ?format=csv for a CSV download.
      operationId: reportPayments
      x-coinwaka-scopes: [reports:read]
      parameters:
        - { name: from, in: query, schema: { type: string, format: date }, description: "ISO date; defaults to 30 days ago." }
        - { name: to, in: query, schema: { type: string, format: date } }
        - { name: format, in: query, schema: { type: string, enum: [json, csv], default: json } }
      responses:
        "200":
          description: A payments report (max 366-day window).
          headers:
            Coinwaka-Request-Id: { $ref: "#/components/headers/CoinwakaRequestId" }
            Coinwaka-Environment: { $ref: "#/components/headers/CoinwakaEnvironment" }
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, enum: [list] }
                  data: { type: array, items: { type: object } }
            text/csv:
              schema: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalServerError" }
  /reports/settlements:
    get:
      tags: [Balances and settlements]
      summary: Settlements report
      description: Settlement records in a date window. JSON, or ?format=csv for a CSV download.
      operationId: reportSettlements
      x-coinwaka-scopes: [reports:read]
      parameters:
        - { name: from, in: query, schema: { type: string, format: date } }
        - { name: to, in: query, schema: { type: string, format: date } }
        - { name: format, in: query, schema: { type: string, enum: [json, csv], default: json } }
      responses:
        "200":
          description: A settlements report (max 366-day window).
          headers:
            Coinwaka-Request-Id: { $ref: "#/components/headers/CoinwakaRequestId" }
            Coinwaka-Environment: { $ref: "#/components/headers/CoinwakaEnvironment" }
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, enum: [list] }
                  data: { type: array, items: { type: object } }
            text/csv:
              schema: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalServerError" }
  /webhook_endpoints:
    post:
      tags: [Webhooks]
      summary: Create a webhook endpoint
      description: The endpoint runs in the key's environment. The signing secret is returned once.
      operationId: createWebhookEndpoint
      x-coinwaka-scopes: [webhooks:write]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url: { type: string, format: uri, description: "Public HTTPS URL." }
                events:
                  type: array
                  items: { type: string }
                  description: "Subscribed event types; empty = all."
      responses:
        "201":
          description: Created (includes the one-time secret).
          headers:
            Coinwaka-Request-Id: { $ref: "#/components/headers/CoinwakaRequestId" }
            Coinwaka-Environment: { $ref: "#/components/headers/CoinwakaEnvironment" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/WebhookEndpoint"
                  - $ref: "#/components/schemas/Envelope"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalServerError" }
    get:
      tags: [Webhooks]
      summary: List webhook endpoints
      operationId: listWebhookEndpoints
      x-coinwaka-scopes: [webhooks:read]
      responses:
        "200":
          description: Webhook endpoints in the key's environment.
          headers:
            Coinwaka-Request-Id: { $ref: "#/components/headers/CoinwakaRequestId" }
            Coinwaka-Environment: { $ref: "#/components/headers/CoinwakaEnvironment" }
          content:
            application/json:
              schema:
                allOf:
                  - type: object
                    required: [object, data]
                    properties:
                      object: { type: string, enum: [list] }
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/WebhookEndpoint" }
                  - $ref: "#/components/schemas/Envelope"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalServerError" }
  /webhook_endpoints/{id}:
    get:
      tags: [Webhooks]
      summary: Retrieve a webhook endpoint
      operationId: getWebhookEndpoint
      x-coinwaka-scopes: [webhooks:read]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The webhook endpoint.
          headers:
            Coinwaka-Request-Id: { $ref: "#/components/headers/CoinwakaRequestId" }
            Coinwaka-Environment: { $ref: "#/components/headers/CoinwakaEnvironment" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/WebhookEndpoint"
                  - $ref: "#/components/schemas/Envelope"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalServerError" }
    delete:
      tags: [Webhooks]
      summary: Delete a webhook endpoint
      operationId: deleteWebhookEndpoint
      x-coinwaka-scopes: [webhooks:write]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Deleted.
          headers:
            Coinwaka-Request-Id: { $ref: "#/components/headers/CoinwakaRequestId" }
            Coinwaka-Environment: { $ref: "#/components/headers/CoinwakaEnvironment" }
          content:
            application/json:
              schema:
                allOf:
                  - type: object
                    required: [id, object, deleted]
                    properties:
                      id: { type: string }
                      object: { type: string, enum: [webhook_endpoint] }
                      deleted: { type: boolean }
                  - $ref: "#/components/schemas/Envelope"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalServerError" }
  /webhook_events:
    get:
      tags: [Webhooks]
      summary: List webhook events
      description: Emitted events for the key's environment, newest first.
      operationId: listWebhookEvents
      x-coinwaka-scopes: [webhooks:read]
      parameters:
        - { name: limit, in: query, schema: { type: integer, default: 20, maximum: 100 } }
        - { name: starting_after, in: query, schema: { type: string } }
        - { name: type, in: query, schema: { type: string } }
      responses:
        "200":
          description: A page of webhook events.
          headers:
            Coinwaka-Request-Id: { $ref: "#/components/headers/CoinwakaRequestId" }
            Coinwaka-Environment: { $ref: "#/components/headers/CoinwakaEnvironment" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListBase"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/WebhookEvent" }
                  - $ref: "#/components/schemas/Envelope"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalServerError" }
  /webhook_events/{id}:
    get:
      tags: [Webhooks]
      summary: Retrieve a webhook event
      operationId: getWebhookEvent
      x-coinwaka-scopes: [webhooks:read]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The webhook event.
          headers:
            Coinwaka-Request-Id: { $ref: "#/components/headers/CoinwakaRequestId" }
            Coinwaka-Environment: { $ref: "#/components/headers/CoinwakaEnvironment" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/WebhookEvent"
                  - $ref: "#/components/schemas/Envelope"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalServerError" }
  /webhook_events/{id}/resend:
    post:
      tags: [Webhooks]
      summary: Resend a webhook event
      description: Re-enqueues the event's deliveries to your endpoints.
      operationId: resendWebhookEvent
      x-coinwaka-scopes: [webhooks:write]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Re-enqueued.
          headers:
            Coinwaka-Request-Id: { $ref: "#/components/headers/CoinwakaRequestId" }
            Coinwaka-Environment: { $ref: "#/components/headers/CoinwakaEnvironment" }
          content:
            application/json:
              schema:
                allOf:
                  - type: object
                    required: [object, id, resent]
                    properties:
                      object: { type: string, enum: [webhook_event] }
                      id: { type: string }
                      resent: { type: integer }
                  - $ref: "#/components/schemas/Envelope"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalServerError" }
  /events:
    get:
      tags: [Webhooks]
      summary: List events
      description: >
        The event feed for the key's environment. Default order is newest first;
        pass `after=<event_id>` to stream events strictly newer than that one,
        oldest first. Powers the CLI `listen` command.
      operationId: listEvents
      x-coinwaka-scopes: [payments:read]
      parameters:
        - { name: limit, in: query, schema: { type: integer, default: 50, maximum: 100 } }
        - { name: after, in: query, schema: { type: string } }
        - { name: type, in: query, schema: { type: string } }
      responses:
        "200":
          description: A page of events.
          headers:
            Coinwaka-Request-Id: { $ref: "#/components/headers/CoinwakaRequestId" }
            Coinwaka-Environment: { $ref: "#/components/headers/CoinwakaEnvironment" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListBase"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Event" }
                  - $ref: "#/components/schemas/Envelope"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalServerError" }
  /sandbox/webhooks/trigger:
    post:
      tags: [Webhooks]
      summary: Trigger a sandbox event
      description: >
        Fire a sample event of the given `type` to your sandbox webhook
        endpoints, using your most recent test payment intent (or a synthetic
        one) as the payload. Sandbox keys only. Powers the CLI `trigger` command.
      operationId: triggerSandboxWebhook
      x-coinwaka-scopes: [payments:write]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                type: { type: string, default: payment_intent.paid, example: payment_intent.paid }
      responses:
        "200":
          description: The event was recorded and delivery to your sandbox endpoints enqueued.
          headers:
            Coinwaka-Request-Id: { $ref: "#/components/headers/CoinwakaRequestId" }
            Coinwaka-Environment: { $ref: "#/components/headers/CoinwakaEnvironment" }
          content:
            application/json:
              schema:
                allOf:
                  - type: object
                    required: [object, type, status]
                    properties:
                      object: { type: string, enum: [event_trigger] }
                      type: { type: string, example: payment_intent.paid }
                      status: { type: string, enum: [sent] }
                  - $ref: "#/components/schemas/Envelope"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalServerError" }
  /customers:
    post:
      tags: [Customers]
      summary: Create a customer
      operationId: createCustomer
      x-coinwaka-scopes: [customers:write]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                email: { type: string, format: email }
                phone: { type: string }
                name: { type: string }
                reference: { type: string, description: "Your own customer id." }
                metadata: { type: object, additionalProperties: true }
      responses:
        "201":
          description: Created.
          headers:
            Coinwaka-Request-Id: { $ref: "#/components/headers/CoinwakaRequestId" }
            Coinwaka-Environment: { $ref: "#/components/headers/CoinwakaEnvironment" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Customer"
                  - $ref: "#/components/schemas/Envelope"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalServerError" }
    get:
      tags: [Customers]
      summary: List customers
      operationId: listCustomers
      x-coinwaka-scopes: [customers:read]
      parameters:
        - { name: limit, in: query, schema: { type: integer, default: 20, maximum: 100 } }
        - { name: starting_after, in: query, schema: { type: string } }
      responses:
        "200":
          description: A page of customers.
          headers:
            Coinwaka-Request-Id: { $ref: "#/components/headers/CoinwakaRequestId" }
            Coinwaka-Environment: { $ref: "#/components/headers/CoinwakaEnvironment" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListBase"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Customer" }
                  - $ref: "#/components/schemas/Envelope"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalServerError" }
  /customers/{id}:
    get:
      tags: [Customers]
      summary: Retrieve a customer
      operationId: getCustomer
      x-coinwaka-scopes: [customers:read]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The customer.
          headers:
            Coinwaka-Request-Id: { $ref: "#/components/headers/CoinwakaRequestId" }
            Coinwaka-Environment: { $ref: "#/components/headers/CoinwakaEnvironment" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Customer"
                  - $ref: "#/components/schemas/Envelope"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalServerError" }
components:
  securitySchemes:
    BearerKey:
      type: http
      scheme: bearer
      description: >
        Your secret (`sk`) or restricted (`rk`) key as a bearer token, e.g.
        `Authorization: Bearer cwk_live_sk_...`. Secret keys carry full scopes;
        restricted keys carry the subset you assign. Publishable (`pk`) keys are
        for client-side checkout initialization only and are not used for the
        server-to-server endpoints in this reference. The key prefix
        (`cwk_test_` / `cwk_live_`) selects the environment.
  headers:
    CoinwakaRequestId:
      description: Unique request identifier. Quote it when contacting support.
      schema: { type: string }
    CoinwakaEnvironment:
      description: The environment that handled the request.
      schema: { type: string, enum: [sandbox, live] }
    RateLimitLimit:
      description: The per-key request ceiling for the current fixed window.
      schema: { type: integer }
    RateLimitRemaining:
      description: Requests remaining in the current window.
      schema: { type: integer }
    RateLimitReset:
      description: Unix epoch seconds at which the current window resets.
      schema: { type: integer }
  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: A unique client key; replaying it returns the original result.
      schema: { type: string }
  responses:
    BadRequest:
      description: Invalid request.
      headers:
        Coinwaka-Request-Id: { $ref: "#/components/headers/CoinwakaRequestId" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unauthorized:
      description: Missing or invalid API key.
      headers:
        Coinwaka-Request-Id: { $ref: "#/components/headers/CoinwakaRequestId" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Forbidden:
      description: The key lacks a required scope, is IP-restricted, or live is not enabled.
      headers:
        Coinwaka-Request-Id: { $ref: "#/components/headers/CoinwakaRequestId" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: Resource not found.
      headers:
        Coinwaka-Request-Id: { $ref: "#/components/headers/CoinwakaRequestId" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Conflict:
      description: The resource is in a state that does not allow this action.
      headers:
        Coinwaka-Request-Id: { $ref: "#/components/headers/CoinwakaRequestId" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    TooManyRequests:
      description: Per-key rate limit exceeded. Back off and retry after the window resets.
      headers:
        Coinwaka-Request-Id: { $ref: "#/components/headers/CoinwakaRequestId" }
        Coinwaka-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
        Coinwaka-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
        Coinwaka-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    InternalServerError:
      description: An unexpected error occurred. Safe to retry idempotent requests.
      headers:
        Coinwaka-Request-Id: { $ref: "#/components/headers/CoinwakaRequestId" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
  schemas:
    Envelope:
      type: object
      description: >
        Fields the API adds to every top-level response object. List responses
        carry these on the wrapper, not on each item in `data`.
      required: [request_id, environment, livemode]
      properties:
        request_id: { type: string }
        environment: { type: string, enum: [sandbox, live] }
        livemode: { type: boolean }
    ListBase:
      type: object
      required: [object, data, has_more]
      properties:
        object: { type: string, enum: [list] }
        data: { type: array, items: { type: object } }
        has_more: { type: boolean }
    Rates:
      type: object
      required: [base, assets, fiat]
      properties:
        base: { type: string, example: USD }
        assets:
          type: array
          items:
            type: object
            required: [asset, usd_price]
            properties:
              asset: { type: string, example: USDT }
              usd_price: { type: [number, "null"], example: 1.0 }
        fiat:
          type: array
          items:
            type: object
            required: [currency, per_usd]
            properties:
              currency: { type: string, example: KES }
              per_usd: { type: number, example: 129.5 }
    Quote:
      type: object
      required: [quote_id, rate, source_currency, source_amount, target_asset, target_amount, expires_at]
      properties:
        quote_id: { type: string, example: pq_test_abc }
        rate: { type: string }
        source_currency: { type: string }
        source_amount: { type: string }
        target_asset: { type: string }
        target_amount: { type: string }
        expires_at: { type: string, format: date-time }
    AuthVerify:
      type: object
      required: [livemode, environment, merchant_id, app_id, key_id, scopes, ip_allowed, status]
      properties:
        livemode: { type: boolean }
        environment: { type: string, enum: [sandbox, live] }
        merchant_id: { type: string }
        app_id: { type: [string, "null"] }
        key_id: { type: string }
        scopes:
          type: array
          items: { type: string, example: payments:read }
        ip_allowed: { type: boolean }
        status: { type: string, example: active }
    PaymentIntent:
      type: object
      required:
        [id, status, amount, currency, settlement_currency, pay_asset,
         checkout_url, expires_at]
      properties:
        id: { type: string, example: pi_test_abc123 }
        status:
          type: string
          enum:
            [created, awaiting_payment, confirming, paid, underpaid, overpaid,
             expired, cancelled, failed, refunded, settled, disputed]
        amount: { type: string }
        currency: { type: string }
        settlement_currency: { type: string }
        pay_amount: { type: [string, "null"] }
        pay_asset: { type: string }
        checkout_url: { type: string, format: uri }
        merchant_reference: { type: [string, "null"] }
        description: { type: [string, "null"] }
        metadata: { type: [object, "null"], additionalProperties: true }
        expires_at: { type: string, format: date-time }
    PaymentLink:
      type: object
      required: [id, object, url, status, paid_count]
      properties:
        id: { type: string, example: pl_test_abc }
        object: { type: string, enum: [payment_link] }
        url: { type: string, format: uri }
        title: { type: [string, "null"] }
        description: { type: [string, "null"] }
        amount: { type: [string, "null"] }
        currency: { type: string }
        settlement_currency: { type: string }
        status: { type: string, enum: [active, disabled] }
        paid_count: { type: integer }
        max_payments: { type: [integer, "null"] }
        expires_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }
    Refund:
      type: object
      description: >
        A refund of a payment. At most one per payment. Statuses: `requested`
        (queued for review), `succeeded` (completed, including sandbox), or
        `failed`. A refund request that an admin rejects is removed entirely
        (so the payment becomes eligible for a new request) and is never
        returned — a retrieve of a rejected request returns 404.
      required: [id, object, payment_intent, amount, asset, status, created_at]
      properties:
        id: { type: string, description: Opaque refund id., example: clx9f2a8b0001q7m3 }
        object: { type: string, enum: [refund] }
        payment_intent: { type: string, example: pi_live_abc123 }
        amount: { type: string, description: "Decimal string; the full captured amount." }
        asset: { type: string, example: KES }
        status: { type: string, enum: [requested, succeeded, failed] }
        reason: { type: [string, "null"] }
        created_at: { type: string, format: date-time }
    WebhookEndpoint:
      type: object
      required: [id, object, url, environment, events, enabled, created_at]
      properties:
        id: { type: string, example: whe_test_abc }
        object: { type: string, enum: [webhook_endpoint] }
        url: { type: string, format: uri }
        environment: { type: string, enum: [sandbox, live] }
        events: { type: array, items: { type: string } }
        enabled: { type: boolean }
        created_at: { type: string, format: date-time }
        secret: { type: string, description: "Returned only when the endpoint is created." }
    WebhookEvent:
      type: object
      required: [id, object, type, environment, created_at, data]
      properties:
        id: { type: string, example: evt_live_abc }
        object: { type: string, enum: [webhook_event] }
        type: { type: string, example: payment_intent.paid }
        environment: { type: string, enum: [sandbox, live] }
        created_at: { type: string, format: date-time }
        data: { type: object, additionalProperties: true }
    Event:
      type: object
      description: >
        An item in the event feed. `environment` mirrors the calling key
        (`test` or `live`); `data` is the event's resource snapshot.
      required: [id, type, environment, created_at, data]
      properties:
        id: { type: string, example: evt_test_abc }
        type: { type: string, example: payment_intent.paid }
        environment: { type: string, enum: [test, live] }
        created_at: { type: string, format: date-time }
        data: { type: [object, "null"], additionalProperties: true }
    Customer:
      type: object
      required: [id, object, created_at]
      properties:
        id: { type: string, example: cus_test_abc }
        object: { type: string, enum: [customer] }
        email: { type: [string, "null"] }
        phone: { type: [string, "null"] }
        name: { type: [string, "null"] }
        reference: { type: [string, "null"] }
        metadata: { type: [object, "null"], additionalProperties: true }
        created_at: { type: string, format: date-time }
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [type, code, message]
          properties:
            type:
              type: string
              enum:
                [authentication_error, permission_error, invalid_request_error,
                 not_found_error, conflict_error, rate_limit_error, api_error]
            code: { type: string, example: invalid_api_key }
            message: { type: string }
            param: { type: [string, "null"] }
            request_id: { type: string }
            docs_url: { type: string, format: uri }
webhooks:
  paymentIntentEvent:
    post:
      operationId: onPaymentIntentEvent
      summary: Payment intent lifecycle event
      description: >
        Sent to your webhook endpoints. Verify it before trusting it: compute
        `HMAC-SHA256(webhook_secret, "<Coinwaka-Timestamp>.<raw_request_body>")`
        as lowercase hex and compare, in constant time, against the
        `Coinwaka-Signature` header. Reject deliveries whose `Coinwaka-Timestamp`
        is more than 5 minutes old unless you are deliberately replaying from the
        dashboard. `Coinwaka-Event-Id` is stable across retries, so dedupe on it.
      parameters:
        - name: Coinwaka-Signature
          in: header
          required: true
          description: Lowercase hex HMAC-SHA256 over "<timestamp>.<rawBody>".
          schema: { type: string }
        - name: Coinwaka-Timestamp
          in: header
          required: true
          description: Unix epoch seconds the signature was computed at.
          schema: { type: string }
        - name: Coinwaka-Event-Id
          in: header
          required: true
          description: The event id; stable across retries for idempotent handling.
          schema: { type: string }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [id, type, created_at, data]
              properties:
                id: { type: string, example: evt_live_abc }
                type:
                  type: string
                  description: >
                    One of payment_intent.paid, .failed, .expired, .cancelled,
                    .underpaid, .overpaid, .refunded, .disputed,
                    .requires_review, .duplicate_payment.
                  example: payment_intent.paid
                api_version: { type: string, example: "2026-06-01" }
                created_at: { type: string, format: date-time }
                data:
                  type: object
                  properties:
                    id: { type: string }
                    status: { type: string }
                    amount: { type: string }
                    currency: { type: string }
                    payment_method: { type: string }
                    settlement_currency: { type: string }
                    merchant_reference: { type: [string, "null"] }
      responses:
        "200":
          description: Return any 2xx to acknowledge receipt.
