Directed-pay
Consumer-mandate flow — accounts charge an end-consumer's rail (Pix consent, card token, TED debit-auth) under a signed, capped, revocable consent. The non-wallet half of the governed commerce primitives.
Directed-pay
A directed-pay flow lets an account charge an end-consumer's funding rail — their Pix consent, their card token, their TED debit authorization — under a signed mandate that names the agent, the cap, and the expiry. The consumer signs once at a hosted consent page; every subsequent debit re-verifies the signature against the consumer's HMAC secret and the cap on the original consent.
It's the non-wallet half of the governed commerce primitives. Wallets segregate an account's own funds for an agent to spend. Directed-pay lets that agent debit a consumer's rail under explicit consent. Both surface in /dashboard (Wallets and Consumers respectively) and share the same mandate verification path.
M1.x is shipping in stages. M1.0–M1.5 ship the schema, the consent flow, the lifecycle, the SSE step stream, and the Stripe staging adapter. M1.6 adds real Open Finance / TED debit-auth / Coinbase adapters. Dashboard surfaces a SANDBOX banner until M1.4–M1.6 unlocks the real-money path.
Why directed-pay
Wallets cover one half of agent commerce: spend the account's funds. The other half — debiting a consumer's account on the consumer's behalf — needs a different primitive because:
- The consumer is not the account's customer of the account. The account is the platform; the consumer is the platform's end-user. The consumer authorizes once, then leaves; the agent acts later, asynchronously, possibly across many sessions.
- The cap and expiry belong to the consumer, not the agent. Wallet mandates are gated by account policy. Directed-pay mandates are gated by consumer consent — the consumer signs "agent X may debit me up to R$500 over the next 90 days for purpose Y."
- Audit-grade evidence is regulatory. Open Finance Brasil, BCB, and LGPD all require replayable consent: timestamp, IP, user-agent, exact payload signed. The consent log is append-only by design — a revocation is a new row with
kind='revoke', never an update.
Wallets without directed-pay would force every consumer-charging agent to either (a) collect raw card tokens client-side and bypass mandate verification, or (b) custody consumer funds in the wallet first and then debit the wallet — a custody posture most agents can't take.
The non-overridable invariants
Four invariants enforced at the database layer:
| Invariant | How it's enforced |
|---|---|
| Every debit references a signed mandate | The verifier checks the mandate JWT's HMAC against consumer_secrets.vault_ref for the version stamped on the consent row — past mandates verify under past secrets, not the current one |
| Cross-tenant operations are blocked | All four tables (consumer_funding_sources, consumer_consents, consumer_secrets, consent_tokens) carry org_id; every read filters by authContext.orgId |
| Consent is replayable | consumer_consents is append-only — UPDATE/DELETE never fire. Revocation inserts a kind='revoke' row. LGPD evidence stays whole |
| Consent tokens are one-shot | consent_tokens carries a partial unique index on status='pending' per (org_id, agent_id) — the second submit attempt for the same token trips the index |
App-layer bugs cannot bypass these. A double-submit hits the partial unique; a missing mandate_id on a debit-shaped ledger row trips the wallet's CHECK constraint; cross-tenant reads find no rows.
The four-step flow
┌──────────────────────────┐ POST /v1/consents/init
│ account backend mints a │ → consent_tokens row (status=pending)
│ one-shot token │ → returns hosted URL /consent/[token]
└────────────┬─────────────┘
│
▼
┌──────────────────────────┐ GET /consent/[token]
│ consumer opens the │ POST /consent/[token] submit:
│ hosted page, picks rail │ - provision consumer_secret (v1)
│ + signs the consent │ - insert consumer_funding_source
└────────────┬─────────────┘ - insert consumer_consents (kind=grant)
│ - mark consent_tokens row consumed
│ - sign the mandate, return JWT
▼
┌──────────────────────────┐ account callback receives the
│ account stores the │ signed mandate JWT + signature
│ mandate (server-side) │ → typically alongside the order
└────────────┬─────────────┘
│
▼
┌──────────────────────────┐ POST /v1/consumer-payments/execute
│ agent debits the rail │ with the mandate JWT
│ on the consumer's behalf│ → verify, hold, debit (or release)
└──────────────────────────┘ → audit step stream (SSE) optionalThe consent token is the only auth surface for the consumer-facing leg — the consumer doesn't have a CodeSpar bearer token, and shouldn't. The token in the URL is the auth; the partial unique on consent_tokens.status='pending' is the replay guard.
Storage
Four migrations land the directed-pay surface:
| Table | Migration | Holds | Append-only |
|---|---|---|---|
consumer_funding_sources | 0040 | Active rails per (org_id, consumer_id) — vault_ref to provider_token, status, expiry | No (status flips active → revoked / expired) |
consumer_consents | 0041 | Audit log of grant / revoke / amend events with HMAC signature, IP, UA, payload | Yes |
consumer_secrets | 0042 | Per-consumer HMAC secret versions (active / rotated). Secret material in the encrypted credential vault | No (rotation flips active → rotated) |
consent_tokens | 0043 | Hosted-page tokens — pending until submitted, then consumed (one-shot) | No |
The funding source carries the active rail credential; the consent log carries the evidence the consumer authorized a specific debit pattern. They're separate tables because:
- A consumer can sign a single grant against multiple rails (Pix + card) — three funding sources, one consent.
- Rotating the secret invalidates future mandates. Past consent rows still verify under the version stamped on each row, so the audit trail survives rotation.
Rails
| Rail | Currency | Adapter | Status (M1.1) |
|---|---|---|---|
pix-consent | BRL | Asaas Open Finance | Mock or asaas-staging (real round-trip, no settlement) |
card-token | USD / BRL | Stripe payment intents | Mock or stripe-staging (real Stripe API, capture_method=manual) |
ted-debit-auth | BRL | Banco Inter / Itaú TED | Mock only (M1.6 wires real) |
usd-ach-debit | USD | Wise / Plaid | Mock only (M1.6) |
usdc-onchain | USDC | Coinbase Commerce | Mock only (M1.6) |
The PSP adapter mode is selected from env (CONSUMER_PSP_ASAAS_MODE, CONSUMER_PSP_STRIPE_MODE). Every adapter returns a moneyMoved: boolean on its result; mock + staging stubs return false. The audit row stamps the adapter id (asaas-staging, stripe-staging, mock) so an operator looking at a past payment knows whether real settlement happened.
The dashboard /dashboard/consumers surface renders a SANDBOX pill on every funding source row in M1.1 because every PSP path returns moneyMoved=false. Once M1.4 unlocks per-rail real-money paths, the pill becomes per-row.
Mandate verification
The mandate is a JWT signed with the consumer's HMAC secret at the version stamped on the consent row. The verifier (packages/consumer-mandate) checks five things on every POST /v1/consumer-payments/execute:
- Signature — HMAC against
consumer_secrets.vault_refforsecret_version - Expiry — mandate's own
expclaim - Funding source still active —
consumer_funding_sources.status='active' - Cap not exceeded — running sum of past debits + the requested amount ≤
cap_minor - Per-tx cap not exceeded — single debit ≤
per_tx_cap_minor
Any failure returns a typed error with the specific gate that tripped. The audit row stamps the gate name so a CFO walking through a denied debit sees the exact reason — "signature_failed", "cap_exceeded:35000", etc.
Mandate lifecycle
A mandate is not sign-once, live-forever. Three transitions flip its lifecycle status, and the execute path refuses any mandate that is not active:
| Endpoint | Transition |
|---|---|
POST /v1/consumers/mandates/:id/pause | active to paused |
POST /v1/consumers/mandates/:id/resume | paused to active |
POST /v1/consumers/mandates/:id/revoke | active or paused to revoked (terminal) |
The body is optional: { "reason": "…" } (max 280 chars). Each applied transition does two things atomically: it flips the mandate status and appends a consumer_consents evidence row (kind=amend for pause/resume, kind=revoke for revoke) carrying the IP, the user agent, and a hash of the transition payload. The LGPD trail stays append-only: a revocation is a new row, never an update.
Transitions are idempotent and optimistically locked:
- Already at the target state:
200with{ "mandate": { "id": "cm_…", "status": "paused" }, "changed": false }and no new evidence row. - Invalid source state (for example, resuming a revoked mandate):
409 invalid_transition. - The status changed concurrently mid-flight:
409 transition_conflict. Re-read and retry. - Unknown or cross-tenant mandate:
404 mandate_not_found.
Receipts
GET /v1/consumers/mandates/:id/receipts?limit=50 is the consumer-facing receipt feed for a mandate: every settled debit against it, newest first, read straight from the wallet ledger.
{
"mandate_id": "cm_…",
"receipts": [
{
"ledger_id": "1042",
"amount_minor": "1990",
"currency": "BRL",
"psp_tx_id": "E12345…",
"payee": "pix-key@example.com",
"purpose": "groceries",
"settled_at": "2026-08-06T12:00:00Z"
}
]
}amount_minor is the positive spend amount. psp_tx_id carries the rail-native settlement reference (the Pix EndToEndId when the rail produced one). limit accepts 1 to 200, default 50.
Per-step execution stream (SSE)
POST /v1/consumer-payments/execute-stream runs the same lifecycle as POST /v1/consumer-payments/execute, same body, but answers as a Server-Sent Events stream with one frame per step, so a dashboard can render the audit chain as it builds:
event: hello data: { "ts": … }
event: step data: { "timestamp": "…", "step": "psp_debit", "status": "pass", "detail": "…" }
event: result data: { …the full execute response… }
event: done data: { "ts": … }A failed run emits event: error with the same code, message, and HTTP status the single-shot endpoint would have returned, then done. Both endpoints produce the identical audit array; the stream only changes when you see it.
Sandbox posture (M1.1)
Until M1.4 ships real adapters, two things are stubbed:
provider_tokenis unverified. The consent submit endpoint stores whatever the consumer pastes (or whatever Stripe Elements returns client-side). Real Open Finance handshakes / TED debit-auth flows / Coinbase address resolution land in M1.4.- Every PSP debit is mock or staging.
moneyMoved=falseon every result — even the Stripe staging adapter that does a real Stripe API round-trip usescapture_method='manual'and never captures.
The dashboard's /dashboard/consumers page banners both facts above the tables. An operator reviewing the surface during M1.x cannot mistake a row with status=active for a real-money commitment.
Distinction from wallets
Both primitives share the audit row format and the mandate verifier. They differ in custody and gating:
| Wallets | Directed-pay | |
|---|---|---|
| Funds custodied by | The account (in the wallet) | The consumer (at their bank / PSP) |
| Mandate gated by | Account policy (cap, kind whitelist) | Consumer consent (cap, expiry, agent_id) |
| Signed by | Account service key | Consumer's HMAC secret |
| Debit endpoint | POST /v1/wallets/:id/execute | POST /v1/consumer-payments/execute |
| Storage scope | (org_id, project_id) — project-scoped | (org_id, consumer_id) — account-scoped |
| Reconciliation | wallet_recon_anomalies | TBD (M1.6 — open finance event match) |
A common composition: an agent holds an account wallet for fees + a directed-pay mandate to debit a customer for the principal. The execute path on the consumer side returns a debit receipt; the wallet side records the platform fee.
Self-host parity
Per the VISION five-point MIT commitment, self-host customers get the OSS reference verifier — single-tenant, in-process HMAC verification against an env-stored secret. The managed tier adds:
- Multi-tenant
consumer_secretstable with per-tenant scrypt-derived encryption keys - Hosted consent page at
/consent/[token]with replay-guard partial unique - LGPD-compliant consent log with append-only invariant + IP / UA capture
- Dashboard surface for grant / revoke audit
Both tiers enforce the same four invariants. The managed tier adds operational primitives, not safety primitives.
Next
- Wallets — the account-funds half of the same mandate machinery
- Authentication — service-key vs bearer
- Projects — directed-pay is account-scoped, wallets are project-scoped
Wallets
Programmable wallets — per-agent fund pools with mandate-gated debits, multi-rail funding (Pix, TED, USD wire, USDC, BRLA), and automatic reconciliation. Governed by Guardrails in the managed tier.
Pay to Agent
Pay another agent by Agent ID instead of a raw address. The recipient must be a registered, non-revoked identity — never an anonymous wallet. Payouts, splits, and tips over USDC on Base mainnet.