Wallets
Programmable wallets — per-agent fund pools with mandate-gated debits, multi-rail funding (Pix, TED, USD wire, USDC, BRLA), and automatic reconciliation. Part of the AgentGate managed tier.
Wallets
A wallet is a per-agent fund pool. The agent can spend only from this pool — never from the org's main account — and every spend is mandate-gated. Multi-rail funding (Pix, TED, USD wire, USDC, BRLA) lands on the same ledger; reconciliation against provider receipts runs automatically.
Wallets are part of AgentGate, the commerce-governance capability set in the managed tier. Self-host customers get a single-tenant reference wallet; the multi-tenant ledger, recon engine, and accounting export ship in the managed tier.
F2.M4 Programmable Wallets shipped in April 2026. The companion design doc is docs/designs/DESIGN-F2.M4-programmable-wallets.md in the codespar-web repo.
Why wallets
Without a wallet primitive, agent spend either goes through the org's main account (no segregation) or against an abstract policy budget (no real funds, no real settlement). Both fail the auditor test for regulated enterprise customers.
A wallet adds three things at once:
- Funds segregation — the agent has a per-pool balance the operator funded explicitly. Cross-tenant transfers are blocked at the database layer.
- Mandate gating — every debit references a signed mandate with cap + expiry. The HMAC verification + cap check + the wallet's own available-funds check are three independent gates; bypassing all three requires a coordinated bug in three packages.
- Reconciliation — every debit pairs with a provider receipt. The recon engine flags unmatched debits and orphan receipts so the operator sees mismatches without reading raw logs.
The non-overridable invariants
Four invariants enforced at the database layer (not app code):
| Invariant | How it's enforced |
|---|---|
| Wallet debits are mandate-gated | wallet_ledger CHECK constraint: kind IN ('hold','debit') requires mandate_id IS NOT NULL |
| Cross-tenant operations are blocked | Every wallet route filters by (org_id, project_id) from authContext; foreign keys cascade via org_id |
| Negative balance is impossible | wallet_balances CHECK: balance_minor >= 0 AND available_minor >= 0 AND available_minor <= balance_minor |
| Reconciliation anomalies require human review | Debits older than grace with no matching provider event land in wallet_recon_anomalies; resolved by the operator, not the engine |
App-layer bugs cannot bypass these. A retried hold that would overdraw trips the CHECK; the transaction rolls back; the runtime returns a typed InsufficientFundsError.
Lifecycle
┌──────────────┐
│ fund │ webhook from provider →
│ (kind=fund) │ bridge inserts ledger row
└──────┬───────┘ → balance + available go up
│
▼
┌──────────────┐
│ hold │ gateway reserves available
│ (kind=hold) │ → available drops, balance same
└──────┬───────┘
│
┌────────────┴────────────┐
▼ ▼
┌──────────────┐ ┌──────────────┐
│ debit │ │ release │ abort path:
│ (commit) │ │ (rollback) │ available restored,
└──────┬───────┘ └──────┬───────┘ no money moved
│ │
▼ ▼
balance + available balance + available
drop by amount + fee unchangedEvery entry is append-only. Corrections post a new kind=reverse row; existing rows are never updated except for reconciled_at (set by the recon engine).
Funding rails
The funding bridge is provider-agnostic: every webhook adapter normalizes payments to commerce.payment.{succeeded,refunded,failed} and the same code path posts to the wallet ledger. A new provider is one adapter entry — wallet code untouched.
| Rail | Provider | Currency | Auth |
|---|---|---|---|
| Pix | Asaas | BRL | API key (access_token header) |
| Card | Stripe | USD / BRL | webhook signing secret |
| Card / Pix | Mercado Pago | BRL | HMAC over id;request-id;ts |
| Card / Pix | Zoop | BRL | HTTP Basic |
| Stablecoin onramp | UnblockPay | USDC | HMAC over body |
| Stablecoin onramp | BRLA Digital | BRLA | HMAC over body |
To bind a connection as a funding source, see POST /v1/wallets/:id/funding-sources. The dashboard panel at /dashboard/wallets/{id} does this visually.
Execute lifecycle
POST /v1/wallets/:id/execute runs the full gateway lifecycle:
policy_check → mandate_check → wallet_hold → route_select →
payment_execution → wallet_settle (debit | release) → budget_recordEach step produces an audit entry. The dashboard's Execute modal renders the full audit trail inline — every gate (pass / fail / skip) with the detail string. This is the primary differentiation surface against Stripe Issuing or Brex when walking a CFO through a denied transaction.
| Outcome | HTTP | Wallet impact |
|---|---|---|
completed | 200 | hold + release + debit (+ optional fee) |
requires-approval (insufficient funds) | 402 | no hold posted; top up to retry |
denied (policy or mandate fail) | 403 | no hold posted |
failed (route or execute) | 422 | hold + release (no debit) |
Reconciliation
A two-pass periodic engine (every 60s):
- Match debits to events — unreconciled
kind=debitrows older than the grace window get matched againstevents.provider_event_id. Hit → stampreconciled_at. Miss → flag asdebit_without_receipt. - Flag orphan receipts —
commerce.payment.*events with no matching wallet ledger row are flagged asreceipt_without_debitagainst the connection's bound wallet.
When the webhook never arrived (provider lost the event, delivery URL misconfigured), the engine can fall back to provider-side fetch — querying the provider's API directly for the receipt. Behind a per-environment flag (WALLET_RECON_PROVIDER_FETCH=on).
The dashboard recon panel auto-collapses on clean wallets — when there's nothing to triage, there's no panel. When the engine flags, the panel surfaces with Resolve / Dismiss buttons inline.
Crash-recovery sweeper
Every 5 minutes a sweeper scans for holds older than mandate.expires_at OR a 24h hard ceiling, with no matching release or debit. Each survivor gets an automatic release. This catches:
- gateway crashed between hold and the settle step
- provider execute hung past timeout, no settle event arrived
- operator-side bug stranded a hold
A hold that the provider DID charge gets released by the sweeper AND surfaces in the recon engine as a debit-without-receipt orphan. The operator manually reconciles.
Stablecoin entry path (M4.2)
USDC and BRLA enter via webhook adapters (UnblockPay and BRLA Digital). Both rails land on the same ledger as fiat — an enterprise agent operating in BRL + USDC sees one wallet_balances table with multiple currency rows.
The execute path does not yet route stablecoin currencies — calling POST /v1/wallets/:id/execute with currency=USDC or currency=BRLA returns 400 currency_not_routable. The offramp execute path (BRLA→Pix-out, USDC→fiat) ships in M4.4.
Self-host parity
Per the VISION five-point MIT commitment, self-host customers get the OSS reference wallet: single-tenant, file-backed double-entry ledger, no settlement automation. The managed tier adds:
- Multi-tenant ledger with per-tenant scrypt-derived encryption keys
- Reconciliation engine (event match + provider-side fetch)
- Crash-recovery sweeper
- Per-tenant accounting export (CSV / SAP / NetSuite / Conta Azul)
Both tiers enforce the same four invariants. The managed tier adds operational primitives, not safety primitives.
Next
- Wallets API reference — REST endpoints
- Build an agent with a wallet (cookbook) — Asaas sandbox end-to-end
- Authentication — service-key vs bearer
- Projects — wallets are project-scoped
Last updated on