#Controlled Funding API — Overview

A sponsor funds someone else's card at the moment of the tap, from the sponsor's own bank account or card, and only if the transaction obeys the rules the sponsor set. No prefunding, no stored value, no shared wallet.
- Reference:
reference.md— every endpoint, with real captured traffic. - Building an app?
AGENT_BUILD_GUIDE.md— a build brief for humans and coding agents. - Want ideas?
SAMPLE_PROMPTS.md— three complete product briefs to paste into an agentic development platform. - Machine-readable:
GET /api/openapi(OpenAPI 3.1), rendered at/docs.
#1. What problem this solves
A parent wants their child to be able to buy lunch, but not a video game. An employer wants to cover a contractor's travel, but not their groceries. A friend wants to cover one dinner, tonight, up to $60.
The traditional answer is a prepaid wallet: move money to the recipient first, then hope the rules hold. That creates float, stale balances, refunds that go to the wrong place, and money sitting somewhere it does not belong.
This API takes the opposite approach. The sponsor's money stays in the sponsor's account until a compliant transaction actually happens. At the tap:
- the rules are checked (Gate 1 — policy),
- the sponsor's instrument is checked and pulled (Gate 2 — funding),
- the exact amount funded by each sponsor is recorded as an allocation, so a later refund goes back to the account that actually paid.
A decline never moves money and never contacts a bank if the rules failed.
#2. The domain model in one picture
party (sponsor) party (recipient)
│ │
├── linked_payment_source (the real bank/card) ├── card (the mCard that gets tapped)
│ │ │ │
│ └───────────────┐ │ └── personal_funding (optional, consented, last resort)
│ │ │
└── commitment ──────────────┴──────────────────────┘
│ "I, Dana, will fund Alex's card from THIS account,
│ under THIS policy, up to THIS cap, at THIS priority."
│
├── policy ─── policy_version (immutable, versioned rules)
└── budget_cap (e.g. $50 / week)
tap ─────► authorization
│
├── authorization_allocation (per sponsor slice)
│ ├── pull_transaction (money left the sponsor's instrument)
│ └── ledger_entry (double-entry record)
└── decision_detail (why: gate 1 + gate 2 trace)
| Object | What it is |
|---|---|
party |
A person, business or organization. A sponsor and a recipient are both parties; nothing about a party says which role it plays. |
linked_payment_source |
A real payment instrument owned by a party (bank_account, card, credit_line). Never a balance we hold. |
card |
The recipient's mCard — the thing that gets tapped. It has no balance of its own. |
commitment |
The sponsor's promise: sponsor + one instrument + recipient + priority + validity window. This is the unit routing chooses between. |
policy / policy_version |
The rules, as a versioned boolean expression tree (data, not code). Versions are immutable; a policy points at one active version. |
budget_cap |
A spend ceiling for a commitment over a period (daily, weekly, monthly, lifetime, or none). |
authorization |
One tap. Always created (201), whether approved or declined, and always carries a full decision trace. |
authorization_allocation |
The slice of one authorization funded by one source. Attribution lives here — refunds, reversals and sponsor-scoped reads all key off it. |
pull_transaction |
The actual movement against a sponsor's instrument. |
ledger_entry |
Double-entry bookkeeping: recipient_card, operating_float, sponsor_settlement. |
#3. The two gates
Every authorization goes through the same two gates, in order. Which gate failed changes what you tell the user, so never collapse them into one "declined" message.
#Gate 1 — POLICY
Pure evaluation of the commitment's active policy version plus its cap, against the transaction. Predicates available today:
amount_max, amount_min, time_window, mcc_in, mcc_not_in,
merchant_in, merchant_not_in, channel_in, country_in, cap_remaining,
context_required — composed with and / or / not.
A Gate 1 decline means no bank was contacted and nothing moved. Reason codes:
POLICY_MCC_DENIED POLICY_MERCHANT_DENIED POLICY_MERCHANT_NOT_ALLOWED
POLICY_AMOUNT_LIMIT POLICY_TIME_WINDOW POLICY_CHANNEL
POLICY_LOCATION POLICY_CONTEXT_REQUIRED POLICY_RECIPIENT_INELIGIBLE
COMMITMENT_CAP_EXCEEDED COMMITMENT_INACTIVE NO_ELIGIBLE_COMMITMENT
Gate 1 is simulatable without side effects — see POST /policies/{id}/simulate.
#Gate 2 — FUNDING
Can the sponsor's instrument actually cover the slice, and does the pull settle?
SPONSOR_SOURCE_INSUFFICIENT SPONSOR_SOURCE_DECLINED
SPONSOR_SOURCE_UNAVAILABLE SETTLEMENT_RISK_BLOCK
A Gate 2 decline means the rules were fine but the sponsor's money source could not cover it. Say that, and say it without exposing the sponsor's balance to the recipient.
Interim sufficiency rule. Today an instrument must hold
ceil(1.5 × amount)— a placeholder buffer standing in for risk scoring. A $40.00 tap therefore needs $60.00 available, and a $30.00 balance declines with"last-known balance 3000 below required buffer 6000". There is no partial approval: the API never approves the $20 the balance could support.
#4. How money is routed across sponsors
One card can be funded by many commitments at once — a parent, an employer, a grandparent — plus, optionally, the recipient's own money.
Candidates are ranked priority ASC, then created_at, then id. Each
candidate must independently pass its own Gate 1 (its policy, its cap) and
have capacity under Gate 2. The waterfall then fills the amount from the top
down; a lower-priority commitment funds only the remainder.
tap $40.00
├─ Dana (priority 10) capacity $23.33 (balance-limited) → funds $23.33
└─ Northwind (priority 20) capacity $500.00 → funds $16.67
───────────
approved $40.00, 2 allocations
Two properties worth internalising:
- One commitment = one instrument. Routing chooses between commitments, never between the accounts a single sponsor happens to own. A sponsor who wants fallback across two of their accounts creates two commitments — and note each carries its own cap, so "$50/week from either account" is not expressible today.
- Personal funds are always last. A recipient's own instrument is only used
when explicitly consented (
POST /cards/{id}/actions,set_personal_funding), is appended after every sponsor candidate, and has no policy, no cap and no commitment. It can never displace a sponsor's instrument or route around a sponsor's rules.
Revocation is prospective. revoke / suspend / expire on a commitment
stops future routing immediately, but slices already funded stay attributed to
that sponsor and remain capturable, refundable and reversible to their
instrument. The response reports the in-flight exposure:
"in_flight": {
"authorization_ids": ["auth_da35…"],
"outstanding_amount": { "amount": 1667, "currency": "USD" }
}
#5. Lifecycles
Commitment — POST /commitments/{id}/actions:
draft ──invite──► invited ──accept──► accepted ──activate──► active
│ │ │
│ ├─reject──► rejected ├─suspend──► suspended ──activate──► active
│ └─expire──► expired └─expire───► expired
└─activate──► active (revoke from any live state ──► revoked)
Only an active commitment inside its effective_at / expires_at window
routes. set_priority reorders it without a status change.
Policy — versions are immutable. Create a policy (draft), write
version 1, then activate it with the version number. retire stops it
governing new authorizations. Editing rules means writing a new version and
activating that; existing authorizations keep the version number that judged
them.
Authorization — approved → capture (with the final cleared amount,
which may differ from the approved amount) → refund (partial or full, split
proportionally back across allocations) or reverse (release the whole thing).
Refunds and reversals return money to the instrument that actually paid, and
release the cap they consumed.
#6. Protocol rules you cannot skip
| Rule | Detail |
|---|---|
| Money | Always { "amount": <integer minor units>, "currency": "USD" }. 4000 is $40.00. Floats are rejected. |
| Tenant | x-tenant-id scopes everything. A different tenant reading a valid id gets 404, not 403. |
| Idempotency | Every POST under /api/v1 requires idempotency-key. A replay returns the stored response with idempotent-replay: true; the same key with a different body is a 409. |
| Concurrency | Lifecycle mutations require if-match with the ETag from the last read/write. Stale → 409 RESOURCE_VERSION_CONFLICT; refetch and retry. ETags are opaque — never parse them. Compressing CDNs may weaken a validator to W/"…"; the API compares with the prefix stripped, so echo back whatever you received. |
| Pagination | Lists return { object: "list", data, has_more, next_cursor }. Pass ?cursor= and ?limit= (default 25, max 100). Cursors are opaque. |
| Errors | { "error": { "code", "message", "details", "correlation_id" } }. correlation_id is echoed as x-correlation-id on every response — log it. |
| Declines are not errors | A declined tap is 201 Created with decision: "declined". Only protocol problems produce 4xx/5xx. |
#7. Privacy between sponsors
An unscoped read of an authorization returns everything: all allocations, all pulls, all ledger entries, the full decision trace.
Passing x-sponsor-party-id narrows the read to that sponsor's own slices:
funded and refunded amounts limited to what they paid, no other sponsor's
instrument, cap or commitment, no personal-funds slice, and 404 if they funded
nothing. Scoped responses carry a distinct ETag and Vary: x-sponsor-party-id
so a cache can never cross-serve a wider body. Sponsor-scoped callers cannot
mutate (403), and endpoints that have not implemented the narrowing reject a
scoped caller outright rather than answering with unscoped data.
Security note.
x-sponsor-party-idis caller-asserted and only accepted when the deployment setsALLOW_HEADER_SPONSOR_SCOPE=true. That is a development and demo switch. In production the scope must come from a scope-bearing token; never enable the header against real financial data.
The recipient never learns which instrument paid, and a sponsor never learns that another sponsor exists.
#8. Known limitations (be honest with users)
- No partial approval. A tap that exceeds available funding declines in full; the API does not offer the amount that would have cleared.
- Balance is a cached number. Gate 2 sufficiency uses
last_known_balance, so a real instrument can still decline at the provider. - Provider atomicity. Pulls happen inside the database transaction: a
provider failure on a later slice of a split can leave an earlier charge
settled. It is recorded as an
authorization.orphaned_pullsaudit entry; a durable outbox plus automatic compensating reversal is Phase 5 work. - Instrument state has no public API. Activating a linked source or setting
a balance is not exposed under
/api/v1; the demo and simulator do it through a gated, simulator-only endpoint. - The 1.5× buffer is a placeholder pending risk scoring, and it is visible to users as surprising declines.
#9. Seeing it work
Two dev-only surfaces exercise the same public API — nothing in them reimplements funding or policy logic:
| Surface | What it does |
|---|---|
/admin/demo |
A parent sets up an allowance and a child taps a card, with every literal request and response on screen. |
/admin/simulator |
Declarative scenarios run as real HTTP calls under a throwaway sim- tenant, with runner-enforced ledger invariants. |
Both require ENABLE_SIMULATOR=true and sign-in, and both run real
authorizations against whatever database the deployment points at.