Payout from Your Own Wallet
Pay a PIX from BRS held in a Solana wallet you control, such as a Privy embedded wallet. Hodle prepares the debit, your user signs it, Hodle pays the PIX.
Disponível só em produção. This flow does not run in the sandbox: both calls answer
400there. Test it in production with small values (minimum R$ 5,00).
Overview
Use this flow when your users' BRS sits in a Solana wallet you create and control, for example an embedded wallet from Privy. Hodle never holds the key: your user signs one transfer, and Hodle pays the PIX.
If the BRS sits in a wallet created through /api/wallet/keys, use
POST /api/wallet/payout with the walletPin instead.
What the user signs. One Solana transaction that moves value + fee BRS from their
wallet to Hodle. Nothing else: the signature gives Hodle no other access to the wallet.
The user needs no SOL. Hodle is the fee payer of the transaction and adds its own signature when you submit it.
Flow
POST /api/wallet/payout/external/prepare(your backend): send the PIX key, the value and the wallet address. Hodle answers with the price, apayoutIntentIdand an unsignedserializedTransaction.- Sign (your app): the user approves the transaction in their wallet. Sign it; do not send it to the network.
POST /api/wallet/payout/external/submit(your backend): send the signed transaction and thepayoutIntentId. Hodle answers202with atransactionId.- Hodle broadcasts the transfer, waits until it is finalized on Solana, and only then
pays the PIX. Follow it with
GET /api/wallet/payout/{transactionId}or the payout webhooks.
The intent expires 60 seconds after prepare, because the transaction carries a
recent Solana blockhash. If the user takes longer, call prepare again.
Your API key stays on your backend. The app only receives serializedTransaction and
returns the signed bytes.
Fee
The fee is 0.00 while the payout spends BRS Hodle delivered to that wallet. BRS that
reached the wallet from somewhere else pays the external-origin fee. feeReason in the
prepare response says which one applied, and amount is value + fee: the BRS that
leaves the wallet. The PIX pays value.
Prepare
curl --request POST \
--url https://api.hodle.com.br/api/wallet/payout/external/prepare \
--header "Authorization: Bearer $API_KEY" \
--header "Content-Type: application/json" \
--data '{
"network": "solana",
"asset": "BRS",
"value": 500,
"pixKey": "[email protected]",
"pixKeyType": "EMAIL",
"payerAddress": "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU",
"externalId": "order-1234"
}'const res = await fetch('https://api.hodle.com.br/api/wallet/payout/external/prepare', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.HODLE_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
network: 'solana',
asset: 'BRS',
value: 500,
pixKey: '[email protected]',
pixKeyType: 'EMAIL',
payerAddress: '7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU',
externalId: 'order-1234',
}),
})
const prepared = await res.json()| Field | Type | Required | Description |
|---|---|---|---|
network | string | Yes | solana. |
asset | string | Yes | BRS. |
value | integer | Yes | BRL cents the PIX pays. Minimum 500. |
pixKey | string | Yes | Beneficiary PIX key. |
pixKeyType | string | Yes | CPF, CNPJ, EMAIL, PHONE or RANDOM. |
payerAddress | string | Yes | Solana address of the wallet that holds the BRS and signs. |
externalId | string | No | Your idempotency key. The same key never pays twice. |
taxId | string | No | Beneficiary CPF/CNPJ, for accounts restricted to paying themselves. |
subAccountId | string | No | Sub-account the payout is attributed to. |
{
"success": true,
"payoutIntentId": "3f6c2a8e-5b1d-4d7e-9a0c-1e2f3a4b5c6d",
"serializedTransaction": "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA...",
"network": "solana",
"asset": "BRS",
"amount": "5.00",
"valueInBrl": "5.00",
"fee": "0.00",
"feeReason": "INTERNAL_COVERED",
"expiresAt": "2026-10-02T14:35:00.000Z"
}Pricing reads the wallet's BRS history. The call waits up to about 5 seconds for it; if
that is not enough, it answers 409 with pricingPending: true. Repeat the same request.
Sign with Privy
In the app (React)
serializedTransaction is base64. Decode it, sign it with the user's embedded wallet, and
send the result back to your backend as base64.
import { useSignTransaction, useWallets } from '@privy-io/react-auth/solana'
const { wallets } = useWallets()
const { signTransaction } = useSignTransaction()
const wallet = wallets[0]
const prepared = await backend.preparePayout({
pixKey,
pixKeyType,
value,
payerAddress: wallet.address,
})
const { signedTransaction } = await signTransaction({
transaction: Buffer.from(prepared.serializedTransaction, 'base64'),
wallet,
})
await backend.submitPayout({
payoutIntentId: prepared.payoutIntentId,
signedTransaction: Buffer.from(signedTransaction).toString('base64'),
})Use signTransaction, not signAndSendTransaction: the transaction still needs Hodle's
fee-payer signature, so the wallet cannot send it on its own.
On your server (Privy server wallets)
If your backend signs with Privy server wallets and policies, call Privy's wallet RPC with
method: "signTransaction", params.transaction set to serializedTransaction and
params.encoding set to "base64". The response carries the signed transaction in
signed_transaction, already in base64: pass it as signedTransaction to submit. See
Privy's documentation for the authorization headers and SDK helpers.
Submit
curl --request POST \
--url https://api.hodle.com.br/api/wallet/payout/external/submit \
--header "Authorization: Bearer $API_KEY" \
--header "Content-Type: application/json" \
--data '{
"payoutIntentId": "3f6c2a8e-5b1d-4d7e-9a0c-1e2f3a4b5c6d",
"signedTransaction": "AbcD...base64..."
}'{
"success": true,
"transactionId": "6a878236a83b0a4e8cc8c40b",
"externalId": "order-1234",
"status": "PROCESSING",
"stableAmount": "5.00",
"valueInBrl": "5.00",
"fee": "0.00",
"network": "solana",
"asset": "BRS"
}Hodle accepts only the exact transaction it prepared. Changing any byte (amount,
destination, an extra instruction) answers 400, and so does a missing or invalid
signature from payerAddress. A rejected submit keeps the intent, so the user can sign
again before it expires.
Submitting the same payoutIntentId twice answers 200 with alreadyProcessed: true and
the same transactionId: only one payout is ever created.
If the PIX fails after the BRS left the wallet, the BRS is returned to payerAddress.
Idempotency
Each payoutIntentId becomes at most one payout. Send externalId to make the whole
order idempotent: a prepare for an externalId that already paid answers 200 with
alreadyProcessed: true and that payout's transactionId instead of a new transaction,
so check alreadyProcessed before asking the user to sign:
{
"success": true,
"alreadyProcessed": true,
"transactionId": "6a878236a83b0a4e8cc8c40b",
"status": "PENDING",
"txHash": null
}Two different wallets paying the same value to the same PIX key are two payouts; only
externalId or payoutIntentId deduplicates.
Errors
| Status | Call | When |
|---|---|---|
400 | both | Validation failed (details[] lists the fields), or the request reached the sandbox. |
400 | prepare | value below R$ 5,00; INVALID_PAYER (not a wallet address, or a Hodle wallet); INSUFFICIENT_BALANCE. |
400 | submit | Signed transaction differs from the prepared one, or the payer signature is missing or invalid. The intent is kept. |
401 | both | Missing or invalid API key. |
403 | both | Feature not enabled, account blocked, limit exceeded or destination blocked. |
404 | both | payoutIntentId unknown or expired (submit), or a subAccountId you do not hold. |
409 | prepare | pricingPending: true; repeat the request. |
409 | submit | PAYOUT_IN_PROGRESS: this order, or another submit of your account, is in flight; retry. |
410 | submit | PAYOUT_INTENT_EXPIRED: the transaction is about to expire on Solana; prepare again. |
429 | prepare | Rate limited. |
502 | prepare | Pricing could not read the wallet's history; retry. |
503 | both | PIX payouts temporarily disabled, or BALANCE_UNAVAILABLE (the wallet balance could not be read; retry). |
Error bodies are { "success": false, "error": "...", "errorCode"?: "..." }. Some
error messages are in Portuguese; branch on errorCode and the status.
Requires the WALLET_PAYOUT_API permission and BRS payouts enabled on your account.
Ask support to enable them.