Wallets API
HTTP API reference for Programmable Wallets — create wallets, post ledger entries, bind funding sources, execute mandate-gated payments, triage reconciliation anomalies.
Wallets API
REST API for the Wallets concept. Per-agent fund pools with mandate-gated debits, multi-rail funding, and automatic reconciliation.
Base URL: https://api.codespar.dev
All endpoints require authentication. See Authentication. Endpoints noted as admin require either a bearer api key OR service auth with an x-codespar-user header for an account admin/owner.
Wallet object
| Field | Type | Description |
|---|---|---|
id | string | Wallet ID, wlt_<16chars> |
org_id | string | Owning account |
project_id | string | Owning project — wallets are project-scoped |
agent_id | string | null | Optional agent binding; null for account-level wallets |
display_name | string | Free-form, max 120 chars |
status | "active" | "frozen" | "closed" | Frozen wallets reject new ops; closed is terminal |
created_at | string | ISO 8601 |
closed_at | string | null | Set when status flips to closed |
metadata | object | Free-form operator-supplied JSON |
When fetched via GET /v1/wallets/:id, the response also embeds a balances array (one entry per currency).
Currency whitelist
BRL, USD, MXN, COP, ARS, USDC, BRLA. Fiat-only currencies route through the gateway (Stripe / Mercado Pago / Asaas / Pix). On account wallets, stablecoin currencies (USDC, BRLA) support funding only; execute returns 400 currency_not_routable. Consumer wallets route stablecoins in production, see Consumer wallets below.
Wallets
Create a wallet
POST /v1/wallets
Body
{
"display_name": "Customer Service Agent",
"currency": "BRL",
"agent_id": "agt_optional",
"metadata": {}
}Seeds a zero-balance row in the requested currency. Other currencies get rows lazily as funding events for those currencies post.
Response — 201 Created with the wallet object.
List wallets
GET /v1/wallets
Project-scoped. Optional query params:
status=active|frozen|closedagent_id=<id>limit=1..100(default 25)
Response
{ "wallets": [/* Wallet */] }Get a wallet (with balances)
GET /v1/wallets/:id
Response — Wallet object with embedded balances:
{
"id": "wlt_…",
"balances": [
{
"wallet_id": "wlt_…",
"currency": "BRL",
"balance_minor": "10000",
"available_minor": "8500",
"updated_at": "2026-04-26T12:00:00Z"
}
]
}balance_minor and available_minor are bigint strings (centavos / cents). available <= balance is enforced at the DB layer.
Receive and custody
Two read endpoints for the on-chain side of a wallet. Both apply to consumer-scoped wallets (wallets whose metadata.consumer_id links them to a consumer): the on-chain address is the consumer's derived account on Base, the same address the onramp delivers to and the spend legs pay from.
Get receive details
GET /v1/wallets/:id/receive?currency=USDC
Returns rail-appropriate deposit instructions for the requested currency (default USDC).
For USDC, the response carries the wallet's on-chain Base address plus the network and the USDC asset contract:
{
"currency": "USDC",
"rail": "onchain",
"network": "base",
"address": "0x…",
"asset_contract": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"note": "Base USDC only"
}network follows the key environment: live keys resolve to base, test keys to base-sepolia, each with the matching USDC contract. Send only Base USDC to this address.
Fiat currencies have no deposit address. The deposit path is a funding source, so the response is a descriptor with rail (pix for BRL, wire for USD, unknown otherwise), address, network, and asset_contract all null, and a note the dashboard renders as guidance.
Errors: 409 no_onchain_address (USDC receive on a wallet that is not consumer-scoped), 502 cdp_unavailable (address resolution failed).
Compare custody against the ledger
GET /v1/wallets/:id/custody?currency=USDC
What the ledger has attributed vs what the address actually holds. Custody is the source of truth for what money exists; the ledger is authoritative for what the money means. The two are allowed to differ, and the difference is information: funds sent directly to the receive address park as unattributed credit until claimed. They are never auto-credited into available balance.
USDC only (409 no_custody_view otherwise), consumer-scoped wallets only (409 no_onchain_address). This is a separate call, deliberately not folded into GET /v1/wallets/:id, so a slow or unavailable RPC costs a missing panel, not a missing wallet.
{
"wallet_id": "wlt_…",
"currency": "USDC",
"address": "0x…",
"network": "base",
"observed_at": "2026-08-06T12:00:00Z",
"ledger_atomic": "540000",
"onchain_atomic": "1160000",
"difference_atomic": "620000",
"state": "unattributed_credit",
"note": "0.62 USDC is held on-chain but not yet attributed to this wallet's balance. Funds sent directly to the address land here until they are claimed."
}All figures are atomic USDC (6 decimals) on both sides. difference_atomic is on-chain minus ledger, signed and never clamped. state is one of:
| State | Meaning |
|---|---|
reconciled | Ledger and chain agree exactly |
unattributed_credit | The chain holds more than the ledger attributed. Benign; parks until claimed |
ledger_exceeds_custody | The ledger claims more than the chain holds. A spend authorized against it can fail at settle |
When the chain cannot be read the endpoint returns 502 chain_unavailable rather than falling back to the ledger figure alone.
Ledger
Post a ledger entry
POST /v1/wallets/:id/ledger — admin
Direct ledger writes. The gateway's processPayment is the primary caller for hold/release/debit; webhook adapters use this for fund. Hand-rolled writes are typically operator-driven corrections.
Body
{
"currency": "BRL",
"amount_minor": "5000",
"kind": "fund",
"mandate_id": null,
"attempt_id": "demo-fund-001",
"external_ref": "E12345…",
"metadata": { "description": "Pix incoming" }
}kind enum: fund | hold | release | debit | reconcile | reverse | fee. Sign refinements:
| Kind | Sign | Mandate required |
|---|---|---|
fund | positive | no |
release | positive | no |
hold | negative | yes |
debit | negative | yes |
fee | negative | no |
reconcile | zero | no |
reverse | mirrors original | no |
Idempotency — (wallet_id, attempt_id, kind) and (wallet_id, kind, external_ref) are partial unique indexes. A retried call with the same attempt_id returns the prior row with HTTP 200, no double-post; a fresh insert returns 201.
Errors — 400 invalid_body, 409 wallet_not_active, 409 balance_constraint_violation (CHECK trip).
List ledger entries
GET /v1/wallets/:id/ledger
Query params:
limit=1..200(default 50)before_id=<bigint>— cursor pagination, descending by idkind=<one of the kinds above>
Response
{
"entries": [/* LedgerEntry */],
"next_before": "12345"
}Funding sources
A funding source binds a connection to a wallet for a specific currency. The funding bridge converts that connection's commerce.payment.* events into kind=fund ledger entries.
Constraint — at most one binding per (connection_id, currency) while enabled=true. Prevents the recon engine from double-applying the same receipt.
Bind a funding source
POST /v1/wallets/:id/funding-sources — admin
{
"connection_id": "ca_…",
"currency": "BRL",
"metadata": {}
}Errors — 404 connection_not_found (not in caller's project), 409 connection_not_active, 409 funding_source_conflict.
List funding sources
GET /v1/wallets/:id/funding-sources
{ "funding_sources": [
{
"wallet_id": "wlt_…",
"connection_id": "ca_…",
"currency": "BRL",
"enabled": true,
"created_at": "…",
"metadata": {}
}
] }Unbind a funding source
DELETE /v1/wallets/:id/funding-sources/:connection_id/:currency — admin
Returns 204 No Content on success, 404 funding_source_not_found otherwise.
Execute
Run a payment through the gateway
POST /v1/wallets/:id/execute — admin
Drives the full mandate-gated payment lifecycle: policy → mandate → wallet hold → route → execute → wallet settle → budget record.
Body
{
"amount": 19.99,
"currency": "BRL",
"recipient": "Acme Supplier Co.",
"description": "Pix supplier payout",
"mandate_id": "mnd_abc123",
"preferred_method": "pix",
"attempt_id": "exec-2026-04-26-001",
"metadata": {}
}amount is in major units (e.g. 19.99 = R$ 19,99). The gateway converts internally.
Status mapping
| Gateway status | HTTP | When |
|---|---|---|
completed | 200 | All gates passed; provider returned settled |
requires-approval | 402 | InsufficientFundsError — top up the wallet to retry |
denied | 403 | Policy or mandate gate failed |
failed | 422 | Route or execute gate failed |
Response body — Full GatewayPaymentResult always (even on non-200):
{
"requestId": "gw-…",
"status": "completed",
"policy": { "allowed": true, "reason": "" },
"mandate": { "valid": true, "mandateId": "mnd_…" },
"route": { "method": "pix", "provider": "asaas" },
"payment": { "transactionId": "tx_…", "amountSent": 19.99 },
"wallet": {
"holdId": "100",
"debitId": "102",
"releaseId": "101",
"insufficientFunds": false
},
"audit": [
{ "timestamp": "…", "step": "policy_check", "status": "pass", "detail": "…" }
]
}Stablecoin currencies (USDC, BRLA) return 400 currency_not_routable — the fiat gateway has no on-chain rails today.
Transfer (owner withdrawal)
Send wallet funds out
POST /v1/wallets/:id/transfer (admin)
Sends the wallet's own funds out to an external destination. This route requires the consumer's signed mandate. Being an authenticated admin is not sufficient on its own and has not been since the withdrawal-mandate change: a request carrying only the four transfer fields is refused with mandate_required. Consumer-scoped wallets only (409 no_onchain_wallet otherwise).
The proof is the same shape the spend path and the payment-link gateway already take, so a caller that can authorize an agent spend can authorize a withdrawal.
Body
{
"currency": "USDC",
"to_address": "0xRecipient…",
"amount_minor": 250000,
"idempotency_key": "wd-2026-08-06-001",
"mandate": { "…": "the consumer's mandate object" },
"signature": "…64 hex chars…",
"agent_id": "agt_…",
"purpose": "owner withdrawal"
}| Field | Type | Description |
|---|---|---|
currency | string | USDC (on-chain Base) or BRL (Pix). Anything else: 400 unsupported_currency |
to_address | string | An EVM address for USDC, a Pix key for BRL |
amount_minor | int | Positive integer. Atomic units (6 decimals) for USDC, centavos for BRL |
idempotency_key | string? | Max 128 chars. See retry semantics below |
mandate | object | The consumer's signed authorization for this withdrawal |
signature | string | 64 hex characters over the mandate |
agent_id | string? | Max 200 chars |
purpose | string? | Max 120 chars |
The proof fields are declared optional in the request schema on purpose: omitting them produces the typed refusal below rather than a shapeless 400 invalid_body.
Mandate refusals
| Code | Meaning |
|---|---|
mandate_required | No proof was supplied |
mandate_consumer_mismatch | The mandate does not belong to this wallet's consumer |
mandate_currency_mismatch | The mandate does not cover this currency |
mandate_<status> | The mandate exists but is not usable in its current status |
withdrawal_destination_unpinned | The destination is not pinned by the mandate |
merchant_not_allowed | The destination is not in the mandate's allowlist |
The mandate is checked in addition to policy, not instead of it: the withdrawal also runs through the same policy engine as agent spend, under the tool name wallet:withdraw. Rules targeting wallet:* therefore apply to a human moving the wallet's own money out. A denial returns 403 policy_denied (with approval_id and expires_at when an approval-required rule matched); an unavailable engine fails closed with 503 policy_engine_error.
Flow: the withdrawal row and a hold ledger entry commit in one transaction before the send. On send success, a debit settles the hold and the withdrawal is marked sent. On send failure, a release returns the reserved funds and the withdrawal is marked failed (502 send_failed). BRL withdrawals cash out via Pix from the consumer's active BRL funding source (409 no_funding_source when none is active), and the returned tx_hash carries the Pix EndToEndId.
Response (201 Created):
{
"withdrawal_id": "wtd_…",
"tx_hash": "0x…",
"status": "sent",
"network": "base",
"to_address": "0xRecipient…",
"amount_minor": 250000,
"currency": "USDC"
}network is base / base-sepolia for USDC (following the key environment) and pix for BRL.
Retry semantics with the same idempotency_key:
| Prior withdrawal status | Result |
|---|---|
sent | 201 with the prior result, no second send |
authorized (in flight) | 409 withdrawal_in_progress |
failed | 409 withdrawal_failed: use a new key to retry |
Other errors: 400 invalid_amount, 400 invalid_address (USDC), 400 invalid_pix_key (BRL), 409 insufficient_funds (available balance too low, checked under a row lock before the hold), 422 insufficient_gas (live USDC only: the sending account has no ETH for gas; fund it and retry).
Reconciliation anomalies
The recon engine flags two failure modes:
debit_without_receipt— a debit older than the grace window has no matching provider eventreceipt_without_debit— acommerce.payment.*event with no matching wallet ledger row
List anomalies
GET /v1/wallets/:id/recon-anomalies
Optional query: status=open|resolved|dismissed (default open).
{ "anomalies": [
{
"id": "1",
"wallet_id": "wlt_…",
"kind": "debit_without_receipt",
"ledger_entry_id": "100",
"external_ref": "tx_…",
"amount_minor": "-1000",
"currency": "BRL",
"detected_at": "…",
"status": "open",
"resolved_at": null,
"resolution_note": null,
"metadata": {}
}
] }Resolve / dismiss
POST /v1/wallets/:id/recon-anomalies/:aid — admin
{
"status": "resolved",
"note": "Reconciled manually against bank statement"
}Or {"status": "dismissed", "note": "False positive — webhook arrived 90s late"}.
Errors — 404 anomaly_not_found (already resolved or wrong wallet).
Import a bank statement
POST /v1/wallets/:id/statement-import
Batch reconciliation for rails the webhook layer does not cover today: TED, USD wire, on-chain USDC before an adapter exists, banks pending Open Finance Brasil. Upload a normalized statement extracted from the bank's file (CSV/PDF parsed client-side or by an aggregation tool); each entry inserts a synthetic commerce.payment.received event that the recon engine's next pass matches against unreconciled ledger rows by external_ref.
Body
{
"source": "manual-csv-upload",
"entries": [
{
"provider_event_id": "E12345678202608061200abcdef",
"amount_minor": 5000,
"currency": "BRL",
"occurred_at": "2026-08-06T12:00:00-03:00",
"memo": "TED inbound"
}
]
}| Field | Type | Description |
|---|---|---|
source | string | Lowercase kebab-case label for the import origin, e.g. manual-csv-upload, ofb-statement-import, ted-batch-fetch |
entries | array | 1 to 1000 entries per call |
entries[].provider_event_id | string | The bank's transaction id (Pix EndToEndId, OFB transactionId). Must equal the ledger row's external_ref for the match to fire |
entries[].amount_minor | int | Amount in minor units |
entries[].currency | string | 3 to 8 chars |
entries[].occurred_at | string? | ISO 8601 with offset. Reporting only; matching reads provider_event_id |
entries[].memo | string? | Max 512 chars |
Response (202 Accepted):
{
"wallet_id": "wlt_…",
"source": "manual-csv-upload",
"imported": 12,
"duplicates": 3,
"next_recon_cycle_within_seconds": 60
}Re-importing the same statement is safe: (source, provider_event_id) is unique, so duplicates are skipped and counted in duplicates. Matching happens in the next recon cycle, within 60 seconds.
Idempotency at a glance
| Path | Idempotency key | Behavior on retry |
|---|---|---|
| Ledger POST | (wallet_id, attempt_id, kind) OR (wallet_id, kind, external_ref) | HTTP 200 + prior row |
| Funding-source POST | (connection_id, currency) partial unique | HTTP 409 funding_source_conflict |
| Execute POST | walletAttemptId (defaults to requestId) | Forwarded to wallet ops; same prior-row semantics |
| Recon-anomaly resolve | WHERE status='open' predicate | HTTP 404 if already resolved |
| Transfer POST | idempotency_key (optional) | HTTP 201 + prior result if sent; 409 if in flight or failed |
| Statement-import POST | (source, provider_event_id) unique | Skipped and counted in duplicates |
Errors
All non-2xx responses follow the shared error envelope:
{
"error": {
"code": "balance_constraint_violation",
"message": "ledger entry would violate a wallet balance invariant",
"details": { "wallet_id": "…", "currency": "BRL", "kind": "hold" }
},
"request_id": "req_…"
}Specific codes used by this surface:
| Code | HTTP | Meaning |
|---|---|---|
wallet_not_active | 409 | Wallet is frozen or closed |
balance_constraint_violation | 409 | DB CHECK trip — overdraw or negative balance |
ledger_conflict | 409 | Different unique index than the partials (rare) |
funding_source_conflict | 409 | Connection already bound for this currency |
funding_source_not_found | 404 | Binding doesn't exist |
connection_not_found | 404 | Connection not in caller's project |
connection_not_active | 409 | Connection status ≠ connected |
currency_not_routable | 400 | Stablecoin execute on an account wallet |
anomaly_not_found | 404 | Already resolved/dismissed or wrong wallet |
no_onchain_address | 409 | Receive or custody on a wallet that is not consumer-scoped |
no_custody_view | 409 | Custody comparison for a non-USDC currency |
no_onchain_wallet | 409 | Transfer on a wallet that is not consumer-scoped |
cdp_unavailable | 502 | Could not resolve the wallet's on-chain address |
chain_unavailable | 502 | Could not read the on-chain balance |
insufficient_funds | 409 | Transfer amount exceeds the available balance |
insufficient_gas | 422 | Live USDC transfer with no ETH for gas on the sending account |
withdrawal_in_progress | 409 | A withdrawal with this idempotency key is in flight |
withdrawal_failed | 409 | A prior withdrawal with this idempotency key failed |
send_failed | 502 | The transfer leg failed; reserved funds were released |
not_found | 404 | Wallet doesn't exist or cross-tenant |
Consumer wallets (multi-slot mandate wallet)
A consumer wallet is the buyer-side surface: the spend authority a consumer has granted to agents via signed mandates. One wallet per consumer, with per-currency slots (for example BRL + USDC) minted from a single mandate signature. There is no FX inside the wallet: each slot has its own cap and its own settled-spend ledger, and the payee type routes a payment to the matching slot (a URL or 0x address routes to USDC, a Pix key or copia-e-cola routes to BRL).
These endpoints are live in production. Legacy single-currency mandates fold in as a one-currency wallet, so the shapes below are uniform across both.
Get a consumer's wallet
GET /v1/consumers/:id/walletReturns the consumer's spend authority per currency, aggregated across their active mandates:
{
"consumer_id": "user_123",
"currencies": [
{
"currency": "BRL",
"rail": "pix",
"authorized_minor": 5000000,
"spent_minor": 123400,
"available_minor": 4876600
},
{
"currency": "USDC",
"rail": "usdc",
"authorized_minor": 10000,
"spent_minor": 0,
"available_minor": 10000
}
]
}authorized_minor is the sum of slot caps across active mandates for that currency; spent_minor is the sum of settled debits in that currency; available_minor is authorized minus spent, floored at 0.
Move balance between slots
POST /v1/consumers/:id/wallet/transfer| Field | Type | Description |
|---|---|---|
from_currency | string | Source slot, e.g. "BRL" |
to_currency | string | Destination slot, e.g. "USDC" |
amount_minor | int | Amount in the source currency's minor units |
execute | boolean? | Default false = return the governed plan only (route, whether it converts, cap headroom). true runs the ramp legs (real money). |
agent_id | string? | Attribution for the audit trail |
purpose | string? | Free text, max 120 chars |
A cross-currency move is never a synthetic FX conversion: it is a real fiat-stablecoin trade settled through the ramp at the real quoted rate, and the destination slot is credited whatever the ramp delivers. With execute: true the response is 202 with transaction_id, status, pix_copy_paste / destination_address for the in-flight leg, and a settle_via poll URL (GET /v1/consumers/:id/fund/:txId) for the async settlement.
Current rail status (honest matrix):
| Route | Plan (execute absent) | Execute (execute: true) |
|---|---|---|
Onramp (BRL to USDC) | ✅ | ✅ |
Offramp (USDC to BRL) | ✅ | ✅ |
| Any other pair | 422 unsupported_transfer_route | 501 not_wired |
Other errors: 422 same_slot_transfer (from = to), 422 currency_not_authorized (wallet has no slot in that currency), 400 invalid_body.
Spend within a mandate
POST /v1/consumers/mandates/:id/spendExecutes a payment inside an existing signed mandate: the stored mandate and signature are reconstructed and verified, caps and allowlists are enforced server-side, and the settlement produces a signed receipt.
| Field | Type | Description |
|---|---|---|
amount_minor | int | Amount in the mandate slot's minor units |
payee | string | A Pix key OR a copia-e-cola / QR (decoded to the receiver key for the allowlist check); a URL / 0x address routes to the USDC slot |
agent_id | string? | Attribution |
attempt_id | string? | Idempotency key |
quote | object? | The offer being paid (seller / resource / price the agent approved); bound and signed into the receipt |
Failure modes surface the full audit chain in the error body (404 mandate_not_found, cap/allowlist denials, provider errors). The USDC-to-Pix offramp leg of spend answers 501 when the offramp rail is not configured for the environment.
- Build an agent with a wallet — Asaas sandbox walkthrough
- Wallets concept — design + invariants
- Errors — full error envelope reference
Shopper Identity
Connect-once + vaulted shopper identity — let a shopper connect a login-walled store once, or vault a guest-checkout profile, so an agent can buy on their behalf without re-asking. Per-consumer, inbound, credentials never exposed.
Agent identity and trust (KYA)
Verifiable agent identity (did:web + Ed25519), signed reputation, and the register / rotate / revoke key lifecycle. Know Your Agent for the money side.