#Controlled Funding API — Overview

Sponsor bank accounts connected to a single recipient card, funding it at the moment of a tap

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:

  1. the rules are checked (Gate 1 — policy),
  2. the sponsor's instrument is checked and pulled (Gate 2 — funding),
  3. 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

Flow diagram: a tap passes Gate 1 (policy and cap) then Gate 2 (sponsor funding) before allocations, pulls and ledger entries are written; a decline at either gate moves nothing

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

Diagram: a $40 tap fills from ranked sponsor commitments in priority order, producing two allocations that sum to the amount, with the recipient's consented personal funds last and unused

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-id is caller-asserted and only accepted when the deployment sets ALLOW_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_pulls audit 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.