Agent Integration Patterns

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.

These patterns are tied directly to the tool signatures in Connect via MCP. Nothing below adds new API surface — it's how to use what's already defined in the sandbox.

Check before you work

Call pink.check_policy (or POST /v1/payments/check) before the agent commits effort to a plan that might not clear. It runs the exact same rule engine as request_payment but never spends, holds, or creates a payment record — so calling it as many times as you like costs nothing and changes nothing.

The returned decision is would_allow / would_ask / would_block — note these are different strings from request_payment's allowed / pending_human / blocked. They come from different code paths that both run the same underlying rule evaluation; don't write code that compares a check_policy result directly to a request_payment result as if they were the same enum.

Handling pending_human

When request_payment returns "decision": "pending_human", the response has a hold_id (identical string to payment_id) and expires_at (an ISO timestamp — verified value 30 minutes after created_at).

  1. Don't treat pending_human as a failure — it means a person has to look at it. The response also includes who (a human-readable string of the approver(s), e.g. "Luis Ortega (Store manager)") and approvers_needed/approvals_so_far, useful for telling the user who they're waiting on.
  2. Poll pink.get_credential(hold_id) (or GET /v1/payments/{id}) until decision changes.
  3. After expires_at passes with no decision, the next poll flips the record to "decision": "expired", not "approved" — the sandbox checks the current time against expires_at on every poll and flips a still-pending hold to expired once it's passed. This matches the product's own line, "No answer = no." Plan for expired as a distinct terminal state, separate from declined (a person said no) and approved (a person said yes).
  4. If the final decision is declined, the response has credential: null and declined_by: [...] (the approver names). If approved, it has credential and approved_by: [...].

The credential is single-use, short-lived, and shaped by payment method

Every credential carries sandbox: true, max_amount, currency, locked_to (the payee name), single_use: true, and expires_at (verified: 15 minutes after issuance). Three concrete shapes, chosen by the payee's own method field (never by the agent):

  • type: "virtual_card" → card: { pan, exp, cvv, name: "PINK SANDBOX" }. pan is always in the 4111 1111 **** test range (Luhn-shaped, a sandbox test value, not a real card).
  • type: "bank_transfer" → transfer: { rail, reference, status: "submitted" }. rail is picked from the payment currency: ACH (USD), SEPA (EUR), FPS (GBP), FPS (HK) (HKD), FAST (SGD), Zengin (JPY), wire otherwise.
  • type: "platform_refund" → platform: { name, reference } (for payees marked method: "platform", e.g. Stripe/Shopify refund payees in the e-commerce template).

An agent never chooses which of these it gets — the payee's own record decides. Don't branch on payment amount or currency to guess the credential shape; read credential.type from the response.

idempotency_key — verified behavior, not assumed

Unlike early API sketches (which flagged this as an open question), the real sandbox does implement idempotency: request_payment checks for an existing payment from the same agent with the same idempotency_key before evaluating anything, and if found, returns the original payment with "replayed": true added.

  • Pass a unique idempotency_key per payment intent (not per HTTP attempt) so that retrying a timed-out network call is safe.
  • The dedup key is scoped to the calling agent — two different agents can reuse the same string safely.
  • REST: the key can go in the request body (idempotency_key) or the Idempotency-Key header; the header is only read as a fallback when the body field is absent.
  • This closes the exact gap the product's own "bug case" describes (a 03:14 retry loop trying to buy $49,600 of API credits) — but the $200/day rule in that scenario is still the real backstop; idempotency_key only prevents duplicate submissions of the same intent, not an agent that keeps generating new intents.

Filing receipts

pink.report_receipt(payment_id, receipt) — verified: it raises the agent's trust score by exactly 1 per call (capped at 100; seed value in every template is 95), and returns { ok: true, payment_id, trust_score }. Use the payment_id from the request_payment/get_credential response, not hold_id from an earlier step in your own variable naming — in this API they're the same string, but keep the naming honest in your code since other payment systems don't guarantee that.

receipt is a loosely-typed object (merchant, total, currency, reference, url, note are all optional, and extra fields pass through) — there's no format validation in this sandbox build, so don't rely on the API to catch a malformed receipt.

What blocked means for the agent's plan

A "decision": "blocked" response has credential: null, retry_after: null (always, in this build — there is no automatic retry timer), and a why field naming the specific reason — either a named rule ("Never: gift cards, cash-like, crypto") or a budget/ceiling message the engine generates itself ("$3,756.40 used of $4,000 · this would exceed it"). Treat blocked as final for that specific request:

  • Surface why to the user/log — it's the same trace a human approver would see with check_policy's trace array.
  • Don't mechanically resubmit the identical request_payment call on a block; if the cause is a per-transaction rule (e.g., a $500 cap), replan with a different amount/payee, not a retry loop.
  • If the cause is a budget/ceiling, check pink.get_budget before trying anything else in that vault — re-submitting will just produce another blocked response with the same why.

Rules are evaluated top-to-bottom, first match wins

pink.list_rules returns them in evaluation order, and check_policy's trace array shows every rule the engine walked, marked PASS (it didn't apply, kept going), SKIP (structurally didn't apply — wrong agent, wrong hours, missing evidence flag), or the final matching rule's action. If nothing matches, the default is block (verified: the gift-card rule and every template's rule list end with catch-alls, and giftcards.com — a payee not on any list — was blocked by the first rule, not fell through unblocked).

Two flag-gated rule types worth designing around explicitly if your agent works across templates:

  • requires: <flag> rules only match if you pass that flag in evidence_flags (e.g., "statement" for a carrier-invoice rule in the e-commerce template) — the rule is silently SKIPped without it, which usually routes the payment into a stricter, human-approval rule instead.
  • absent: <flag> rules match only if you do not pass that flag (e.g., a purchase-order match: absent: "po" blocks a supplier payment with no PO reference).

An agent that never populates evidence_flags will get asked/blocked far more often than one that passes the flags its workflow actually has evidence for — this is by design ("Anything uncovered is blocked" — the product's own policy philosophy), not a bug to work around.