Skip to main content
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.
  3. Webhook subscription. You’ll want one before creating intents in production — see Webhooks.

Create a payment intent

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

FieldTypeRequiredNotes
externalIdstring (1–200)yesYour primary key. Unique per (organization, externalId). Re-POSTing the same value returns the existing intent — safe under network retry.
amountdecimal stringyesTwo-decimal precision; pass as a string to avoid float drift ("12500.00", not 12500). **Minimum US10.00buyersfundbybanktransfer,whichenforcesaUS10.00** — buyers fund by bank transfer, which enforces a US10 floor, so a smaller USD amount can’t be paid and is rejected with AmountBelowMinimum.
currencyISO 4217 codenoDefaults to USD.
buyer.emailstringyesReal address — the buyer receives a magic-link sign-in to this address.
buyer.name, buyer.country, buyer.phonestringnoPre-fills the KYC form; the buyer can amend.
returnUrlURLnoWhere the hosted page redirects after settlement.
display.title, display.notes, display.lineItemsvariesnoRendered on the hosted page. Free-text — Caratuva does not validate NCM or origin codes here.
metadataobjectnoRound-tripped verbatim on every webhook. Never shown to the buyer.
expiresAtRFC 3339noIntent 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

{
  "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

# 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.
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

StatuserrorCause
400KybNotApprovedYour org has no virtual account. Complete KYB first.
400AmountBelowMinimumThe USD amount is below the US$10.00 bank-transfer funding minimum. Increase it.
400ValidationErrorZod schema rejected the body. The message field lists the offending paths.
401InvalidApiKeyKey is malformed, unknown, or revoked.
401InvalidApiKeyFormatHeader is not pk_(test|live)_<id>.<secret>.
409IdempotencyKeyConflictThe Idempotency-Key was previously used for a non-payment-intent invoice. Use a fresh key.
429RateLimitExceededPer-API-key limit on POST /v1/payment-intents. Back off and retry.
All error responses share the envelope { statusCode, error, message }.