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.

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:

NetworkSupported assetsAutomatic order
polygonUSDT, USDC, BRLABRLAUSDTUSDC
baseUSDC, BRLABRLAUSDC
tronUSDTUSDT
solanaUSDT, USDC, BRSUSDTUSDC

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 returns errorCode: 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 gated by its own flag. Tron payouts (USDT-TRC20 only) require the TRON_PAYOUT per-user feature flag in addition to WALLET_PAYOUT_API. Without it the endpoint responds 403 for network: "tron".

BRS is gated by the Nora provider flag. BRS payouts require the NORA_RAIL per-user feature flag in addition to WALLET_PAYOUT_API. Without it the endpoint responds 403 for "asset": "BRS". BRS exists only on solana — see BRS.

Know who you are paying before you pay. POST /api/wallet/payout/beneficiary resolves the owner of a PIX key — name, masked tax id, bank — and locks the price of the payout to it, returning a quoteId you 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 protectedSymmetricKey returned by /api/wallet/create or POST /api/wallet/keys. Cache it per walletId and refresh after that wallet's protected key changes.

Gated by feature flag. All three endpoints require the WALLET_PAYOUT_API per-user feature flag. Without it the endpoints respond 403. Contact Hodle to enable it.

Flow

  1. POST /api/wallet/keys — fetch the selected wallet's key if it is not already cached from creation. Cache the response per walletId.
  2. POST /api/wallet/payout — every payout. Resolves the asset and fee before signing, then returns 202 immediately with a transactionId.
  3. GET /api/wallet/payout/{transactionId} — poll every 5s until COMPLETED or FAILED.

A typical run reaches COMPLETED in 30–90 seconds. A BRS payout can remain PENDING longer when Hodle's operating balance is below the requested amount: Hodle burns the BRS with Nora, 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": "recipient@example.com",
    "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: 'recipient@example.com',
    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": "recipient@example.com",
        "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": "recipient@example.com",
    "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": "recipient@example.com",
    "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

FieldTypeRequiredDescription
valueintegerYesAmount 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.
networkstringYespolygon, base, tron, or solana.
assetstringNoFunding preference. polygon: BRLAUSDT/USDC. base: BRLAUSDC. tron: USDT only. solana: omitted uses USDTUSDC; explicit USDT or USDC is used directly. BRS is explicit-only and feature-gated.
pixKeystringYes (or qrCode)PIX key of the recipient. Mutually exclusive with qrCode.
pixKeyTypestringYes (when using pixKey)One of PHONE, CPF, EMAIL, RANDOM, CNPJ.
qrCodestringYes (or pixKey)PIX BR Code copy-paste. Mutually exclusive with pixKey.
walletIdstringNoSource wallet ID returned as data.id by /api/wallet/create. Must match the owner, subaccount, network and supplied protected key. Omit for default selection.
walletPinstringYesThe 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.
protectedSymmetricKeystringYesThe value returned by POST /api/wallet/keys.
externalIdstringNoYour 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).
subAccountIdstringNoThe 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.
quoteIdstringNoA 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.
taxIdstringNoCPF 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

202 Accepted
{
  "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 with the PAYOUT_ASSET_FEE flag can have a fixed or percentage fee per asset, 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

403 — feature flag disabled
{ "success": false, "error": "WALLET_PAYOUT_API feature flag is not enabled for this user" }
403 — third-party payout not enabled
{ "success": false, "error": "Third party operations does not enabled to your company, call with support" }
403 — over the per-transaction limit
{ "success": false, "error": "Valor excede o limite por transação de ... R$ 3000,00", "errorCode": "PER_TX_LIMIT_EXCEEDED" }
400 — invalid PIN
{ "success": false, "error": "Invalid PIN" }
400 — locked after 3 wrong PINs
{ "success": false, "error": "Too many invalid PIN attempts. Locked for 24h." }
400 — insufficient stable balance
{ "success": false, "error": "Failed to prepare full UserOp: ... ERC20: transfer amount exceeds balance ..." }
400 — pixKeyType missing
{
  "success": false,
  "error": "Validation failed",
  "details": [{ "field": "pixKeyType", "message": "pixKeyType is required when pixKey is provided" }]
}
400 — asset not supported on network
{ "success": false, "error": "USDT is not supported on base" }
400 — no candidate asset can cover the payout
{
  "success": false,
  "error": "Insufficient balance for the payout",
  "errorCode": "INSUFFICIENT_BALANCE"
}
429 — rate limit
{ "success": false, "error": "Wait 28 seconds before retrying" }
503 — globally disabled
{ "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

status: PENDING
{
  "success": true,
  "data": {
    "transactionId": "65f1a8...",
    "status": "PENDING",
    "network": "polygon",
    "txHash": null,
    "endToEndId": null,
    "valueInBrl": "50.00",
    "fee": "1.55",
    "pixKey": "recipient@example.com",
    "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"
  }
}
status: COMPLETED
{
  "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",
      "rail": "WOOVI",
      "amountInBrl": "50.00",
      "payerIspb": "12345678",
      "brCode": null,
      "receiver": {
        "name": "MARIA SOUZA",
        "taxId": "***.241.413-**",
        "pixKey": "recipient@example.com",
        "pixKeyType": "EMAIL",
        "bankName": "Example Bank",
        "bankCode": "001",
        "ispb": "54811417",
        "branch": "0001",
        "account": "****5716",
        "accountType": "TRAN"
      }
    },
    "creditParty": {
      "name": "MARIA SOUZA",
      "taxId": "***.241.413-**",
      "pixKey": "recipient@example.com",
      "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.

FieldTypeMeaning
receiptobject | nullLatest persisted receipt. Its fields are listed in Receipt detail.
creditPartyobject | nullNormalized, masked credited-party details from receipt.receiver; null until valid recipient details are persisted.
receiptUrlstring | nullPDF download URL. An incomplete PDF can be replaced with more details at the same URL.
receiptCompleteboolean | nullReceipt enrichment state, independent of the payout's settlement status.

For WOOVI, 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 WALLET_PAYOUT_API enabled. Another user's transaction returns 404.

status: FAILED
{
  "success": true,
  "data": {
    "transactionId": "65f1a8...",
    "status": "FAILED",
    "txHash": "0xeafe9c...",
    "failureCode": "PAYOUT_FAILED",
    "failureReason": "Não foi possível processar o pagamento no momento. Tente novamente mais tarde."
  }
}

Status values

StatusMeaning
PENDINGPayout in progress. Keep polling.
COMPLETEDOn-chain transfer mined and PIX confirmed.
FAILEDSomething went wrong. failureCode and failureReason are stable, provider-agnostic failure details.
REFUNDEDReserved (not produced by this endpoint today).

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, insufficient BRL in Hodle's operating account is not a terminal PIX failure. The BRS already received by Hodle is burned through Nora, the transaction stays PENDING, and the payout resumes after Nora's BRL reaches Hodle. This may take roughly 18 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.