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 400 there. 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

  1. POST /api/wallet/payout/external/prepare (your backend): send the PIX key, the value and the wallet address. Hodle answers with the price, a payoutIntentId and an unsigned serializedTransaction.
  2. Sign (your app): the user approves the transaction in their wallet. Sign it; do not send it to the network.
  3. POST /api/wallet/payout/external/submit (your backend): send the signed transaction and the payoutIntentId. Hodle answers 202 with a transactionId.
  4. 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()
FieldTypeRequiredDescription
networkstringYessolana.
assetstringYesBRS.
valueintegerYesBRL cents the PIX pays. Minimum 500.
pixKeystringYesBeneficiary PIX key.
pixKeyTypestringYesCPF, CNPJ, EMAIL, PHONE or RANDOM.
payerAddressstringYesSolana address of the wallet that holds the BRS and signs.
externalIdstringNoYour idempotency key. The same key never pays twice.
taxIdstringNoBeneficiary CPF/CNPJ, for accounts restricted to paying themselves.
subAccountIdstringNoSub-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

StatusCallWhen
400bothValidation failed (details[] lists the fields), or the request reached the sandbox.
400preparevalue below R$ 5,00; INVALID_PAYER (not a wallet address, or a Hodle wallet); INSUFFICIENT_BALANCE.
400submitSigned transaction differs from the prepared one, or the payer signature is missing or invalid. The intent is kept.
401bothMissing or invalid API key.
403bothFeature not enabled, account blocked, limit exceeded or destination blocked.
404bothpayoutIntentId unknown or expired (submit), or a subAccountId you do not hold.
409preparepricingPending: true; repeat the request.
409submitPAYOUT_IN_PROGRESS: this order, or another submit of your account, is in flight; retry.
410submitPAYOUT_INTENT_EXPIRED: the transaction is about to expire on Solana; prepare again.
429prepareRate limited.
502preparePricing could not read the wallet's history; retry.
503bothPIX 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.