openapi: 3.1.0
info:
  title: Caravel Pay API
  version: "2026-11-09"
  summary: Hosted card and crypto checkouts that pay out on Polygon, minus a flat 2% fee.
  description: |
    Caravel Pay creates hosted checkouts for your orders. The buyer pays by card, Apple Pay,
    Google Pay or directly in USDC, USDC.e or POL on Polygon. Each paid checkout is sent to your
    payout wallet automatically, minus a flat 2% fee. The payout is in the token the buyer paid
    with (card payments arrive as USDC) and is never swapped.

    There are no accounts, logins or dashboards. `POST /v1/connect` creates a merchant (the
    WooCommerce plugin calls it for you); keep the returned merchant id and secret on your
    server and sign every other request with them.

    ## Signing requests

    Every endpoint except `POST /v1/connect` needs three headers:

    - `Caravel-Merchant`: your merchant id, for example `mch_7Q2KZX4PRB`.
    - `Caravel-Timestamp`: the current time in unix seconds (10 digits). It must be within
      300 seconds of our clock.
    - `Caravel-Signature`: lowercase hex HMAC-SHA256 of the string to sign, keyed with your
      secret used as a plain ASCII string (not hex-decoded).

    The string to sign joins four values with a single newline and no trailing newline:

    ```
    ${timestamp}\n${METHOD}\n${path}\n${rawBody}
    ```

    - `METHOD` is uppercase (`GET` or `POST`).
    - `path` is the request path without scheme or host, plus the query string if you send one,
      for example `/v1/checkouts?limit=20`. Never sign the full URL.
    - `rawBody` is the exact bytes you send. Use an empty string for `GET`.

    Webhooks we send you are signed the same way, over the path and query of your `callback_url`,
    and also carry `Caravel-Event` (the event id, equal to `id` in the body) and
    `User-Agent: CaravelPay-Webhooks/1` (see the `checkout.*` webhooks below).
    Published test vectors: `signature-vectors.json`, next to this file (e.g. https://caravelpay.app/signature-vectors.json).

    ## Errors

    Failed requests return a non-2xx status and `{"error": {"code", "message", "param"?}}`.
    Branch on `code`; `message` is for humans and may change. `param` names the field or header
    the error is about, when there is one. `429` and `503` responses carry `Retry-After` (seconds).

    | code | status | meaning |
    |---|---|---|
    | invalid_request | 400 | Malformed JSON, an unknown or missing field, a bad query parameter, or a body over 16 KiB. See `param`. |
    | invalid_amount | 400 | `amount` isn't a string with exactly two decimals between 0.01 and 1000000.00. |
    | unsupported_currency | 400 | `currency` isn't `"USD"`. |
    | invalid_url | 400 | A URL isn't absolute HTTPS on your merchant's origins (or, for `site_url`, isn't a plain HTTPS site address). |
    | unknown_merchant | 401 | `Caravel-Merchant` is missing or doesn't match a merchant. |
    | invalid_signature | 401 | `Caravel-Signature` is missing or doesn't match. |
    | timestamp_expired | 401 | `Caravel-Timestamp` is missing, malformed or more than 300 seconds from our clock. |
    | merchant_paused | 403 | New checkouts are paused for this merchant. Open checkouts and payouts still finish. |
    | merchant_pending_review | 403 | The merchant is waiting for manual approval (only when review mode is on). |
    | not_found | 404 | No such checkout for this merchant, or no such endpoint. |
    | order_id_conflict | 409 | That `order_id` already belongs to a checkout with different details. |
    | limit_exceeded | 409 | A per-merchant limit was reached (order size, daily count or volume, open checkouts), or the payout wallet already serves the maximum number of merchants. |
    | rate_limited | 429 | Too many requests. Retry after `Retry-After` seconds. |
    | server_error | 500 | Unexpected error on our side. Safe to retry. |
    | service_unavailable | 503 | New checkouts are temporarily unavailable. Retry after `Retry-After` seconds with the same `order_id`. |

    ## Rate limits

    - Signed requests: 600 per minute per merchant.
    - Failed authentication: 60 per minute per IP address.
    - `POST /v1/connect`: 10 per hour per IP address, plus a global limit.
    - Request bodies are limited to 16 KiB.
  contact:
    email: hello@caravelpay.app
servers:
  - url: https://api.caravelpay.app
    description: Production. Placeholder host until the final domain is announced.
security:
  - CaravelMerchant: []
    CaravelTimestamp: []
    CaravelSignature: []
tags:
  - name: Connect
    description: Create a merchant. Connecting again creates a new one (new wallet or secret).
  - name: Checkouts
    description: Create, retrieve and list hosted checkouts.
  - name: Merchant
    description: Read your merchant settings.
  - name: Reports
    description: Report a seller that breaks the acceptable-use policy.
  - name: Payment links
    description: Shareable links that open a fresh checkout, so sellers without a website can get paid.
paths:
  /v1/connect:
    post:
      tags:
        - Connect
      operationId: connectMerchant
      summary: Connect a site
      description: |
        Creates a merchant and returns its id and signing secret. Unsigned. The WooCommerce
        plugin calls it when you save your payout wallet; API merchants call it once from their
        server. There is nothing to verify and no account to log in to: the credentials are the
        merchant.

        - `site_url` sets the origins your `return_url`, `cancel_url` and `callback_url` must use
          (the HTTPS site and its `www.` twin).
        - The secret is returned once, in this response. Store it on your server only.
        - **Every call creates a new merchant** with a new id and secret. We never return the
          credentials of an existing merchant.
        - **To change the payout wallet or rotate the secret, connect again** and switch to the new
          credentials for new checkouts. Keep the old credentials until the old merchant's open
          checkouts have finished: their webhooks stay signed with the old secret.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ConnectRequest"
            examples:
              woocommerce:
                summary: Plugin connect
                value:
                  site_url: https://shop.example
                  payout_address: "0x52908400098527886E0F7030069857D2E4169EE7"
                  email: owner@shop.example
                  platform: woocommerce
                  plugin_version: 1.0.0
              api:
                summary: API merchant
                value:
                  site_url: https://shop.example
                  payout_address: "0x52908400098527886E0F7030069857D2E4169EE7"
                  platform: api
      responses:
        "201":
          description: Connected. Store `merchant_id` and `secret`; the secret isn't shown again.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ConnectResponse"
              examples:
                connected:
                  value:
                    merchant_id: mch_7Q2KZX4PRB
                    secret: 9b2e7c4a1f6d8e3b5a0c9d2f7e4b1a6c3d8f5e2b9a7c4d1e6f3b8a5c2d9e7f4a
                    status: active
                    fee_rate: 2%
        "400":
          description: A field is invalid, for example a payout wallet that can't receive payouts.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                payout_address:
                  value:
                    error:
                      code: invalid_request
                      message: This payout address can't receive payouts. Use an ordinary Polygon wallet you control.
                      param: payout_address
                site_url:
                  value:
                    error:
                      code: invalid_url
                      message: site_url must be your site's HTTPS address, like https://shop.example.
                      param: site_url
        "409":
          description: This payout wallet already serves the maximum number of active merchants.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                limit_exceeded:
                  value:
                    error:
                      code: limit_exceeded
                      message: This payout wallet can't be connected to more merchants.
                      param: payout_address
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/ServerError"
        "503":
          $ref: "#/components/responses/ServiceUnavailable"
  /v1/merchant:
    get:
      tags:
        - Merchant
      operationId: getMerchant
      summary: Retrieve your merchant settings
      description: Returns the site and origins this merchant was connected with, its payout wallet, its status and, while paused, the reason. Plugins use it to test the connection and show the status.
      responses:
        "200":
          description: Your merchant settings.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Merchant"
              examples:
                active:
                  value:
                    id: mch_7Q2KZX4PRB
                    site_url: https://shop.example
                    origins:
                      - https://shop.example
                      - https://www.shop.example
                    payout_address: "0x52908400098527886E0F7030069857D2E4169EE7"
                    status: active
                    paused_reason: null
                    fee_rate: 2%
                paused:
                  value:
                    id: mch_7Q2KZX4PRB
                    site_url: https://shop.example
                    origins:
                      - https://shop.example
                      - https://www.shop.example
                    payout_address: "0x52908400098527886E0F7030069857D2E4169EE7"
                    status: paused
                    paused_reason: Paused while we check an unusual payment pattern. Email hello@caravelpay.app.
                    fee_rate: 2%
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/ServerError"
  /v1/checkouts:
    post:
      tags:
        - Checkouts
      operationId: createCheckout
      summary: Create a checkout
      description: |
        Creates a hosted checkout for one order. Redirect the buyer to `checkout_url`.

        `order_id` is unique per merchant. Sending the same `order_id` again with the same
        details returns the existing checkout (`200`) instead of creating a second one, so you
        can retry timeouts and `5xx` responses freely. The same `order_id` with a different
        amount, currency, email or URL is rejected with `409 order_id_conflict`. A checkout
        expires 60 minutes after creation; to let the buyer try again after that, use a new
        `order_id` such as `1042-2`.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateCheckoutRequest"
            examples:
              order:
                value:
                  order_id: "1042"
                  amount: "136.41"
                  currency: USD
                  customer_email: buyer@example.com
                  return_url: https://shop.example/thanks
                  cancel_url: https://shop.example/cart
                  callback_url: https://shop.example/webhooks/caravel
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CheckoutCreated"
              examples:
                created:
                  value:
                    id: chk_8Qe2TnV4kR
                    checkout_url: https://pay.caravelpay.app/c/chk_8Qe2TnV4kR
                    status: awaiting_payment
                    amount: "136.41"
                    currency: USD
                    expires_at: "2026-11-09T15:04:12Z"
        "200":
          description: A checkout for this `order_id` already existed with the same details; it is returned unchanged.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CheckoutCreated"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: The merchant can't create checkouts right now.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                merchant_paused:
                  value:
                    error:
                      code: merchant_paused
                      message: New checkouts are paused for this merchant. Contact hello@caravelpay.app.
                merchant_pending_review:
                  value:
                    error:
                      code: merchant_pending_review
                      message: This merchant is waiting for approval. Checkouts open once it is approved.
        "409":
          description: The `order_id` was used with different details, or a per-merchant limit was reached.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                order_id_conflict:
                  value:
                    error:
                      code: order_id_conflict
                      message: This order_id already belongs to a checkout with different details. Use a new order_id.
                      param: order_id
                limit_exceeded:
                  value:
                    error:
                      code: limit_exceeded
                      message: A limit for this merchant was reached. Try a smaller order or try again later.
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/ServerError"
        "503":
          $ref: "#/components/responses/ServiceUnavailable"
    get:
      tags:
        - Checkouts
      operationId: listCheckouts
      summary: List checkouts
      description: |
        Your checkouts, newest first, for reconciliation. Pass `next_cursor` from one page as
        `cursor` to get the next; it is `null` on the last page. Sign the path with its query
        string, for example `/v1/checkouts?limit=50&cursor=...`.
      parameters:
        - name: limit
          in: query
          required: false
          description: Page size, 1 to 100. Defaults to 20.
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - name: cursor
          in: query
          required: false
          description: The `next_cursor` of the previous page.
          schema:
            type: string
            maxLength: 200
      responses:
        "200":
          description: One page of checkouts.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CheckoutList"
              examples:
                page:
                  value:
                    data:
                      - id: chk_8Qe2TnV4kR
                        checkout_url: https://pay.caravelpay.app/c/chk_8Qe2TnV4kR
                        status: paid
                        amount: "136.41"
                        currency: USD
                        expires_at: "2026-11-09T15:04:12Z"
                        order_id: "1042"
                        created_at: "2026-11-09T14:04:12Z"
                        paid_amount: "136.41"
                        payout:
                          amount: "133.68"
                          asset: USDC
                          tx_hash: "0x5d1e8c0b7a4f2e19c6d3b8a0f5e7c2d1a9b4e6f8c0d2a7b5e3f1c9d8a6b4a07b"
                    next_cursor: null
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/ServerError"
  /v1/checkouts/{id}:
    get:
      tags:
        - Checkouts
      operationId: getCheckout
      summary: Retrieve a checkout
      description: Returns the checkout with its current `status`, `paid_amount` and, once paid, the `payout`. Checkouts of other merchants answer `404 not_found`.
      parameters:
        - name: id
          in: path
          required: true
          description: The checkout id, for example `chk_8Qe2TnV4kR`.
          schema:
            type: string
            pattern: "^chk_[A-Za-z0-9_-]{8,64}$"
      responses:
        "200":
          description: The checkout.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Checkout"
              examples:
                paid:
                  value:
                    id: chk_8Qe2TnV4kR
                    checkout_url: https://pay.caravelpay.app/c/chk_8Qe2TnV4kR
                    status: paid
                    amount: "136.41"
                    currency: USD
                    expires_at: "2026-11-09T15:04:12Z"
                    order_id: "1042"
                    created_at: "2026-11-09T14:04:12Z"
                    paid_amount: "136.41"
                    payout:
                      amount: "133.68"
                      asset: USDC
                      tx_hash: "0x5d1e8c0b7a4f2e19c6d3b8a0f5e7c2d1a9b4e6f8c0d2a7b5e3f1c9d8a6b4a07b"
                awaiting:
                  value:
                    id: chk_8Qe2TnV4kR
                    checkout_url: https://pay.caravelpay.app/c/chk_8Qe2TnV4kR
                    status: awaiting_payment
                    amount: "136.41"
                    currency: USD
                    expires_at: "2026-11-09T15:04:12Z"
                    order_id: "1042"
                    created_at: "2026-11-09T14:04:12Z"
                    paid_amount: "0.00"
                paid_two_tokens:
                  summary: Paid with two tokens, so the payout went out as two transfers
                  value:
                    id: chk_8Qe2TnV4kR
                    checkout_url: https://pay.caravelpay.app/c/chk_8Qe2TnV4kR
                    status: paid
                    amount: "136.41"
                    currency: USD
                    expires_at: "2026-11-09T15:04:12Z"
                    order_id: "1042"
                    created_at: "2026-11-09T14:04:12Z"
                    paid_amount: "136.41"
                    payout:
                      amount: "97.27"
                      asset: USDC
                      tx_hash: "0x5d1e8c0b7a4f2e19c6d3b8a0f5e7c2d1a9b4e6f8c0d2a7b5e3f1c9d8a6b4a07b"
                    payouts:
                      - amount: "97.27"
                        asset: USDC
                        tx_hash: "0x5d1e8c0b7a4f2e19c6d3b8a0f5e7c2d1a9b4e6f8c0d2a7b5e3f1c9d8a6b4a07b"
                      - amount: "36.41"
                        asset: USDC.e
                        tx_hash: "0x9a4c2e7b1d5f3a8c6e0b2d4f7a9c1e3b5d8f0a2c4e6b8d1f3a5c7e9b0d2f4a6c"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: No checkout with this id belongs to your merchant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                not_found:
                  value:
                    error:
                      code: not_found
                      message: No checkout with that id belongs to this merchant.
                      param: id
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/ServerError"
  /v1/reports:
    post:
      tags:
        - Reports
      operationId: createReport
      summary: Report a seller
      description: |
        Reports a site or checkout that sells something illegal, defrauds buyers or impersonates someone. Unsigned:
        anyone can report, from the report form or a pay page's "Report this seller" link. We review every report.
        Don't put card numbers or passwords in `details`.

        - Send `checkout_id` when you have it (the pay page link carries `?checkout=chk_…`).
        - `turnstile_token` is required when the form shows a verification widget.
        - Limited to 5 reports an hour from one address.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required:
                - url
                - category
                - details
              properties:
                url:
                  type: string
                  format: uri
                  maxLength: 2048
                  description: The page that sells or links to the problem (http or https).
                checkout_id:
                  type: string
                  pattern: "^chk_[A-Za-z0-9_-]{1,64}$"
                link_id:
                  type: string
                  pattern: "^[A-Za-z0-9_-]{1,80}$"
                category:
                  type: string
                  enum:
                    - illegal_goods
                    - scam_fraud
                    - impersonation
                    - other
                details:
                  type: string
                  minLength: 1
                  maxLength: 2000
                contact_email:
                  type: string
                  format: email
                  description: Optional, only if you want us to be able to reply.
                turnstile_token:
                  type: string
                  description: The verification widget's token, when the form shows one.
            examples:
              report:
                value:
                  url: https://shop.example/product/123
                  checkout_id: chk_4l8cajaL4MW14hj21uCI3CTtm7yWQGyw
                  category: illegal_goods
                  details: The product page sells prescription drugs without a prescription.
      responses:
        "202":
          description: Received. Keep the id if you write to us about it.
          content:
            application/json:
              schema:
                type: object
                required:
                  - id
                properties:
                  id:
                    type: string
                    pattern: "^rpt_[A-Z2-7]{16}$"
              examples:
                received:
                  value:
                    id: rpt_K7Q2ZX4PRBM3N5T6
        "400":
          description: A field is missing, unknown or invalid; `param` names it.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          $ref: "#/components/responses/ServiceUnavailable"
  /v1/payment-links:
    post:
      tags:
        - Payment links
      operationId: createPaymentLink
      summary: Create a payment link
      description: |
        Creates a shareable link (`https://pay.caravelpay.app/l/lnk_…`). Every time a buyer opens it they get a fresh
        hosted checkout; the same browser session gets its unpaid checkout back.

        - **Without API keys** (no signature headers): send `payout_address`. The answer is only `id` and `url`; no
          credentials are ever returned and these links send no webhooks. The wallet gets the same checks as
          `POST /v1/connect`. Limited to 30 links an hour from one address.
        - **With API keys** (signed): the link belongs to your merchant and pays out to its wallet. Its checkouts show
          up in `GET /v1/checkouts` and send webhooks to the link's `callback_url`. The answer is the full payment link.

        `amount: null` lets the buyer enter the amount, between `min_amount` and `max_amount`.
      security:
        - {}
        - CaravelMerchant: []
          CaravelTimestamp: []
          CaravelSignature: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreatePaymentLinkRequest"
            examples:
              without_keys:
                value:
                  payout_address: "0x52908400098527886E0F7030069857D2E4169EE7"
                  amount: "25.00"
                  currency: USD
                  description: Logo design, 2 rounds
              open_amount:
                value:
                  payout_address: "0x52908400098527886E0F7030069857D2E4169EE7"
                  amount: null
                  min_amount: "5.00"
                  max_amount: "250.00"
                  currency: USD
                  description: Tips for the crew
              signed_single_use:
                value:
                  amount: "49.00"
                  currency: USD
                  description: Workshop seat
                  reusable: false
                  callback_url: https://shop.example/webhooks/caravel
      responses:
        "201":
          description: Created. Without keys only `id` and `url`; with keys the full payment link object.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PaymentLinkCreated"
              examples:
                without_keys:
                  value:
                    id: lnk_K7Q2ZX4PRBM5TW3NHJ6D
                    url: https://pay.caravelpay.app/l/lnk_K7Q2ZX4PRBM5TW3NHJ6D
                signed:
                  value:
                    id: lnk_M5TW3NHJ6DK7Q2ZX4PRB
                    url: https://pay.caravelpay.app/l/lnk_M5TW3NHJ6DK7Q2ZX4PRB
                    status: active
                    amount: "49.00"
                    min_amount: null
                    max_amount: null
                    currency: USD
                    description: Workshop seat
                    reusable: false
                    return_url: null
                    callback_url: https://shop.example/webhooks/caravel
                    payments_count: 0
                    checkouts_count: 0
                    created_at: "2026-11-09T21:04:12Z"
                    deactivated_at: null
        "400":
          description: A field is missing, unknown or invalid (`param` names it), or the wallet can't receive payouts.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                invalid_amount:
                  value:
                    error:
                      code: invalid_amount
                      message: amount must be a string with two decimals, like "25.00", from 1.00 to 1000.00.
                      param: amount
                wallet_refused:
                  value:
                    error:
                      code: invalid_request
                      message: This payout address can't be used. Use a plain wallet address you control on Polygon, not a contract, token or exchange deposit address.
                      param: payout_address
                description_link:
                  value:
                    error:
                      code: invalid_request
                      message: description can't contain links or web addresses.
                      param: description
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: Payments to this wallet or merchant are paused, or the merchant is waiting for approval.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                merchant_paused:
                  value:
                    error:
                      code: merchant_paused
                      message: Payment links for this wallet are paused. Contact hello@caravelpay.app.
        "409":
          description: The wallet already receives payouts for the most merchants allowed.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                limit_exceeded:
                  value:
                    error:
                      code: limit_exceeded
                      message: This wallet already receives payouts for the maximum number of merchants. Use another wallet.
                      param: payout_address
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          $ref: "#/components/responses/ServiceUnavailable"
  /v1/payment-links/{id}:
    get:
      tags:
        - Payment links
      operationId: getPaymentLink
      summary: Retrieve a payment link
      description: Signed. Returns the link with its `status` and how many of its checkouts are paid. Links of other merchants, and links made without keys, answer `404 not_found`.
      parameters:
        - name: id
          in: path
          required: true
          description: The link id, for example `lnk_M5TW3NHJ6DK7Q2ZX4PRB`.
          schema:
            type: string
            pattern: "^lnk_[A-Z2-7]{20}$"
      responses:
        "200":
          description: The payment link.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PaymentLink"
              examples:
                completed:
                  value:
                    id: lnk_M5TW3NHJ6DK7Q2ZX4PRB
                    url: https://pay.caravelpay.app/l/lnk_M5TW3NHJ6DK7Q2ZX4PRB
                    status: completed
                    amount: "49.00"
                    min_amount: null
                    max_amount: null
                    currency: USD
                    description: Workshop seat
                    reusable: false
                    return_url: null
                    callback_url: https://shop.example/webhooks/caravel
                    payments_count: 1
                    checkouts_count: 2
                    created_at: "2026-11-09T21:04:12Z"
                    deactivated_at: null
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/PaymentLinkNotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
  /v1/payment-links/{id}/deactivate:
    post:
      tags:
        - Payment links
      operationId: deactivatePaymentLink
      summary: Deactivate a payment link
      description: Signed, with an empty body or `{}`. The link stops opening new checkouts at once; checkouts that were already open can still be paid. Calling it again changes nothing. A deactivated link can't be turned back on.
      parameters:
        - name: id
          in: path
          required: true
          description: The link id.
          schema:
            type: string
            pattern: "^lnk_[A-Z2-7]{20}$"
      responses:
        "200":
          description: The deactivated payment link.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PaymentLink"
              examples:
                deactivated:
                  value:
                    id: lnk_K7Q2ZX4PRBM5TW3NHJ6D
                    url: https://pay.caravelpay.app/l/lnk_K7Q2ZX4PRBM5TW3NHJ6D
                    status: deactivated
                    amount: null
                    min_amount: "1.00"
                    max_amount: "1000.00"
                    currency: USD
                    description: null
                    reusable: true
                    return_url: null
                    callback_url: null
                    payments_count: 3
                    checkouts_count: 5
                    created_at: "2026-11-09T21:04:12Z"
                    deactivated_at: "2026-11-10T09:30:00Z"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/PaymentLinkNotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
webhooks:
  checkout.paid:
    post:
      operationId: checkoutPaidWebhook
      summary: checkout.paid
      description: The full amount arrived and both payout transfers (yours and the 2% fee) confirmed on-chain. Fulfil the order.
      parameters:
        - $ref: "#/components/parameters/WebhookMerchant"
        - $ref: "#/components/parameters/WebhookTimestamp"
        - $ref: "#/components/parameters/WebhookSignature"
        - $ref: "#/components/parameters/WebhookEvent"
        - $ref: "#/components/parameters/WebhookUserAgent"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CheckoutEvent"
            examples:
              paid:
                value:
                  id: evt_3Lk9Wq2Zr7Yp
                  type: checkout.paid
                  created: "2026-11-09T14:09:37Z"
                  data:
                    id: chk_8Qe2TnV4kR
                    checkout_url: https://pay.caravelpay.app/c/chk_8Qe2TnV4kR
                    status: paid
                    amount: "136.41"
                    currency: USD
                    expires_at: "2026-11-09T15:04:12Z"
                    order_id: "1042"
                    created_at: "2026-11-09T14:04:12Z"
                    paid_amount: "136.41"
                    payout:
                      amount: "133.68"
                      asset: USDC
                      tx_hash: "0x5d1e8c0b7a4f2e19c6d3b8a0f5e7c2d1a9b4e6f8c0d2a7b5e3f1c9d8a6b4a07b"
      responses:
        "200":
          $ref: "#/components/responses/WebhookAccepted"
  checkout.underpaid:
    post:
      operationId: checkoutUnderpaidWebhook
      summary: checkout.underpaid
      description: Less than the checkout amount arrived. `paid_amount` shows how much. The buyer can still top up the same checkout before it expires.
      parameters:
        - $ref: "#/components/parameters/WebhookMerchant"
        - $ref: "#/components/parameters/WebhookTimestamp"
        - $ref: "#/components/parameters/WebhookSignature"
        - $ref: "#/components/parameters/WebhookEvent"
        - $ref: "#/components/parameters/WebhookUserAgent"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CheckoutEvent"
      responses:
        "200":
          $ref: "#/components/responses/WebhookAccepted"
  checkout.expired:
    post:
      operationId: checkoutExpiredWebhook
      summary: checkout.expired
      description: The checkout passed `expires_at` without payment.
      parameters:
        - $ref: "#/components/parameters/WebhookMerchant"
        - $ref: "#/components/parameters/WebhookTimestamp"
        - $ref: "#/components/parameters/WebhookSignature"
        - $ref: "#/components/parameters/WebhookEvent"
        - $ref: "#/components/parameters/WebhookUserAgent"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CheckoutEvent"
      responses:
        "200":
          $ref: "#/components/responses/WebhookAccepted"
  checkout.review:
    post:
      operationId: checkoutReviewWebhook
      summary: checkout.review
      description: A payment needs a manual look, for example one that arrived after expiry. Hold the order; a follow-up event is sent if it resolves.
      parameters:
        - $ref: "#/components/parameters/WebhookMerchant"
        - $ref: "#/components/parameters/WebhookTimestamp"
        - $ref: "#/components/parameters/WebhookSignature"
        - $ref: "#/components/parameters/WebhookEvent"
        - $ref: "#/components/parameters/WebhookUserAgent"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CheckoutEvent"
      responses:
        "200":
          $ref: "#/components/responses/WebhookAccepted"
components:
  securitySchemes:
    CaravelMerchant:
      type: apiKey
      in: header
      name: Caravel-Merchant
      description: Your merchant id.
    CaravelTimestamp:
      type: apiKey
      in: header
      name: Caravel-Timestamp
      description: Unix seconds, within 300 seconds of our clock.
    CaravelSignature:
      type: apiKey
      in: header
      name: Caravel-Signature
      description: Lowercase hex HMAC-SHA256 of `${timestamp}\n${METHOD}\n${path}\n${rawBody}`, keyed with your secret as ASCII.
  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: Optional. 8 to 128 printable ASCII characters. When absent, `order_id` is the idempotency key.
      schema:
        type: string
        pattern: "^[\\x21-\\x7e]{8,128}$"
    WebhookMerchant:
      name: Caravel-Merchant
      in: header
      required: true
      description: Your merchant id.
      schema:
        type: string
    WebhookTimestamp:
      name: Caravel-Timestamp
      in: header
      required: true
      description: Unix seconds when this attempt was signed. Each retry has a fresh timestamp.
      schema:
        type: string
        pattern: "^[0-9]{10}$"
    WebhookSignature:
      name: Caravel-Signature
      in: header
      required: true
      description: Lowercase hex HMAC-SHA256 of `${timestamp}\nPOST\n${callback path and query}\n${raw body}` keyed with your secret.
      schema:
        type: string
        pattern: "^[0-9a-f]{64}$"
    WebhookEvent:
      name: Caravel-Event
      in: header
      required: true
      description: The event id, always equal to `id` in the body, for example `evt_3Lk9Wq2Zr7Yp`. It stays the same on every retry of that event, so you can check it against the ids you have already processed (after verifying the signature) without parsing the body.
      schema:
        type: string
        pattern: "^evt_[A-Za-z0-9_-]{8,64}$"
    WebhookUserAgent:
      name: User-Agent
      in: header
      required: true
      description: Always `CaravelPay-Webhooks/1`.
      schema:
        type: string
        const: CaravelPay-Webhooks/1
  headers:
    RetryAfter:
      description: Seconds to wait before retrying.
      schema:
        type: integer
        minimum: 1
  responses:
    BadRequest:
      description: The request is malformed or a field is invalid. Fix it before retrying.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          examples:
            invalid_amount:
              value:
                error:
                  code: invalid_amount
                  message: amount must be a string with two decimals, like "136.41"
                  param: amount
            invalid_request:
              value:
                error:
                  code: invalid_request
                  message: order_id is required.
                  param: order_id
    Unauthorized:
      description: Missing or wrong merchant id, signature or timestamp.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          examples:
            invalid_signature:
              value:
                error:
                  code: invalid_signature
                  message: Caravel-Signature doesn't match. Sign the exact path and body you send.
                  param: Caravel-Signature
    RateLimited:
      description: Too many requests.
      headers:
        Retry-After:
          $ref: "#/components/headers/RetryAfter"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          examples:
            rate_limited:
              value:
                error:
                  code: rate_limited
                  message: Too many requests. Retry after the Retry-After header.
    ServerError:
      description: Unexpected error on our side. Safe to retry with the same `order_id`.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          examples:
            server_error:
              value:
                error:
                  code: server_error
                  message: Something went wrong on our side. Retry with the same order_id.
    ServiceUnavailable:
      description: Temporarily unavailable. Retry after `Retry-After` seconds.
      headers:
        Retry-After:
          $ref: "#/components/headers/RetryAfter"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          examples:
            service_unavailable:
              value:
                error:
                  code: service_unavailable
                  message: New checkouts are temporarily unavailable. Retry shortly with the same order_id.
    WebhookAccepted:
      description: Any `2xx` within 10 seconds marks the event delivered. Anything else, a timeout or a connection error is retried at 1, 5, 15 and 30 minutes, 1, 2 and 3 hours after the first attempt, then every 3 hours until 24 hours after the first attempt. Each attempt has a fresh timestamp and signature over the same body bytes.
    PaymentLinkNotFound:
      description: No payment link with this id belongs to your merchant.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          examples:
            not_found:
              value:
                error:
                  code: not_found
                  message: No payment link with that id belongs to this merchant.
                  param: id
  schemas:
    Error:
      type: object
      additionalProperties: false
      required:
        - error
      properties:
        error:
          type: object
          additionalProperties: false
          required:
            - code
            - message
          properties:
            code:
              type: string
              enum:
                - invalid_request
                - invalid_amount
                - unsupported_currency
                - invalid_url
                - unknown_merchant
                - invalid_signature
                - timestamp_expired
                - merchant_paused
                - merchant_pending_review
                - not_found
                - order_id_conflict
                - limit_exceeded
                - rate_limited
                - server_error
                - service_unavailable
            message:
              type: string
            param:
              type: string
              description: The field, header or query parameter the error is about.
    ConnectRequest:
      type: object
      additionalProperties: false
      required:
        - site_url
        - payout_address
        - platform
      properties:
        site_url:
          type: string
          format: uri
          description: Your site's HTTPS address, such as `https://shop.example`. It sets the origins your return, cancel and callback URLs must use. A path is allowed for WordPress installs in a subfolder.
        payout_address:
          type: string
          pattern: "^0x[0-9a-fA-F]{40}$"
          description: The Polygon wallet that receives your payouts. It must be an ordinary wallet you control, not a contract, exchange deposit contract or token address.
        email:
          type: string
          format: email
          maxLength: 254
          description: Optional contact for operational notices. Never used to log in.
        platform:
          type: string
          enum:
            - woocommerce
            - api
        plugin_version:
          type: string
          maxLength: 32
          description: The plugin version, when `platform` is `woocommerce`.
    ConnectResponse:
      type: object
      additionalProperties: false
      required:
        - merchant_id
        - secret
        - status
        - fee_rate
      properties:
        merchant_id:
          type: string
          pattern: "^mch_[A-Z2-7]{10}$"
        secret:
          type: string
          pattern: "^[0-9a-f]{64}$"
          description: Your signing secret. Shown only in this response.
        status:
          $ref: "#/components/schemas/MerchantStatus"
        fee_rate:
          type: string
          const: 2%
    MerchantStatus:
      type: string
      enum:
        - active
        - pending_review
        - paused
      description: "`active` can create checkouts. `paused` can't create new checkouts, but open ones and their payouts finish. `pending_review` appears only when manual review is switched on."
    Merchant:
      type: object
      additionalProperties: false
      required:
        - id
        - site_url
        - origins
        - payout_address
        - status
        - fee_rate
      properties:
        id:
          type: string
          pattern: "^mch_[A-Z2-7]{10}$"
        site_url:
          type: string
        origins:
          type: array
          description: The origins your return, cancel and callback URLs must use.
          items:
            type: string
        payout_address:
          type:
            - string
            - "null"
          pattern: "^0x[0-9a-fA-F]{40}$"
          description: The wallet this merchant's checkouts pay out to. Connected merchants always have one; `null` only for a store set up without a payout wallet.
        status:
          $ref: "#/components/schemas/MerchantStatus"
        paused_reason:
          type:
            - string
            - "null"
          maxLength: 200
          description: A short reason from Caravel Pay while `status` is `paused`, for example to show in your plugin or admin page. `null` when the merchant isn't paused or no reason was given.
        fee_rate:
          type: string
          const: 2%
    CreateCheckoutRequest:
      type: object
      additionalProperties: false
      required:
        - order_id
        - amount
        - currency
        - return_url
        - cancel_url
        - callback_url
      properties:
        order_id:
          type: string
          minLength: 1
          maxLength: 128
          description: Your order reference, unique per merchant. No control characters.
        amount:
          type: string
          pattern: "^[0-9]{1,7}\\.[0-9]{2}$"
          description: Order total in USD with exactly two decimals, from "0.01" to "1000000.00".
        currency:
          type: string
          const: USD
        customer_email:
          type: string
          format: email
          maxLength: 254
          description: Optional. Pre-fills the buyer's email on the hosted checkout.
        return_url:
          type: string
          format: uri
          description: Where the buyer goes after paying. HTTPS on one of your merchant origins. Not proof of payment.
        cancel_url:
          type: string
          format: uri
          description: Where the buyer goes if they cancel. HTTPS on one of your merchant origins.
        callback_url:
          type: string
          format: uri
          description: HTTPS endpoint on one of your merchant origins that receives webhooks for this checkout.
    CheckoutStatus:
      type: string
      enum:
        - awaiting_payment
        - confirming
        - paid
        - underpaid
        - expired
        - review
      description: "`paid` means the full amount arrived and both payout transfers (yours and the fee) confirmed. Possible late transitions: `underpaid` to `paid` (a top-up), `expired` to `paid` (an on-time payment found late) and any status to `review`."
    CheckoutCreated:
      type: object
      additionalProperties: false
      required:
        - id
        - checkout_url
        - status
        - amount
        - currency
        - expires_at
      properties:
        id:
          type: string
          pattern: "^chk_[A-Za-z0-9_-]{8,64}$"
        checkout_url:
          type: string
          format: uri
          description: The hosted checkout. Redirect the buyer here.
        status:
          $ref: "#/components/schemas/CheckoutStatus"
        amount:
          type: string
          pattern: "^[0-9]+\\.[0-9]{2}$"
        currency:
          type: string
          const: USD
        expires_at:
          type: string
          format: date-time
          pattern: "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$"
          description: When the checkout stops accepting payment, 60 minutes after creation. ISO 8601 UTC, whole seconds.
    Checkout:
      type: object
      additionalProperties: false
      required:
        - id
        - checkout_url
        - status
        - amount
        - currency
        - expires_at
        - order_id
        - created_at
        - paid_amount
      properties:
        id:
          type: string
          pattern: "^chk_[A-Za-z0-9_-]{8,64}$"
        checkout_url:
          type: string
          format: uri
        status:
          $ref: "#/components/schemas/CheckoutStatus"
        amount:
          type: string
          pattern: "^[0-9]+\\.[0-9]{2}$"
        currency:
          type: string
          const: USD
        expires_at:
          type: string
          format: date-time
          pattern: "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$"
        order_id:
          type: string
          description: The `order_id` you sent when creating the checkout.
        created_at:
          type: string
          format: date-time
          pattern: "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$"
        paid_amount:
          type: string
          pattern: "^[0-9]+\\.[0-9]{2}$"
          description: How much has arrived and been valued so far, in USD.
        payout:
          $ref: "#/components/schemas/Payout"
        payouts:
          type: array
          minItems: 2
          description: Additive. Present only when the payout went out as more than one transfer, for example when the buyer paid part in USDC and part in USDC.e (each token is paid out as itself, never swapped). It lists every transfer to your wallet, oldest first, and `payout` is then the first of them. Add up `payouts` for the total; never assume a single transfer.
          items:
            $ref: "#/components/schemas/Payout"
    Payout:
      type: object
      additionalProperties: false
      description: Present once the checkout is `paid`. The transfer to your payout wallet, in the token the buyer paid with, never swapped.
      required:
        - amount
        - asset
        - tx_hash
      properties:
        amount:
          type: string
          pattern: "^[0-9]+\\.[0-9]{2,18}$"
          description: Amount of `asset` sent to your wallet.
        asset:
          type: string
          enum:
            - USDC
            - USDC.e
            - POL
        tx_hash:
          type: string
          pattern: "^0x[0-9a-f]{64}$"
          description: The Polygon transaction of the transfer to your wallet.
    CheckoutList:
      type: object
      additionalProperties: false
      required:
        - data
        - next_cursor
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/Checkout"
        next_cursor:
          type:
            - string
            - "null"
          description: Pass as `cursor` to get the next page. `null` on the last page.
    CheckoutEvent:
      type: object
      additionalProperties: false
      required:
        - id
        - type
        - created
        - data
      properties:
        id:
          type: string
          pattern: "^evt_[A-Za-z0-9_-]{8,64}$"
          description: Event id. The same event keeps the same id on every retry.
        type:
          type: string
          enum:
            - checkout.paid
            - checkout.underpaid
            - checkout.expired
            - checkout.review
        created:
          type: string
          format: date-time
          pattern: "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$"
          description: When the event happened. ISO 8601 UTC, whole seconds.
        data:
          $ref: "#/components/schemas/Checkout"
    CreatePaymentLinkRequest:
      type: object
      additionalProperties: false
      required:
        - amount
        - currency
      properties:
        payout_address:
          type: string
          pattern: "^0x[0-9a-fA-F]{40}$"
          description: Required without API keys, refused with them. A plain Polygon wallet you control.
        amount:
          type:
            - string
            - "null"
          pattern: "^[0-9]{1,7}\\.[0-9]{2}$"
          description: Two decimals, from 1.00 to your per-checkout limit (1000.00 for new merchants). `null` lets the buyer enter it.
        min_amount:
          type: string
          pattern: "^[0-9]{1,7}\\.[0-9]{2}$"
          description: Only with `amount` null. Default 1.00.
        max_amount:
          type: string
          pattern: "^[0-9]{1,7}\\.[0-9]{2}$"
          description: Only with `amount` null. Default your per-checkout limit.
        currency:
          type: string
          enum:
            - USD
        description:
          type: string
          maxLength: 80
          description: Plain text shown to the buyer as "Note from the seller". No links, web addresses or HTML.
        reusable:
          type: boolean
          description: Default true. false closes the link after one paid checkout.
        return_url:
          type: string
          format: uri
          description: Signed links only. HTTPS on your merchant origins. Default a neutral "Payment received" page.
        callback_url:
          type: string
          format: uri
          description: Signed links only. HTTPS on your merchant origins; webhooks for the link's checkouts go here.
    PaymentLinkCreated:
      type: object
      required:
        - id
        - url
      properties:
        id:
          type: string
          pattern: "^lnk_[A-Z2-7]{20}$"
        url:
          type: string
          format: uri
          description: The link to share.
      description: Without API keys exactly these two fields. Signed requests get the full PaymentLink.
    PaymentLink:
      type: object
      additionalProperties: false
      required:
        - id
        - url
        - status
        - amount
        - min_amount
        - max_amount
        - currency
        - description
        - reusable
        - return_url
        - callback_url
        - payments_count
        - checkouts_count
        - created_at
        - deactivated_at
      properties:
        id:
          type: string
          pattern: "^lnk_[A-Z2-7]{20}$"
        url:
          type: string
          format: uri
        status:
          type: string
          enum:
            - active
            - completed
            - deactivated
          description: completed means a single-use link has been paid.
        amount:
          type:
            - string
            - "null"
          pattern: "^[0-9]{1,7}\\.[0-9]{2}$"
        min_amount:
          type:
            - string
            - "null"
          pattern: "^[0-9]{1,7}\\.[0-9]{2}$"
        max_amount:
          type:
            - string
            - "null"
          pattern: "^[0-9]{1,7}\\.[0-9]{2}$"
        currency:
          type: string
          enum:
            - USD
        description:
          type:
            - string
            - "null"
        reusable:
          type: boolean
        return_url:
          type:
            - string
            - "null"
        callback_url:
          type:
            - string
            - "null"
        payments_count:
          type: integer
          minimum: 0
          description: Checkouts from this link that are paid.
        checkouts_count:
          type: integer
          minimum: 0
          description: Checkouts opened from this link, paid or not.
        created_at:
          type: string
          format: date-time
          pattern: "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$"
        deactivated_at:
          type:
            - string
            - "null"
          pattern: "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$"
