#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

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 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 active source funds anything. Moving a source from pending to active (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_remaining rule evaluates unknown and — under the default missing_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": N to 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.

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.
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.