Ethereum end-to-end
Whitelist a wallet, mint BRS by PIX, and burn it back to BRL on Ethereum. Every request and response below is what the sandbox returns today.
This guide runs the whole Ethereum integration once, in the sandbox, in the order you will implement it. Each step shows the call, the response, and the value to keep for the next step. The same calls work in production with a production key and the production base URL.
| Sandbox | Production | |
|---|---|---|
| Base URL | https://sandbox.api.nora.finance | https://api.nora.finance |
| PIX deposit | Simulated for the amount this guide uses: the intent moves to fiat_received on its own shortly after creation. Other amounts wait on a PIX nobody pays in sandbox. | Your user pays the PIX code. |
Addresses, chainRef values and explorers per network: see the
Addresses page. Per-mint and
daily limits: Wallet whitelisting → Reference.
In both environments Nora waits for the chain's confirmation depth before
acting on a transaction. You never size that wait yourself: submit answers
409 RECEIPT_PENDING with a Retry-After header until then, sync answers
outcome: "not_on_chain", and intents move on their own.
BRS has 6 decimals on both chains. Amounts on the API are always BRL
cents (amountCents: 500 = R$5,00 = 5 BRS); token units appear only in
amountMinor fields ("5000000" = 5 BRS).
Base. This same flow applies unchanged with
chainId: "base"— sandbox is Base Sepolia (eip155:84532, explorersepolia.basescan.org), production is Base mainnet (eip155:8453, explorerbasescan.org), and the BRS token address on Base matches the one above for each stage.
Before you start
- A sandbox API key. See the Quickstart.
- A whitelister contract provisioned by Nora for your organization,
whose
owner()is a wallet you control (an EOA or a Safe). Ask your Nora onboarding contact for this once per environment. Until it exists, step 1 returns409withdetails.code: "WHITELISTER_NOT_PROVISIONED". - A wallet to whitelist: the address that will receive mints and send burns. An EOA or a smart account, with Sepolia ETH for the burn's gas.
- A PIX key on your Pix-key list for the environment, added on the dashboard under Pix Keys (how). Offramp payouts go only to keys on that list, your own CNPJ included.
Placeholder values below (0xYourWhitelister…, 0xYourOwner…,
0xYourWallet…, YOUR_CNPJ_DIGITS) stand for your organization's own
addresses and keys. Every other value is copied from a
real sandbox run; … marks fields shortened for the page.
1. Whitelist the wallet
Register the wallet. Nora answers with the calldata your whitelister's owner must send.
WALLET_KEY=$(uuidgen) # one per registration, reused on retries
curl -X POST https://sandbox.api.nora.finance/v2/wallets \
-H "X-API-Key: $NORA_API_KEY" \
-H "Idempotency-Key: $WALLET_KEY" \
-H "Content-Type: application/json" \
-d '{
"chainId": "ethereum",
"address": "0xYourWallet000000000000000000000000000000",
"limitMinor": 100000,
"label": "treasury"
}'limitMinor is the wallet's daily limit in BRL cents (100000 =
R$1.000,00).
{
"wallet": {
"id": "630b4aea-63c1-41ce-9509-090b71ed9692",
"chainId": "ethereum",
"address": "0xyourwallet000000000000000000000000000000",
"whitelisterWallet": "0xYourOwner0000000000000000000000000000000",
"role": "minter",
"state": "pending",
"statusReason": null,
"reconciliationStatus": "missing_onchain",
"label": "treasury",
"limitMinor": null,
"calldata": {
"to": "0xYourWhitelister000000000000000000000000",
"data": "0x059ef5bf000000000000000000000000…",
"chainRef": "eip155:11155111",
"signerWallet": "0xYourOwner0000000000000000000000000000000",
"limitMinor": 100000,
"expiresAt": "2026-09-09T04:19:31.124Z"
},
"createdAt": "2026-09-08T04:19:31.035Z",
"updatedAt": "2026-09-08T04:19:31.035Z"
},
"operation": { "id": "09a44c93-…", "operationType": "grant", "status": "prepared", "txSignature": null, "errorMessage": null, "createdAt": "…", "updatedAt": "…" },
"transaction": null,
"linkedExisting": false,
"replayed": false
}whitelisterWallet in the response is your whitelister contract's owner,
echoed by Nora; it is never sent in the request. wallet.limitMinor stays
null until the grant is confirmed on-chain; the requested value is in
calldata.limitMinor.
Keep wallet.id. Then, from calldata.signerWallet, send a transaction
with to: calldata.to, data: calldata.data, value: 0 before
calldata.expiresAt. Any wallet software works: a script with
viem or ethers, MetaMask, or a Safe. Take the transaction hash and submit it:
SUBMIT_KEY=$(uuidgen) # keep it: retries of this submit reuse the same key
curl -X POST https://sandbox.api.nora.finance/v2/wallets/630b4aea-63c1-41ce-9509-090b71ed9692/submit \
-H "X-API-Key: $NORA_API_KEY" \
-H "Idempotency-Key: $SUBMIT_KEY" \
-H "Content-Type: application/json" \
-d '{ "txHash": "0x…" }'While the transaction is not yet confirmed, submit returns 409 with
details.code: "RECEIPT_PENDING" and a Retry-After header. Retry with
the same txHash and the same Idempotency-Key ($SUBMIT_KEY above). On
success the wallet is active:
{
"wallet": {
"id": "630b4aea-63c1-41ce-9509-090b71ed9692",
"state": "active",
"reconciliationStatus": "synced",
"limitMinor": 100000,
"calldata": null,
"…": "…"
},
"operation": { "operationType": "grant", "status": "confirmed", "txSignature": "0x…", "…": "…" },
"linkedExisting": false,
"replayed": false
}If the owner is a Safe or another contract wallet and you executed the grant from its UI without keeping the hash, sync instead. Nora reads the authorization from your whitelister and activates the wallet:
curl -X POST https://sandbox.api.nora.finance/v2/wallets/630b4aea-63c1-41ce-9509-090b71ed9692/sync \
-H "X-API-Key: $NORA_API_KEY" \
-H "Content-Type: application/json" \
-d '{}'The response's outcome is activated when the grant was found and
already_synced when the wallet was active already. not_on_chain means
the grant is not visible yet (retry after the confirmation depth), was
never sent, or was zeroed since; in the last case wallet.state flips to
revoked once the zero is confirmed.
The full wallet surface, including limits, clamping, revoke and repair, is in Wallet whitelisting, Path C.
2. Mint: BRL in, BRS out
Create an onramp intent to the whitelisted wallet.
ORDER_KEY=$(uuidgen) # one per user order, reused on retries; without a clientReference a new key is a new charge
curl -X POST https://sandbox.api.nora.finance/v2/intents/onramp \
-H "X-API-Key: $NORA_API_KEY" \
-H "Idempotency-Key: $ORDER_KEY" \
-H "Content-Type: application/json" \
-d '{
"amountCents": 500,
"destinationWallet": "0xYourWallet000000000000000000000000000000",
"chainId": "ethereum",
"clientReference": "order-1001"
}'{
"id": "e29fa7ce-c600-45d7-a54d-f5a3ac39d989",
"instanceId": "…",
"partyId": "…",
"intentType": "onramp",
"flowVersion": 1,
"status": "awaiting_fiat_payment",
"statusReason": null,
"failureCode": null,
"providerFailureCode": null,
"originalFailureCode": null,
"originalProviderFailureCode": null,
"clientReference": "order-1001",
"metadata": { "chainId": "ethereum", "amountCents": 500, "environment": "sandbox", "destinationAddress": "0xYourWallet000000000000000000000000000000", "…": "…" },
"expiresAt": "2026-09-08T04:46:58.035Z",
"createdAt": "2026-09-08T04:16:58.036Z",
"updatedAt": "2026-09-08T04:16:58.036Z",
"pixPayment": {
"brcode": "00020101021126580014br.gov.bcb.pix0136xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx520400005303986540…",
"txid": "e29fa7cec60045d7a54df5a3a",
"amountCents": 500,
"expiresAt": "2026-09-08T04:46:58.035Z"
},
"depositInstructions": null,
"party": { "displayName": "Your Company", "documentMasked": "64***65", "solanaWallet": null },
"chainId": "ethereum"
}Keep id. Show pixPayment.brcode to your user as a QR code and a
copy-and-paste string. In the sandbox, for the amount this guide uses,
nobody has to pay: the intent moves to fiat_received on its own
shortly after, then to minting, then to completed. Other amounts
wait on a PIX nobody pays in sandbox.
Poll GET /v2/intents/e29fa7ce-c600-45d7-a54d-f5a3ac39d989 or wait for
the payin.completed webhook. A completed Ethereum mint looks like this:
{
"id": "e29fa7ce-c600-45d7-a54d-f5a3ac39d989",
"status": "completed",
"statusReason": "EVM mint confirmed on-chain.",
"metadata": {
"chainId": "ethereum",
"mintChain": "eip155:11155111",
"amountCents": 500,
"mintAmountMinor": "5000000",
"mintTxSignature": "0xcce195a3f5b81f5e78f697b90f4dde9955c7666c2569e64a400e342e56245cd9",
"destinationAddress": "0xYourWallet000000000000000000000000000000",
"…": "…"
},
"chainId": "ethereum",
"…": "…"
}metadata.mintTxSignature is the Ethereum transaction hash of the mint.
metadata keys are informational and not part of the versioned response
contract; key off status. The wallet now holds 5 BRS. Minting costs your
wallet nothing on-chain; Nora pays that gas.
Before charging the PIX, Nora reads your whitelister and the BRS token
on-chain — see the evm_* rows in What can go wrong
below for the rejection reasons and how to recover.
3. Burn: BRS in, BRL out
Create an offramp intent. destinationAddress is the PIX key that
receives the BRL. It must be on your organization's Pix-key list for this
environment, managed on the dashboard under Pix Keys (see
Offramp Pix keys); that applies to every key,
including your own CNPJ. Accepted types: your organization's CNPJ (digits
only), an email, a +55 phone or a random (EVP) key. CPF keys and other
companies' CNPJs are rejected.
PAYOUT_KEY=$(uuidgen) # one per user payout, reused on retries (see the 409 row below)
curl -X POST https://sandbox.api.nora.finance/v2/intents/offramp \
-H "X-API-Key: $NORA_API_KEY" \
-H "Idempotency-Key: $PAYOUT_KEY" \
-H "Content-Type: application/json" \
-d '{
"amountCents": 500,
"destinationAddress": "YOUR_CNPJ_DIGITS",
"chainId": "ethereum",
"clientReference": "payout-2001"
}'{
"id": "cb353457-227a-43a7-83d5-bec8f616c9fd",
"intentType": "offramp",
"flowVersion": 2,
"status": "awaiting_burn_approval",
"clientReference": "payout-2001",
"metadata": { "chainId": "ethereum", "amountCents": 500, "environment": "sandbox", "destinationAddress": "YOUR_CNPJ_DIGITS", "…": "…" },
"expiresAt": "2026-09-08T04:44:59.817Z",
"pixPayment": null,
"depositInstructions": null,
"chainId": "ethereum",
"…": "…"
}Keep id. Ask Nora to prepare the burn for the wallet that holds the
BRS:
curl -X POST https://sandbox.api.nora.finance/v2/intents/cb353457-227a-43a7-83d5-bec8f616c9fd/prepare-burn \
-H "X-API-Key: $NORA_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "tokenHolder": "0xYourWallet000000000000000000000000000000" }'The first answer is { "status": "preparing" }. Nora signs a burn
authorization for this exact intent, amount and holder. Call the same
endpoint again a few seconds later, with the same body, and you get the
call to send. If it keeps answering preparing on repeated calls, the
intent is too close to its expiresAt to be authorized, or an earlier
authorization lapsed. Create a new intent only if no burn was broadcast;
if one was, submit its hash with approve-burn. If a fresh intent
behaves the same way, stop and contact Nora with the intent id.
{
"status": "ready",
"burn": {
"chainRef": "eip155:11155111",
"from": "0xYourWallet000000000000000000000000000000",
"to": "0x0000884179F857221EFC49C21BCb94af93CEB125",
"data": "0xd03c72eb000000000000000000000000…",
"amountMinor": "5000000",
"expiresAt": 1788842399
}
}Send a transaction with to: burn.to, data: burn.data, value: 0
from burn.from, before burn.expiresAt. See
Burn signing → Ethereum
for the full contract: who must send it, what reverts it on-chain, and
what happens if the authorization lapses before you broadcast.
Then hand the hash back:
curl -X POST https://sandbox.api.nora.finance/v2/intents/cb353457-227a-43a7-83d5-bec8f616c9fd/approve-burn \
-H "X-API-Key: $NORA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"txHash": "0xad833de78479734b737d69d000c58e37e19fc53ce354435d7e74d6493d31a276",
"tokenHolder": "0xYourWallet000000000000000000000000000000"
}'{ "status": "burn_approved" }Send only the hash of the transaction you actually broadcast. Nora
accepts the hash immediately and verifies it in the background; once the
intent is burn_approved, the endpoint rejects any other hash. A reverted
burn fails the intent. A hash that never mines (dropped or replaced
transaction, or a typo) leaves the intent at burn_approved for Nora's
operations team to reconcile: contact Nora with the intent id.
Poll GET /v2/intents/:id or wait for webhooks. The intent goes
burn_approved → burning → paying_out → completed. A completed
Ethereum burn:
{
"id": "05cf40a4-ba7e-408b-8dc0-2b69ff27716e",
"intentType": "offramp",
"status": "completed",
"statusReason": "EFI Pix payout completed via webhook.",
"metadata": {
"chainId": "ethereum",
"burnChain": "eip155:11155111",
"amountCents": 500,
"burnTxSignature": "0xad833de78479734b737d69d000c58e37e19fc53ce354435d7e74d6493d31a276",
"burnCompletedAt": "2026-09-07T20:05:39.178Z",
"payoutCompletedAt": "2026-09-07T20:06:10.588Z",
"…": "…"
},
"chainId": "ethereum",
"…": "…"
}The full signing contract, including recovery when your app restarts
between broadcast and approve-burn, is in
Burn signing, Ethereum.
4. Replace polling with webhooks
Register an endpoint on the dashboard and handle payin.* for mints and
payout.* for burns. data.chainId is "ethereum" on every event for
these intents. See Webhooks.
What can go wrong
| You see | Where | Meaning | Do this |
|---|---|---|---|
409, details.code: "WHITELISTER_NOT_PROVISIONED" | POST /v2/wallets | No whitelister contract for your organization in this environment. | Contact Nora. |
400 validation error whose error[] includes Unrecognized key: "whitelisterWallet" (nested in an invalid_union entry, next to red-herring "Invalid Solana wallet address" messages) | POST /v2/wallets | The Ethereum body has no whitelisterWallet. | Remove the field. |
400, tokenHolder is not a valid address for chain family "evm" on a real 0x address | prepare-burn, approve-burn | Mixed-case addresses must carry a valid EIP-55 checksum. | Send the address all lower-case, or correctly checksummed. |
409, details.code: "RECEIPT_PENDING" | submit | The grant transaction is not confirmed yet. | Retry after Retry-After with the same txHash and Idempotency-Key. |
state: "active" with statusReason: "limit_clamped: …" | submit | Your limit was reduced to your organization's ceiling. | Nothing. This is success. |
400, destinationAddress must equal the organization CNPJ for CNPJ-typed Pix keys. | POST /v2/intents/offramp | A CNPJ key that is not your organization's. | Use your own CNPJ, or an email, phone or EVP key from your Pix-key list. |
400, The Pix key attempting to receive the offramp is not whitelisted by the organization. | POST /v2/intents/offramp | The key is not on your organization's Pix-key list for this environment. | Add it on the dashboard under Pix Keys, then retry. |
409, Intent with clientReference 'order-1001' already exists | POST /v2/intents/onramp, POST /v2/intents/offramp | clientReference values are unique per environment, forever: an intent with that reference already exists, even if it has expired. An onramp retry with the same Idempotency-Key and body is replayed instead; an offramp retry that carries a clientReference always gets this 409. | To find the existing intent, page through GET /v2/intents?intentType=onramp (or offramp) and match clientReference; there is no filter for it. For a new order or payout, send a new clientReference. |
400, evm_destination_wallet_not_authorized | POST /v2/intents/onramp | Your whitelister has no authorization for destinationWallet on-chain (never granted, or zeroed). | Finish step 1. If the wallet shows active but was zeroed on-chain, call sync again after the confirmation depth (not in a loop; it is rate-limited) until wallet.state is revoked, then prepare + submit. |
400, evm_destination_wallet_rate_limit_exceeded | POST /v2/intents/onramp | The wallet's on-chain daily limit is below amountCents. | Lower the amount, or ask Nora about the limit. |
400, evm_mint_amount_below_minimum / evm_mint_amount_above_maximum | POST /v2/intents/onramp | amountCents is outside the BRS token's per-mint bounds. | Adjust the amount; the bounds are agreed at onboarding. |
400, tokenHolder is not a valid address for chain family "evm" | prepare-burn | A non-Ethereum address on an Ethereum intent. | Send a 0x address. |
400, A burn grant is already outstanding for 0x… on this intent | prepare-burn | You named a different holder while an authorization is live. | Use the first holder, or create a new offramp intent. |
{ "status": "preparing" } that never becomes ready | prepare-burn | The intent is too close to its expiresAt, so no authorization is issued, or an earlier one lapsed. | If no burn was broadcast, create a new offramp intent and call prepare-burn right away. If one was, submit its hash with approve-burn. If a fresh intent behaves the same way, stop and contact Nora with the intent id. |
400, Intent is not awaiting burn approval (current status: expired) | prepare-burn, approve-burn | The intent passed its expiresAt. | If no burn was broadcast, create a new offramp intent. If one was, do not: the BRS is gone; contact Nora with the intent id and the txHash. |
400, txHash is not a valid transaction hash for chain family "evm" (the message names whichever field you sent) | approve-burn | The value is not a 0x 32-byte transaction hash (a base58 signature, or truncated). | Send the hash of the burn transaction as txHash. |
400, Intent is not awaiting burn approval (current status: burn_approved) | approve-burn | A hash was already accepted. | Nothing. Poll the intent. |
| Burn transaction reverts | On-chain | Sender is not the holder, is not whitelisted, lacks balance, or the authorization expired. | Fix the cause and create a new offramp intent; a lapsed authorization is not replaced. |
Intent stays burn_approved | Polling | The submitted hash never mined. | Contact Nora with the intent id. |
Going to production
- Get your organization's KYB approved on the dashboard; production
offramps are refused (
400) until it is. - Create a production API key on the dashboard, with your production servers' IPs on its allowlist.
- Ask your Nora onboarding contact to provision your mainnet whitelister with the owner you will use.
- Point the calls above at
https://api.nora.finance. Nothing else in the bodies changes:chainIdstays"ethereum",chainRefvalues becomeeip155:1, and the BRS token is0x0000006238e3C7797Ae71827da303560bae1B125. - Add your production PIX keys on the dashboard: the Pix-key list is per environment, so the sandbox list does not carry over.
- Fund the burning wallets with ETH. Mints still cost you no gas.
- Register a production webhook endpoint; endpoints are per environment too.
Per-mint limits, your whitelister's daily cap and the network-wide daily cap: see Wallet whitelisting → Reference.
Burn signing
Ask Nora to prepare the burn, have the token holder's wallet sign and broadcast it on Solana, Ethereum, Base or Polygon, and submit the proof back via /v2/intents/:id/approve-burn.
Bridging BRS between chains
Move BRS between Ethereum, Base and Polygon straight from the token contract, with no Nora API call. BRS is a LayerZero OFT, so any holder can bridge.