The whole documentation in one file
Copy or download the full reference as Markdown and hand it to your coding assistant to generate the integration in one go.
Quick start
Three steps to your first charge in production. All amounts in cents, dates in ISO 8601 (UTC).
curl -X GET https://finance.zendry.co/api/public/v1/balance \ -H "Authorization: Bearer gw_sua_chave_aqui"Authentication
Every request requires your API key in the Authorization: Bearer <key> or x-api-key: <key> header — both formats are accepted. Generate and revoke keys in Integrations.
- Monetary values always in cents (integer). Dates in ISO 8601 (UTC).
- With allowed IPs registered in Integrations, calls from other IPs get 403.
- Payment confirmation is webhook-driven — the GETs are for re-checking, not aggressive polling.
Pix
Create Pix charge
Creates a charge and returns the Pix copy-and-paste code. Payment confirmation arrives through the pix.received webhook — do not poll.
- state: pending → paid, or expired if it lapses unpaid.
- external_id does not deduplicate: two identical calls create two charges. Store the returned id.
| Field | Type | Required | Description |
|---|---|---|---|
| amount_cents | integer | Yes | Amount in cents. From 1 to 100000000 (R$ 1 million). |
| description | text | No | Up to 140 characters. |
| external_id | text | No | Your identifier. Echoed back in webhooks. Does not deduplicate. |
curl -X POST https://finance.zendry.co/api/public/v1/charges \ -H "Authorization: Bearer gw_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "amount_cents": 15000, "description": "Order #1234", "external_id": "1234"}'{ "id": "3f1c...", "state": "pending", "amount_cents": 15000, "description": "Order #1234", "external_id": "1234", "pix_code": "00020126...5303986540515.00...", "created_at": "2026-01-01T12:00:00Z"}Create your account to run this call in the sandbox, with a test key and isolated data.
Get charge
Returns the current state of a Pix or boleto charge (the same endpoint serves both — the response follows the charge type's format).
- 404 when the id does not exist or belongs to another account.
curl -X GET https://finance.zendry.co/api/public/v1/charges/:id \ -H "Authorization: Bearer gw_your_key_here"{ "id": "3f1c...", "state": "paid", "amount_cents": 15000, "description": "Order #1234", "external_id": "1234", "pix_code": "00020126...", "created_at": "2026-01-01T12:00:00Z", "paid_at": "2026-01-01T12:04:11Z"}Create your account to run this call in the sandbox, with a test key and isolated data.
Send Pix (payout)
Debits the available balance and sends a Pix to the given key. Amount + fee are reserved immediately; the final result arrives through the pix.sent webhook.
- state: sending → completed or failed — notified via the pix.sent webhook; on failure the reserved amount returns to the balance.
- 409 when the balance cannot cover amount + fee.
- Idempotency: send the Idempotency-Key header. Retrying with the SAME key returns the payout already created, without duplicating the debit.
- Without that header, do not retry after a timeout before calling GET /payouts/{id}: the debit may already be reserved.
| Field | Type | Required | Description |
|---|---|---|---|
| amount_cents | integer | Yes | Amount in cents. From 1 to 100000000. |
| pix_key | text | Yes | Recipient's Pix key. |
| pix_key_type | cpf | cnpj | email | phone | evp | No | When omitted, the type is inferred from the key's format. |
| description | text | No | Up to 140 characters. |
curl -X POST https://finance.zendry.co/api/public/v1/payouts \ -H "Authorization: Bearer gw_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "amount_cents": 5000, "pix_key": "customer@email.com", "pix_key_type": "email", "description": "Payout"}'{ "id": "9ac2...", "state": "sending", "amount_cents": 5000, "fee_cents": 149, "reserved_cents": 5149, "idempotent_id": "b7e1...", "created_at": "2026-01-01T12:00:00Z"}Create your account to run this call in the sandbox, with a test key and isolated data.
Get a payout
Returns the current state of a payout by the id from creation. Use it when the pix.sent webhook delivery fails, or to reconcile.
- 404 when the id does not exist or belongs to another account. A test key only sees sandbox payouts.
- failure_reason is only set on failed, with one of four Portuguese phrases: "Chave Pix inválida." (invalid Pix key), "Saque cancelado." (payout canceled), the sandbox simulated failure, or "Não foi possível concluir a operação. Tente novamente em alguns instantes." (could not complete; try again). It is display text, not something to parse. end_to_end_id only on completed.
curl -X GET https://finance.zendry.co/api/public/v1/payouts/{id} \ -H "Authorization: Bearer gw_your_key_here"{ "id": "9ac2...", "state": "failed", "amount_cents": 5000, "fee_cents": 149, "reserved_cents": 5149, "pix_key": "customer@email.com", "pix_key_type": "email", "description": "Transfer", "idempotent_id": "b7e1...", "end_to_end_id": null, "failure_reason": "Não foi possível concluir a operação. Tente novamente em alguns instantes.", "created_at": "2026-01-01T12:00:00Z", "completed_at": null, "failed_at": "2026-01-01T12:00:07Z"}Create your account to run this call in the sandbox, with a test key and isolated data.
List payouts
Lists the account payouts, newest first. Filter by status to find the ones still in flight.
- status=sending returns what has no outcome yet — this is the call to reconcile when the webhook never arrived.
- 422 when status is not one of the three.
| Field | Type | Required | Description |
|---|---|---|---|
| status | sending | completed | failed | No | Without this field, returns all of them. |
| limit | integer | No | From 1 to 100. Defaults to 50. |
curl -X GET https://finance.zendry.co/api/public/v1/payouts \ -H "Authorization: Bearer gw_your_key_here"{ "payouts": [ { "id": "9ac2...", "state": "sending", "amount_cents": 5000, "fee_cents": 149, "reserved_cents": 5149, "pix_key": "customer@email.com", "pix_key_type": "email", "description": "Transfer", "idempotent_id": "b7e1...", "end_to_end_id": null, "failure_reason": null, "created_at": "2026-01-01T12:00:00Z", "completed_at": null, "failed_at": null } ]}Create your account to run this call in the sandbox, with a test key and isolated data.
Boleto
Create boleto charge
Issues a boleto (Brazilian bank slip) for the given buyer. Confirmation arrives through the boleto.paid webhook (bank clearing, usually D+1).
- Requires card enabled for the account.
- data_limite is the last day the boleto is still accepted after the due date. linha_digitavel is the typeable line; codigo_barras the barcode.
- pdf_url is the boleto page, to print or save as PDF.
- Query it through the same GET /charges/:id.
| Field | Type | Required | Description |
|---|---|---|---|
| amount_cents | integer | Yes | Amount in cents. |
| due_date | date (YYYY-MM-DD) | Yes | Boleto due date. |
| buyer | object | Yes | first_name, last_name, email and taxpayer_id (CPF or CNPJ). |
| buyer.address | object | No | line1, neighborhood, city, state, postal_code. Optional as a whole. |
| description | text | No | Up to 140 characters. |
| external_id | text | No | Your identifier. |
curl -X POST https://finance.zendry.co/api/public/v1/charges/boleto \ -H "Authorization: Bearer gw_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "amount_cents": 15000, "description": "Order #1234", "external_id": "1234", "due_date": "2026-01-15", "buyer": { "first_name": "Maria", "last_name": "Souza", "email": "maria@email.com", "taxpayer_id": "12345678900", "address": { "line1": "Av. Paulista, 1000", "neighborhood": "Bela Vista", "city": "São Paulo", "state": "SP", "postal_code": "01310100" } }}'{ "id": "3f1c...", "state": "pending", "amount_cents": 15000, "description": "Order #1234", "external_id": "1234", "due_date": "2026-01-15", "deadline_date": "2026-01-17", "typeable_line": "23791.23456 78900.123456 ...", "barcode": "23797890000015000123456...", "pdf_url": "https://finance.zendry.co/boleto/8f1c.../pdf", "created_at": "2026-01-01T12:00:00Z"}Create your account to run this call in the sandbox, with a test key and isolated data.
Card
Checkout & 3DS — step by step
Zendry's 3DS is smart (data-only): for most sales, authentication happens behind the scenes, no screen — just send the buyer's device data. The browser challenge (the bank's iframe) only appears when the issuer demands it, and Zendry.js handles it. The SDK never sees the card number or credentials.
Buyer
Fills in the card on your checkout page.
Your front end
Zendry.js collects the device (language, screen, timezone). The card never goes through the SDK.
Your backend
POST /card_payments with card, device, ip_address and user_agent.
Zendry
Authenticates with the issuer and responds to your server.
Issuer
Decides without a screen (data-only) or challenges the buyer.
200 approved
Data-only: authenticated behind the scenes, no screen.
201 requires_action
The issuer asked for a challenge — Zendry.js finishes it in the browser.
402 declined
failure_reason carries the cause. Offer another card.
Webhook card.approved / card.declined
The official outcome. Only release the order here — never from the SDK's return.
É o mesmo arquivo dos dois lados. O envio não sai do simulador: o desfecho abaixo é escolhido por você. Numa venda real, quem confirma é sempre o webhook.
Load the SDK and collect the device
Put the script on your checkout page and call Zendry.threeDS() right before submitting the form. It returns a small object with the buyer's language, screen and timezone — no card data goes through the SDK. The whole page is in the simulator above, on the Code tab.
<script src="https://api.zendry.co/sdk/v1/zendry.js"></script> const threeds = await Zendry.threeDS();// threeds.device → { language, screen_height, screen_width, time_zone_offset }Create the sale on your backend
Your key never reaches the browser: your server is who calls Zendry. Forward the device received from the front end and add the buyer's ip_address and user_agent — they are what sustains screenless authentication.
app.post("/checkout/pay", async (req, res) => { const r = await fetch("https://finance.zendry.co/api/public/v1/card_payments", { method: "POST", headers: { Authorization: `Bearer ${process.env.ZENDRY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ amount_cents: req.body.amount_cents, installments: 1, external_id: req.body.order_id, buyer: req.body.buyer, card: req.body.card, device: req.body.device, ip_address: req.headers["x-forwarded-for"]?.split(",")[0], user_agent: req.headers["user-agent"], }), }); const sale = await r.json(); switch (sale.status) { case "aprovada": return res.json({ status: "approved", id: sale.id }); case "requires_action": // return the action to the front end — Zendry.js finishes the challenge return res.json({ status: "requires_action", action: sale.action, id: sale.id }); default: return res.status(402).json({ status: "declined", reason: sale.failure_reason }); }});Finish the challenge when you get 201 requires_action
The action is opaque: return it to your front end exactly as received and call Zendry.completeThreeDS. Without an actual challenge (frictionless), it resolves in seconds, no screen.
const outcome = await Zendry.completeThreeDS(sale.action); if (outcome.status === "authorized") showSuccess();else if (outcome.status === "expired") askToRetry();else showDecline(); // this is UX. Only release the order after your server's confirmation.Confirm the outcome on the server
The SDK's return is for the screen; the truth is the webhook (or the GET /api/public/v1/card_payments/:id). Validate the HMAC signature before releasing any order.
import crypto from "node:crypto"; app.post("/webhooks/zendry", express.raw({ type: "*/*" }), (req, res) => { const signature = req.headers["x-zendry-signature"]; const expected = crypto .createHmac("sha256", process.env.ZENDRY_WEBHOOK_SECRET) .update(req.body) .digest("hex"); if (signature !== expected) return res.status(401).end(); const event = JSON.parse(req.body.toString()); if (event.event === "card.approved") releaseOrder(event.data.external_id); if (event.event === "card.declined") cancelOrder(event.data.external_id); res.status(200).end();});| Field | Type | Required | Description |
|---|---|---|---|
| aprovada (200) | show success | No | The card.approved webhook confirms. Release the order there. |
| requires_action (201) | call completeThreeDS | No | After the challenge, card.approved or card.declined arrives. Without completion in 15 min, the sale expires. |
| recusada (200) | offer another card | No | failure_reason carries the cause. Webhook card.declined. |
| 422 | fix the request | No | error_type names the cause (e.g. INVALID_REQUEST for a missing device under reinforced posture). |
- Send device on EVERY sale: it raises approval rates, and accounts under reinforced risk posture reject the request without it (422).
- action.session_id is single-use and expires in 15 minutes — if the buyer doesn't finish, the sale expires and is declined.
- Never release the order from the SDK's return: only the webhook (or the GET) is the official result.
- Do not reuse the same action on another attempt — create a new sale.
In the sandbox the card is simulated without real 3DS: any valid number approves and 4000 0000 0000 0002 declines (use the Card payment endpoint's console). The real 3DS challenge only happens in production.
Card payment
Charges a credit card server-to-server. Requires card enabled for the account. The card number is never stored.
- Sandbox (gw_test_ key): authorization is simulated — any valid card number APPROVES and 4000 0000 0000 0002 DECLINES; no 3DS; refunds are simulated too.
- 201 + status requires_action: finish in the browser with Zendry.completeThreeDS(response.action) — see the Checkout & 3DS guide above. The official outcome arrives through the card.approved / card.declined webhook and the GET below.
- A decline is NOT an HTTP error: it comes as 200 with state declined and a readable failure_reason.
- Business errors come as 422 in the { error, error_type, error_code, retry } format — catalog in the Errors & limits section.
- If your platform resells to merchants (chained gateway), send the END buyer's ip_address and user_agent — the HTTP headers carry your server, not the buyer.
| Field | Type | Required | Description |
|---|---|---|---|
| amount_cents | integer | Yes | Amount in cents. |
| installments | integer | No | 1 to 12. Default 1. |
| buyer | object | Yes | name, email and taxpayer_id (CPF, 11 digits). |
| card | object | Yes | holder_name, number, expiration_month (MM), expiration_year (YYYY) and security_code. |
| billing | object | No | Billing address. |
| device | object | No | Buyer's device data collected by Zendry.js (Zendry.threeDS()). Raises approval rates — and accounts under reinforced risk posture REQUIRE this field. Fields: language, screen_height, screen_width, time_zone_offset (hours, Brazil = -3). |
| ip_address | text | No | The BUYER's IP. Required in practice for chained integrations. |
| user_agent | text | No | The BUYER's user-agent. |
| external_id | text | No | Your identifier. |
curl -X POST https://finance.zendry.co/api/public/v1/card_payments \ -H "Authorization: Bearer gw_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "amount_cents": 15000, "installments": 1, "external_id": "1234", "buyer": { "name": "Maria Souza", "email": "maria@email.com", "taxpayer_id": "12345678900" }, "card": { "holder_name": "MARIA SOUZA", "number": "4111111111111111", "expiration_month": "12", "expiration_year": "2030", "security_code": "123" }, "ip_address": "203.0.113.10", "user_agent": "Mozilla/5.0 ..."}'{ "id": "7d21...", "charge_id": "1b8e...", "state": "approved", "amount_cents": 15000, "installments": 1, "external_id": "1234", "created_at": "2026-01-01T12:00:00Z"}Create your account to run this call in the sandbox, with a test key and isolated data.
Get card transaction
Returns the transaction by the id given at creation — including to follow the outcome of a 3DS challenge.
- state: approved | declined | refunded | requires_action.
- nsu: the receipt NSU. null when the sale never reached the card network (declined before authorization, sandbox).
curl -X GET https://finance.zendry.co/api/public/v1/card_payments/:id \ -H "Authorization: Bearer gw_your_key_here"{ "id": "7d21...", "charge_id": "1b8e...", "state": "approved", "amount_cents": 15000, "gross_cents": 15000, "fee_cents": 899, "net_cents": 14101, "installments": 1, "external_id": "1234", "failure_reason": null, "nsu": "Z000186-1049...", "refunded_at": null, "created_at": "2026-01-01T12:00:00Z"}Create your account to run this call in the sandbox, with a test key and isolated data.
Refund transaction
Full refund. Available only for approved transactions and within 24 hours of approval.
- After 24h, or on a non-approved transaction: 422 with the reason in the error field.
- The card.refunded event fires on the receiving webhook.
curl -X POST https://finance.zendry.co/api/public/v1/card_payments/:id/refund \ -H "Authorization: Bearer gw_your_key_here"{ "id": "7d21...", "state": "refunded", "refunded_at": "2026-01-01T18:22:40Z"}Create your account to run this call in the sandbox, with a test key and isolated data.
Crypto (USDT)
Convert to USDT and send (crypto payout)
Converts the amount in BRL to USDT at the current rate and sends it to the given wallet. The BRL leaves the balance immediately; the USDT stays blocked until the on-chain confirmation, delivered by the crypto.sent webhook.
- state: requested → completed (crypto.sent webhook, with tx_hash) or failed (crypto.failed webhook; the USDT returns to the available USDT balance — the BRL conversion is not undone).
- usdt_amount is what reaches the wallet: the converted USDT minus the USDT outbound fee (fee_usdt_minor). applied_rate is the conversion rate: BRL paid per USDT converted (amount_cents ÷ (usdt_amount_minor + fee_usdt_minor), in each currency's units). Sending from the USDT balance involves no conversion: applied_rate is 0.
- 409 when the BRL balance is insufficient. 422 when the BRL → USDT conversion is not enabled for the account or the amount breaks a payout limit.
- Idempotency: send the Idempotency-Key header. Repeating the call with the SAME key returns the payout already created, without converting again.
- Sending from the USDT balance: send usdt_amount_minor instead of amount_cents. This is the way to resend a failed payout (the USDT returns to the balance, not to BRL). amount_cents in the response is 0 in that case.
- Sandbox: the outcome is immediate — an amount ending in 99 cents simulates a failure, any other completes without tx_hash (the sandbox never reaches the network; network identifiers are never made up).
| Field | Type | Required | Description |
|---|---|---|---|
| amount_cents | inteiro | No | Amount in BRL, in cents. From 1 to 100000000. The account's payout minimum, maximum and daily cap apply. |
| usdt_amount_minor | inteiro | No | Alternative to amount_cents: sends USDT already in your balance (minor unit, 6 decimals) without converting. Send exactly one of the two. |
| wallet | texto | Yes | Destination wallet address on the chosen network. |
| network | trc20 | erc20 | bep20 | polygon | Yes | Network for the transfer: TRON (TRC20), Ethereum (ERC20), BNB Smart Chain (BEP20) or Polygon. |
| description | texto | No | Up to 140 characters. |
curl -X POST https://finance.zendry.co/api/public/v1/crypto_payouts \ -H "Authorization: Bearer gw_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "amount_cents": 50000, "wallet": "TKcXiZ1ovginwWvoCHZwwSPn7ZUhCA7Ndg", "network": "trc20", "description": "Repasse"}'{ "id": "5b7d...", "state": "requested", "amount_cents": 50000, "usdt_amount": 94.754323, "usdt_amount_minor": 94754323, "fee_usdt_minor": 0, "applied_rate": 5.276804, "wallet": "TKcXiZ1ovginwWvoCHZwwSPn7ZUhCA7Ndg", "network": "trc20", "description": "Repasse", "tx_hash": null, "failure_reason": null, "conversion_id": "c0a1...", "created_at": "2026-01-01T12:00:00Z", "completed_at": null, "failed_at": null}Create your account to run this call in the sandbox, with a test key and isolated data.
Get crypto payout
Returns the current state of a USDT payout by the id from creation. Use it when the crypto.sent webhook delivery failed or to reconcile.
- 404 if the id does not exist or belongs to another account. A test key only sees sandbox payouts.
- tx_hash is only set in completed; failure_reason only in failed.
curl -X GET https://finance.zendry.co/api/public/v1/crypto_payouts/{id} \ -H "Authorization: Bearer gw_your_key_here"{ "id": "5b7d...", "state": "completed", "amount_cents": 50000, "usdt_amount": 94.754323, "usdt_amount_minor": 94754323, "fee_usdt_minor": 0, "applied_rate": 5.276804, "wallet": "TKcXiZ1ovginwWvoCHZwwSPn7ZUhCA7Ndg", "network": "trc20", "description": "Repasse", "tx_hash": "7f3a9c...e21b", "failure_reason": null, "conversion_id": "c0a1...", "created_at": "2026-01-01T12:00:00Z", "completed_at": "2026-01-01T12:03:40Z", "failed_at": null}Create your account to run this call in the sandbox, with a test key and isolated data.
List crypto payouts
Lists the account's USDT payouts, newest first. Filter by status to find what is still in progress.
- status=requested returns what has no outcome yet — this is the call to reconcile when the webhook never arrived.
- 422 when status is not one of the three.
| Field | Type | Required | Description |
|---|---|---|---|
| status | requested | completed | failed | No | Without this field, returns all. |
| limit | inteiro | No | From 1 to 100. Default 50. |
curl -X GET https://finance.zendry.co/api/public/v1/crypto_payouts \ -H "Authorization: Bearer gw_your_key_here"{ "crypto_payouts": [ { "id": "5b7d...", "state": "requested", "amount_cents": 50000, "usdt_amount": 94.754323, "usdt_amount_minor": 94754323, "fee_usdt_minor": 0, "applied_rate": 5.276804, "wallet": "TKcXiZ1ovginwWvoCHZwwSPn7ZUhCA7Ndg", "network": "trc20", "description": "Repasse", "tx_hash": null, "failure_reason": null, "conversion_id": "c0a1...", "created_at": "2026-01-01T12:00:00Z", "completed_at": null, "failed_at": null } ]}Create your account to run this call in the sandbox, with a test key and isolated data.
Disputes
List disputes
Disputes (MED and chargebacks) on your account, newest first. Opening is announced by the dispute.opened webhook; use this list to follow their state.
- The dispute amount is always the whole transaction — there is no partial dispute.
- While alive, the amount is held from the available balance; the outcome arrives via the dispute.closed webhook (outcome won/lost).
| Field | Type | Required | Description |
|---|---|---|---|
| status | open | closed | exact | No | open = alive (open, under_defense, under_review); closed = won and lost; or an exact state. |
| limit | integer | No | From 1 to 100. Default 50. |
curl -X GET https://finance.zendry.co/api/public/v1/disputes \ -H "Authorization: Bearer gw_your_key_here"{ "disputes": [ { "id": "19ba...", "type": "med", "state": "open", "amount_cents": 15000, "currency": "BRL", "transaction_reference": "469208464", "transaction_identifier": "d6f1849fe1654d83a4049db495f26ee8", "charge_id": "94a9f429-dc23-4b32-861a-351499d91790", "transaction_identifier": "d6f1849fe1654d83a4049db495f26ee8", "charge_id": "94a9f429-dc23-4b32-861a-351499d91790", "reason": "MED REFUND_REQUEST", "opened_at": "2026-01-01T12:00:00Z", "defense_deadline": "2026-01-08T12:00:00Z", "responded_at": null, "closed_at": null } ]}Create your account to run this call in the sandbox, with a test key and isolated data.
Get dispute
Full detail: dispute data, message history (author: seller = you, zendry = us) and evidence already attached.
- 404 when the id does not exist or belongs to another account.
curl -X GET https://finance.zendry.co/api/public/v1/disputes/:id \ -H "Authorization: Bearer gw_your_key_here"{ "id": "19ba...", "type": "med", "state": "under_defense", "amount_cents": 15000, "currency": "BRL", "transaction_reference": "469208464", "transaction_identifier": "d6f1849fe1654d83a4049db495f26ee8", "charge_id": "94a9f429-dc23-4b32-861a-351499d91790", "reason": "MED REFUND_REQUEST", "opened_at": "2026-01-01T12:00:00Z", "defense_deadline": "2026-01-08T12:00:00Z", "responded_at": "2026-01-02T09:30:00Z", "closed_at": null, "messages": [ { "author": "seller", "text": "Service delivered, receipt attached.", "created_at": "2026-01-02T09:30:00Z" } ], "evidence": [ { "name": "delivery-receipt.pdf", "content_type": "application/pdf", "size_bytes": 182044, "created_at": "2026-01-02T09:29:00Z" } ]}Create your account to run this call in the sandbox, with a test key and isolated data.
Submit defense
Records your written defense and moves the dispute to under_defense. Attach evidence before or after — the defense can be supplemented while the dispute is alive.
- 422 when the dispute is already closed (won or lost).
| Field | Type | Required | Description |
|---|---|---|---|
| text | text | Yes | The defense argument. From 5 to 4000 characters. |
curl -X POST https://finance.zendry.co/api/public/v1/disputes/:id/defense \ -H "Authorization: Bearer gw_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "text": "Service delivered on 01/01, customer accepted in the app. Receipt attached."}'{ "id": "19ba...", "state": "under_defense"}Create your account to run this call in the sandbox, with a test key and isolated data.
Attach evidence
Uploads ONE evidence file (delivery receipt, acceptance, customer conversation). The body is multipart/form-data with the `file` field — the file itself, not base64. For several files, repeat the call.
- Example: curl -X POST .../disputes/{id}/evidence -H "Authorization: Bearer …" -F "file=@receipt.pdf"
- 413 above 15MB; 415 when the format is not one of the accepted ones; 422 when the dispute is already closed.
| Field | Type | Required | Description |
|---|---|---|---|
| file | file | Yes | Up to 15MB. PDF, image (PNG, JPEG, GIF, WEBP, HEIC), document (DOC, DOCX, ODT, RTF, TXT) or spreadsheet (XLS, XLSX, ODS, CSV). |
curl -X POST https://finance.zendry.co/api/public/v1/disputes/:id/evidence \ -H "Authorization: Bearer gw_your_key_here"{ "id": "19ba...", "name": "delivery-receipt.pdf", "size_bytes": 182044}Create your account to run this call in the sandbox, with a test key and isolated data.
Do not contest
Declares you will not defend this dispute. It proceeds to review and the outcome is announced by the dispute.closed webhook.
- Accepted only for disputes in open or under_defense (422 in other states).
curl -X POST https://finance.zendry.co/api/public/v1/disputes/:id/accept \ -H "Authorization: Bearer gw_your_key_here"{ "id": "19ba...", "state": "under_review"}Create your account to run this call in the sandbox, with a test key and isolated data.
Queries
Get balance
Returns the available balance of the authenticated account.
curl -X GET https://finance.zendry.co/api/public/v1/balance \ -H "Authorization: Bearer gw_your_key_here"{ "balance_cents": 84851, "currency": "BRL"}Create your account to run this call in the sandbox, with a test key and isolated data.
Statement
Lists account movements, newest first (up to 200 items).
- Fixed limit of 200 items per call — paginate by period with from/to.
- For real-time reconciliation prefer webhooks; the statement is the source for closing and audit.
| Field | Type | Required | Description |
|---|---|---|---|
| from | date (YYYY-MM-DD) | No | First day, Brasília timezone. |
| to | date (YYYY-MM-DD) | No | Last day, Brasília timezone. |
| type | text | No | Filters by the responses' "type" field (e.g. pagamento_confirmado). |
curl -X GET https://finance.zendry.co/api/public/v1/statement \ -H "Authorization: Bearer gw_your_key_here"{ "transactions": [ { "id": "9ac2...", "type": "pagamento_confirmado", "gross_cents": 15000, "fee_cents": 299, "net_cents": 14701, "end_to_end_id": "E18236120202601011204a1b2c3", "description": "Order #1234", "counterparty": "Maria Souza", "created_at": "2026-01-01T12:04:11Z" } ]}Create your account to run this call in the sandbox, with a test key and isolated data.
Webhook endpoints
List subscribable events
Catalog of the events an endpoint can subscribe to, with family and description. Use the exact name in the events field.
- Only events that already have an emitter are listed — the catalog grows without breaking existing subscriptions.
- "*" is not listed but is accepted in events: it subscribes to every event, including future ones.
curl -X GET https://finance.zendry.co/api/public/v1/webhook_events \ -H "Authorization: Bearer gw_your_key_here"{ "events": [ { "event": "pix.received", "family": "Pix", "description": "Pix charge paid" }, { "event": "pix.sent", "family": "Pix payout", "description": "Pix payout completed" }, { "event": "dispute.opened", "family": "Dispute", "description": "Dispute (MED/chargeback) opened" } ]}Create your account to run this call in the sandbox, with a test key and isolated data.
List endpoints
Every endpoint of the account, including the mirrors of the fixed channels (managed: true).
- managed: true is the mirror of a fixed channel (receiving, payout, dispute): URL and events change in the Integrations page; the API only reads, tests and lists deliveries.
curl -X GET https://finance.zendry.co/api/public/v1/webhook_endpoints \ -H "Authorization: Bearer gw_your_key_here"{ "endpoints": [ { "id": "e7a1...", "url": "https://yourstore.com/webhooks/payouts", "label": "Finance ERP", "events": ["pix.sent", "crypto.sent", "crypto.failed"], "active": true, "dialect": "en", "managed": false, "consecutive_failures": 0, "last_delivered_at": "2026-09-05T12:04:12Z", "last_failed_at": null, "created_at": "2026-09-05T11:00:00Z", "updated_at": "2026-09-05T11:00:00Z" } ]}Create your account to run this call in the sandbox, with a test key and isolated data.
Create endpoint
Registers a URL and the events it receives. The same event can go to several URLs; every delivery is signed with the account secret.
- Up to 10 endpoints per account (fixed-channel mirrors don't count) — 422 webhook_endpoint_limit_reached.
- 422 webhook_url_invalid for a URL outside the rule; 422 webhook_event_unknown for an event outside the catalog.
- Test the URL right after creating it (POST /webhook_endpoints/:id/test).
| Field | Type | Required | Description |
|---|---|---|---|
| url | text | Yes | HTTPS with a public host (no private IP, localhost or non-standard port). |
| events | list of text | Yes | Catalog names, or ["*"] for all. |
| label | text | No | Display name, up to 80 characters. |
| active | boolean | No | Default true. Inactive receives nothing. |
| dialect | en | pt | No | Default en. pt delivers the fields in the legacy format (only for accounts migrated from zendry.com). |
curl -X POST https://finance.zendry.co/api/public/v1/webhook_endpoints \ -H "Authorization: Bearer gw_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "url": "https://yourstore.com/webhooks/payouts", "label": "Finance ERP", "events": [ "pix.sent", "crypto.sent", "crypto.failed" ]}'{ "id": "e7a1...", "url": "https://yourstore.com/webhooks/payouts", "label": "Finance ERP", "events": ["pix.sent", "crypto.sent", "crypto.failed"], "active": true, "dialect": "en", "managed": false, "consecutive_failures": 0, "last_delivered_at": null, "last_failed_at": null, "created_at": "2026-09-05T11:00:00Z", "updated_at": "2026-09-05T11:00:00Z"}Create your account to run this call in the sandbox, with a test key and isolated data.
Get, update or delete endpoint
GET returns the endpoint; PATCH changes only the fields sent; DELETE removes it (204, no body).
- 404 webhook_endpoint_not_found if the id doesn't exist or belongs to another account.
- 409 webhook_endpoint_managed on PATCH/DELETE of a fixed-channel mirror — change it in the Integrations page.
- Consecutive failures show in consecutive_failures and last_failed_at; the endpoint is never disabled on its own — watch it and fix the URL.
| Field | Type | Required | Description |
|---|---|---|---|
| url | text | No | Same rule as on creation. |
| events | list of text | No | Replaces the whole list. |
| label | text | No | Up to 80 characters. |
| active | boolean | No | false pauses deliveries without deleting the endpoint. |
| dialect | en | pt | No | Format of the data fields. |
curl -X PATCH https://finance.zendry.co/api/public/v1/webhook_endpoints/:id \ -H "Authorization: Bearer gw_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "events": [ "pix.sent" ], "active": true}'{ "id": "e7a1...", "url": "https://yourstore.com/webhooks/payouts", "label": "Finance ERP", "events": ["pix.sent"], "active": true, "dialect": "en", "managed": false, "consecutive_failures": 0, "last_delivered_at": "2026-09-05T12:04:12Z", "last_failed_at": null, "created_at": "2026-09-05T11:00:00Z", "updated_at": "2026-09-05T12:30:00Z"}Create your account to run this call in the sandbox, with a test key and isolated data.
Test endpoint
Delivers a sample event right away, signed like a real delivery, with test: true in the envelope and the X-Zendry-Test header.
- A single attempt, no retries; the result is also kept in the delivery history.
- Skip deliveries with test: true in your business processing — the ids in data are fictitious.
| Field | Type | Required | Description |
|---|---|---|---|
| event | text | Yes | Catalog event whose sample will be sent. |
curl -X POST https://finance.zendry.co/api/public/v1/webhook_endpoints/:id/test \ -H "Authorization: Bearer gw_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "event": "pix.sent"}'{ "result": "delivered", "http_status": 200, "duration_ms": 184, "response": "ok", "error": null}Create your account to run this call in the sandbox, with a test key and isolated data.
Deliveries of an endpoint
Latest delivery attempts to the endpoint, newest first.
- message_id is the X-Zendry-Webhook-Id of that message: redeliveries and retries share the value.
- result: delivered, failed or no_destination.
| Field | Type | Required | Description |
|---|---|---|---|
| limit | integer | No | Query string. Default 50, maximum 200. |
| event | text | No | Query string. Filters by event. |
curl -X GET https://finance.zendry.co/api/public/v1/webhook_endpoints/:id/deliveries \ -H "Authorization: Bearer gw_your_key_here"{ "deliveries": [ { "id": "d41c...", "message_id": "b0d3c1e2-...", "event": "pix.sent", "source_event": "pix.sent", "result": "delivered", "http_status": 200, "duration_ms": 184, "error": null, "attempt": 1, "resent_from": null, "created_at": "2026-09-05T12:04:12Z" } ]}Create your account to run this call in the sandbox, with a test key and isolated data.
Webhooks
How they work
Configure the fixed channels in Integrations — receiving (charges, card, subscriptions), payout (pix.sent and other outflows) and dispute — or registerendpoints per event, choosing which events each URL receives. We send a JSON POST with the X-Zendry-Event, X-Zendry-Webhook-Id and X-Zendry-Signature: sha256=<hmac> headers.
{ "id": "b0d3c1e2-...", "event": "pix.received", "sent_at": "2026-01-01T12:04:12Z", "data": { ...event fields... }}{ "charge_id": "3f1c...", "external_id": "1234", "amount_cents": 15000, "description": "Order #1234", "end_to_end_id": "E1823612...", "environment": "producao", "state": "paid"}const crypto = require("node:crypto"); function isSignatureValid(rawBody, header, secret) { const expected = "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex"); return ( header?.length === expected.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(header)) );}// isSignatureValid(rawBody, req.headers["x-zendry-signature"], SECRET)- Respond 2xx within 10 seconds; process asynchronously if needed.
- On failure (network, timeout or status ≥ 400) we retry up to 3 times, with growing backoff.
- Every delivery and redelivery is logged in Integrations — from there you can resend a lost event.
- Handle deliveries idempotently by charge_id/payout_id: redeliveries of the same event can happen.
Endpoints per event
Beyond the fixed channels, register up to 10 URLs and pick the events for each — in the Integrations page or through the API (/api/public/v1/webhook_endpoints). The same event can go to several URLs; events: ["*"] subscribes to everything, including future events.
- Every delivery carries X-Zendry-Webhook-Id (same value as id in the envelope). Redeliveries repeat it — use it to deduplicate.
- The fixed channels show up in the list as mirrors (managed: true): URL and events are edited in the fixed fields; there you test them and follow their health.
- There is a single signing secret per account: the same HMAC applies to every endpoint.
- Consecutive failures stay visible (consecutive_failures, last_failed_at); nothing is disabled automatically.
Event catalog
- pix.receivedPix charge paid (receiving channel)
- pix.sentPix payout completed or failed (payout channel)
- boleto.paidBoleto cleared
- card.approvedTransaction approved (including after 3DS). The nsu field carries the receipt NSU
- card.declinedTransaction declined
- card.refundedTransaction refunded
- charge.expiredCharge expired unpaid
- charge.canceledCharge canceled
- dispute.openedDispute (chargeback/MED) opened — infraction webhook
- dispute.closedDispute closed — infraction webhook
- subscription.createdSubscription created
- subscription.charge_approvedRecurring charge approved
- subscription.charge_declinedRecurring charge declined
- subscription.retry_scheduledNew attempt scheduled
- subscription.retries_exhaustedAttempts exhausted
- subscription.canceledSubscription canceled
- subscription.completedSubscription completed
- crypto.sentCrypto payout completed
- crypto.failedCrypto payout failed
- intl.deposit.receivedInternational deposit received
- intl.payout.sentInternational payout completed
Sandbox
Test environment
Generate a test key on the Integrations page (prefix gw_test_). It uses the same base URL and the same routes, but operates an isolated environment: test balance, statement and charges stay apart from production and no real money moves — Pix is simulated internally.
- Coverage: full Pix, boleto and card (creation, query, simulated payment/authorization, refund) — with webhooks and test balance.
- Boleto: issues with test numbers (fake typeable line/barcode) and is paid through the same POST /charges/:id.
- Card: simulated without 3DS — any valid number approves; 4000 0000 0000 0002 declines; refunds are simulated too.
- Webhooks are real (same URL and HMAC signature), with environment: "sandbox" in the payload.
- Network identifiers (end_to_end_id, nsu, tx_hash) are null in the sandbox: nothing is made up.
- A test key cannot see production data, and vice versa.
- Test payouts complete instantly (pix.sent webhook). An amount ending in 99 cents (e.g. 10099) simulates failure — the amount returns to the balance.
Simulate payment (sandbox)
Marks the test charge (Pix OR boleto) as paid: credits the sandbox balance and fires the real webhook (pix.received or boleto.paid), HMAC-signed. Test-key only — in production it returns 403.
- Only works with a gw_test_ key and a sandbox charge (otherwise 403/404).
- Idempotent: an already-paid charge responds with already_paid: true.
- An expired charge cannot be paid (422).
curl -X POST https://finance.zendry.co/api/public/v1/charges/:id \ -H "Authorization: Bearer gw_your_key_here"{ "id": "3f1c...", "state": "paid", "paid_at": "2026-01-01T12:04:11Z"}Create your account to simulate payments in the sandbox with your test key.
Errors & limits
Standard envelope
Every error uses this shape. code is the stable contract — branch on it, never on the message, which is written for the human reading the log and may change. retryable tells you whether repeating the SAME request can succeed, and details shows up only when there is something to show (the invalid field, the limit and the amount you sent).
{ "error": "Amount is above the maximum for this operation", "code": "amount_above_maximum", "retryable": false}| code | HTTP | retryable | error (new key) |
|---|---|---|---|
| Authentication and access | |||
| missing_credentials | 401 | false | Credential missing |
| invalid_credentials | 401 | false | Invalid credential |
| ip_not_allowed | 403 | false | IP not allowed for this key |
| Invalid request | |||
| invalid_json | 400 | false | Request body is not valid JSON |
| invalid_payload | 422 | false | Invalid payload |
| sandbox_only | 422 | false | Only available in the sandbox environment |
| Limits and amounts | |||
| amount_below_minimum | 422 | false | Amount is below the minimum for this operation |
| amount_above_maximum | 422 | false | Amount is above the maximum for this operation |
| daily_limit_exceeded | 422 | false | Daily limit exceeded |
| Balance and blocks | |||
| insufficient_balance | 409 | false | Insufficient balance |
| Resource and state | |||
| charge_not_found | 404 | false | Charge not found |
| payout_not_found | 404 | false | Payout not found |
| charge_already_paid | 422 | false | Charge already paid |
| charge_expired | 422 | false | Charge expired |
| charge_finalized | 422 | false | Charge already finalized |
| transaction_not_found | 404 | false | Transaction not found |
| Payment | |||
| payment_method_not_enabled | 422 | false | This payment method is not enabled for this account |
| card_declined | 422 | false | The card payment was declined |
| Disputes and files | |||
| dispute_not_found | 404 | false | Dispute not found |
| dispute_closed | 422 | false | Dispute already closed |
| dispute_action_not_allowed | 422 | false | This dispute no longer accepts this action |
| file_missing | 422 | false | Send multipart/form-data with a `file` field |
| file_too_large | 413 | false | File is larger than 15MB |
| file_type_not_allowed | 415 | false | Send a PDF, image, document or spreadsheet |
| Webhooks | |||
| webhook_endpoint_not_found | 404 | false | Webhook endpoint not found |
| webhook_url_invalid | 422 | false | Webhook URL must use https and a public host |
| webhook_event_unknown | 422 | false | Unknown webhook event |
| webhook_endpoint_limit_reached | 422 | false | Maximum number of webhook endpoints reached |
| webhook_endpoint_managed | 409 | false | This endpoint mirrors a fixed channel; edit it in the integration settings |
| Our fault | |||
| service_unavailable | 503 | true | The operation could not be completed. Please try again in a few moments. |
| internal_error | 500 | true | The operation could not be completed. Please try again in a few moments. |
- Legacy keys keep the Portuguese message and the HTTP status they have today; `code`, `retryable` and `details` are added alongside, so nothing breaks for existing integrations.
- No internal detail reaches the response. An operation that could not be completed right now becomes `service_unavailable` (503); an unexpected failure on our side, `internal_error` (500). Both say "The operation could not be completed. Please try again in a few moments.", and the reason stays in our log.
- Before repeating a money-moving POST after a `retryable` error, read the resource with GET — the API does not accept a client idempotency key.
HTTP codes
| Field | Type | Required | Description |
|---|---|---|---|
| 400 | format | No | Body is not valid JSON. |
| 401 | auth | No | Missing, invalid or revoked credential. |
| 403 | access | No | IP outside the allowlist. |
| 404 | resource | No | Resource missing or owned by another account. |
| 409 | conflict | No | Insufficient balance on a payout, or an endpoint mirroring a fixed channel. |
| 413 | file | No | Dispute evidence larger than 15MB. |
| 415 | file | No | Dispute evidence in a format that is not accepted. |
| 422 | validation | No | Invalid payload, account limit or business rule. Carries details. |
| 500 | ours | No | Unexpected failure on our side; retryable: true. |
| 503 | unavailable | No | The operation could not be completed right now; try again shortly (retryable: true). |
{ "error": "Payload inválido", "details": [ { "path": ["amount_cents"], "message": "Number must be greater than or equal to 1" } ]}Card errors
Card routes use their own 422 format — retry tells whether the same request is worth retrying.
{ "error": "Meio de pagamento não habilitado para esta conta", "error_type": "INVALID_REQUEST", "error_code": "payment_method_not_enabled", "retry": false}| Field | Type | Required | Description |
|---|---|---|---|
| PAYMENT_DECLINED | retry: false | No | Payment declined; offer another card. |
| INVALID_REQUEST | retry: false | No | Invalid data, or card not enabled for the account (error_code payment_method_not_enabled). |
| PENDING_CHECK | retry: false | No | Payment under verification; wait for the webhook or check the GET before retrying. |
| TEMPORARY_ERROR | retry: true | No | Could not complete right now; try again shortly. |
| INTERNAL_ERROR | retry: true | No | Our failure; try again. |
Limits & good practice
- Each account has a minimum and a maximum per method (Pix, card, boleto and payout), and payouts also have a daily cap; USDT payouts have their own maximum and daily cap, in USDT. Outside the range the API rejects with amount_below_minimum, amount_above_maximum or daily_limit_exceeded. The response does not include the limit values: they are shown in the dashboard.
- Idempotency: the API does not accept a client idempotency key. On a timeout of a money-moving POST (payout, card), check the statement/GET before retrying.
- Payment confirmation is webhook-driven; if you re-check, limit the frequency (e.g. every 30s) — abusive traffic can be blocked by the firewall.