#Build Guide — for coding agents building on the Controlled Funding API
Give this whole file to your coding agent as the system/context prompt. It is written to be executable guidance: the model, the exact call sequences, the invariants you must not break, the UI patterns per surface, and the scenarios your build must pass before you call it done.
Companion docs: overview.md (the model),
reference.md (every endpoint, real traffic),
SAMPLE_PROMPTS.md (three product briefs to paste into an
agentic platform), GET /api/openapi (machine-readable).
#0. Prompt preamble (copy this into your agent)
You are building a client application on the mCards Controlled Funding API. The API is the source of truth for every funding decision. Your application presents decisions; it never makes them.
Non-negotiable rules:
- Never reimplement policy, routing, sufficiency, cap arithmetic or ledgering in the client. If your code contains "if amount > cap" to decide an outcome, you have built a lie that will disagree with the API. Ask the API —
POST /policies/{id}/simulatefor previews,POST /authorizationsfor the real thing.- Money is integer minor units.
4000means $40.00. Never store money in a float, never round in the client, format only at the display edge.- Every
POSTunder/api/v1carries a freshidempotency-key— one per logical user intent, reused verbatim on retry.- ETags are opaque. Keep the last one you saw per resource, send it as
if-match, and on409 RESOURCE_VERSION_CONFLICTrefetch and retry once.- A decline is a
201, not an error. Distinguishgate_failed: "POLICY"from"FUNDING"in the UI; they are different sentences to a human.- Never show a recipient anything about a sponsor's money — no balance, no instrument, no
decision_detail.gate_2, nocandidates.- Never claim money moved unless the response says so. A decline has no allocations, no pulls and no ledger entries. Do not write "payment failed, you may see a pending charge".
#1. The mental model in 90 seconds
A sponsor (parent, employer, friend) links a real bank account or card
(linked_payment_source) and creates a commitment to fund a recipient's
card: one instrument, one policy, one cap, one priority. Money stays with the
sponsor until a tap happens.
A tap is an authorization. It passes two gates:
- Gate 1 — POLICY: do the sponsor's rules and cap allow this? No bank is contacted. Simulatable with zero side effects.
- Gate 2 — FUNDING: can the sponsor's instrument cover it, and does the pull settle?
Many commitments can fund one card. They are ranked by priority (ascending)
and filled as a waterfall; each funded slice becomes an allocation, which is
what refunds, reversals and sponsor-scoped views key off. The recipient's own
money is used only with explicit consent and only after every sponsor.
Two facts that shape UI copy: an instrument must hold 1.5× the amount (interim buffer) — a $40 tap needs $60 available — and there is no partial approval.
#2. Setup sequence (the one you will implement first)
Run in this order. Every step is a real call; † needs if-match from the
previous step's ETag.
1 POST /api/v1/parties → sponsor party
2 POST /api/v1/parties → recipient party
3 POST /api/v1/linked_payment_sources → sponsor's instrument (must become `active`)
4 POST /api/v1/cards → recipient's card
5 POST /api/v1/commitments → status: draft
6 POST /api/v1/commitments/{id}/actions † → { "action": "invite" }
7 POST /api/v1/commitments/{id}/actions † → { "action": "accept" }
8 POST /api/v1/commitments/{id}/actions † → { "action": "activate" }
9 POST /api/v1/budget_caps → e.g. 5000 weekly
10 POST /api/v1/policies → scope_type: "commitment", scope_id: {commitment}
11 POST /api/v1/policies/{id}/versions → the rule tree (version 1)
12 POST /api/v1/policies/{id}/actions † → { "action": "activate", "version_number": 1 }
(the ETag here is the POLICY's, from step 10)
13 POST /api/v1/authorizations → the first tap
Pitfalls that will bite you at exactly these steps:
- Step 3: a
pendingsource funds nothing, and there is no public endpoint to activate it. Assume your integration receives already-active sources. - Steps 6–8: each action bumps
version. Using the ETag from step 5 at step 7 is a409. Always thread the ETag from the response you just got. - Step 12:
if-matchis the policy's ETag, not the version's. - Step 11: give every predicate a stable
id— it is the name the decision trace uses, and therefore the name your UI renders.
TypeScript client sketch:
type Money = { amount: number; currency: string }; // integer minor units
async function call<T>(
method: "GET" | "POST" | "PATCH",
path: string,
opts: { body?: unknown; ifMatch?: string; sponsor?: string } = {}
): Promise<{ status: number; etag: string | null; body: T }> {
const headers: Record<string, string> = { "x-tenant-id": TENANT };
if (opts.body !== undefined) headers["content-type"] = "application/json";
if (method !== "GET") headers["idempotency-key"] = crypto.randomUUID();
if (opts.ifMatch) headers["if-match"] = opts.ifMatch; // send back verbatim
if (opts.sponsor) headers["x-sponsor-party-id"] = opts.sponsor;
const res = await fetch(BASE + path, {
method,
headers,
body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
});
return { status: res.status, etag: res.headers.get("etag"), body: await res.json() };
}
// lifecycle helper: thread the ETag through every action
async function act(kind: "commitments" | "policies" | "cards" | "authorizations",
id: string, body: unknown, etag: string) {
const r = await call<{ version: number }>("POST", `/api/v1/${kind}/${id}/actions`,
{ body, ifMatch: etag });
if (r.status === 409) throw new Error("stale ETag — refetch and retry");
return r; // r.etag is the next If-Match
}
#3. Surfaces to build, and what each one may see
| Surface | Reads | Writes | Must never see |
|---|---|---|---|
| Sponsor (parent/employer) app | own commitments, own caps, own policies, GET /authorizations with x-sponsor-party-id |
create/activate/suspend/revoke commitments, caps, policy versions, set_priority |
other sponsors' commitments, allocations, instruments |
| Recipient (child/employee) app | own card, the rules in plain language, own authorization history | taps (POST /authorizations) |
any sponsor balance, decision_detail.gate_2, candidates, pulls, ledger_entries, which instrument paid |
| Merchant / POS simulation | — | POST /authorizations, then capture / refund / reverse |
everything else |
| Admin / support console | everything unscoped, including decision_detail and candidates |
lifecycle actions | — |
#3.1 Sponsor: "set up an allowance"
One form → the 8-call sequence above. Design notes:
- Collect amount in major units, convert once at submit:
Math.round(parseFloat(input) * 100). Never keep a formatted string in state and reformat on every render — that mangles typing. - Model rules as chips the sponsor picks (categories, per-transaction max,
hours, countries) and compile them into one
whentree withop: "and". - Show the cap as
remaining_amount / cap_amountfromGET /budget_caps/{id}— never a number you computed yourself. - Changing rules = new policy version + activate, not an edit. Show version history; every past authorization names the version that judged it.
- "Pause" is
suspend, "cancel" isrevoke. Both returnin_flight— surface it: "Stopped. $16.67 already funded on 1 transaction can still be captured or refunded." Do not imply the money is clawed back.
#3.2 Recipient: "what can I spend?"
Read the active policy version and render the rule tree in plain language — do not echo the sponsor's form input, and never invent limits:
mcc_in ["5812","5814","5499"] → "Food and drink only"
amount_max 5000 USD → "Up to $50.00 per purchase"
time_window 06:00–20:00 mon–fri → "Weekdays, 6am to 8pm"
cap_remaining + cap {5000 weekly, 1200 used} → "$38.00 left this week"
country_in ["US"] → "In the United States"
Show remaining_amount, never a sponsor balance. If several commitments fund
the card, show the recipient the union of what they can do, not a per-sponsor
breakdown.
#3.3 Recipient: the tap screen
amount entry → (optional) preview → POST /api/v1/authorizations → render outcome
Preview (POST /policies/{id}/simulate) is free of side effects and needs
no idempotency key, so you may call it on debounce. Two rules:
- pass
cap_remainingexplicitly, or acap_remainingpredicate evaluatesunknownand denies undermissing_data: "DENY"; - simulation is Gate 1 only. Never present it as "this will work" — funding is only known at the real tap.
Rendering the outcome, driven entirely by the response:
| Response | Recipient copy | Sponsor/admin copy |
|---|---|---|
decision: "approved" |
"Approved — $12.00 at Joe's Diner" | + which allocations funded it |
gate_failed: "POLICY", reason_code: "POLICY_MCC_DENIED" |
"This store isn't covered by your allowance" | + the failing rule_id |
POLICY_AMOUNT_LIMIT |
"That's over your $50.00 per-purchase limit" | |
COMMITMENT_CAP_EXCEEDED |
"You've used this week's allowance" | + remaining_amount |
NO_ELIGIBLE_COMMITMENT / COMMITMENT_INACTIVE |
"No one is funding this card right now" | + which commitment stopped |
gate_failed: "FUNDING" (SPONSOR_SOURCE_*) |
"Your sponsor's payment method couldn't cover this right now" | + sufficiency.message |
Map the failing predicate to its human sentence via rule_id — that is exactly
why rule ids exist:
const failed = auth.decision_detail?.gate_1?.evaluated?.find(r => r.passed === false);
const text = RULE_COPY[failed?.rule_id ?? ""] ?? DECLINE_COPY[auth.reason_code];
For a Gate 1 decline, state plainly that no payment was attempted. For a Gate 2 decline, do not leak the amount required or the balance to a recipient — that is the sponsor's private financial data.
#3.4 Merchant / lifecycle
capturewithfinal_amountwhen the merchant clears (tip, fuel adjustment). Above the approved amount, the difference pulls from the primary allocation's instrument.refundwithamount— split proportionally back to the instruments that actually paid; bounded by the outstanding funded amount, so an over-refund is rejected rather than silently truncated.reversereleases the entire authorization.- All three need
if-matchand are refused (403) to a sponsor-scoped caller.
#3.5 Developer/API console panel (highly recommended)
The most persuasive UI on this API is one that shows its own traffic. Log
{ method, path, requestHeaders, requestBody, status, responseBody, correlation_id }
per call and render request/response side by side with the two-gate trace.
Redact before you store or display: authorization headers, cookies, and
anything from a scoped session. Label non-/api/v1 calls (e.g. simulator world
controls) distinctly so nobody mistakes them for platform API.
#4. Protocol implementation checklist
// Idempotency: one key per user intent, reused on retry — not per HTTP attempt.
const key = intentKey ?? (intentKey = crypto.randomUUID());
// Concurrency: keep the last ETag per resource id.
etags.set(res.body.id, res.headers.get("etag"));
// 409 handling: refetch, then retry once with the fresh validator.
if (res.status === 409 && res.body.error.code === "RESOURCE_VERSION_CONFLICT") {
const fresh = await call("GET", `/api/v1/commitments/${id}`);
return act("commitments", id, body, fresh.etag!);
}
- Set
x-tenant-idon every call; a wrong tenant yields404, not403. - Paginate with
next_cursorwhilehas_more; cursors and ETags are opaque. - Log
correlation_idfrom every response; it ties a user complaint to an authorization trace. - Retry only on
429/5xx, with backoff and the same idempotency key. POST /authorizationsis not a "try it and see" call: it may move real money. Preview withsimulate.
#5. Privacy rules that are part of "working correctly"
- Sponsor-scoped reads use
x-sponsor-party-idand return only that sponsor's allocations, with a distinct ETag andVary: x-sponsor-party-id. Do not cache scoped and unscoped bodies under one key. - That header is caller-asserted and only accepted when the deployment sets
ALLOW_HEADER_SPONSOR_SCOPE=true. It is a dev/demo switch — in production the scope comes from a scope-bearing token. Never point a client that relies on the header at real financial data. - Recipients see rules and outcomes; sponsors see their own funding; nobody
sees another sponsor exists.
candidatesandgate_2are admin-only fields. - A sponsor who funded no slice of an authorization gets
404— treat that as "not yours", not as an error to surface.
#6. Acceptance scenarios — your build is not done until these pass
Drive each one through your UI and assert on the API response, not your own state:
| # | Scenario | Expected |
|---|---|---|
| 1 | Full setup, then an in-policy tap within cap and balance | 201 approved, cap remaining_amount drops by the amount |
| 2 | Tap at a category the policy excludes | declined, POLICY, POLICY_MCC_DENIED, no gate_2 in the trace, no pulls/ledger entries |
| 3 | Tap above the per-transaction max | declined, POLICY, POLICY_AMOUNT_LIMIT |
| 4 | Tap that exceeds the remaining cap | declined, POLICY, COMMITMENT_CAP_EXCEEDED |
| 5 | Tap with the sponsor's balance below 1.5× the amount | declined, FUNDING, SPONSOR_SOURCE_INSUFFICIENT; UI says nothing about the balance |
| 6 | Two commitments, first balance-limited | approved with 2 allocations summing exactly to the amount |
| 7 | Capture, then partial refund | captured_amount decreases; allocations' refunded_amount rises proportionally |
| 8 | Reverse | authorization reversed, caps released |
| 9 | Revoke a commitment, then tap again | new tap NO_ELIGIBLE_COMMITMENT; the earlier authorization still refundable, and in_flight was surfaced at revoke |
| 10 | Personal-funds consent, then a tap no sponsor can cover | personal allocation appears last; withdraw consent and it disappears from routing |
| 11 | Replay an idempotency key | same resource id, idempotent-replay: true, no duplicate |
| 12 | Reuse a stale ETag | 409; your UI refetches and succeeds |
| 13 | Sponsor-scoped read of a split authorization | only that sponsor's allocation; no total amount, no trace |
| 14 | Same id under a different x-tenant-id |
404 |
Test tenants must be isolated (x-tenant-id: sim-… or similar) and cleaned up.
Never point acceptance tests at a tenant holding real funding data.
#7. Anti-patterns (each has bitten a real implementation)
| Don't | Do |
|---|---|
| Compute "will this be approved?" client-side | POST /policies/{id}/simulate, then the real authorization |
| Store money as dollars/floats, or reformat a controlled input on every render | Integer minor units in state; format at render, normalise on blur |
| Reuse an ETag across actions in a lifecycle chain | Thread the ETag from the response you just received |
| Show "declined" with no reason | Branch on gate_failed + reason_code + failing rule_id |
| Say "your card was charged" on a decline | A decline has no pulls and no ledger entries — say nothing moved |
Index pulls[2] |
Match by allocation_id or amount; slices share a timestamp and have no defined order |
| Edit a policy version in place | Create a new version and activate it |
Show a recipient sufficiency.message or candidates |
Keep sponsor financials out of the recipient surface |
Enable ALLOW_HEADER_SPONSOR_SCOPE in production |
Use scope-bearing tokens |
Treat a 404 on a sponsor-scoped read as a bug |
It means the sponsor funded no slice |
#8. Reference material for the agent
overview.md— domain model, gates, routing, privacy, limits.reference.md— every endpoint with captured request/response pairs.SAMPLE_PROMPTS.md— three complete product briefs you can paste straight into an agentic development platform.GET /api/openapi— generate a typed client from this rather than hand-writing one./admin/demo— a working two-persona reference implementation (parent sets up, child taps, API console shows the literal traffic) that imports no funding or policy code: the decisions on screen are the API's./admin/simulator— declarative scenarios executed as real HTTP calls with runner-enforced ledger invariants; a good source of expected outcomes.
Both admin surfaces require ENABLE_SIMULATOR=true and sign-in, and they run
real authorizations against whatever database the deployment points at.