Skip to main content

Wallets API

HTTP API reference for Programmable Wallets — create wallets, post ledger entries, bind funding sources, execute mandate-gated payments, triage reconciliation anomalies.

11 min read
View MarkdownEdit on GitHub

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

FieldTypeDescription
idstringWallet ID, wlt_<16chars>
org_idstringOwning account
project_idstringOwning project — wallets are project-scoped
agent_idstring | nullOptional agent binding; null for account-level wallets
display_namestringFree-form, max 120 chars
status"active" | "frozen" | "closed"Frozen wallets reject new ops; closed is terminal
created_atstringISO 8601
closed_atstring | nullSet when status flips to closed
metadataobjectFree-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.

Response201 Created with the wallet object.

List wallets

GET /v1/wallets

Project-scoped. Optional query params:

  • status=active|frozen|closed
  • agent_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:

StateMeaning
reconciledLedger and chain agree exactly
unattributed_creditThe chain holds more than the ledger attributed. Benign; parks until claimed
ledger_exceeds_custodyThe 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/ledgeradmin

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:

KindSignMandate required
fundpositiveno
releasepositiveno
holdnegativeyes
debitnegativeyes
feenegativeno
reconcilezerono
reversemirrors originalno

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.

Errors400 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 id
  • kind=<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-sourcesadmin

{
  "connection_id": "ca_…",
  "currency": "BRL",
  "metadata": {}
}

Errors404 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/:currencyadmin

Returns 204 No Content on success, 404 funding_source_not_found otherwise.


Execute

Run a payment through the gateway

POST /v1/wallets/:id/executeadmin

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 statusHTTPWhen
completed200All gates passed; provider returned settled
requires-approval402InsufficientFundsError — top up the wallet to retry
denied403Policy or mandate gate failed
failed422Route 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"
}
FieldTypeDescription
currencystringUSDC (on-chain Base) or BRL (Pix). Anything else: 400 unsupported_currency
to_addressstringAn EVM address for USDC, a Pix key for BRL
amount_minorintPositive integer. Atomic units (6 decimals) for USDC, centavos for BRL
idempotency_keystring?Max 128 chars. See retry semantics below
mandateobjectThe consumer's signed authorization for this withdrawal
signaturestring64 hex characters over the mandate
agent_idstring?Max 200 chars
purposestring?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

CodeMeaning
mandate_requiredNo proof was supplied
mandate_consumer_mismatchThe mandate does not belong to this wallet's consumer
mandate_currency_mismatchThe mandate does not cover this currency
mandate_<status>The mandate exists but is not usable in its current status
withdrawal_destination_unpinnedThe destination is not pinned by the mandate
merchant_not_allowedThe 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 statusResult
sent201 with the prior result, no second send
authorized (in flight)409 withdrawal_in_progress
failed409 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 event
  • receipt_without_debit — a commerce.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/:aidadmin

{
  "status": "resolved",
  "note": "Reconciled manually against bank statement"
}

Or {"status": "dismissed", "note": "False positive — webhook arrived 90s late"}.

Errors404 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"
    }
  ]
}
FieldTypeDescription
sourcestringLowercase kebab-case label for the import origin, e.g. manual-csv-upload, ofb-statement-import, ted-batch-fetch
entriesarray1 to 1000 entries per call
entries[].provider_event_idstringThe bank's transaction id (Pix EndToEndId, OFB transactionId). Must equal the ledger row's external_ref for the match to fire
entries[].amount_minorintAmount in minor units
entries[].currencystring3 to 8 chars
entries[].occurred_atstring?ISO 8601 with offset. Reporting only; matching reads provider_event_id
entries[].memostring?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

PathIdempotency keyBehavior 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 uniqueHTTP 409 funding_source_conflict
Execute POSTwalletAttemptId (defaults to requestId)Forwarded to wallet ops; same prior-row semantics
Recon-anomaly resolveWHERE status='open' predicateHTTP 404 if already resolved
Transfer POSTidempotency_key (optional)HTTP 201 + prior result if sent; 409 if in flight or failed
Statement-import POST(source, provider_event_id) uniqueSkipped 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:

CodeHTTPMeaning
wallet_not_active409Wallet is frozen or closed
balance_constraint_violation409DB CHECK trip — overdraw or negative balance
ledger_conflict409Different unique index than the partials (rare)
funding_source_conflict409Connection already bound for this currency
funding_source_not_found404Binding doesn't exist
connection_not_found404Connection not in caller's project
connection_not_active409Connection status ≠ connected
currency_not_routable400Stablecoin execute on an account wallet
anomaly_not_found404Already resolved/dismissed or wrong wallet
no_onchain_address409Receive or custody on a wallet that is not consumer-scoped
no_custody_view409Custody comparison for a non-USDC currency
no_onchain_wallet409Transfer on a wallet that is not consumer-scoped
cdp_unavailable502Could not resolve the wallet's on-chain address
chain_unavailable502Could not read the on-chain balance
insufficient_funds409Transfer amount exceeds the available balance
insufficient_gas422Live USDC transfer with no ETH for gas on the sending account
withdrawal_in_progress409A withdrawal with this idempotency key is in flight
withdrawal_failed409A prior withdrawal with this idempotency key failed
send_failed502The transfer leg failed; reserved funds were released
not_found404Wallet 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/wallet

Returns 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
FieldTypeDescription
from_currencystringSource slot, e.g. "BRL"
to_currencystringDestination slot, e.g. "USDC"
amount_minorintAmount in the source currency's minor units
executeboolean?Default false = return the governed plan only (route, whether it converts, cap headroom). true runs the ramp legs (real money).
agent_idstring?Attribution for the audit trail
purposestring?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):

RoutePlan (execute absent)Execute (execute: true)
Onramp (BRL to USDC)
Offramp (USDC to BRL)
Any other pair422 unsupported_transfer_route501 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/spend

Executes 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.

FieldTypeDescription
amount_minorintAmount in the mandate slot's minor units
payeestringA 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_idstring?Attribution
attempt_idstring?Idempotency key
quoteobject?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.

Wallets API | CodeSpar