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.br with a hodle_live_ key and it is the production integration — nothing to rewrite.

Base URL

https://sandbox-api.hodle.com.br

Get a sandbox key

  1. Log into the Hodle admin.
  2. Open API Keys and create a new key with environment Sandbox.
  3. Copy the key. Sandbox keys are prefixed hodle_test_; production keys are hodle_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.

SurfaceSandbox hostNotes
/api/subaccount (create, list, get)MirroredCreates a real sub-account in the Avenia sandbox.
/api/kyc, /api/kyc/{attemptId}, /api/kyc/documentMirroredReal Avenia sandbox attempts — see Simulating a KYC outcome.
/api/kyb, /api/kyb/proof-of-address, /api/kyb/proof-of-financial-capacityMirroredIncluding the GET attempt polls.
/api/quoteMirroredPriced off the Avenia sandbox book, not the live one.
/api/wallet/createMirroredCreates 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/keysMirroredWallet is provisioned on first use — see Wallets in sandbox. GET /api/wallet also reports your BRLH/USDH test wallet — see Balances.
/api/wallet/listMirroredAdds a sandboxWallet object with your BRLH/USDH balances.
/api/wallet/payoutMirroredDebit 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/transferMirroredReal Base Sepolia transfer to the address you name. With asset: "BRLH" | "USDH" it spends your test wallet balance — see Transfer.
/api/wallet/swapMirroredBRLH ↔ USDH at a fixed rate, settled on Base Sepolia — see Swap. Any other pair: SANDBOX_SWAP_PAIR_UNSUPPORTED.
/api/wallet/swap/internal501No sandbox settlement.
/api/deposit/assetMirroredUnpayable BR Code in, real test token out. Accepts asset: "BRLH" | "USDH" — see On-ramp. Settle it with the webhook.
/api/lightning/invoiceMirroredReturns a bolt11-shaped string that is not payable — see below.
/avenia/webhookMirroredThe settlement trigger — see Settling a payment.
/api/withdraw/pix501Deprecated; 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 sendSandbox settles inDecimalsMirrors
asset: "BRLA"BRLH 0x4ec5f42ee07d5d1cfe431ec7059df4df556d44a818BRLA, BRL-pegged 1:1
asset: "USDC" or "USDT"USDH 0x1c7af3c850c1d1d8d97d40db0acdd0195c6323f76USDC/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.br with a hodle_live_ key, de-risked with small amounts (value: 10 is R$ 0.10), your own kill switch, and a low perTxLimitBrl.

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
PIN123456 — pass it as walletPin
Provisioned byThe first call to /api/wallet/keys or /api/wallet/get
NetworkBase 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 trackId belonging 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.

GET /api/wallet?sync=true
{
  "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:

POST /api/wallet/list
{
  "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.

POST /api/deposit/asset
{ "value": 2500, "asset": "BRLH" }
200 OK
{
  "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.

POST /api/wallet/swap
{ "fromAsset": "USDH", "toAsset": "BRLH", "amount": "10", "clientRequestId": "swap-1" }
200 OK
{
  "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
  }
}
ErrorStatusWhen
SANDBOX_SWAP_PAIR_UNSUPPORTED400Any pair other than BRLH ↔ USDH.
SANDBOX_INVALID_AMOUNT400Not a positive decimal, too many decimals for the token, or above 1,000,000.
SANDBOX_INSUFFICIENT_BALANCE400The test wallet does not hold amount of fromAsset.
SANDBOX_SWAP_IN_PROGRESS409A replay of a clientRequestId whose swap is still running.
SANDBOX_SWAP_REFUNDED422The credit failed and the debited amount was minted back.
SANDBOX_SETTLEMENT_FAILED422An on-chain step failed or had no receipt within 60 s; the receipt keeps any hash.
SANDBOX_SUBACCOUNT_UNSUPPORTED400The 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.

POST /api/wallet/payout
{
  "value": 1234,
  "asset": "BRLH",
  "network": "base",
  "pixKey": "12345678909",
  "pixKeyType": "CPF",
  "externalId": "payout-001"
}
202 Accepted
{
  "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.

POST /api/wallet/transfer
{ "asset": "USDH", "amount": "1.5", "recipientAddress": "0x2222222222222222222222222222222222222222", "reference": "tx-1" }
200 OK
{
  "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:

  1. Environment — the server is the sandbox deployment. A production server never runs these paths, whatever the request says.
  2. Host — the request was addressed to sandbox-api.hodle.com.br (or sandbox-api-stg.hodle.com.br for 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).