Wallets
Generated HTTP reference for the 15 operations the published OpenAPI document describes under wallets.
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
| 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
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 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
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
| Status | Body | Description |
|---|---|---|
200 | object | OK |
Response 200
| Field | Type | Required | Description |
|---|---|---|---|
wallets | array of Wallet | yes | — |
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 body — WalletCreate
| Field | Type | Required | Description |
|---|---|---|---|
agent_id | string,null | no | — |
currency | "BRL" | "USD" | "MXN" | "COP" | "ARS" | "USDC" | "BRLA" | yes | — |
display_name | string | yes | — |
metadata | object | no | — |
Responses
| Status | Body | Description |
|---|---|---|
201 | Wallet | OK |
Response 201
| Field | Type | Required | Description |
|---|---|---|---|
agent_id | string,null | yes | — |
balances | array of WalletBalance | no | — |
closed_at | string,null (date-time) | yes | — |
created_at | string (date-time) | yes | — |
display_name | string | yes | — |
id | string | yes | — |
metadata | object | yes | — |
org_id | string | yes | — |
project_id | string | yes | — |
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
| Name | Type | Required | Description |
|---|---|---|---|
id | string | yes | — |
Responses
| Status | Body | Description |
|---|---|---|
200 | Wallet | OK |
404 | object | Not Found |
Response 200
| Field | Type | Required | Description |
|---|---|---|---|
agent_id | string,null | yes | — |
balances | array of WalletBalance | no | — |
closed_at | string,null (date-time) | yes | — |
created_at | string (date-time) | yes | — |
display_name | string | yes | — |
id | string | yes | — |
metadata | object | yes | — |
org_id | string | yes | — |
project_id | string | yes | — |
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
| Name | Type | Required | Description |
|---|---|---|---|
id | string | yes | The wallet id. |
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
currency | string | no | Case-insensitive, and USDC is the only accepted value. Anything else is 409, not 400 — see below. |
Responses
| Status | Body | Description |
|---|---|---|
200 | object | OK |
404 | object | Not Found. No wallet with this id is visible to this credential. |
409 | object | Conflict, 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. |
502 | object | Bad 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
| Field | Type | Required | Description |
|---|---|---|---|
address | string | yes | The same address GET /v1/wallets/\{id\}/receive hands out. That symmetry is the point. |
currency | "USDC" | yes | — |
difference_atomic | string | yes | onchain_atomic - ledger_atomic. Signed; a leading - is the dangerous case. |
ledger_atomic | string | yes | What the ledger says is spendable. Atomic USDC. |
network | "base" | "base-sepolia" | yes | From the credential's environment: live is base, test is base-sepolia. |
note | string | yes | One 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_at | string (date-time) | yes | When the chain was read. |
onchain_atomic | string | yes | What the address holds. Atomic USDC. |
state | "reconciled" | "unattributed_credit" | "ledger_exceeds_custody" | yes | — |
wallet_id | string | yes | — |
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
| Name | Type | Required | Description |
|---|---|---|---|
id | string | yes | — |
Request body — WalletExecute
| Field | Type | Required | Description |
|---|---|---|---|
amount | number | yes | — |
attempt_id | string | no | — |
currency | "BRL" | "USD" | "MXN" | "COP" | "ARS" | "USDC" | "BRLA" | yes | — |
description | string | yes | — |
mandate_id | string | yes | — |
metadata | object | no | — |
preferred_method | string | no | — |
purpose | string | no | — |
recipient | string | yes | — |
target_currency | "USD" | "EUR" | "BRL" | "MXN" | no | — |
Responses
| Status | Body | Description |
|---|---|---|
200 | GatewayPaymentResult | OK |
402 | GatewayPaymentResult | OK |
403 | GatewayPaymentResult | OK |
404 | object | Not Found |
422 | GatewayPaymentResult | OK |
Response 200
| Field | Type | Required | Description |
|---|---|---|---|
audit | array of object | yes | — |
requestId | string | yes | — |
status | "completed" | "denied" | "requires-approval" | "failed" | yes | — |
wallet | object | no | — |
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
| Name | Type | Required | Description |
|---|---|---|---|
id | string | yes | — |
Responses
| Status | Body | Description |
|---|---|---|
200 | object | OK |
404 | object | Not Found |
Response 200
| Field | Type | Required | Description |
|---|---|---|---|
funding_sources | array of WalletFundingSource | yes | — |
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
| Name | Type | Required | Description |
|---|---|---|---|
id | string | yes | — |
Request body — WalletFundingSourceBind
| Field | Type | Required | Description |
|---|---|---|---|
connection_id | string | yes | — |
currency | "BRL" | "USD" | "MXN" | "COP" | "ARS" | "USDC" | "BRLA" | yes | — |
metadata | object | no | — |
Responses
| Status | Body | Description |
|---|---|---|
201 | WalletFundingSource | OK |
404 | object | Not Found |
Response 201
| Field | Type | Required | Description |
|---|---|---|---|
connection_id | string | yes | — |
created_at | string (date-time) | yes | — |
currency | "BRL" | "USD" | "MXN" | "COP" | "ARS" | "USDC" | "BRLA" | yes | — |
enabled | boolean | yes | — |
metadata | object | yes | — |
wallet_id | string | yes | — |
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
| Name | Type | Required | Description |
|---|---|---|---|
connection_id | string | yes | — |
currency | "BRL" | "USD" | "MXN" | "COP" | "ARS" | "USDC" | "BRLA" | yes | — |
id | string | yes | — |
Responses
| Status | Body | Description |
|---|---|---|
204 | — | No Content |
404 | object | Not 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
| Name | Type | Required | Description |
|---|---|---|---|
id | string | yes | — |
Responses
| Status | Body | Description |
|---|---|---|
200 | object | OK |
404 | object | Not Found |
Response 200
| Field | Type | Required | Description |
|---|---|---|---|
entries | array of WalletLedgerEntry | yes | — |
next_before | string,null | yes | — |
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
| Name | Type | Required | Description |
|---|---|---|---|
id | string | yes | — |
Request body — WalletLedgerPost
| Field | Type | Required | Description |
|---|---|---|---|
amount_minor | string | yes | — |
attempt_id | string,null | yes | — |
currency | "BRL" | "USD" | "MXN" | "COP" | "ARS" | "USDC" | "BRLA" | yes | — |
external_ref | string,null | yes | — |
kind | "fund" | "hold" | "release" | "debit" | "reconcile" | "reverse" | "fee" | yes | — |
mandate_id | string,null | yes | — |
metadata | object | no | — |
org_id | string | yes | — |
wallet_id | string | yes | — |
Responses
| Status | Body | Description |
|---|---|---|
200 | WalletLedgerEntry | OK |
201 | WalletLedgerEntry | OK |
404 | object | Not Found |
Response 200
| Field | Type | Required | Description |
|---|---|---|---|
amount_minor | string | yes | bigint signed minor units |
attempt_id | string,null | yes | — |
currency | "BRL" | "USD" | "MXN" | "COP" | "ARS" | "USDC" | "BRLA" | yes | — |
external_ref | string,null | yes | — |
id | string | yes | bigserial as string |
kind | "fund" | "hold" | "release" | "debit" | "reconcile" | "reverse" | "fee" | yes | — |
mandate_id | string,null | yes | — |
metadata | object | yes | — |
org_id | string | yes | — |
posted_at | string (date-time) | yes | — |
reconciled_at | string,null (date-time) | yes | — |
wallet_id | string | yes | — |
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
| Name | Type | Required | Description |
|---|---|---|---|
id | string | yes | The wallet id. |
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
currency | string | no | Case-insensitive. Defaults to USDC, the only rail with an address. |
Responses
| Status | Body | Description |
|---|---|---|
200 | object | OK |
404 | object | Not Found. No wallet with this id is visible to this credential. |
409 | object | Conflict. 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. |
502 | object | Bad 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
| Field | Type | Required | Description |
|---|---|---|---|
address | string,null | yes | The Base address to send USDC to. Null on every fiat rail. |
asset_contract | string,null | yes | The USDC ERC-20 contract on network. Check it before sending: it is what distinguishes real USDC from a token that merely calls itself that. |
currency | string | yes | — |
network | "base" | "base-sepolia" | yes | Non-null only on onchain, and decided by the credential's environment rather than by anything you send. |
note | string | yes | One sentence a dashboard can render as-is. Prose, not a code to branch on. |
rail | "onchain" | "pix" | "wire" | "unknown" | yes | onchain 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
| Name | Type | Required | Description |
|---|---|---|---|
id | string | yes | — |
Responses
| Status | Body | Description |
|---|---|---|
200 | object | OK |
404 | object | Not Found |
Response 200
| Field | Type | Required | Description |
|---|---|---|---|
anomalies | array of WalletReconAnomaly | yes | — |
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
| Name | Type | Required | Description |
|---|---|---|---|
aid | string | yes | — |
id | string | yes | — |
Request body — WalletAnomalyResolve
| Field | Type | Required | Description |
|---|---|---|---|
note | string | no | — |
status | "resolved" | "dismissed" | yes | — |
Responses
| Status | Body | Description |
|---|---|---|
200 | WalletReconAnomaly | OK |
404 | object | Not Found |
Response 200
| Field | Type | Required | Description |
|---|---|---|---|
amount_minor | string,null | yes | — |
currency | "BRL" | "USD" | "MXN" | "COP" | "ARS" | "USDC" | "BRLA" | yes | — |
detected_at | string (date-time) | yes | — |
external_ref | string,null | yes | — |
id | string | yes | — |
kind | "debit_without_receipt" | "receipt_without_debit" | yes | — |
ledger_entry_id | string,null | yes | — |
metadata | object | yes | — |
org_id | string | yes | — |
resolution_note | string,null | yes | — |
resolved_at | string,null (date-time) | yes | — |
status | "open" | "resolved" | "dismissed" | yes | — |
wallet_id | string | yes | — |
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
| Name | Type | Required | Description |
|---|---|---|---|
id | string | yes | The wallet whose ledger these lines will be matched against. |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
entries | array of object | yes | — |
source | string | yes | — |
Responses
| Status | Body | Description |
|---|---|---|
202 | object | Accepted. Every entry has been stored or recognized as already stored. Reconciliation has not run yet. |
400 | object | Bad 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. |
403 | object | Forbidden, 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. |
404 | object | Not Found. No wallet with this id is visible to this credential. |
Response 202
| Field | Type | Required | Description |
|---|---|---|---|
duplicates | integer | yes | Entries already held under this (source, provider_event_id). |
imported | integer | yes | Entries that were new. |
next_recon_cycle_within_seconds | integer | yes | An 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. |
source | string | yes | Echoes what you sent. |
wallet_id | string | yes | — |
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
| Name | Type | Required | Description |
|---|---|---|---|
id | string | yes | The wallet id. Must be consumer-scoped. |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
agent_id | string | no | — |
amount_minor | integer | yes | — |
currency | string | yes | — |
idempotency_key | string | no | — |
mandate | — | no | — |
purpose | string | no | — |
signature | string | no | — |
to_address | string | yes | — |
Responses
| Status | Body | Description |
|---|---|---|
201 | object | OK |
400 | object | Bad 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). |
401 | object | Unauthorized, 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. |
403 | object | object | object | Forbidden, 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. |
404 | object | Not 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. |
409 | object | Conflict. 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. |
413 | object | Payload Too Large, from the policy gate rather than from a body-size limit on this route. BARE body. |
422 | object | Unprocessable. 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. |
502 | object | Bad 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. |
503 | object | object | Service 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
| Field | Type | Required | Description |
|---|---|---|---|
amount_minor | integer | yes | — |
currency | string | yes | — |
network | string | yes | base or base-sepolia for USDC; pix for BRL. |
status | "sent" | yes | The 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_address | string | yes | — |
tx_hash | string | yes | The on-chain transaction hash for USDC, or the Pix settlement id for BRL. One field, two meanings, decided by currency. |
withdrawal_id | string | yes | wtd_-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"
}'