Card Payments
Accept Visa, Mastercard, American Express, Elo, Hipercard, and other major schemes through a Liqfy-hosted payment page — no card data ever touches your servers, 3D Secure handled automatically.
#Flow at a glance
1. POST /v1/payments ──────▶ Liqfy returns the payment: id, status WAITING_PAYMENT, cardRedirectUrl + checkoutUrl
2. Redirect (or open in iframe) the customer to cardRedirectUrl
3. Customer enters card details + completes 3DS challenge on Liqfy's secure page
4. Customer is bounced back to your returnUrl (success or failure)
5. Webhook charge.paid (or charge.failed) confirms the outcome — trust the webhook, not the redirectWhy a redirect? It keeps cardholder data off your infrastructure entirely. Your application — backend or frontend — never sees a PAN, CVV, or expiry. Liqfy's processor handles 3DS, tokenisation, and fraud screening on our side.
#1. Create the payment
Endpoint POST /v1/payments
curl -X POST https://api.liqfy.com.br/v1/payments \
-H "Authorization: Bearer $LIQFY_API_KEY" \
-H "Idempotency-Key: ORD-9821" \
-H "Content-Type: application/json" \
-d '{
"amount": 8990,
"currency": "EUR",
"paymentMethods": ["CREDIT_CARD"],
"customerName": "João Costa",
"customerEmail": "joao@example.com",
"customerPhone": "+351912345678",
"returnUrl": "https://shop.example.com/orders/9821/return",
"cancelUrl": "https://shop.example.com/cart",
"metadata": { "orderId": "ORD-9821" }
}'Idempotency. POST /v1/payments accepts the Idempotency-Key header (preferred) or the body field idempotencyKey as a fallback when the header is absent; send at least one. If you send both, they must match — divergent values are 400, and neither at all is 400 on this route. Any non-blank string (surrounding whitespace is trimmed), scoped per merchant ((merchant, key)): replaying the same key and body returns the stored response, the same key with a different body is 409. The Idempotency-Replayed header is not emitted on this route — do not build retry logic on it.
| Field | Notes |
|---|---|
amount | Integer > 0, smallest unit (cents). 8990 = € 89,90. |
currency | 3–8 chars, default "BRL" (BRL, EUR, USD; additional currencies on request). |
paymentMethods | ["CREDIT_CARD"] here. The API accepts PIX, CRYPTO, CREDIT_CARD, BOLETO, MBWAY, MULTIBANCO, IDEAL, BANCONTACT, SEPA, BIZUM, SWAP_NOW. At least one item. Required unless checkout is sent (else 400 — paymentMethods is required unless a checkout template is provided). |
checkout | Optional checkout template slug (1–80). With a template you may omit paymentMethods: the template plus the charge currency decide the methods, filtered to what is actually routable, and they become the charge's paymentMethods. Unknown slug → 404; template service unreachable → 503. |
customerId | Optional string. |
customerName / customerDocument / customerEmail / customerPhone | Flat customer fields. customerEmail must be an e-mail address; customerPhone is free-form (E.164 recommended). customerEmail is strongly recommended — receipts, fraud signals, chargeback dispute proof. |
customer | Alternative nested { name?, document?, email?, phone? } (email must be an e-mail address); flattened into the fields above (flat fields win). |
expectedPayerDocument | Digits-only expected payer CPF/CNPJ; becomes the payer lock where the provider applies it. A locked charge also needs the payer name (customerName), else PAYER_NAME_REQUIRED. |
returnUrl | Where to send the customer after they finish (success or failure). http(s) URL without fragment. Canonical field. |
cancelUrl | Where to send the customer on abort. Same URL rule; requires returnUrl (else 400 — cancelUrl requires returnUrl). |
metadata | Free-form key/value. metadata.returnUrl / metadata.cancelUrl, if present, must be http(s) URLs. metadata.returnUrl still drives the hosted-checkout redirect, but the canonical field is the top-level returnUrl. |
items | Optional { name, quantity, unitPrice, totalPrice }[]; name non-empty, the numbers integers > 0. |
webhookUrl | Optional per-transaction webhook URL override; must be a valid URL. |
idempotencyKey | Body fallback for the Idempotency-Key header (see above). |
country | Optional 2–3 chars; provider-account routing input. |
riskTier | Optional LOW, MEDIUM or HIGH; provider-account routing input. |
environment | Optional SANDBOX, STAGING, PRODUCTION — lets you explicitly target your sandbox account. When omitted it is derived from the server environment. |
expiresIn | Optional Pix QR time-to-live in seconds, 60–604800; forwarded to the provider as the requested expiry (a provider-returned expiry wins). |
Response 201 Created — the create response already carries the method artefacts (here cardRedirectUrl) and checkoutUrl; you do not need a follow-up GET to see them.
{
"id": "b2c3d4e5-f6a7-4890-9bcd-ef0123456789",
"status": "WAITING_PAYMENT",
"amount": 8990,
"currency": "EUR",
"paymentMethods": ["CREDIT_CARD"],
"customerName": "João Costa",
"customerEmail": "joao@example.com",
"customerPhone": "+351912345678",
"cardRedirectUrl": "https://checkout.liqfy.com.br/c/b2c3d4e5-f6a7-4890-9bcd-ef0123456789",
"checkoutUrl": "https://checkout.liqfy.com.br/b2c3d4e5-f6a7-4890-9bcd-ef0123456789",
"createdAt": "2026-04-25T15:50:11.000Z"
}checkoutUrl is present on create and retrieve only — list rows and the cancel response omit it.
#Errors on create
| HTTP | code | When |
|---|---|---|
400 | — (flattened validation errors) | Invalid body; idempotency key missing or header/body divergent; cancelUrl without returnUrl; paymentMethods missing without checkout. |
401 | — | The request does not identify your account (it must arrive authenticated through the API gateway). |
403 | fraud.tenant_velocity_exceeded | Too many payment attempts from your account in a short window (default: 200 per 60 s). |
403 | fraud.customer_velocity_exceeded | Too many payment attempts for the same customerDocument or customerEmail in a short window (default: 10 per 300 s). Expired and cancelled charges do not count; refused ones do. Charges with neither field are not subject to this rule. |
404 | — | Unknown checkout slug. |
4xx | NO_PROVIDER_CONFIGURED | No payment provider is configured for this payment method and currency. |
4xx | UNKNOWN_METHOD | Payment method not supported. |
4xx | MOCK_PROVIDER_IN_PRODUCTION | The method is not available for live charges. |
4xx | PAYER_IDENTITY_REQUIRED | The charge requires the payer's full identity (name, email, phone and tax ID). |
4xx | PAYER_NAME_REQUIRED | expectedPayerDocument sent without the payer name. |
4xx | PAYMENT_REJECTED | The provider rejected the charge (also the fallback code for any other provider-side rejection). |
503 | PROVIDER_UNAVAILABLE | The payment provider is temporarily unavailable — try again later (same Idempotency-Key). |
5xx | PROVIDER_ERROR | The provider returned an unexpected response. |
Provider-side 4xx errors keep the HTTP status the routing layer returned. Error messages are neutral by design — they never name the provider or its internal codes; quote the request id to support if you need the detail.
With the official SDK (same neutral package for every brand — configure it with https://api.liqfy.com.br):
| node | python | php | dotnet |
|---|---|---|---|
client.payments.create(input, { idempotencyKey }) | client.payments.create(input, idempotency_key) | $client->payments->create($input, $idempotencyKey) | client.Payments.CreateAsync(input, idempotencyKey) |
client.payments.retrieve(id) | client.payments.retrieve(id) | $client->payments->retrieve($id) | client.Payments.RetrieveAsync(id) |
client.payments.list(query) | client.payments.list(**query) | $client->payments->list($query) | client.Payments.ListAsync(query) |
payments.list (GET /v1/payments) returns the page envelope { data, total, page, limit }. Query parameters: page (default 1), limit (default 20, max 100), status, currency, startDate / endDate (ISO-8601 date-time), source (API, PANEL, MANUAL, INDEFINIDO), sortBy, sortOrder (asc / desc).
#2. Redirect the customer
cardRedirectUrl comes in the create response (and on every GET /v1/payments/{id}), so you can redirect straight away:
curl https://api.liqfy.com.br/v1/payments/b2c3d4e5-f6a7-4890-9bcd-ef0123456789 \
-H "Authorization: Bearer $LIQFY_API_KEY"{
"id": "b2c3d4e5-f6a7-4890-9bcd-ef0123456789",
"status": "WAITING_PAYMENT",
"amount": 8990,
"currency": "EUR",
"cardRedirectUrl": "https://checkout.liqfy.com.br/c/b2c3d4e5-f6a7-4890-9bcd-ef0123456789",
"createdAt": "2026-04-25T15:50:11.000Z"
}Send the customer to cardRedirectUrl:
<a href="{{ cardRedirectUrl }}">Pay with card</a>Or do a server-side 303 See Other:
res.redirect(303, transaction.cardRedirectUrl);After the customer completes the flow (or aborts), they bounce back to the returnUrl you supplied (the hosted checkout reads the same redirect from metadata.returnUrl) — with ?transactionId=...&status=... appended. Do not trust those query parameters. They're informational only — wait for the webhook before fulfilling.
#3. Confirm via webhook
{
"id": "evt_00000000000000000000000000000001",
"object": "event",
"api_version": "2026-07-23",
"type": "charge.paid",
"created_at": "2026-04-25T15:52:34.000Z",
"data": {
"object": {
"id": "ch_b2c3d4e5-f6a7-4890-9bcd-ef0123456789",
"object": "charge",
"status": "paid",
"amount": 8990,
"currency": "EUR",
"payment_method": "card",
"settlement": {}
}
}
}The public charge status is paid for successful card payments. The public status vocabulary is pending, processing, paid, failed, expired, cancelled, refunded, disputed.
Declines arrive as charge.failed; expiry prefers charge.expired. Read data.object.status for the public outcome. The event does not expose issuer decline reasons.
#Lifecycle
WAITING_PAYMENT ──▶ PROCESSING ──▶ APPROVED ✓ fulfill order
──▶ REFUSED issuer declined
──▶ CANCELLED customer abandoned
──▶ EXPIRED redirect link aged out (24h)After settlement (D+1 to D+30 depending on scheme and your contract), you may also see:
APPROVED ──▶ CHARGEBACK issuer raised a chargeback
APPROVED ──▶ DISPUTE cardholder opened a dispute
APPROVED ──▶ REFUNDED you (or Liqfy) issued a refundEach one fires a webhook so you can sync the order state.
#Other operations on the payment
| Endpoint | What it does |
|---|---|
POST /v1/payments/{id}/cancel | Cancels the payment; returns it in the same shape as GET /v1/payments/{id} (without checkoutUrl). |
POST /v1/payments/{id}/resend-webhook | Asks Liqfy to resend the payment's webhook. |
POST /v1/payments/{id}/reconcile | Re-checks the payment's status with the provider; returns { reconciled, status }. |
GET /v1/payments/{id}/refunds | Refund history of the payment. failureReason is always null and reason is only filled for refunds you requested yourself. |
POST /v1/payments/{id}/confirm-sandbox | Sandbox only — confirms a simulated charge so the completion webhook fires. Refused for any non-sandbox charge; never credits a wallet. |
POST /v1/payments/{id}/refund | Not available to API-key callers: returns 403 REFUND_ADMIN_ONLY (see FAQ). |
#Test cards
In sandbox, the redirect page accepts these deterministic numbers (any future expiry, any CVV):
| Number | Outcome |
|---|---|
4111 1111 1111 1111 | Always APPROVED |
4000 0000 0000 0002 | Always REFUSED — generic decline |
4000 0000 0000 0069 | Always REFUSED — expired card |
4000 0000 0000 9995 | Always REFUSED — insufficient funds |
4000 0000 0000 3220 | Triggers 3DS challenge then APPROVED |
Sandbox charges run on the simulator: they never credit your wallet and are kept out of volume, revenue and metrics figures (they still appear in lists and in the charge detail). Use POST /v1/payments/{id}/confirm-sandbox to fire the completion webhook end-to-end with a test key.
See sandbox for the full list.
#FAQ
Q: Can I keep the customer on my domain? A: Phase 2 we'll ship an embeddable widget (iframe + tokeniser) that keeps your branding while still keeping cardholder data off your infrastructure. For now: hosted redirect.
Q: Do you support saved cards? A: Yes, but it requires your side to hold its own card-data-security compliance attestation. Email suporte@liqfy.com.br to enable the Vault API.
Q: Which 3DS version is used? A: 3DS 2.x with frictionless flow when the issuer permits. Strong Customer Authentication (SCA) is mandatory for EUR — this is enforced server-side.
Q: Refunds?
A: Refunds are requested from the admin panel today, full or partial — the API does not expose a merchant-usable refund endpoint: POST /v1/payments/{id}/refund with an API key returns 403 REFUND_ADMIN_ONLY. You can still read the refund history with GET /v1/payments/{id}/refunds.
Q: My customer was charged but I never got a webhook.
A: Check GET /v1/webhooks/deliveries — likely we tried and your endpoint returned a non-2xx. We retry up to 15 times. After that, use POST /v1/payments/{id}/resend-webhook or contact support to replay.
#Next
→ MB WAY · Multibanco · API Reference