Sandbox
Test the Hodle API against the BRLH and USDH test stables on Base Sepolia, with no real money.
The sandbox lets you integrate without moving real funds. It runs on Base Sepolia testnet against Avenia's own sandbox. Sandbox data is fully isolated from production.
No real money moves, and no route changes. The sandbox has no endpoints of its own: you call the same paths you will call in production, on the sandbox host, with a sandbox key. Point the same client at
https://api.hodle.com.brwith ahodle_live_key and it is the production integration — nothing to rewrite.
Base URL
https://sandbox-api.hodle.com.brGet a sandbox key
- Log into the Hodle admin.
- Open API Keys and create a new key with environment Sandbox.
- Copy the key. Sandbox keys are prefixed
hodle_test_; production keys arehodle_live_.
Authenticate every request with Authorization: Bearer hodle_test_.... A sandbox key only
works against the sandbox base URL; a production key is rejected (401) on sandbox routes.
Sandbox keys are stored in the isolated sandbox data plane and HMAC'd with a shared
sandbox secret, so a key created in the admin works against https://sandbox-api.hodle.com.br.
Keys created before this wiring must be recreated — an older sandbox key returns
401 Invalid API key or wrong environment on every route. If a freshly created hodle_test_
key still 401s, ask support to confirm it landed in the sandbox data plane before debugging
your client.
Three things have to agree for a request to be served as sandbox, and all three are checked on
every call: the host it arrived on, the key prefix, and the data plane the
application record lives in. A hodle_live_ key on the sandbox host is rejected, and so is a
hodle_test_ key on the production host — there is no configuration in your client that can
cross the planes by accident.
What the sandbox covers
Every endpoint below is the production path running the production handler. Only the upstream changes: Avenia's calls go to Avenia's own sandbox, and anything on-chain settles on Base Sepolia. There is no mirrored copy of a handler that could drift from the real one, which is why the shapes and the errors are the ones you will get in production.
| Surface | Sandbox host | Notes |
|---|---|---|
/api/subaccount (create, list, get) | Mirrored | Creates a real sub-account in the Avenia sandbox. |
/api/kyc, /api/kyc/{attemptId}, /api/kyc/document | Mirrored | Real Avenia sandbox attempts — see Simulating a KYC outcome. |
/api/kyb, /api/kyb/proof-of-address, /api/kyb/proof-of-financial-capacity | Mirrored | Including the GET attempt polls. |
/api/quote | Mirrored | Priced off the Avenia sandbox book, not the live one. |
/api/wallet/create | Mirrored | Creates a real Safe on Base Sepolia. The response carries chainId: 84532 — read it, not network: the wallet keeps the network you asked for, but in sandbox it is materialised on Base Sepolia, which is the only chain the sandbox settles on. |
/api/wallet, /api/wallet/get, /api/wallet/keys | Mirrored | Wallet is provisioned on first use — see Wallets in sandbox. GET /api/wallet also reports your BRLH/USDH test wallet — see Balances. |
/api/wallet/list | Mirrored | Adds a sandboxWallet object with your BRLH/USDH balances. |
/api/wallet/payout | Mirrored | Debit settles on-chain on Base Sepolia; the PIX leg is simulated. Response carries X-Hodle-Sandbox: simulated. With asset: "BRLH" | "USDH" it debits your test wallet — see Payout. |
/api/wallet/transfer | Mirrored | Real Base Sepolia transfer to the address you name. With asset: "BRLH" | "USDH" it spends your test wallet balance — see Transfer. |
/api/wallet/swap | Mirrored | BRLH ↔ USDH at a fixed rate, settled on Base Sepolia — see Swap. Any other pair: SANDBOX_SWAP_PAIR_UNSUPPORTED. |
/api/wallet/swap/internal | 501 | No sandbox settlement. |
/api/deposit/asset | Mirrored | Unpayable BR Code in, real test token out. Accepts asset: "BRLH" | "USDH" — see On-ramp. Settle it with the webhook. |
/api/lightning/invoice | Mirrored | Returns a bolt11-shaped string that is not payable — see below. |
/avenia/webhook | Mirrored | The settlement trigger — see Settling a payment. |
/api/withdraw/pix | 501 | Deprecated; no sandbox settlement. |
Settlement tokens
A sandbox payout moves a real test token in a real Base Sepolia transaction, so you get a hash you can open on BaseScan. Only the PIX leg is simulated — the Brazilian payment system has no testnet.
| You send | Sandbox settles in | Decimals | Mirrors |
|---|---|---|---|
asset: "BRLA" | BRLH 0x4ec5f42ee07d5d1cfe431ec7059df4df556d44a8 | 18 | BRLA, BRL-pegged 1:1 |
asset: "USDC" or "USDT" | USDH 0x1c7af3c850c1d1d8d97d40db0acdd0195c6323f7 | 6 | USDC/USDT, converted at the FX rate |
Decimals and the peg match production on purpose: amount maths that is right here is right there. Both tokens are worthless, mint is permissionless, and your sandbox wallet is topped up automatically — you never need a faucet. Spending more than you hold still fails, so the insufficient-balance path stays testable.
What is still imaginary. The incoming PIX and the Lightning payment never happen — there is no testnet for the Brazilian payment system, and the invoice above is unpayable. Delivery and debit legs are real transactions on Base Sepolia. Before going live, run the PIX legs once against
https://api.hodle.com.brwith ahodle_live_key, de-risked with small amounts (value: 10is R$ 0.10), your own kill switch, and a lowperTxLimitBrl.
Simulating a KYC outcome
Pass sandboxReject: true in POST /api/kyc to drive the attempt to REJECTED; omit it to
get APPROVED. The flag is Avenia's own, forwarded untouched, so the rejection you get is a
real rejection from a real KYC engine. Poll GET /api/kyc/{attemptId} exactly as you would in
production.
Rates
Quotes are priced off the Avenia sandbox book, not the live one, so the numbers are real-shaped but not real. Do not assert on exact values in sandbox.
Wallets in sandbox
Production requires the account holder to establish a wallet PIN during onboarding, and the API deliberately cannot mint one — that is what keeps the wallet non-custodial. Sandbox has no onboarding, so it provisions the wallet for you on first use.
| Sandbox | |
|---|---|
| PIN | 123456 — pass it as walletPin |
| Provisioned by | The first call to /api/wallet/keys or /api/wallet/get |
| Network | Base Sepolia (84532) |
Six digits because that is the platform's format. Every sandbox wallet shares this PIN, so it protects nothing and is safe to publish.
The key material is real: it goes through the same path as production, so
/api/wallet/keys returns a genuine protectedSymmetricKey and you exercise the real
handshake rather than a stub that would pass here and fail in production.
Settling a payment
A sandbox charge cannot be paid: the BR Code from /api/deposit/asset carries a sandbox marker
instead of a live PSP payload, so no banking app can pay it — deliberately, so nobody pays a
test charge with real money.
You settle it the way production does: by the webhook. In production the PSP posts the ticket
event to /avenia/webhook when the PIX lands; in sandbox you post it, because no PIX will
ever land. Same route, same payload, same handler.
curl --request POST \
--url https://sandbox-api.hodle.com.br/avenia/webhook \
--header "Authorization: Bearer $HODLE_SANDBOX_API_KEY" \
--header "Content-Type: application/json" \
--data '{
"event": {
"subscription": "TICKET",
"data": {
"type": "DEPOSIT-SUCCESS",
"ticket": { "id": "<the trackId from /api/deposit/asset>" }
}
}
}'Your sandbox key is required here, and that is the one difference from the call Avenia makes. The route accepts two proofs of who you are:
- Avenia's signature, which it puts on every webhook including the ones from its sandbox. You cannot produce it.
- Your sandbox API key, which stands in for it. It says which charges you may settle: your
own, and only those — a
trackIdbelonging to another integrator matches nothing.
Neither one and you get 401. The signature is verified on every environment, sandbox
included; the key does not switch it off, it is simply the other accepted proof.
A key-authenticated request can only settle. It never reaches the handlers that credit deposits or complete KYC, so nobody can drive those on your data — or you on theirs.
Only DEPOSIT-SUCCESS and TICKET-COMPLETE settle. A DELIVERY-FAILED cannot be used to mark
a charge paid.
Idempotent: settling twice delivers once, including two requests racing each other. The
delivery leg is a real Base Sepolia transaction, so the asset genuinely lands at the address
you asked for. If that transaction fails, the charge goes to FAILED and stays there — create
a new one with /api/deposit/asset rather than retrying the settle.
You control the timing, which is the point: you exercise the PENDING to COMPLETED
transition and your own webhook handling — against the very payload shape you will parse in
production — instead of waiting on a rail that will never fire.
Lightning invoices are not payable
/api/lightning/invoice returns a bolt11-shaped string so you can wire up parsing and the
amount fields, but no wallet will settle it — there is no channel, no preimage, and the
checksum does not validate. Drive settlement through the webhook above.
When a call fails with a missing fake
An endpoint whose upstream is neither the Avenia sandbox nor Base Sepolia fails with a message naming the host and path rather than silently succeeding. If you hit one, it is a gap on our side — send us the error and we will map it.
BRLH and USDH: the sandbox stables
The sandbox has two test stables on Base Sepolia (chainId 84532): BRLH (BRL, 18 decimals)
and USDH (USD, 6 decimals). Every account gets one test wallet that holds them. It is
the same wallet whether you use the API (the owner of your hodle_test_ key) or the sandbox
dashboard, so both always show the same balance. The key behind it is a worthless testnet key
held by Hodle on the sandbox data plane — never a production wallet.
Balances are read on-chain (balanceOf on Base Sepolia). Buying mints into the test wallet,
swapping debits one token and mints the other, and payouts and transfers are real transfers
out of it. Nothing is minted to cover a spend: spend more than you hold and you get
SANDBOX_INSUFFICIENT_BALANCE.
All conversions use one fixed rate: 1 USD = 5.4 BRL. Amounts are truncated, never rounded up.
The test wallet is created by your first write (a deposit, swap, payout or transfer). Reading a
balance never creates it: until then address (and explorerUrl) is null and both balances
are 0.00.
The test wallet belongs to the account. Sub-accounts are not supported here: a sandbox
deposit, swap, payout or transfer that sends subAccountId (or fromSubAccountId /
toSubAccountId) is refused with 400 and "code": "SANDBOX_SUBACCOUNT_UNSUPPORTED" instead of
settling on the account's own wallet.
Balances
GET /api/wallet adds your test wallet under the base-sepolia network. Add ?sync=true to
skip the few-seconds balance cache.
{
"success": true,
"data": {
"userId": "665f1c2e9b1e8a0012345678",
"subAccountId": null,
"addresses": {
"base-sepolia": "0x5A3c...9e21"
},
"balances": [
{ "network": "base-sepolia", "asset": "BRLH", "amount": "0.00" },
{ "network": "base-sepolia", "asset": "USDH", "amount": "7.25" }
],
"syncedAt": "2026-09-27T12:00:00.000Z"
}
}POST /api/wallet/list returns your wallets as in production, plus a sandboxWallet object:
{
"success": true,
"data": {
"wallets": [],
"sandboxWallet": {
"address": "0x5A3c...9e21",
"network": "base-sepolia",
"chainId": 84532,
"explorerUrl": "https://sepolia.basescan.org/address/0x5A3c...9e21",
"balances": [
{ "asset": "BRLH", "amount": "0.00", "raw": "0", "decimals": 18, "tokenAddress": "0x4ec5f42ee07d5d1cfe431ec7059df4df556d44a8" },
{ "asset": "USDH", "amount": "7.25", "raw": "7250000", "decimals": 6, "tokenAddress": "0x1c7af3c850c1d1d8d97d40db0acdd0195c6323f7" }
]
}
}
}On-ramp: buy BRLH or USDH
POST /api/deposit/asset with asset: "BRLH" or "USDH" creates a PENDING charge with an
unpayable BR Code. value is in BRL cents (minimum 100). Without address, the token is
delivered to your test wallet; with one, it goes to that Base Sepolia address. USDH is priced at
5.4 BRL per USD. Fee is zero.
{ "value": 2500, "asset": "BRLH" }{
"success": true,
"externalId": "7f0f2b8e-3c1a-4d5e-9b1f-2a6c8d9e0f11",
"walletCharge": "66fd0a1b2c3d4e5f60718293",
"qrCode": "00020126580014br.gov.bcb.pix0136sandbox-4b8e...6304SBX0",
"trackId": "4b8e1f7a-2c3d-4e5f-8a9b-0c1d2e3f4a5b",
"fee": 0,
"fxRateAtTx": 5.4,
"address": "0x5A3c...9e21",
"network": "base-sepolia",
"sandbox": {
"payable": false,
"settle": "POST /avenia/webhook with ticket.id \"4b8e1f7a-2c3d-4e5f-8a9b-0c1d2e3f4a5b\" and your hodle_test_ key"
}
}Then simulate the payment by posting the webhook with that trackId.
The charge goes to COMPLETED and 25 BRLH are minted to the address above. Settling twice
mints once.
Swap
POST /api/wallet/swap swaps BRLH ↔ USDH in one call: the paying token leaves your test
wallet (to Hodle's settlement address) and the receiving token is minted into it, both on Base
Sepolia. walletPin and protectedSymmetricKey are accepted and ignored — the test wallet is
held by Hodle. clientRequestId is the idempotency key: repeating it moves nothing and answers
with the swap it already created — 200 only if that swap COMPLETED, 409 SANDBOX_SWAP_IN_PROGRESS while it is still running, and the error below (with the receipt under
sandbox) if it failed. If the receiving token cannot be minted after your token was debited,
the debited amount is minted back and the swap ends REFUNDED, carrying both debitTxHash and
refundTxHash.
{ "fromAsset": "USDH", "toAsset": "BRLH", "amount": "10", "clientRequestId": "swap-1" }{
"success": true,
"swapId": "66fd0b2c3d4e5f6071829304",
"status": "COMPLETED",
"legs": [
{
"index": 0,
"fromAsset": "USDH",
"toAsset": "BRLH",
"status": "COMPLETED",
"originTxHash": "0x9f1c...",
"expectedAmountOut": "54"
}
],
"sandbox": {
"swapId": "66fd0b2c3d4e5f6071829304",
"status": "COMPLETED",
"fromAsset": "USDH",
"toAsset": "BRLH",
"amountIn": "10",
"amountOut": "54",
"fxRate": 5.4,
"debitTxHash": "0x9f1c...",
"creditTxHash": "0x2a7d...",
"debitExplorerUrl": "https://sepolia.basescan.org/tx/0x9f1c...",
"creditExplorerUrl": "https://sepolia.basescan.org/tx/0x2a7d...",
"walletAddress": "0x5A3c...9e21",
"network": "base-sepolia",
"chainId": 84532,
"sandbox": true
}
}| Error | Status | When |
|---|---|---|
SANDBOX_SWAP_PAIR_UNSUPPORTED | 400 | Any pair other than BRLH ↔ USDH. |
SANDBOX_INVALID_AMOUNT | 400 | Not a positive decimal, too many decimals for the token, or above 1,000,000. |
SANDBOX_INSUFFICIENT_BALANCE | 400 | The test wallet does not hold amount of fromAsset. |
SANDBOX_SWAP_IN_PROGRESS | 409 | A replay of a clientRequestId whose swap is still running. |
SANDBOX_SWAP_REFUNDED | 422 | The credit failed and the debited amount was minted back. |
SANDBOX_SETTLEMENT_FAILED | 422 | An on-chain step failed or had no receipt within 60 s; the receipt keeps any hash. |
SANDBOX_SUBACCOUNT_UNSUPPORTED | 400 | The request named a sub-account. |
GET /api/wallet/swap/{swapId} does not read sandbox swaps yet: keep the response of the
POST, which is already final.
Payout (PIX-out)
POST /api/wallet/payout with asset: "BRLH" or "USDH" debits your test wallet on Base
Sepolia and simulates the PIX. value is in BRL cents: BRLH is debited 1:1, USDH at 5.4 BRL
per USD. Send pixKey + pixKeyType or qrCode. externalId is the idempotency key. The
payout is already COMPLETED when the 202 comes back, with a simulated end-to-end id that
always starts with SANDBOX. No PIX is ever sent.
{
"value": 1234,
"asset": "BRLH",
"network": "base",
"pixKey": "12345678909",
"pixKeyType": "CPF",
"externalId": "payout-001"
}{
"success": true,
"transactionId": "66fd0c3d4e5f607182930415",
"externalId": "payout-001",
"status": "COMPLETED",
"stableAmount": "12.34",
"valueInBrl": "12.34",
"fee": "0.00",
"network": "base-sepolia",
"asset": "BRLH",
"endToEndId": "SANDBOX8F2C1D0E9B7A4C3D2E1F0A9B8C",
"txHash": "0x4c2e...",
"explorerUrl": "https://sepolia.basescan.org/tx/0x4c2e...",
"sandbox": true
}Spending more than the wallet holds returns 400 with
"errorCode": "SANDBOX_INSUFFICIENT_BALANCE". Reusing an externalId returns the same payout
without debiting again. If the debit gets no receipt within 60 seconds, the payout is recorded
as FAILED (keeping the hash) with "errorCode": "SANDBOX_SETTLEMENT_FAILED" instead of being
left pending.
The quote flow (POST /api/wallet/payout/beneficiary + quoteId) is not available for BRLH
or USDH: pay directly with pixKey. With USDT, USDC or BRLA the payout keeps the
behaviour described in Settlement tokens.
Transfer
POST /api/wallet/transfer with asset: "BRLH" or "USDH" sends from your test wallet to any
Base Sepolia address. amount is in token units. reference is the idempotency key: repeating
it returns the first transfer (transactionId included) and sends nothing, or 409 SANDBOX_TRANSFER_IN_PROGRESS while that one is still running.
{ "asset": "USDH", "amount": "1.5", "recipientAddress": "0x2222222222222222222222222222222222222222", "reference": "tx-1" }{
"success": true,
"data": {
"txHash": "0x7b1e...",
"senderAddress": "0x5A3c...9e21",
"recipientAddress": "0x2222222222222222222222222222222222222222",
"amount": "1.5",
"asset": "USDH",
"transactionId": "66fd0d4e5f60718293041526",
"reference": "tx-1",
"network": "base-sepolia",
"explorerUrl": "https://sepolia.basescan.org/tx/0x7b1e...",
"sandbox": true
}
}Isolation: environment and host
Every BRLH/USDH path checks two things before it does anything, and both must hold:
- Environment — the server is the sandbox deployment. A production server never runs these paths, whatever the request says.
- Host — the request was addressed to
sandbox-api.hodle.com.br(orsandbox-api-stg.hodle.com.brfor staging). A forwarded host outside that list is refused.
If either fails, the request takes the production path unchanged — for example,
asset: "BRLH" on https://api.hodle.com.br gets the ordinary 400 Validation failed, and no
charge, swap or payout is created. Everything these paths touch on-chain is Base Sepolia
(chainId 84532).