Directed-pay
Consumer-mandate flow — orgs charge an end-consumer's rail (Pix consent, card token, TED debit-auth) under a signed, capped, revocable consent. The non-wallet half of AgentGate's commerce primitives.
Directed-pay
A directed-pay flow lets an org 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 AgentGate's commerce primitives. Wallets segregate an org'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 org'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 org's customer of the org. The org 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 org 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
│ org 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
▼
┌──────────────────────────┐ org callback receives the
│ org backend 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 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.
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 org (in the wallet) | The consumer (at their bank / PSP) |
| Mandate gated by | Org policy (cap, kind whitelist) | Consumer consent (cap, expiry, agent_id) |
| Signed by | Org 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) — org-scoped |
| Reconciliation | wallet_recon_anomalies | TBD (M1.6 — open finance event match) |
A common composition: an agent holds an org 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 org-funds half of the same mandate machinery
- Authentication — service-key vs bearer
- Projects — directed-pay is org-scoped, wallets are project-scoped
Last updated on