#Controlled Funding API — Reference
Base path: /api/v1 · Machine-readable spec: GET /api/openapi · Rendered: /docs
New here? Read overview.md first — this page assumes you know
what a commitment, an allocation and the two gates are.
Every request/response pair below is real traffic, captured by
scripts/capture-doc-examples.ts against a live server and a real Postgres
database. Ids and timestamps differ per run; shapes and status codes do not.
#Contents
- Conventions — headers, errors, pagination, ETags, idempotency
- Parties
- Linked payment sources
- Cards
- Commitments
- Budget caps
- Policies · versions · simulate
- Authorizations · actions
- Health & spec
- Endpoint index
#Conventions
#Headers
| Header | Where | Notes |
|---|---|---|
content-type: application/json |
every request with a body | |
x-tenant-id |
every request | Scopes all data. Defaults to tnt_default if omitted — always set it explicitly. |
idempotency-key |
every POST under /api/v1 |
Required. Missing → 422. Any unique string; a UUID v4 per logical operation is the norm. |
if-match |
lifecycle POSTs and PATCH /parties/{id} |
The ETag from your last read or write of that resource. |
x-sponsor-party-id |
sponsor-scoped reads only | Caller-asserted; only accepted when the deployment sets ALLOW_HEADER_SPONSOR_SCOPE=true, otherwise 403. Never use with production data. |
x-correlation-id |
optional | Echoed back; generated if absent. Log it — it ties an authorization to its trace. |
#Error envelope
Every 4xx/5xx has the same shape:
{
"error": {
"code": "VALIDATION_FAILED",
"message": "request validation failed",
"details": [{ "field": "currency", "issue": "must be an ISO-4217 code" }],
"correlation_id": "78516fff-8b15-44c3-8121-4116d46bf195"
}
}
| Code | HTTP | Meaning |
|---|---|---|
VALIDATION_FAILED |
422 | Bad body, bad query param, or a missing Idempotency-Key. |
UNAUTHENTICATED |
401 | No principal. |
FORBIDDEN |
403 | Sponsor scope not permitted, unsupported on this endpoint, or a scoped caller attempting a mutation. |
NOT_FOUND |
404 | Unknown id or an id belonging to another tenant. |
METHOD_NOT_ALLOWED |
405 | |
IDEMPOTENCY_CONFLICT |
409 | Same Idempotency-Key, different body. |
RESOURCE_VERSION_CONFLICT |
409 | Stale If-Match. Refetch, then retry with the new ETag. |
RATE_LIMITED |
429 | |
DEPENDENCY_UNAVAILABLE |
503 | |
INTERNAL |
500 |
A declined authorization is not an error. It is 201 Created with
decision: "declined" and a full trace.
#Money
{ "amount": 4000, "currency": "USD" }
Integer minor units, always. 4000 is $40.00. Non-integers are rejected;
currency must be ISO-4217 uppercase.
#Idempotency
POST /api/v1/parties
x-tenant-id: docs-1784947571256
content-type: application/json
idempotency-key: 31577ce4-12dc-45f6-9df5-4fbe0830cae5
{ "type": "person", "display_name": "Replay Example" }
201
etag: "mxxrYZkzEz3BheIIGrOCyp9b-3n"
{ "id": "pty_8dea53764d9b4651bca7143563ba322e", "object": "party", … }
Replaying the same key with the same body returns the stored response
(201, same id, header idempotent-replay: true) — it does not create a second
party. Omitting the key:
422
{ "error": { "code": "VALIDATION_FAILED",
"message": "Idempotency-Key header is required",
"details": [{ "field": "Idempotency-Key", "issue": "missing" }],
"correlation_id": "4c68e8bd-e175-4709-8539-6154d7233ef1" } }
#ETags and If-Match
Reads and writes of a versioned resource return an etag. Send it back on the
next mutation of that resource:
if-match: "gho8EsAFWFGbWOJP1uSwD8nzF9k"
Stale validator:
409
{ "error": { "code": "RESOURCE_VERSION_CONFLICT",
"message": "resource has been modified; refetch and retry with the current ETag",
"details": [], "correlation_id": "77051775-…" } }
ETags are opaque — do not parse, derive or construct them. A compressing CDN
may hand you W/"…"; echo back exactly what you received, the API compares with
the weak prefix stripped.
#Pagination
GET /api/v1/cards?recipient_party_id=pty_455d…&limit=1
{
"object": "list",
"data": [ { "id": "card_b10352…", "object": "card", … } ],
"has_more": false,
"next_cursor": null
}
limit defaults to 25, caps at 100. Follow next_cursor while has_more is
true. Cursors are opaque base64url tokens; an invalid one is a 422.
#Tenant isolation
GET /api/v1/cards/card_b10352a515ee42329508068497005344
x-tenant-id: docs-1784947571256-other
404
{ "error": { "code": "NOT_FOUND", "message": "card not found", … } }
#Parties
A person, business or organization. Sponsors and recipients are both parties.
#POST /api/v1/parties
| Field | Type | Required |
|---|---|---|
type |
person | business | organization |
yes |
display_name |
string (1–255) | yes |
email |
no | |
mcards_ref |
string (≤255) | no |
metadata |
object | no |
POST /api/v1/parties
x-tenant-id: docs-1784947571256
content-type: application/json
idempotency-key: 9dcb0dc9-6f6b-4f5b-9a63-6e2f0f4ce5ad
{ "type": "person", "display_name": "Dana Chen", "email": "dana@example.com" }
201
etag: "9CS9WNM63c-hcJKrNIvOD3XZTQK"
{
"id": "pty_4d59a0e84f8e482491d9d58eb91e767e",
"object": "party",
"type": "person",
"display_name": "Dana Chen",
"email": "dana@example.com",
"mcards_ref": null,
"metadata": {},
"version": 1,
"created_at": "2026-07-25T02:46:11.256Z",
"updated_at": "2026-07-25T02:46:11.256Z"
}
#GET /api/v1/parties · GET /api/v1/parties/{id}
List is paginated. Single read returns the party plus an etag.
#PATCH /api/v1/parties/{id}
Updates display_name, email (nullable) or metadata. If-Match is honoured
when supplied. No Idempotency-Key is required on PATCH.
#Linked payment sources
A real instrument owned by a party. The sponsor's side of the funding.
#POST /api/v1/linked_payment_sources
| Field | Type | Required |
|---|---|---|
owner_party_id |
string | yes |
type |
bank_account | card | credit_line |
yes |
display_name |
string (1–255) | yes |
currency |
ISO-4217 | yes |
status |
pending | active | suspended | unlinked |
no (default pending) |
instrument_descriptor |
string (≤64) — e.g. "•••• 4242" |
no |
mcards_funding_source_ref |
string (≤255) | no |
supports_balance_check |
boolean | no |
last_known_balance |
integer minor units | no |
metadata |
object | no |
POST /api/v1/linked_payment_sources
x-tenant-id: docs-1784947571256
content-type: application/json
idempotency-key: 1a4a2f26-4b9d-4f0f-9d3e-2fb7f2b0a0dd
{
"owner_party_id": "pty_4d59a0e84f8e482491d9d58eb91e767e",
"type": "bank_account",
"display_name": "Dana's checking",
"currency": "USD",
"supports_balance_check": true,
"last_known_balance": 25000
}
201
etag: "0d5PXwOwjMlp0YHhkPlKAcHDL9d"
{
"id": "lps_3b0d60da480c4719a0ea0519b9a0e120",
"object": "linked_payment_source",
"owner_party_id": "pty_4d59a0e84f8e482491d9d58eb91e767e",
"type": "bank_account",
"status": "pending",
"display_name": "Dana's checking",
"currency": "USD",
"instrument_descriptor": null,
"mcards_funding_source_ref": null,
"supports_balance_check": true,
"last_known_balance": 25000,
"metadata": {},
"version": 1,
"created_at": "2026-07-25T02:46:11.316Z",
"updated_at": "2026-07-25T02:46:11.316Z"
}
Only an
activesource funds anything. Moving a source frompendingtoactive(real-world: instrument verification) has no public endpoint yet — the demo and simulator do it through a gated simulator-only path. Plan for your integration to receive already-active sources.
#GET /api/v1/linked_payment_sources?owner_party_id=…
Paginated list, optionally filtered by owner.
#Cards
The recipient's mCard. It holds no balance — every approved tap is funded just in time.
#POST /api/v1/cards
| Field | Type | Required |
|---|---|---|
recipient_party_id |
string | yes |
display_name |
string (1–255) | yes |
currency |
ISO-4217 | yes |
mcards_card_ref |
string (≤255) | no |
metadata |
object | no |
201
{
"id": "card_b10352a515ee42329508068497005344",
"object": "card",
"recipient_party_id": "pty_455d192bfb374474ba1fdafe8c32e053",
"status": "active",
"display_name": "Alex's mCard",
"currency": "USD",
"mcards_card_ref": null,
"personal_funding": null,
"metadata": {},
"version": 1,
"created_at": "2026-07-25T02:46:11.400Z",
"updated_at": "2026-07-25T02:46:11.400Z"
}
#GET /api/v1/cards?recipient_party_id=… · GET /api/v1/cards/{id}
#POST /api/v1/cards/{id}/actions
Personal-funds consent (FCM-008). Requires If-Match; returns the card with a
new ETag.
// consent — the recipient's own instrument becomes a LAST-RESORT source
{
"action": "set_personal_funding",
"personal_funding": {
"linked_payment_source_id": "lps_…", // must be owned by the recipient
"consented_by_party_id": "pty_…" // who gave consent
}
}
// withdraw consent — removed from all future routing
{ "action": "clear_personal_funding" }
Once set, the card serialises it:
"personal_funding": {
"linked_payment_source_id": "lps_…",
"consented_at": "2026-07-25T02:46:11.512Z",
"consented_by_party_id": "pty_…"
}
Personal funds are appended after every sponsor candidate, carry no policy, no cap and no commitment, and can never displace a sponsor's instrument.
#Commitments
The sponsor's promise, and the unit routing chooses between.
#POST /api/v1/commitments
| Field | Type | Required |
|---|---|---|
sponsor_party_id |
string | yes |
recipient_party_id |
string | yes |
linked_payment_source_id |
string | yes — one instrument per commitment |
display_name |
string (1–255) | yes |
priority |
integer 0–100000 (lower funds first) | no |
effective_at / expires_at |
RFC-3339 | no |
metadata |
object | no |
Created in draft. Nothing is reserved and no money moves.
#POST /api/v1/commitments/{id}/actions
invite · accept · reject · activate · suspend · revoke · expire ·
set_priority (requires priority).
Requires If-Match. Each action bumps version, so use the ETag returned by
the previous action:
POST /api/v1/commitments/cmt_69d70ca6ed494aa8bf3eca37ecba8295/actions
x-tenant-id: docs-1784947571256
content-type: application/json
idempotency-key: 4578e97c-8dae-48b2-99d7-27adad7b9c8c
if-match: "dQ5b-G2eekcar-tLAQIii0U0nJG"
{ "action": "revoke" }
200
etag: "7NUrdJQrY9F33eq1J20a7rJPT1n"
{
"id": "cmt_69d70ca6ed494aa8bf3eca37ecba8295",
"object": "commitment",
"sponsor_party_id": "pty_4c9142684d52498890cd45aaeea2f71e",
"recipient_party_id": "pty_455d192bfb374474ba1fdafe8c32e053",
"linked_payment_source_id": "lps_6ffd1514d2eb415da1721c6cb59e66ef",
"status": "revoked",
"display_name": "Northwind → Alex",
"priority": 20,
"effective_at": null,
"expires_at": null,
"accepted_at": "2026-07-25T02:46:12.110Z",
"revoked_at": "2026-07-25T02:46:12.251Z",
"metadata": {},
"version": 5,
"created_at": "2026-07-25T02:46:12.059Z",
"updated_at": "2026-07-25T02:46:12.251Z",
"in_flight": {
"authorization_ids": ["auth_da3509eef8c242b2b229a95d76eecb7c"],
"outstanding_amount": { "amount": 1667, "currency": "USD" }
}
}
in_flight appears on revoke, suspend and expire: those authorizations
were already funded by this commitment and remain capturable, refundable and
reversible against the sponsor's instrument. Revocation is prospective only.
An illegal transition (e.g. accept on an active commitment) is a 422.
#GET /api/v1/commitments?sponsor_party_id=…&recipient_party_id=… · GET /api/v1/commitments/{id}
#Budget caps
A spend ceiling attached to one commitment.
#POST /api/v1/budget_caps
| Field | Type | Required |
|---|---|---|
commitment_id |
string | yes |
display_name |
string (1–255) | yes |
cap_amount |
integer minor units | yes |
currency |
ISO-4217 | yes |
period |
none | daily | weekly | monthly | lifetime |
no (default none) |
rollover |
boolean | no |
metadata |
object | no |
200 GET /api/v1/budget_caps/cap_42b4643edafc46c986c30f9fca3ce33b
{
"id": "cap_42b4643edafc46c986c30f9fca3ce33b",
"object": "budget_cap",
"commitment_id": "cmt_00caad5488f348febe8a393c519f7ad8",
"display_name": "Weekly allowance",
"cap_amount": 5000,
"used_amount": 0,
"remaining_amount": 5000,
"currency": "USD",
"period": "weekly",
"period_started_at": null,
"rollover": false,
"metadata": {},
"version": 1,
"created_at": "2026-07-25T02:46:11.542Z",
"updated_at": "2026-07-25T02:46:11.542Z"
}
remaining_amount is the number to show a user. An approval reserves against
used_amount; refunds and reversals release it.
#GET /api/v1/budget_caps?commitment_id=… · GET /api/v1/budget_caps/{id}
#Policies
Rules as data: a versioned boolean expression tree. Nothing about a policy is code you deploy.
#POST /api/v1/policies
| Field | Type | Required |
|---|---|---|
scope_type |
e.g. commitment |
yes |
scope_id |
string (the commitment id) | no |
display_name |
string | yes |
metadata |
object | no |
Created status: "draft" with active_version: null.
#Policy versions
#POST /api/v1/policies/{id}/versions
| Field | Type | Default |
|---|---|---|
when |
expression tree | required |
timezone |
IANA tz | UTC |
effect |
ALLOW (allow-list) | DENY (block-list) |
ALLOW |
missing_data |
DENY | ALLOW — what an unevaluable predicate means |
DENY |
POST /api/v1/policies/pol_593be628eedb4f1aa03136470aecfb83/versions
x-tenant-id: docs-1784947571256
content-type: application/json
idempotency-key: 2ff54a5e-1c1e-4a6c-b0d5-9a0e5b7c7c3f
{
"timezone": "America/New_York",
"effect": "ALLOW",
"missing_data": "DENY",
"when": {
"op": "and",
"rules": [
{ "id": "allowed_categories", "predicate": "mcc_in", "values": ["5812", "5814", "5499"] },
{ "id": "per_transaction_max", "predicate": "amount_max", "value": { "amount": 5000, "currency": "USD" } },
{ "id": "cap_remaining", "predicate": "cap_remaining" }
]
}
}
Combinators: { "op": "and" | "or", "rules": [...] }, { "op": "not", "rule": {...} }.
Predicates (every leaf needs a unique id ≤64 chars — it is what the
decision trace names):
predicate |
Fields | Fails when |
|---|---|---|
amount_max / amount_min |
value: Money |
Amount above / below the bound. |
time_window |
start, end ("HH:MM"), optional days: ["mon",…] |
Outside the window in the version's timezone. |
mcc_in / mcc_not_in |
values: ["5812", …] (4 digits) |
Category not in / in the list. |
merchant_in / merchant_not_in |
values: [merchant_id, …] |
Merchant not in / in the list. |
channel_in |
values ⊂ pos, ecommerce, atm, moto, contactless, recurring |
Channel not allowed. |
country_in |
values: ["US", …] (ISO-3166 alpha-2) |
Country not allowed. |
cap_remaining |
— | Remaining cap does not cover the amount. |
context_required |
keys: ["approval_id", …] |
A required context key is absent. |
A predicate whose input is absent evaluates unknown, then resolves per
missing_data.
#GET /api/v1/policies/{id}/versions · GET /api/v1/policies/{id}/versions/{version}
Versions are immutable. To change a rule, write a new version and activate it.
#POST /api/v1/policies/{id}/actions
{ "action": "activate", "version_number": 1 } // makes that version govern new authorizations
{ "action": "retire" } // stops the policy governing anything new
Requires If-Match. Activation sets status: "active" and active_version.
#Policy simulation
#POST /api/v1/policies/{id}/simulate
Pure Gate 1 evaluation. No funds move, no instrument is contacted, no
authorization is written, and no Idempotency-Key is needed. Safe to call on
every keystroke of a "what would happen if…" screen.
POST /api/v1/policies/pol_593be628eedb4f1aa03136470aecfb83/simulate
x-tenant-id: docs-1784947571256
content-type: application/json
{
"transaction": {
"amount": { "amount": 1200, "currency": "USD" },
"mcc": "5812",
"merchant_name": "Joe's Diner"
}
}
200
{
"object": "policy_simulation",
"policy_id": "pol_593be628eedb4f1aa03136470aecfb83",
"version_number": 1,
"decision": "DENY",
"gate": "POLICY",
"gate_failed": "POLICY",
"reasons": ["COMMITMENT_CAP_EXCEEDED"],
"evaluated": [
{ "rule_id": "allowed_categories", "predicate": "mcc_in", "result": "pass", "passed": true, "reason_code": null },
{ "rule_id": "per_transaction_max", "predicate": "amount_max", "result": "pass", "passed": true, "reason_code": null },
{ "rule_id": "cap_remaining", "predicate": "cap_remaining", "result": "unknown", "passed": false, "reason_code": "COMMITMENT_CAP_EXCEEDED" }
]
}
Gotcha, visible above. Simulation has no cap context of its own, so a
cap_remainingrule evaluatesunknownand — under the defaultmissing_data: "DENY"— denies. Pass the current remaining cap explicitly if you want a realistic answer:"transaction": { …, "cap_remaining": { "amount": 5000, "currency": "USD" } }. Other optional transaction fields:timestamp,merchant_id,channel,country,context. Add"version_number": Nto simulate a version other than the active one.
#Authorizations
One tap. Always 201, approved or declined, always with a trace.
#POST /api/v1/authorizations
| Field | Type | Required |
|---|---|---|
card_id |
string | yes |
amount |
Money (integer minor units) | yes |
commitment_id |
string — pin to one commitment instead of routing | no |
mcc |
string (≤8) | no, but policies usually need it |
merchant_id / merchant_name |
string (≤255) | no |
channel |
string (≤32) — pos, ecommerce, … |
no |
country |
ISO-3166 alpha-2 | no |
timestamp |
RFC-3339 | no (defaults to now) |
context |
object — satisfies context_required rules |
no |
expires_in_seconds |
integer ≤86400 | no (default 5 min) |
#Approved
POST /api/v1/authorizations
x-tenant-id: docs-1784947523548
content-type: application/json
idempotency-key: 7b27c3dc-d7b7-49ac-b84d-82c7ce603b26
{
"card_id": "card_f99dac03d91349598c220b1b9d876283",
"amount": { "amount": 1200, "currency": "USD" },
"mcc": "5812",
"merchant_id": "mch_joes_diner",
"merchant_name": "Joe's Diner",
"channel": "pos",
"country": "US"
}
201
etag: "nrLL4UmsG7FpxueMUHtyL_1ABnk"
{
"id": "auth_ec89f6abf43249db9d33c1e3a16fc0a0",
"object": "authorization",
"card_id": "card_f99dac03d91349598c220b1b9d876283",
"commitment_id": "cmt_3637f80c0eef4f68adaea8564972070a",
"budget_cap_id": "cap_7dce9f7949e447c4ae3a4d1f2f060482",
"linked_payment_source_id": "lps_dad296008cc54aca80fca13f998d75fd",
"amount": { "amount": 1200, "currency": "USD" },
"approved_amount": { "amount": 1200, "currency": "USD" },
"captured_amount": { "amount": 0, "currency": "USD" },
"mcc": "5812",
"merchant_id": "mch_joes_diner",
"merchant_name": "Joe's Diner",
"channel": "pos",
"country": "US",
"decision": "approved",
"status": "approved",
"gate_failed": null,
"reason_code": null,
"policy_id": "pol_fae805922ef549cba7779d72e051a18f",
"policy_version_number": 1,
"decision_detail": {
"gate_1": {
"gate": "POLICY",
"effect": "ALLOW",
"decision": "ALLOW",
"reasons": [],
"gate_failed": null,
"evaluated": [
{ "rule_id": "allowed_categories", "predicate": "mcc_in", "result": "pass", "passed": true },
{ "rule_id": "per_transaction_max", "predicate": "amount_max", "result": "pass", "passed": true },
{ "rule_id": "cap_remaining", "predicate": "cap_remaining", "result": "pass", "passed": true }
]
},
"gate_2": { "provider": "simulated", "sufficiency": { "ok": true, "required": 1800 } }
},
"correlation_id": "14ea609f-1437-465a-9d97-a3d2fd503bad",
"requested_at": "2026-07-25T02:45:27.898Z",
"expires_at": "2026-07-25T02:50:27.897Z",
"metadata": {},
"version": 1,
"created_at": "2026-07-25T02:45:27.898Z",
"updated_at": "2026-07-25T02:45:27.898Z"
}
required: 1800 on a 1200 tap is the interim 1.5× buffer.
#Declined at Gate 1 (policy)
mcc: "5732" (electronics) against a food-and-drink allow-list:
{
"decision": "declined",
"status": "declined",
"gate_failed": "POLICY",
"reason_code": "POLICY_MCC_DENIED",
"approved_amount": null,
"expires_at": null,
"decision_detail": {
"gate_1": {
"decision": "DENY",
"effect": "ALLOW",
"reasons": ["POLICY_MCC_DENIED"],
"gate_failed": "POLICY",
"evaluated": [
{ "rule_id": "allowed_categories", "predicate": "mcc_in", "result": "fail", "passed": false, "reason_code": "POLICY_MCC_DENIED" },
{ "rule_id": "per_transaction_max", "predicate": "amount_max", "result": "pass", "passed": true },
{ "rule_id": "cap_remaining", "predicate": "cap_remaining", "result": "pass", "passed": true }
]
}
}
}
There is no gate_2 key at all — no bank was contacted. Nothing moved:
no allocations, no pulls, no ledger entries, no cap consumed.
#Declined at Gate 2 (funding)
A $40.00 tap against a $30.00 last-known balance:
{
"decision": "declined",
"gate_failed": "FUNDING",
"reason_code": "SPONSOR_SOURCE_INSUFFICIENT",
"approved_amount": null,
"decision_detail": {
"gate_1": { "decision": "ALLOW", "reasons": [], "gate_failed": null, "evaluated": [ … all pass … ] },
"gate_2": {
"sufficiency": {
"ok": false,
"reason": "SPONSOR_SOURCE_INSUFFICIENT",
"message": "last-known balance 3000 below required buffer 6000",
"required": 6000
}
}
}
}
Policy allowed it; the sponsor's source could not cover it. Still nothing moved,
and the API never partially approves. Do not show the sponsor's balance or
the message field to a recipient.
#Approved as a split across sponsors
Same $40.00 tap once a second, lower-priority commitment exists. The first sponsor's balance limits it to $23.33:
"decision_detail": {
"gate_1": { "decision": "ALLOW", … },
"gate_2": {
"split": true,
"allocations": [
{ "kind": "sponsor", "amount": 2333, "commitment_id": "cmt_00caad…", "sponsor_party_id": "pty_4d59a0…" },
{ "kind": "sponsor", "amount": 1667, "commitment_id": "cmt_69d70c…", "sponsor_party_id": "pty_4c9142…" }
]
},
"candidates": [
{ "kind": "sponsor", "priority": 10, "capacity": 2333, "eligible": true, "reason": null, "commitment_id": "cmt_00caad…", "sponsor_party_id": "pty_4d59a0…" },
{ "kind": "sponsor", "priority": 20, "capacity": 50000, "eligible": true, "reason": null, "commitment_id": "cmt_69d70c…", "sponsor_party_id": "pty_4c9142…" }
]
}
candidates is the whole ranked field with each one's capacity and, when
eligible: false, the reason it was skipped — the best debugging surface in
the API. Never render it to a recipient: it names other sponsors.
#GET /api/v1/authorizations/{id}
Unscoped, the read adds allocations, pulls and ledger_entries to the
authorization body:
"allocations": [
{
"id": "alloc_229757f067c34494a70e736643ab4f0d",
"object": "authorization_allocation",
"authorization_id": "auth_ec89f6…",
"kind": "sponsor",
"commitment_id": "cmt_3637f8…",
"sponsor_party_id": "pty_c7fbaa…",
"budget_cap_id": "cap_7dce9f…",
"linked_payment_source_id": "lps_dad296…",
"sequence": 0,
"amount": { "amount": 1200, "currency": "USD" },
"refunded_amount": { "amount": 0, "currency": "USD" },
"policy_id": "pol_fae805…",
"policy_version_number": 1,
"created_at": "2026-07-25T02:45:27.898Z"
}
],
"pulls": [
{
"id": "pull_7aff4c988d5741ef85878ac205e8f171",
"object": "pull_transaction",
"authorization_id": "auth_ec89f6…",
"linked_payment_source_id": "lps_dad296…",
"allocation_id": "alloc_229757…",
"kind": "initial",
"status": "settled",
"amount": { "amount": 1200, "currency": "USD" },
"provider": "simulated",
"provider_ref": "sim_initial_14ea609f-…",
"decline_reason": null,
"created_at": "2026-07-25T02:45:27.898Z"
}
],
"ledger_entries": [
{ "account": "operating_float", "direction": "debit", "amount": { "amount": 1200, "currency": "USD" }, "memo": "float reimbursed by sponsor settlement", … },
{ "account": "operating_float", "direction": "credit", "amount": { "amount": 1200, "currency": "USD" }, "memo": "float fronts recipient transaction", … },
{ "account": "recipient_card", "direction": "debit", "amount": { "amount": 1200, "currency": "USD" }, "memo": "card funded for transaction", … },
{ "account": "sponsor_settlement", "direction": "credit", "amount": { "amount": 1200, "currency": "USD" }, "memo": "sponsor instrument settled", … }
]
Debits equal credits, and the float nets to zero, on every approval. A declined
authorization has all three arrays empty. Do not index into pulls — slices
of one authorization share a timestamp, so their order is undefined; match on
allocation_id or amount.
#Sponsor-scoped read
GET /api/v1/authorizations/auth_ec89f6abf43249db9d33c1e3a16fc0a0
x-tenant-id: docs-1784947523548
x-sponsor-party-id: pty_c7fbaa4642fa4e3fa5ee39472f05492b
200
etag: "hMwVSWBeZyXtJm-aF1nH4_KkjwE"
vary: x-sponsor-party-id
{
"id": "auth_ec89f6abf43249db9d33c1e3a16fc0a0",
"object": "authorization",
"view": "sponsor",
"card_id": "card_f99dac…",
"decision": "approved",
"status": "approved",
"funded_amount": { "amount": 1200, "currency": "USD" },
"refunded_amount": { "amount": 0, "currency": "USD" },
"merchant_name": "Joe's Diner",
"mcc": "5812",
"channel": "pos",
"country": "US",
"allocations": [ { … this sponsor's slices only … } ],
"requested_at": "2026-07-25T02:45:27.898Z",
"created_at": "2026-07-25T02:45:27.898Z"
}
Note what is absent: the total amount, the decision trace, other sponsors'
allocations, pulls, ledger entries, personal-funds slices. The ETag differs from
the unscoped one, and Vary prevents a cache cross-serving them. A sponsor who
funded nothing on this authorization gets 404.
#GET /api/v1/authorizations?card_id=…&commitment_id=…
Paginated. With x-sponsor-party-id, the list is narrowed to authorizations the
sponsor funded, each in the sponsor view.
#Authorization actions
#POST /api/v1/authorizations/{id}/actions
Requires If-Match. Sponsor-scoped callers get 403 — a reverse or refund fans
out across every sponsor's allocations, so it cannot be driven from one
sponsor's scope.
{ "action": "capture", "final_amount": 1200 } // final_amount optional; defaults to approved
{ "action": "refund", "amount": 500 } // partial or full, minor units
{ "action": "reverse" } // release the whole authorization
Capture:
if-match: "nrLL4UmsG7FpxueMUHtyL_1ABnk"
{ "action": "capture", "final_amount": 1200 }
200
etag: "gho8EsAFWFGbWOJP1uSwD8nzF9k"
{ …, "status": "captured", "captured_amount": { "amount": 1200, "currency": "USD" }, "version": 2 }
Then a $5.00 refund:
if-match: "gho8EsAFWFGbWOJP1uSwD8nzF9k"
{ "action": "refund", "amount": 500 }
200
etag: "GRLpDMHoldlPVHbeSqgvfJvmATa"
{ …, "status": "captured", "captured_amount": { "amount": 700, "currency": "USD" }, "version": 3 }
Semantics that matter:
- A capture above the approved amount pulls the difference from the primary allocation's instrument and increases that allocation, so it stays attributed and refundable.
- Refunds and reversals split proportionally across allocations and return money to the instrument that actually paid, releasing each cap.
- Refunds are bounded by the outstanding funded amount; asking for more is rejected rather than silently under-refunded.
- A revoked commitment's already-funded allocations still capture, refund and reverse normally.
#Sponsor-scoped mutation is refused
403
{ "error": { "code": "FORBIDDEN",
"message": "sponsor-scoped principals cannot mutate; retry without a sponsor scope",
"details": [], "correlation_id": "0e120e6a-…" } }
#Health & spec
| Endpoint | Purpose |
|---|---|
GET /health |
Liveness. No auth, no tenant. |
GET /api/openapi |
OpenAPI 3.1 document for the whole /api/v1 surface. |
/docs |
Rendered reference. |
#Endpoint index
| Method | Path | Idempotency-Key | If-Match | Sponsor scope |
|---|---|---|---|---|
| GET | /health |
— | — | — |
| GET | /api/openapi |
— | — | — |
| POST | /api/v1/parties |
required | — | — |
| GET | /api/v1/parties |
— | — | rejected |
| GET | /api/v1/parties/{id} |
— | — | rejected |
| PATCH | /api/v1/parties/{id} |
— | honoured | rejected |
| POST | /api/v1/linked_payment_sources |
required | — | — |
| GET | /api/v1/linked_payment_sources |
— | — | rejected |
| POST | /api/v1/cards |
required | — | — |
| GET | /api/v1/cards |
— | — | rejected |
| GET | /api/v1/cards/{id} |
— | — | rejected |
| POST | /api/v1/cards/{id}/actions |
required | required | — |
| POST | /api/v1/commitments |
required | — | — |
| GET | /api/v1/commitments |
— | — | rejected |
| GET | /api/v1/commitments/{id} |
— | — | rejected |
| POST | /api/v1/commitments/{id}/actions |
required | required | — |
| POST | /api/v1/budget_caps |
required | — | — |
| GET | /api/v1/budget_caps |
— | — | rejected |
| GET | /api/v1/budget_caps/{id} |
— | — | rejected |
| POST | /api/v1/policies |
required | — | — |
| GET | /api/v1/policies |
— | — | rejected |
| GET | /api/v1/policies/{id} |
— | — | rejected |
| POST | /api/v1/policies/{id}/versions |
required | — | — |
| GET | /api/v1/policies/{id}/versions |
— | — | rejected |
| GET | /api/v1/policies/{id}/versions/{version} |
— | — | rejected |
| POST | /api/v1/policies/{id}/actions |
required | required | — |
| POST | /api/v1/policies/{id}/simulate |
not required | — | rejected |
| POST | /api/v1/authorizations |
required | — | — |
| GET | /api/v1/authorizations |
— | — | supported |
| GET | /api/v1/authorizations/{id} |
— | — | supported |
| POST | /api/v1/authorizations/{id}/actions |
required | required | forbidden |
"rejected" means a request carrying x-sponsor-party-id gets 403 rather than
an unscoped body — the narrowing is not implemented there, so answering would
leak.