Connect via REST

Sandbox live. Create a free sandbox workspace at agentic-sandbox.pinkwallet.com (test credentials only; no money moves). Production is not yet available.

Pink Agentic AI Payments (by PinkWallet, early access) is the approval layer between AI agents and company money: plain-language rules, per-agent budgets and human approvals decide each payment before a single-use card or bank transfer is issued.

The REST API is a mirror of the MCP tools (Connect via MCP) for agents that don't speak MCP, plus admin routes with no MCP equivalent. All examples use $PINK_BASE (export PINK_BASE=https://agentic-sandbox.pinkwallet.com — public sandbox: live).

Authentication

Authorization: Bearer <key>

An agent key (pk_sandbox_agent_...) can call the agent routes below. An admin key (pk_sandbox_admin_...) can call /v1/admin/*. Using an agent key on an admin route (or vice versa) returns 403. No key at all returns 401:

curl -s -i "$PINK_BASE/v1/budget"
# HTTP/1.1 401 Unauthorized
# {"error":"Unauthorized: send Authorization: Bearer <key>"}

X-Api-Key: <key> and ?key=<key> are also accepted, but Authorization: Bearer is the documented default.

Agent routes (agent key)

Method & path Does
GET /v1/me Workspace + this agent's id/name/department/vault.
GET /v1/budget Same object as pink.get_budget.
GET /v1/payees { payees: [...] } — same as pink.list_payees.
GET /v1/vaults { vaults: [...] } — just the vault(s) this agent is attached to.
GET /v1/rules { rules: [...] } — same as pink.list_rules.
POST /v1/payments/check Dry run — same as pink.check_policy.
POST /v1/payments The real call — same as pink.request_payment. Returns 201 (allowed), 202 (pending_human), or 403 (blocked).
GET /v1/payments/{id} Same as pink.get_credential(hold_id).
POST /v1/payments/{id}/receipt Same as pink.report_receipt. Body: {"receipt": {...}} (or the receipt object directly — the code accepts both body.receipt and body itself).

GET /v1/budget

curl -s "$PINK_BASE/v1/budget" -H "authorization: Bearer $PURCH"
{
  "agent": "Purchasing AI", "status": "active",
  "monthly_budget": 4000, "spent_this_month": 3570, "left_this_month": 430,
  "spent_today": 1310, "single_payment_cap": 500,
  "vault": { "id": "v_ops", "name": "Operating", "balances": { "USD": 17110.55 } },
  "company_daily_ceiling": 6000, "company_spent_today": 1310, "policy_version": 1
}

POST /v1/payments/check

curl -s -X POST "$PINK_BASE/v1/payments/check" -H "authorization: Bearer $PURCH" -H 'content-type: application/json' \
  -d '{"payee_id":"p_uline","amount":186.4,"purpose":"cups"}'

Returns 200 with the same {decision: would_allow|would_ask|would_block, reason, rule, trace} shape as pink.check_policy — see Connect via MCP for the full trace format.

POST /v1/payments — 201 allowed

curl -s -i -X POST "$PINK_BASE/v1/payments" -H "authorization: Bearer $PURCH" -H 'content-type: application/json' \
  -H 'idempotency-key: rest-demo-1' \
  -d '{"payee_id":"p_uline","amount":186.4,"purpose":"cups"}'
HTTP/1.1 201 Created
{
  "payment_id": "pay_5e7da67d6879", "decision": "allowed",
  "agent": "Purchasing AI", "payee": "Uline", "payee_id": "p_uline",
  "amount": 186.4, "currency": "USD", "purpose": "cups",
  "rule": "Small supply orders go through", "policy_version": 1,
  "created_at": "2026-09-30T21:07:03.192Z",
  "credential": { "type": "virtual_card", "sandbox": true, "max_amount": 186.4, "currency": "USD", "locked_to": "Uline", "single_use": true, "expires_at": "2026-09-30T21:22:03.192Z", "card": { "pan": "4111 1111 1086 9610", "exp": "12/27", "cvv": "201", "name": "PINK SANDBOX" } }
}

pan is always a 4111 1111 test-range BIN — a sandbox test value, never a real card. The idempotency key can go in the body (idempotency_key) or as an Idempotency-Key / idempotency-key header — the server checks the header only as a fallback when the body field is absent. Confirmed via the MCP route (which only takes the body field) that a repeated key returns the original payment with "replayed": true.

POST /v1/payments — 202 pending_human

curl -s -i -X POST "$PINK_BASE/v1/payments" -H "authorization: Bearer $INV" -H 'content-type: application/json' \
  -d '{"payee_id":"p_uline","amount":600,"purpose":"bulk cup order"}'
HTTP/1.1 202 Accepted
{
  "payment_id": "pay_3adfda5d15c7", "decision": "pending_human",
  "agent": "Inventory AI", "payee": "Uline", "amount": 600,
  "rule": "Bigger supply orders: store manager checks",
  "hold_id": "pay_3adfda5d15c7", "approvers_needed": 1, "approvals_so_far": 0,
  "who": "Luis Ortega (Store manager)", "expires_at": "2026-09-30T21:37:19.751Z",
  "poll": "pink.get_credential(hold_id) · or GET /v1/payments/{id}"
}

POST /v1/payments — 403 blocked

Two real ways to get a 403: a rule match, or the agent's own monthly budget being used up (the code returns decision: "blocked" with 403 for either):

// rule match (gift cards)
{ "payment_id": "pay_40009af4f54c", "decision": "blocked", "payee": "giftcards.com", "payee_id": null, "rule": "Never: gift cards, cash-like, crypto", "credential": null, "retry_after": null, "why": "matched · block" }

// budget exhausted (not a named rule — the engine's own budget check)
{ "payment_id": "pay_d1426ff46ad9", "decision": "blocked", "rule": "Purchasing AI's monthly budget is used up", "credential": null, "retry_after": null, "why": "$3,756.40 used of $4,000 · this would exceed it" }

GET /v1/payments/{id} and POST /v1/payments/{id}/receipt

curl -s "$PINK_BASE/v1/payments/pay_5e7da67d6879" -H "authorization: Bearer $PURCH"
# → same 201 body as above, fetched back (200 OK)

curl -s -X POST "$PINK_BASE/v1/payments/pay_5e7da67d6879/receipt" -H "authorization: Bearer $PURCH" -H 'content-type: application/json' \
  -d '{"receipt":{"merchant":"Uline","total":186.4,"reference":"INV-2"}}'
# → {"ok":true,"payment_id":"pay_5e7da67d6879","trust_score":97}

GET /v1/payments/{id} only returns a payment that belongs to the calling agent — a different agent's key gets 404 {"error":"not found"}.

Admin routes (admin key)

Method & path Does
GET /v1/admin/state Full dump: workspace, people, groups, vaults, agents, payees, rules, breakers, pending holds, last 100 transactions.
GET /v1/admin/workspace Admin-view summary (agents with keys, payee ids, rule count).
POST /v1/admin/requests/{id}/approve Approve a pending hold → issues the credential.
POST /v1/admin/requests/{id}/decline Decline a pending hold.
PUT /v1/admin/rules Replace the whole rule set. Body {"rules": [...]}. Bumps policy_version.
PATCH /v1/admin/agents/{id} Patch status (active/paused), monthly, or perPay on one agent.
POST /v1/admin/reset-day Zero every agent's spentToday.
POST /v1/admin/reset Reset the whole workspace back to its template, keeping the same keys.

Approve / decline

curl -s -X POST "$PINK_BASE/v1/admin/requests/pay_c4821e4c4f85/approve" -H "authorization: Bearer $ADMIN" -H 'content-type: application/json' -d '{}'
{
  "payment_id": "pay_c4821e4c4f85", "decision": "approved",
  "credential": { "type": "virtual_card", "sandbox": true, "max_amount": 890, "currency": "USD", "locked_to": "Sysco", "single_use": true, "expires_at": "2026-09-30T21:21:49.073Z", "card": { "pan": "4111 1111 7481 6092", "exp": "12/27", "cvv": "922", "name": "PINK SANDBOX" } },
  "approved_by": ["Sandbox admin"]
}
curl -s -X POST "$PINK_BASE/v1/admin/requests/pay_3adfda5d15c7/decline" -H "authorization: Bearer $ADMIN" -H 'content-type: application/json' -d '{}'
# → {"payment_id":"pay_3adfda5d15c7","decision":"declined","credential":null,"declined_by":["Sandbox admin"]}

by in the approve/decline body (default "Sandbox admin") names the approver; if it's not "Sandbox admin" the code still requires approvedBy.length >= approvers_needed before issuing the credential — a single non-default approver on a 2-of-3 rule leaves the hold pending_human.

PATCH an agent (pause it)

curl -s -i -X PATCH "$PINK_BASE/v1/admin/agents/a_mkt" -H "authorization: Bearer $ADMIN" -H 'content-type: application/json' -d '{"status":"paused"}'
HTTP/1.1 200 OK
{ "id": "a_mkt", "name": "Marketing AI", "dept": "Marketing", "vault": "v_mkt", "owner": "owner", "monthly": 3000, "perPay": 300, "spentMonth": 1840, "spentToday": 0, "status": "paused", "trust": 95, "key": "pk_sandbox_agent_...", "desc": "Runs Instagram and Meta ads on slow days." }

Note this returns the internal agent shape (dept, perPay, spentMonth, raw key) — different field names than the agent-facing pink.get_budget/me views. This endpoint is for the admin console, not for agents.

GET /v1/admin/state (trimmed)

{
  "workspace": { "id": "o8fvsqi24z", "name": "Bloom & Bean", "template": "coffee", "policy_version": { "n": 1, "by": "Sandbox", "at": "..." }, "history": [...] },
  "pending": [ /* holds awaiting a person, with agent_reason, evidence, trace */ ],
  "transactions": [ /* last 100 payments, same shape as pending + agent_reason/evidence */ ],
  "breakers": { "dailyCeiling": 6000, "velocity": 5, "unregistered": true },
  "people": [...], "groups": [...], "vaults": [...], "agents": [...], "payees": [...], "rules": [...]
}

Workspaces and templates (no key required)

curl -s "$PINK_BASE/v1/sandbox/templates"
{
  "templates": [
    { "id": "coffee", "label": "Coffee shop", "blurb": "Four agents, 11 rules. Purchasing, inventory, ads and payroll for a two-location café.", "agents": 4, "rules": 11 },
    { "id": "startup", "label": "Software startup · 50 people", "blurb": "Six agents, 17 rules. API-credit circuit breaker, CFO tier, 2-of-3 leadership above $5,000.", "agents": 6, "rules": 17 },
    { "id": "ecommerce", "label": "E-commerce · 120 people", "blurb": "Seven agents, 30 rules. PO matching, duplicate invoices, payee bank-detail changes, HKD/JPY, refund tiers.", "agents": 7, "rules": 30 }
  ]
}
curl -s -X POST "$PINK_BASE/v1/sandbox/workspaces" -H 'content-type: application/json' \
  -d '{"company":"Bloom & Bean","email":"[email protected]","template":"coffee"}'

Returns 201 with admin_key, all 4 agent keys, and ready-to-use urls.mcp_with_key / urls.rest / urls.console — the exact object is shown in full in Quickstart.