---
title: Directed-pay
description: 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 AgentGate's commerce primitives.
---

import { Callout } from "fumadocs-ui/components/callout";

# 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 AgentGate's 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.

<Callout type="info">
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.
</Callout>

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

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

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

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

1. **Signature** — HMAC against `consumer_secrets.vault_ref` for `secret_version`
2. **Expiry** — mandate's own `exp` claim
3. **Funding source still active** — `consumer_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:

| | 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_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](/docs/concepts/wallets) — the account-funds half of the same mandate machinery
- [Authentication](/docs/concepts/authentication) — service-key vs bearer
- [Projects](/docs/concepts/projects) — directed-pay is account-scoped, wallets are project-scoped
