Wallet Payout
Pay PIX from a user's USDT, USDC, BRLA or BRS balance on Polygon, Base, Tron or Solana in a single API call.
Select a wallet
Pass walletId (the data.id returned by /api/wallet/create) to spend from that wallet, together with its protectedSymmetricKey and the platform walletPin. Also pass subAccountId for a customer wallet. Unknown, removed, foreign or incorrectly scoped wallets fail without falling back to the default. Omit walletId to retain default selection. In sandbox, explicit selection spends from the selected PIN-protected Safe on Base Sepolia; the PIX leg remains simulated. See Wallet Keys.
Overview
/api/wallet/payout triggers a PIX payout funded by the user's stablecoin balance on Polygon, Base, Tron or Solana. You make one POST and one GET — Hodle does the rest.
This is also the endpoint for paying a PIX BR Code (QR) from a balance the user already holds — pass the scanned code as qrCode. It is the only PIX off-ramp the platform offers for stablecoin balances.
If the BRS sits in a Solana wallet you control (for example a Privy embedded wallet) rather than in a wallet created by Hodle, see Payout from Your Own Wallet: Hodle prepares the debit, your user signs it, and Hodle pays the PIX.
asset is an optional funding preference. On Polygon and Base, Hodle prefers BRLA
because it avoids the more expensive USD-stablecoin conversion route, then falls back
to the requested/provider asset only when BRLA cannot cover the complete debit plus
fee:
| Network | Supported assets | Automatic order |
|---|---|---|
polygon | USDT, USDC, BRLA | BRLA → USDT → USDC |
base | USDC, BRLA, BRS | BRLA → USDC |
tron | USDT | USDT |
solana | USDT, USDC, BRS | USDT → USDC |
Fallback is balance-aware and happens before signing. When a candidate cannot cover the full payout debit plus its fee with
INSUFFICIENT_BALANCE, Hodle tries the next candidate. No intermediate USDC/USDT → BRLA swap is performed, and no partial debit is created. If every candidate is insufficient, the request returnserrorCode: INSUFFICIENT_BALANCE.
asset can be used to choose the direct provider fallback. For example, a Polygon
request with asset: "USDC" tries BRLA first and uses USDC directly if BRLA cannot
cover the payout. An explicit Solana USDC request uses USDC directly. Tron always
debits USDT for compatibility, regardless of asset.
BRLA and BRS are BRL-pegged stablecoins, so they convert 1:1 to the PIX amount (no FX). USDT/USDC are converted at the current BRL rate.
On solana the debit is an SPL transferChecked signed with the user's Solana key; Hodle's master wallet pays the network fee, so the user never needs SOL.
Tron is enabled per account. Tron payouts (
USDT-TRC20 only) must be enabled on the account in addition to API payouts — ask support. Without it the endpoint responds403fornetwork: "tron".
BRS is explicit-only and enabled per account.
BRSpayouts must be enabled on the account in addition to API payouts — ask support. Without it the endpoint responds403for"asset": "BRS".BRSis paid fromsolanaorbaseand is never picked by the automatic order: send"asset": "BRS". BRS onbaseis production-only; the sandbox answers400(BRS on base is only available in production). Minimum R$ 5,00 (value: 500). See BRS.
Know who you are paying before you pay.
POST /api/wallet/payout/beneficiaryresolves the owner of a PIX key — name, masked tax id, bank — and locks the price of the payout to it, returning aquoteIdyou send here. A PIX payout is irreversible, so confirming the payee first is the difference between a mistake caught and a mistake paid.
Use the selected wallet's
protectedSymmetricKeyreturned by/api/wallet/createorPOST /api/wallet/keys. Cache it perwalletIdand refresh after that wallet's protected key changes.Enabled per account. All three endpoints require API payouts enabled on the account. Without it the endpoints respond
403. Ask support to enable it.
Flow
POST /api/wallet/keys— fetch the selected wallet's key if it is not already cached from creation. Cache the response perwalletId.POST /api/wallet/payout— every payout. Resolves the asset and fee before signing, then returns202immediately with atransactionId.GET /api/wallet/payout/{transactionId}— poll every 5s untilCOMPLETEDorFAILED.
A typical run reaches COMPLETED in 30–90 seconds. A BRS payout can remain PENDING longer
when the paying balance is below the requested amount: Hodle redeems the BRS, waits for the
corresponding BRL credit, and only then releases the customer's PIX.
POST /api/wallet/payout
Request
curl --request POST \
--url https://api.hodle.com.br/api/wallet/payout \
--header "Authorization: Bearer $API_KEY" \
--header "Content-Type: application/json" \
--data '{
"value": 5000,
"network": "polygon",
"pixKey": "[email protected]",
"pixKeyType": "EMAIL",
"walletPin": "123456",
"protectedSymmetricKey": "AoofiKHyVRLvdrknnXzo..."
}'const res = await fetch('https://api.hodle.com.br/api/wallet/payout', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.HODLE_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
value: 5000,
network: 'polygon',
pixKey: '[email protected]',
pixKeyType: 'EMAIL',
walletPin: '123456',
protectedSymmetricKey: 'AoofiKHyVRLvdrknnXzo...',
}),
})
const data = await res.json()import os, requests
res = requests.post(
"https://api.hodle.com.br/api/wallet/payout",
headers={
"Authorization": f"Bearer {os.environ['HODLE_API_KEY']}",
"Content-Type": "application/json",
},
json={
"value": 5000,
"network": "polygon",
"pixKey": "[email protected]",
"pixKeyType": "EMAIL",
"walletPin": "123456",
"protectedSymmetricKey": "AoofiKHyVRLvdrknnXzo...",
},
)
data = res.json()To fund the payout with BRLA instead, add "asset": "BRLA" (works on both polygon and base):
curl --request POST \
--url https://api.hodle.com.br/api/wallet/payout \
--header "Authorization: Bearer $API_KEY" \
--header "Content-Type: application/json" \
--data '{
"value": 5000,
"network": "base",
"asset": "BRLA",
"pixKey": "[email protected]",
"pixKeyType": "EMAIL",
"walletPin": "123456",
"protectedSymmetricKey": "AoofiKHyVRLvdrknnXzo..."
}'To attribute the payout to one of your subaccounts, add subAccountId. Which wallet that
spends depends on your account setup — see What subAccountId does:
curl --request POST \
--url https://api.hodle.com.br/api/wallet/payout \
--header "Authorization: Bearer $API_KEY" \
--header "Content-Type: application/json" \
--data '{
"value": 5000,
"network": "polygon",
"pixKey": "[email protected]",
"pixKeyType": "EMAIL",
"walletPin": "123456",
"protectedSymmetricKey": "AoofiKHyVRLvdrknnXzo...",
"subAccountId": "b3f1c2d4-5e6a-4b7c-8d9e-0f1a2b3c4d5e"
}'The id is recorded on the payout itself, so Hodle can always trace it back to the customer
it was made for. It is not echoed on
GET /api/wallet/payout/{transactionId} — keep your
own externalId mapping if you need it on your side.
Parameters
| Field | Type | Required | Description |
|---|---|---|---|
value | integer | Yes | Amount in BRL cents. Min 10 (R$ 0.10). No fixed maximum — the ceiling is your per-account per-transaction limit (perTxLimitBrl, default R$ 3000). Ask Hodle to raise it. |
network | string | Yes | polygon, base, tron, or solana. |
asset | string | No | Funding preference. polygon: BRLA → USDT/USDC. base: BRLA → USDC. tron: USDT only. solana: omitted uses USDT → USDC; explicit USDT or USDC is used directly. BRS (solana or base) is explicit-only and feature-gated. |
pixKey | string | Yes (or qrCode) | PIX key of the recipient. Mutually exclusive with qrCode. |
pixKeyType | string | Yes (when using pixKey) | One of PHONE, CPF, EMAIL, RANDOM, CNPJ. |
qrCode | string | Yes (or pixKey) | PIX BR Code copy-paste. Mutually exclusive with pixKey. |
walletId | string | No | Source wallet ID returned as data.id by /api/wallet/create. Must match the owner, subaccount, network and supplied protected key. Omit for default selection. |
walletPin | string | Yes | The wallet PIN the user established on the Hodle platform (see Wallet PIN model). Combined server-side with email + protectedSymmetricKey to unlock the wallet transiently for signing; never stored. Required for polygon, base, tron, and solana. |
protectedSymmetricKey | string | Yes | The value returned by POST /api/wallet/keys. |
externalId | string | No | Your own idempotency key. A retry with the same value returns the original payout (alreadyProcessed: true) instead of paying twice, however far apart the attempts are. Without it duplicates are only caught heuristically (same user, value and key inside a short window). |
subAccountId | string | No | The subaccount this payout belongs to (scoped to your API key — an id you do not own is refused with 404). Defaults to the main account. What it does depends on how your account is set up — see What subAccountId does. |
quoteId | string | No | A quote from POST /api/wallet/payout/beneficiary. The fee, FX rate, asset and destination come from it instead of being priced again, so the payout settles at the price the user approved. pixKey/pixKeyType may then be omitted; anything you do send must agree with the quote. Requires walletPin, and cannot be combined with qrCode or payoutIntentId. |
taxId | string | No | CPF of the beneficiary receiving the PIX, digits only. Omit when paying the account holder. A value that differs from the subaccount's KYC taxId is a third-party operation and needs that enabled — see Third-party operations. |
What subAccountId does
Without an explicit walletId, what the field selects depends on how your integration is set up. There are two setups,
and Hodle configures which one your account is on:
Your customers ramp through the partner. The payout spends the subaccount's wallet
and the on-chain debit lands on that subaccount's address at the regulated partner, which
is what keeps a customer's payout off your own balance. walletPin and
protectedSymmetricKey must then be the subaccount's.
Your customers are onboarded through Hodle's partner KYC, but your ramp settles
elsewhere. Your subaccounts hold no funds and have no address to debit, so the field does
not select one. The payout spends your own wallet and settles on your own rail —
walletPin and protectedSymmetricKey are yours — and the id is recorded on the
transaction, so you can still tell which of your customers each payout was for.
Passing walletId together with subAccountId selects that customer wallet explicitly, including in the second setup. The payout still settles through the owner's configured rail. Use the selected wallet's protected key and its owner's PIN.
Ownership is checked either way: a subaccount id you do not own is refused with 404. The
same setup governs POST /api/deposit/asset. Ask support if you are
not sure which one your account is on.
Response
{
"success": true,
"transactionId": "65f1a8...",
"status": "PROCESSING",
"stableAmount": "9.31",
"valueInBrl": "50.00",
"fee": "1.55",
"network": "polygon",
"asset": "USDT",
"jobId": "1"
}stableAmount is the amount of the selected asset debited from the user. For BRLA
and BRS it equals value + fee in BRL (1:1); for USDT/USDC it is the BRL total
converted at the current rate. The response's asset is the asset that actually
passed the balance check and was signed.
The on-chain transfer is not yet final at this point. Use the GET endpoint to wait for confirmation.
Fees
A payout sent with a quoteId is charged the fee the quote named — it is not priced again.
The rest of this section describes an unquoted payout.
The final fee is calculated again for each candidate before its balance check.
Unless the account has custom pricing, the default base fee on stablecoin networks is
R$ 1.50 fixed + 2.5% of value; affiliate discounts are applied afterward. Accounts
can have a fixed or percentage fee per asset (ask support), with its
configured minimum floor. The selected transaction stores the fee for the asset that
was actually debited.
With default pricing and no affiliate discount, value = 5000 (R$ 50.00) produces a
fee of R$ 2.75.
Errors
{ "success": false, "error": "WALLET_PAYOUT_API feature flag is not enabled for this user" }{ "success": false, "error": "Third party operations does not enabled to your company, call with support" }{ "success": false, "error": "Valor excede o limite por transação de ... R$ 3000,00", "errorCode": "PER_TX_LIMIT_EXCEEDED" }{ "success": false, "error": "Invalid PIN" }{ "success": false, "error": "Too many invalid PIN attempts. Locked for 24h." }{ "success": false, "error": "Failed to prepare full UserOp: ... ERC20: transfer amount exceeds balance ..." }{
"success": false,
"error": "Validation failed",
"details": [{ "field": "pixKeyType", "message": "pixKeyType is required when pixKey is provided" }]
}{ "success": false, "error": "USDT is not supported on base" }{
"success": false,
"error": "Insufficient balance for the payout",
"errorCode": "INSUFFICIENT_BALANCE"
}{ "success": false, "error": "Wait 28 seconds before retrying" }{ "success": false, "error": "PIX payments are temporarily disabled" }GET /api/wallet/payout/{transactionId}
Poll for the final state.
Request
curl --request GET \
--url "https://api.hodle.com.br/api/wallet/payout/65f1a8..." \
--header "Authorization: Bearer $API_KEY"const res = await fetch(
`https://api.hodle.com.br/api/wallet/payout/${transactionId}`,
{ headers: { Authorization: `Bearer ${process.env.HODLE_API_KEY}` } },
)
const data = await res.json()import os, requests
res = requests.get(
f"https://api.hodle.com.br/api/wallet/payout/{transaction_id}",
headers={"Authorization": f"Bearer {os.environ['HODLE_API_KEY']}"},
)
data = res.json()Response
{
"success": true,
"data": {
"transactionId": "65f1a8...",
"status": "PENDING",
"network": "polygon",
"txHash": null,
"endToEndId": null,
"valueInBrl": "50.00",
"fee": "1.55",
"pixKey": "[email protected]",
"qrCode": null,
"receipt": null,
"creditParty": null,
"receiptUrl": null,
"receiptComplete": null,
"failureCode": null,
"failureReason": null,
"createdAt": "2026-04-28T23:32:10.000Z",
"updatedAt": "2026-04-28T23:32:10.000Z"
}
}{
"success": true,
"data": {
"transactionId": "65f1a8...",
"status": "COMPLETED",
"network": "polygon",
"txHash": "0xeafe9c4985963a7a7d6e49f763cca5c6006693031402d46c0da2fced4519fe03",
"endToEndId": "E12345678202604281432abcdef123456",
"valueInBrl": "50.00",
"fee": "1.55",
"failureCode": null,
"failureReason": null,
"receipt": {
"endToEndId": "E12345678202604281432abcdef123456",
"paidAt": "2026-04-28T23:33:41.708Z",
"amountInBrl": "50.00",
"payerIspb": "12345678",
"brCode": null,
"receiver": {
"name": "MARIA SOUZA",
"taxId": "***.241.413-**",
"pixKey": "[email protected]",
"pixKeyType": "EMAIL",
"bankName": "Example Bank",
"bankCode": "001",
"ispb": "54811417",
"branch": "0001",
"account": "****5716",
"accountType": "TRAN"
}
},
"creditParty": {
"name": "MARIA SOUZA",
"taxId": "***.241.413-**",
"pixKey": "[email protected]",
"pixKeyType": "EMAIL",
"bankName": "Example Bank",
"bankCode": "001",
"ispb": "54811417",
"branch": "0001",
"account": "****5716",
"accountType": "TRAN"
},
"receiptUrl": "https://receipts.hodle.com.br/receipts/65f1a8b2c3d4e5f6a7b8c9d0.pdf",
"receiptComplete": true,
"createdAt": "2026-04-28T23:32:10.000Z",
"updatedAt": "2026-04-28T23:33:42.000Z"
}
}receipt follows the PAYOUT_SUCCESSFUL receipt format: the payee, institution, settlement time and end-to-end id. The receipt worker can enrich it after the settlement webhook with the recipient's ISPB, bank code, branch, account type, PIX key type and original BR Code. Missing details stay null; optional fields may be absent in legacy receipts. Bank identifiers preserve leading zeros. CPF and account numbers in the JSON retain the documented masking format.
creditParty is the normalized receipt.receiver block promoted to the response for direct consumption. It is not the raw provider payload. Only the documented fields are returned, CPF and account remain masked, and bank identifiers remain strings. The field is null until receipt enrichment persists a valid recipient block; legacy receipts without one also return null. The response shape is available in sandbox, where simulations without a persisted receipt leave it null. This keeps the public contract identical across settlement rails.
receiptUrl downloads the generated PDF. receiptComplete is true once the worker obtained the required details, false when details are still missing, or null for an unprocessed or legacy receipt. The worker retries incomplete receipts within a bounded retry window; false can remain after retries are exhausted. A payout may already be COMPLETED while its receipt is being prepared. Subsequent GET requests return the latest persisted receipt without starting another payment or generating a PDF in the request.
| Field | Type | Meaning |
|---|---|---|
receipt | object | null | Latest persisted receipt. Its fields are listed in Receipt detail. |
creditParty | object | null | Normalized, masked credited-party details from receipt.receiver; null until valid recipient details are persisted. |
receiptUrl | string | null | PDF download URL. An incomplete PDF can be replaced with more details at the same URL. |
receiptComplete | boolean | null | Receipt enrichment state, independent of the payout's settlement status. |
Completeness requires the end-to-end id, recipient name and tax id, bank name
and ISPB, branch, account and account type. Optional bank code, PIX key
type and BR Code can still be null when receiptComplete is true. For QR
payouts, receipt.brCode contains the original copia e cola when available;
qrCode remains the code stored on the transaction. The PDF also includes the
available BR Code.
Use your Hodle API key in Authorization: Bearer .... The transaction must belong to that key's user and the account must have API payouts enabled (ask support). Another user's transaction returns 404.
{
"success": true,
"data": {
"transactionId": "65f1a8...",
"status": "REFUNDED",
"txHash": "0xeafe9c...",
"failureCode": "PAYOUT_FAILED",
"failureReason": "QR Code inválido"
}
}failureReason carries the reason the recipient side gave when there is one you can act
on, such as an invalid QR Code or Pix key. Otherwise it is the generic message
Não foi possível processar o pagamento no momento. Tente novamente mais tarde.
It never names a provider.
Status values
| Status | Meaning |
|---|---|
PENDING | Payout in progress. Keep polling. |
COMPLETED | On-chain transfer mined and PIX confirmed. |
FAILED | The PIX did not complete. failureCode and failureReason say why. |
REFUNDED | The PIX did not complete and the debited value was returned to the wallet. Final. failureCode and failureReason say why. |
Funding requirements
The user's wallet must have enough of a candidate asset (USDT, USDC, BRLA, or BRS)
to cover value + fee. Hodle checks each candidate against the complete debit before
signing. If a candidate is insufficient, the next eligible asset is tried; if every
candidate is insufficient, the call returns 400 and no funds are moved.
The balance is checked against each asset separately, not the wallet as a whole. There is no automatic swap between stablecoins. The eventual transaction records the asset, fee and FX rate selected by the successful candidate. If the on-chain debit is confirmed but the PIX leg later fails, the refund uses that same asset and network.
For BRS, an insufficient BRL balance on the paying side is not a terminal PIX failure.
The BRS already received by Hodle is redeemed, the transaction stays PENDING, and the
payout resumes as soon as the BRL is credited. This typically takes 15 to 60 minutes.
No candidate asset can cover the payout: required value plus fee exceeds the
available balance for every eligible asset.Any supported asset sent directly to the wallet's network address funds a payout — there is no separate deposit step to register or settle it, for BRLA or for anything else. Balances are read from chain, so a confirmed transfer is spendable as soon as wallet/get reflects it (pass sync: true to force a fresh read).
Gas is sponsored by Hodle. The user does not need MATIC or ETH.