> ## Documentation Index
> Fetch the complete documentation index at: https://docs.caratuva.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Payment intents

> Create payment requests with hosted payment links from your own ERP or CMS.

Payment intents are the integrator-facing surface. You keep your own invoice CMS or ERP and POST a thin "intent" describing what to collect, who the buyer is, and (optionally) what to render on the hosted payment page. Caratuva runs buyer KYC, payment collection, cross-border transfer, FX, and BRL payout via PIX end to end.

The endpoint returns a `hostedPaymentUrl` you forward to your customer. Your customer signs in with email magic-link, completes KYC if they haven't already, and pays. You learn about every state transition through outbound webhooks.

## When to use this vs. invoices

* `POST /v1/payment-intents` — your CMS owns the invoice; Caratuva is the payment + compliance + settlement engine. Skips the seller-side approval step (the API call is the approval).
* `POST /v1/invoices` — Caratuva's dashboard owns the invoice. Sellers create it, a teammate approves it if your organization requires approval, and the platform sends the magic-link. Use this if you don't have your own invoicing system.

Buyer KYC is mandatory in both flows. There is no skip-KYC path for any payment surface.

## Prerequisites

1. **KYB approved.** Your organization must have completed Know-Your-Business onboarding and have a virtual account provisioned. The API returns `400 KybNotApproved` otherwise.
2. **API key.** A live-mode key (`pk_live_...`) — see [API keys](/api-reference/api-keys).
3. **Webhook subscription.** You'll want one before creating intents in production — see [Webhooks](/api-reference/webhooks).

## Create a payment intent

```bash theme={null}
curl -X POST https://api.caratuva.com/v1/payment-intents \
  -H "X-API-Key: $CARATUVA_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "content-type: application/json" \
  -d '{
    "externalId": "INV-2026-00042",
    "amount": "12500.00",
    "currency": "USD",
    "buyer": {
      "email": "buyer@acme.com",
      "name": "ACME Imports LLC",
      "country": "US"
    },
    "returnUrl": "https://erp.example.com/orders/42/paid",
    "display": {
      "title": "Order INV-2026-00042",
      "lineItems": [
        { "description": "Coffee beans, 500 bags", "quantity": "500", "unitPrice": "25.00", "subtotal": "12500.00" }
      ]
    },
    "metadata": {
      "orderId": "42",
      "salesRep": "ana@example.com"
    },
    "expiresAt": "2026-05-09T23:59:59.000Z"
  }'
```

### Request body

| Field                                                 | Type           | Required | Notes                                                                                                                                                                                                                                                           |
| ----------------------------------------------------- | -------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `externalId`                                          | string (1–200) | yes      | Your primary key. Unique per `(organization, externalId)`. Re-POSTing the same value returns the existing intent — safe under network retry.                                                                                                                    |
| `amount`                                              | decimal string | yes      | Two-decimal precision; pass as a string to avoid float drift (`"12500.00"`, not `12500`). \*\*Minimum US$10.00** — buyers fund by bank transfer, which enforces a US$10 floor, so a smaller USD amount can't be paid and is rejected with `AmountBelowMinimum`. |
| `currency`                                            | ISO 4217 code  | no       | Defaults to `USD`.                                                                                                                                                                                                                                              |
| `buyer.email`                                         | string         | yes      | Real address — the buyer receives a magic-link sign-in to this address.                                                                                                                                                                                         |
| `buyer.name`, `buyer.country`, `buyer.phone`          | string         | no       | Pre-fills the KYC form; the buyer can amend.                                                                                                                                                                                                                    |
| `returnUrl`                                           | URL            | no       | Where the hosted page redirects after settlement.                                                                                                                                                                                                               |
| `display.title`, `display.notes`, `display.lineItems` | varies         | no       | Rendered on the hosted page. Free-text — Caratuva does not validate NCM or origin codes here.                                                                                                                                                                   |
| `metadata`                                            | object         | no       | Round-tripped verbatim on every webhook. Never shown to the buyer.                                                                                                                                                                                              |
| `expiresAt`                                           | RFC 3339       | no       | Intent transitions to `expired` if not paid by this time.                                                                                                                                                                                                       |

### Idempotency

Send `Idempotency-Key: <uuid>` on every retry of the same logical create. Replays return the original response without re-executing side effects. Two distinct keys with the same `externalId` collapse to the same intent (per-org `externalId` is itself unique).

### Response

```json theme={null}
{
  "id": "ckabc123...",
  "externalId": "INV-2026-00042",
  "status": "awaiting_buyer",
  "amount": "12500.00",
  "currency": "USD",
  "hostedPaymentUrl": "https://pay.caratuva.com/r/cl9xy...publicId",
  "buyer": {
    "id": "ckdef456...",
    "email": "buyer@acme.com",
    "name": "ACME Imports LLC",
    "country": "US",
    "kycStatus": "not_started"
  },
  "returnUrl": "https://erp.example.com/orders/42/paid",
  "metadata": { "orderId": "42", "salesRep": "ana@example.com" },
  "display": { "title": "Order INV-2026-00042", "lineItems": [/* ... */] },
  "expiresAt": "2026-05-09T23:59:59.000Z",
  "createdAt": "2026-05-02T14:00:00.000Z",
  "updatedAt": "2026-05-02T14:00:00.000Z"
}
```

`hostedPaymentUrl` is the only field most integrators need from the response. Forward it to your customer over your own channel (email, SMS, in-app message). Caratuva does **not** automatically email the buyer when a payment intent is created — that is the integrator's responsibility on this surface, because you already own the customer relationship.

## Buyer journey on the hosted page

When the buyer clicks `hostedPaymentUrl`, they:

1. Land on the buyer portal (`pay.caratuva.com/r/<publicId>`) and see your `display` block plus your seller name.
2. Enter their email; receive a magic-link; sign in (Caratuva-managed session).
3. Complete buyer KYC if their `kycStatus` is not yet `approved`. The KYC step is mandatory — there is no fast-forward path. A returning buyer who's already verified in another integrator's flow reuses their KYC and skips this step.
4. Confirm the FX quote and complete payment.
5. Caratuva runs the cross-border transfer and pays out BRL via PIX to the seller's registered destination.
6. Buyer is redirected to `returnUrl` if set; otherwise sees a Caratuva confirmation page.

Each transition fires an outbound webhook so your ERP can keep its order status in sync.

## State machine

API-created intents skip seller-side approval (`awaiting_approval` / `approved`) and start at `awaiting_buyer`. From there:

```
awaiting_buyer
  → buyer_kyc_pending
  → buyer_kyc_approved
  → fx_quoted
  → on_ramp_pending
  → on_chain_confirmed
  → offramp_pending
  → settled
```

Terminal failure states: `failed`, `cancelled`, `expired`.

## Read an intent

```bash theme={null}
# By Caratuva id
curl https://api.caratuva.com/v1/payment-intents/<id> \
  -H "X-API-Key: $CARATUVA_API_KEY"

# By your externalId (prefix with ext_)
curl https://api.caratuva.com/v1/payment-intents/ext_INV-2026-00042 \
  -H "X-API-Key: $CARATUVA_API_KEY"
```

## Cancel an intent

Only valid before the BRL payout begins. After `settled`, cancel the order in your own system instead.

```bash theme={null}
curl -X POST https://api.caratuva.com/v1/payment-intents/<id>/cancel \
  -H "X-API-Key: $CARATUVA_API_KEY" \
  -H "content-type: application/json" \
  -d '{ "reason": "Buyer requested cancellation" }'
```

## Errors

| Status | `error`                  | Cause                                                                                        |
| ------ | ------------------------ | -------------------------------------------------------------------------------------------- |
| 400    | `KybNotApproved`         | Your org has no virtual account. Complete KYB first.                                         |
| 400    | `AmountBelowMinimum`     | The USD `amount` is below the US\$10.00 bank-transfer funding minimum. Increase it.          |
| 400    | `ValidationError`        | Zod schema rejected the body. The `message` field lists the offending paths.                 |
| 401    | `InvalidApiKey`          | Key is malformed, unknown, or revoked.                                                       |
| 401    | `InvalidApiKeyFormat`    | Header is not `pk_(test\|live)_<id>.<secret>`.                                               |
| 409    | `IdempotencyKeyConflict` | The `Idempotency-Key` was previously used for a non-payment-intent invoice. Use a fresh key. |
| 429    | `RateLimitExceeded`      | Per-API-key limit on `POST /v1/payment-intents`. Back off and retry.                         |

All error responses share the envelope `{ statusCode, error, message }`.
