Skip to main content

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.

6 min read · updated

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:

  1. 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.
  2. 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."
  3. 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:

InvariantHow it's enforced
Every debit references a signed mandateThe 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 blockedAll four tables (consumer_funding_sources, consumer_consents, consumer_secrets, consent_tokens) carry org_id; every read filters by authContext.orgId
Consent is replayableconsumer_consents is append-only — UPDATE/DELETE never fire. Revocation inserts a kind='revoke' row. LGPD evidence stays whole
Consent tokens are one-shotconsent_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) optional

The 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:

TableMigrationHoldsAppend-only
consumer_funding_sources0040Active rails per (org_id, consumer_id) — vault_ref to provider_token, status, expiryNo (status flips active → revoked / expired)
consumer_consents0041Audit log of grant / revoke / amend events with HMAC signature, IP, UA, payloadYes
consumer_secrets0042Per-consumer HMAC secret versions (active / rotated). Secret material in the VaultNo (rotation flips active → rotated)
consent_tokens0043Hosted-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

RailCurrencyAdapterStatus (M1.1)
pix-consentBRLAsaas Open FinanceMock or asaas-staging (real round-trip, no settlement)
card-tokenUSD / BRLStripe payment intentsMock or stripe-staging (real Stripe API, capture_method=manual)
ted-debit-authBRLBanco Inter / Itaú TEDMock only (M1.6 wires real)
usd-ach-debitUSDWise / PlaidMock only (M1.6)
usdc-onchainUSDCCoinbase CommerceMock 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:

  1. Signature — HMAC against consumer_secrets.vault_ref for secret_version
  2. Expiry — mandate's own exp claim
  3. Funding source still activeconsumer_funding_sources.status='active'
  4. Cap not exceeded — running sum of past debits + the requested amount ≤ cap_minor
  5. 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_token is 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=false on every result — even the Stripe staging adapter that does a real Stripe API round-trip uses capture_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:

WalletsDirected-pay
Funds custodied byThe org (in the wallet)The consumer (at their bank / PSP)
Mandate gated byOrg policy (cap, kind whitelist)Consumer consent (cap, expiry, agent_id)
Signed byOrg service keyConsumer's HMAC secret
Debit endpointPOST /v1/wallets/:id/executePOST /v1/consumer-payments/execute
Storage scope(org_id, project_id) — project-scoped(org_id, consumer_id) — org-scoped
Reconciliationwallet_recon_anomaliesTBD (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_secrets table 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
Edit on GitHub

Last updated on