Skip to main content
API reference

Wallets

Generated HTTP reference for the 15 operations the published OpenAPI document describes under wallets.

30 min read
View MarkdownEdit on GitHub

Wallets

This page is generated from the published OpenAPI document. It is complete with respect to that document and says nothing about surfaces the document does not describe yet. See what is generated here for what that means.

Base URL: https://api.codespar.dev

Every operation below requires a Bearer token. See Authentication.

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

Runs the gateway pipeline in order: policy → mandate → wallet hold → route → execute → wallet settle → budget record. Read the mandate_id note below before wiring this into anything, because the mandate stage does not resolve stored mandates.

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.

mandate_id here does not name a stored mandate. This route resolves it against the payment gateway's own in-process mandate store, which starts empty on every process and which no API route writes to. A mandate you created through POST /v1/consumers/mandates is therefore invisible to this route: the call comes back 403 with "mandate": { "valid": false, "reason": "Mandate not found" }. Omitting mandate_id does not help either, because a wallet debit with no mandate is rejected as operator error. The route is a gateway demonstration path in the code, not a production spend path. The buyer-side route that spends against a signed consumer mandate is Spend within a mandate.

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.

Every operation, from the spec

Generated from the published OpenAPI document, so it never drifts from what the API actually serves. The section above is written by hand and carries what a schema cannot: the object model, field rules, and the order to call things in.

GET /v1/wallets

List wallets in the caller's project. Optional filters: status, agent_id.

Responses

StatusBodyDescription
200objectOK

Response 200

FieldTypeRequiredDescription
walletsarray of Walletyes

Example response

{
  "wallets": [
    {
      "id": "wlt_0000000000000000",
      "org_id": "org_0000000000000000",
      "project_id": "prj_0000000000000000",
      "display_name": "Example",
      "status": "active",
      "created_at": "2026-01-15T12:00:00.000Z",
      "metadata": {},
      "balances": [
        {
          "wallet_id": "wlt_0000000000000000",
          "currency": "BRL",
          "balance_minor": "1000",
          "available_minor": "1000",
          "updated_at": "2026-01-15T12:00:00.000Z"
        }
      ]
    }
  ]
}

Example request

curl -X GET https://api.codespar.dev/v1/wallets \
  -H "Authorization: Bearer $CODESPAR_API_KEY"

POST /v1/wallets

Create a per-agent wallet. Seeds a zero-balance row in the requested currency.

Request bodyWalletCreate

FieldTypeRequiredDescription
agent_idstring,nullno
currency"BRL" | "USD" | "MXN" | "COP" | "ARS" | "USDC" | "BRLA"yes
display_namestringyes
metadataobjectno

Responses

StatusBodyDescription
201WalletOK

Response 201

FieldTypeRequiredDescription
agent_idstring,nullyes
balancesarray of WalletBalanceno
closed_atstring,null (date-time)yes
created_atstring (date-time)yes
display_namestringyes
idstringyes
metadataobjectyes
org_idstringyes
project_idstringyes
status"active" | "frozen" | "closed"yes

Example response

{
  "id": "wlt_0000000000000000",
  "org_id": "org_0000000000000000",
  "project_id": "prj_0000000000000000",
  "display_name": "Example",
  "status": "active",
  "created_at": "2026-01-15T12:00:00.000Z",
  "metadata": {},
  "balances": [
    {
      "wallet_id": "wlt_0000000000000000",
      "currency": "BRL",
      "balance_minor": "1000",
      "available_minor": "1000",
      "updated_at": "2026-01-15T12:00:00.000Z"
    }
  ]
}

Example request

curl -X POST https://api.codespar.dev/v1/wallets \
  -H "Authorization: Bearer $CODESPAR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
       "display_name": "Example",
       "currency": "BRL",
       "metadata": {}
     }'

GET /v1/wallets/{id}

Fetch a wallet plus its per-currency balances. Cross-tenant ids return 404.

Path parameters

NameTypeRequiredDescription
idstringyes

Responses

StatusBodyDescription
200WalletOK
404objectNot Found

Response 200

FieldTypeRequiredDescription
agent_idstring,nullyes
balancesarray of WalletBalanceno
closed_atstring,null (date-time)yes
created_atstring (date-time)yes
display_namestringyes
idstringyes
metadataobjectyes
org_idstringyes
project_idstringyes
status"active" | "frozen" | "closed"yes

Example response

{
  "id": "wlt_0000000000000000",
  "org_id": "org_0000000000000000",
  "project_id": "prj_0000000000000000",
  "display_name": "Example",
  "status": "active",
  "created_at": "2026-01-15T12:00:00.000Z",
  "metadata": {},
  "balances": [
    {
      "wallet_id": "wlt_0000000000000000",
      "currency": "BRL",
      "balance_minor": "1000",
      "available_minor": "1000",
      "updated_at": "2026-01-15T12:00:00.000Z"
    }
  ]
}

Example request

curl -X GET https://api.codespar.dev/v1/wallets/{id} \
  -H "Authorization: Bearer $CODESPAR_API_KEY"

GET /v1/wallets/{id}/custody

What the ledger attributed against what the address actually holds

Path parameters

NameTypeRequiredDescription
idstringyesThe wallet id.

Query parameters

NameTypeRequiredDescription
currencystringnoCase-insensitive, and USDC is the only accepted value. Anything else is 409, not 400 — see below.

Responses

StatusBodyDescription
200objectOK
404objectNot Found. No wallet with this id is visible to this credential.
409objectConflict, and the two codes need different actions. no_custody_view: a currency other than USDC was asked for — there is no custody comparison for a fiat rail, and error.details.currency echoes what was asked. no_onchain_address: the wallet is not consumer-scoped, so there is no address to compare against. Both are terminal for the request.
502objectBad Gateway, and the two codes fail at different steps. cdp_unavailable: the address itself could not be resolved, with the underlying failure in error.details.detail. chain_unavailable: the address resolved but its balance could not be read, with error.details.address and error.details.network naming what was being read. Both are transient.

Response 200

FieldTypeRequiredDescription
addressstringyesThe same address GET /v1/wallets/\{id\}/receive hands out. That symmetry is the point.
currency"USDC"yes
difference_atomicstringyesonchain_atomic - ledger_atomic. Signed; a leading - is the dangerous case.
ledger_atomicstringyesWhat the ledger says is spendable. Atomic USDC.
network"base" | "base-sepolia"yesFrom the credential's environment: live is base, test is base-sepolia.
notestringyesOne rendered sentence describing state, with the difference already formatted, so the API and a dashboard cannot drift into describing the same state differently. Prose, not a code to branch on.
observed_atstring (date-time)yesWhen the chain was read.
onchain_atomicstringyesWhat the address holds. Atomic USDC.
state"reconciled" | "unattributed_credit" | "ledger_exceeds_custody"yes
wallet_idstringyes

Example response

{
  "wallet_id": "wlt_0000000000000000",
  "currency": "USDC",
  "address": "string",
  "network": "base",
  "observed_at": "2026-01-15T12:00:00.000Z",
  "ledger_atomic": "string",
  "onchain_atomic": "string",
  "difference_atomic": "string",
  "state": "reconciled",
  "note": "string"
}

Example request

curl -X GET https://api.codespar.dev/v1/wallets/{id}/custody \
  -H "Authorization: Bearer $CODESPAR_API_KEY"

POST /v1/wallets/{id}/execute

Drive the F2.M4 gateway lifecycle: policy → mandate → wallet hold → route → execute → wallet settle → audit. Admin role. HTTP status mirrors the GatewayPaymentResult.status (200 completed, 402 requires-approval, 403 denied, 422 failed).

Path parameters

NameTypeRequiredDescription
idstringyes

Request bodyWalletExecute

FieldTypeRequiredDescription
amountnumberyes
attempt_idstringno
currency"BRL" | "USD" | "MXN" | "COP" | "ARS" | "USDC" | "BRLA"yes
descriptionstringyes
mandate_idstringyes
metadataobjectno
preferred_methodstringno
purposestringno
recipientstringyes
target_currency"USD" | "EUR" | "BRL" | "MXN"no

Responses

StatusBodyDescription
200GatewayPaymentResultOK
402GatewayPaymentResultOK
403GatewayPaymentResultOK
404objectNot Found
422GatewayPaymentResultOK

Response 200

FieldTypeRequiredDescription
auditarray of objectyes
requestIdstringyes
status"completed" | "denied" | "requires-approval" | "failed"yes
walletobjectno

Example response

{
  "requestId": "request_0000000000000000",
  "status": "completed",
  "audit": [
    {
      "timestamp": "2026-01-15T12:00:00.000Z",
      "step": "string",
      "status": "pass",
      "detail": "string"
    }
  ],
  "wallet": {
    "insufficientFunds": true
  }
}

Example request

curl -X POST https://api.codespar.dev/v1/wallets/{id}/execute \
  -H "Authorization: Bearer $CODESPAR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
       "amount": 1000,
       "currency": "BRL",
       "target_currency": "USD",
       "preferred_method": "string",
       "recipient": "string",
       "description": "string",
       "mandate_id": "mandate_0000000000000000",
       "purpose": "string",
       "attempt_id": "attempt_0000000000000000",
       "metadata": {}
     }'

GET /v1/wallets/{id}/funding-sources

List funding-source bindings for this wallet.

Path parameters

NameTypeRequiredDescription
idstringyes

Responses

StatusBodyDescription
200objectOK
404objectNot Found

Response 200

FieldTypeRequiredDescription
funding_sourcesarray of WalletFundingSourceyes

Example response

{
  "funding_sources": [
    {
      "wallet_id": "wlt_0000000000000000",
      "connection_id": "conn_0000000000000000",
      "currency": "BRL",
      "enabled": true,
      "created_at": "2026-01-15T12:00:00.000Z",
      "metadata": {}
    }
  ]
}

Example request

curl -X GET https://api.codespar.dev/v1/wallets/{id}/funding-sources \
  -H "Authorization: Bearer $CODESPAR_API_KEY"

POST /v1/wallets/{id}/funding-sources

Bind a connected_accounts row as a funding rail for this wallet. Admin role. The funding bridge converts the connection's webhook events into kind=fund ledger entries.

Path parameters

NameTypeRequiredDescription
idstringyes

Request bodyWalletFundingSourceBind

FieldTypeRequiredDescription
connection_idstringyes
currency"BRL" | "USD" | "MXN" | "COP" | "ARS" | "USDC" | "BRLA"yes
metadataobjectno

Responses

StatusBodyDescription
201WalletFundingSourceOK
404objectNot Found

Response 201

FieldTypeRequiredDescription
connection_idstringyes
created_atstring (date-time)yes
currency"BRL" | "USD" | "MXN" | "COP" | "ARS" | "USDC" | "BRLA"yes
enabledbooleanyes
metadataobjectyes
wallet_idstringyes

Example response

{
  "wallet_id": "wlt_0000000000000000",
  "connection_id": "conn_0000000000000000",
  "currency": "BRL",
  "enabled": true,
  "created_at": "2026-01-15T12:00:00.000Z",
  "metadata": {}
}

Example request

curl -X POST https://api.codespar.dev/v1/wallets/{id}/funding-sources \
  -H "Authorization: Bearer $CODESPAR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
       "connection_id": "conn_0000000000000000",
       "currency": "BRL",
       "metadata": {}
     }'

DELETE /v1/wallets/{id}/funding-sources/{connection_id}/{currency}

Unbind a funding source. Admin role.

Path parameters

NameTypeRequiredDescription
connection_idstringyes
currency"BRL" | "USD" | "MXN" | "COP" | "ARS" | "USDC" | "BRLA"yes
idstringyes

Responses

StatusBodyDescription
204No Content
404objectNot Found

Example request

curl -X DELETE https://api.codespar.dev/v1/wallets/{id}/funding-sources/{connection_id}/{currency} \
  -H "Authorization: Bearer $CODESPAR_API_KEY"

GET /v1/wallets/{id}/ledger

Paginated ledger view, newest first. Cursor via before_id. Optional kind filter.

Path parameters

NameTypeRequiredDescription
idstringyes

Responses

StatusBodyDescription
200objectOK
404objectNot Found

Response 200

FieldTypeRequiredDescription
entriesarray of WalletLedgerEntryyes
next_beforestring,nullyes

Example response

{
  "entries": [
    {
      "id": "walletledgerentry_0000000000000000",
      "wallet_id": "wlt_0000000000000000",
      "org_id": "org_0000000000000000",
      "currency": "BRL",
      "amount_minor": "1000",
      "kind": "fund",
      "posted_at": "2026-01-15T12:00:00.000Z",
      "metadata": {}
    }
  ]
}

Example request

curl -X GET https://api.codespar.dev/v1/wallets/{id}/ledger \
  -H "Authorization: Bearer $CODESPAR_API_KEY"

POST /v1/wallets/{id}/ledger

Post a ledger entry. Admin role. Idempotent on (wallet_id, attempt_id, kind) and (wallet_id, kind, external_ref). Returns 200 with the prior row on retry, 201 on fresh insert.

Path parameters

NameTypeRequiredDescription
idstringyes

Request bodyWalletLedgerPost

FieldTypeRequiredDescription
amount_minorstringyes
attempt_idstring,nullyes
currency"BRL" | "USD" | "MXN" | "COP" | "ARS" | "USDC" | "BRLA"yes
external_refstring,nullyes
kind"fund" | "hold" | "release" | "debit" | "reconcile" | "reverse" | "fee"yes
mandate_idstring,nullyes
metadataobjectno
org_idstringyes
wallet_idstringyes

Responses

StatusBodyDescription
200WalletLedgerEntryOK
201WalletLedgerEntryOK
404objectNot Found

Response 200

FieldTypeRequiredDescription
amount_minorstringyesbigint signed minor units
attempt_idstring,nullyes
currency"BRL" | "USD" | "MXN" | "COP" | "ARS" | "USDC" | "BRLA"yes
external_refstring,nullyes
idstringyesbigserial as string
kind"fund" | "hold" | "release" | "debit" | "reconcile" | "reverse" | "fee"yes
mandate_idstring,nullyes
metadataobjectyes
org_idstringyes
posted_atstring (date-time)yes
reconciled_atstring,null (date-time)yes
wallet_idstringyes

Example response

{
  "id": "walletledgerentry_0000000000000000",
  "wallet_id": "wlt_0000000000000000",
  "org_id": "org_0000000000000000",
  "currency": "BRL",
  "amount_minor": "1000",
  "kind": "fund",
  "posted_at": "2026-01-15T12:00:00.000Z",
  "metadata": {}
}

Example request

curl -X POST https://api.codespar.dev/v1/wallets/{id}/ledger \
  -H "Authorization: Bearer $CODESPAR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
       "wallet_id": "wlt_0000000000000000",
       "org_id": "org_0000000000000000",
       "currency": "BRL",
       "amount_minor": "1000",
       "kind": "fund",
       "metadata": {}
     }'

GET /v1/wallets/{id}/receive

How to put money into a wallet, per rail

Path parameters

NameTypeRequiredDescription
idstringyesThe wallet id.

Query parameters

NameTypeRequiredDescription
currencystringnoCase-insensitive. Defaults to USDC, the only rail with an address.

Responses

StatusBodyDescription
200objectOK
404objectNot Found. No wallet with this id is visible to this credential.
409objectConflict. no_onchain_address: USDC was asked for on a wallet that is not consumer-scoped, so there is no consumer whose custody address could be resolved. Org and seller wallets are always in this state. Terminal for the request — retrying cannot change it.
502objectBad Gateway. cdp_unavailable: the custody provider could not be reached or refused, so no address could be resolved. error.details.detail carries the underlying failure as free text for an operator. Transient — retry.

Response 200

FieldTypeRequiredDescription
addressstring,nullyesThe Base address to send USDC to. Null on every fiat rail.
asset_contractstring,nullyesThe USDC ERC-20 contract on network. Check it before sending: it is what distinguishes real USDC from a token that merely calls itself that.
currencystringyes
network"base" | "base-sepolia"yesNon-null only on onchain, and decided by the credential's environment rather than by anything you send.
notestringyesOne sentence a dashboard can render as-is. Prose, not a code to branch on.
rail"onchain" | "pix" | "wire" | "unknown"yesonchain only for USDC. unknown means no deposit path is defined.

Example response

{
  "currency": "BRL",
  "rail": "onchain",
  "network": "base",
  "note": "string"
}

Example request

curl -X GET https://api.codespar.dev/v1/wallets/{id}/receive \
  -H "Authorization: Bearer $CODESPAR_API_KEY"

GET /v1/wallets/{id}/recon-anomalies

List reconciliation anomalies the engine has flagged. Default status filter is open.

Path parameters

NameTypeRequiredDescription
idstringyes

Responses

StatusBodyDescription
200objectOK
404objectNot Found

Response 200

FieldTypeRequiredDescription
anomaliesarray of WalletReconAnomalyyes

Example response

{
  "anomalies": [
    {
      "id": "walletreconanomaly_0000000000000000",
      "wallet_id": "wlt_0000000000000000",
      "org_id": "org_0000000000000000",
      "kind": "debit_without_receipt",
      "currency": "BRL",
      "detected_at": "2026-01-15T12:00:00.000Z",
      "status": "open",
      "metadata": {}
    }
  ]
}

Example request

curl -X GET https://api.codespar.dev/v1/wallets/{id}/recon-anomalies \
  -H "Authorization: Bearer $CODESPAR_API_KEY"

POST /v1/wallets/{id}/recon-anomalies/{aid}

Operator marks an open anomaly as resolved or dismissed. Admin role. Idempotent on the partial unique covering open rows.

Path parameters

NameTypeRequiredDescription
aidstringyes
idstringyes

Request bodyWalletAnomalyResolve

FieldTypeRequiredDescription
notestringno
status"resolved" | "dismissed"yes

Responses

StatusBodyDescription
200WalletReconAnomalyOK
404objectNot Found

Response 200

FieldTypeRequiredDescription
amount_minorstring,nullyes
currency"BRL" | "USD" | "MXN" | "COP" | "ARS" | "USDC" | "BRLA"yes
detected_atstring (date-time)yes
external_refstring,nullyes
idstringyes
kind"debit_without_receipt" | "receipt_without_debit"yes
ledger_entry_idstring,nullyes
metadataobjectyes
org_idstringyes
resolution_notestring,nullyes
resolved_atstring,null (date-time)yes
status"open" | "resolved" | "dismissed"yes
wallet_idstringyes

Example response

{
  "id": "walletreconanomaly_0000000000000000",
  "wallet_id": "wlt_0000000000000000",
  "org_id": "org_0000000000000000",
  "kind": "debit_without_receipt",
  "currency": "BRL",
  "detected_at": "2026-01-15T12:00:00.000Z",
  "status": "open",
  "metadata": {}
}

Example request

curl -X POST https://api.codespar.dev/v1/wallets/{id}/recon-anomalies/{aid} \
  -H "Authorization: Bearer $CODESPAR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
       "status": "resolved",
       "note": "string"
     }'

POST /v1/wallets/{id}/statement-import

Hand the reconciler a bank statement

Path parameters

NameTypeRequiredDescription
idstringyesThe wallet whose ledger these lines will be matched against.

Request body

FieldTypeRequiredDescription
entriesarray of objectyes
sourcestringyes

Responses

StatusBodyDescription
202objectAccepted. Every entry has been stored or recognized as already stored. Reconciliation has not run yet.
400objectBad Request. invalid_body, with the Zod issues in error.details.issues. The batch is all-or-nothing at this step: one malformed entry refuses the whole request and nothing is stored.
403objectForbidden, from the role gate, in a BARE body — not the apiError envelope the 400 and 404 use. insufficient_role when the acting member is not senior enough; on an API key, bearer_admin_role_missing, bearer_admin_user_not_member or bearer_admin_role_unresolved, each with a message and status.
404objectNot Found. No wallet with this id is visible to this credential.

Response 202

FieldTypeRequiredDescription
duplicatesintegeryesEntries already held under this (source, provider_event_id).
importedintegeryesEntries that were new.
next_recon_cycle_within_secondsintegeryesAn upper bound on the wait until matching runs — currently 60. A constant of the deployment, not a per-request estimate, and not a promise that a match will be found.
sourcestringyesEchoes what you sent.
wallet_idstringyes

Example response

{
  "wallet_id": "wlt_0000000000000000",
  "source": "string",
  "imported": 0,
  "duplicates": 0,
  "next_recon_cycle_within_seconds": 0
}

Example request

curl -X POST https://api.codespar.dev/v1/wallets/{id}/statement-import \
  -H "Authorization: Bearer $CODESPAR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
       "source": "string",
       "entries": [
         {
           "provider_event_id": "providerevent_0000000000000000",
           "amount_minor": 1000,
           "currency": "BRL",
           "occurred_at": "2026-01-15T12:00:00.000Z",
           "memo": "string"
         }
       ]
     }'

POST /v1/wallets/{id}/transfer

Withdraw from a consumer wallet under the consumer's signed mandate

Path parameters

NameTypeRequiredDescription
idstringyesThe wallet id. Must be consumer-scoped.

Request body

FieldTypeRequiredDescription
agent_idstringno
amount_minorintegeryes
currencystringyes
idempotency_keystringno
mandateno
purposestringno
signaturestringno
to_addressstringyes

Responses

StatusBodyDescription
201objectOK
400objectBad Request. invalid_body when the request does not match the schema, with the Zod issues in error.details.issues. invalid_mandate when the mandate object carries no consumer or no nonce, and invalid_payload when the verifier cannot read it at all. The four withdrawal-side codes reject the destination or the amount before any money moves: unsupported_currency, invalid_amount, invalid_address (USDC) and invalid_pix_key (BRL).
401objectUnauthorized, and it is about the MANDATE, not about your credential — your credential was already accepted to reach this handler. mandate_proof_invalid: no stored mandate matches this payload and signature. bad_signature: the verifier rejected the signature over the presented payload. Neither is retryable without a correctly signed mandate.
403object | object | objectForbidden, from one of THREE gates, in THREE different body shapes. Parse defensively: error is a string on two of them and an object on the third. (a) THE ROLE GATE, bare body: \{ error, required: "admin", message?, status? \}. error is insufficient_role when the acting member is not senior enough, bearer_admin_role_missing when no x-codespar-user was forwarded, bearer_admin_user_not_member when the forwarded user is not in the organization, or bearer_admin_role_unresolved when the role could not be looked up. The last three carry a message and status. (b) THE MANDATE GATE, apiError envelope. mandate_required (one of the four proof fields is missing), mandate_consumer_mismatch, mandate_currency_mismatch, mandate_\<status\> for a mandate that is not active, mandate_sig_invalid (the STORED row failed its own integrity check), expired, agent_mismatch, purpose_mismatch, per_tx_cap_exceeded, total_cap_exceeded, currency_not_authorized, merchant_not_allowed, and withdrawal_destination_unpinned — which carries error.details naming what the mandate DID authorize, so an agent can open a re-consent flow rather than stopping at a refusal it cannot interpret. (c) THE POLICY GATE, bare body: \{ error: "policy_denied", reason, ruleType, ruleId \}, each of the last three nullable. When the matched rule requires an approval the body also carries approval_id and expires_at; their presence is the signal that the withdrawal is pending a human rather than refused. total_cap_exceeded appears under (b) but is emitted from TWO places — the mandate verifier and the withdrawal executor's own cap check against what the mandate has already spent. The code and the envelope are the same either way; do not read it as proof of which check ran.
404objectNot Found, not_found, for two different misses that are deliberately indistinguishable: no such wallet for this credential, or a mandate belonging to another organization. Another tenant's mandate is invisible rather than forbidden, so its existence cannot be probed.
409objectConflict. no_onchain_wallet: the wallet is not consumer-scoped and has no withdrawal path at all. no_funding_source: the rail has no active funding source to cash out through. withdrawal_in_progress and withdrawal_failed: the idempotency key is already spent — see the note above. insufficient_funds: the wallet does not hold the amount, caught by the balance invariant rather than by a pre-check, so it can also surface after the hold is attempted. no_default_project: the paying organization has no default project for the mandate to settle into.
413objectPayload Too Large, from the policy gate rather than from a body-size limit on this route. BARE body.
422objectUnprocessable. insufficient_gas: the custody account cannot pay the network fee for this send. Nothing about the request is wrong and nothing has moved; it is an operational condition on our side.
502objectBad Gateway. send_failed: the rail refused or failed the send. The hold is released, so the balance is restored, and the withdrawal row is marked failed — which means the idempotency key, if you sent one, is now permanently spent and a retry needs a new one.
503object | objectService Unavailable, and this status is the one place where the SAME code arrives in two different shapes. policy_engine_error comes in the apiError envelope when the engine threw, and in a BARE \{ error: "policy_engine_error" \} when the engine answered with that reason instead. Either way it is fail-closed: nothing has moved. mandate_secret_unavailable and secret_unavailable mean the consumer's signing secret could not be read, so the mandate could be neither confirmed nor rejected; they always use the envelope. All three are transient — retry.

Response 201

FieldTypeRequiredDescription
amount_minorintegeryes
currencystringyes
networkstringyesbase or base-sepolia for USDC; pix for BRL.
status"sent"yesThe only value a 201 carries. It means the send was accepted by the rail, not that it has finally settled — for USDC that is a broadcast, not a confirmation depth.
to_addressstringyes
tx_hashstringyesThe on-chain transaction hash for USDC, or the Pix settlement id for BRL. One field, two meanings, decided by currency.
withdrawal_idstringyeswtd_-prefixed. The handle for reconciliation.

Example response

{
  "withdrawal_id": "withdrawal_0000000000000000",
  "tx_hash": "string",
  "status": "sent",
  "network": "string",
  "to_address": "string",
  "amount_minor": 1000,
  "currency": "BRL"
}

Example request

curl -X POST https://api.codespar.dev/v1/wallets/{id}/transfer \
  -H "Authorization: Bearer $CODESPAR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
       "currency": "BRL",
       "to_address": "string",
       "amount_minor": 1000,
       "idempotency_key": "string",
       "signature": "string",
       "agent_id": "agt_0000000000000000",
       "purpose": "string"
     }'
Wallets | CodeSpar