---
title: "ACP Checkout & Delegated Payment"
description: "Agentic Commerce Protocol — real Stripe-backed checkout sessions and vaulted payment delegation for autonomous agent buyers"
canonical: "https://docs.ainative.studio/docs/web3/acp-checkout"
last-updated: "2026-10-28T06:34:16.301Z"
---

# ACP Checkout & Delegated Payment

Source: https://docs.ainative.studio/docs/web3/acp-checkout

> Agentic Commerce Protocol — real Stripe-backed checkout sessions and vaulted payment delegation for autonomous agent buyers

# ACP Checkout & Delegated Payment

**Protocol:** [Agentic Commerce Protocol](https://github.com/agenticcommerceprotocol/agentic-commerce-protocol) (ACP)
**Base URL:** `https://ainative-browser-builder.up.railway.app` (bare apex paths, no `/api/v1` prefix — per the ACP spec)
**Auth:** None required to create a checkout session or delegate a payment method — the same no-auth checkout pattern the platform's existing Stripe checkout flow uses
**Source:** `acp_checkout.py`, `acp_delegate_payment.py` (Refs core#9247)

:::tip Live & Public
Both endpoints are real and live in production today:

```bash
curl -i -X OPTIONS https://ainative-browser-builder.up.railway.app/checkout_sessions
# HTTP/1.1 204 No Content
# Allow: POST, OPTIONS
```
:::

## What it's for

ACP (Agentic Commerce Protocol) is an emerging standard that lets an AI agent — not a human in a browser — initiate and complete a real purchase: create a checkout session for a line item, then vault a payment method under a spend-limited allowance so the agent can be delegated to pay without ever seeing a raw card number.

AINative implements the two core ACP write operations against **real AINative subscription plans and a real Stripe account** — not a mock or sandboxed stand-in:

- `POST /checkout_sessions` creates an actual `stripe.checkout.Session` (`mode="subscription"`) for one of AINative's real plans, the same plan catalog (`plan_catalog_service.py`'s `STRIPE_PRICE_IDS`) the platform's own pricing page checks out against.
- `POST /agentic_commerce/delegate_payment` vaults an already-tokenized Stripe `PaymentMethod` id into a real, single-use `stripe.SetupIntent`, constrained by the allowance's `max_amount`/`currency`/`expires_at` recorded as SetupIntent metadata.

:::info Scope of this increment
This implements the functional core of ACP — real Stripe-backed checkout sessions and real vaulted payment delegation with an ACP-shaped request/response. It does **not** yet implement the full ACP request-signing/idempotency-header contract (HMAC `Signature` header, `Idempotency-Key` echo, webhook delivery, etc.) — that is a protocol-compliance hardening pass, tracked separately from the baseline functionality below.
:::

---

## `POST /checkout_sessions`

Creates a real Stripe checkout session for one line item.

### Request

```http
POST /checkout_sessions
Content-Type: application/json
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `items` | `array` | Yes | Non-empty list of items to purchase. Only `items[0]` is used — ACP checkout here is single-line-item. |
| `items[].id` | `string` | Yes | A real AINative plan name: `Hobbyist`, `Starter`, `Pro`, `Business`, `Enterprise`, or `Cody — Your Virtual CTO`. Anything else is rejected — there is no silent fallback to a different plan. |
| `items[].quantity` | `integer` | No (default `1`) | Must be `>= 1`. |
| `buyer` | `object` | No | Optional buyer info. `buyer.email`, if present, is passed to Stripe as `customer_email`. |
| `fulfillment_address` | `object` | No | Accepted but not currently used (AINative plans are digital subscriptions, no shipping). |

```json
{
  "items": [
    { "id": "Pro", "quantity": 1 }
  ],
  "buyer": {
    "email": "agent-buyer@example.com"
  }
}
```

### Response — `201 Created`

```json
{
  "id": "cs_live_a1B2c3D4e5F6g7H8i9J0...",
  "payment_provider": {
    "provider": "stripe",
    "supported_payment_methods": ["card"]
  },
  "status": "ready_for_payment",
  "currency": "usd",
  "line_items": [
    {
      "id": "line_item_Pro",
      "item": { "id": "Pro", "quantity": 1 }
    }
  ],
  "checkout_url": "https://checkout.stripe.com/c/pay/cs_live_...",
  "messages": []
}
```

| Field | Type | Description |
|-------|------|-------------|
| `id` | `string` | The real Stripe Checkout Session id. |
| `payment_provider.provider` | `string` | Always `"stripe"`. |
| `payment_provider.supported_payment_methods` | `array` | Always `["card"]` currently. |
| `status` | `string` | `"ready_for_payment"` if the Stripe session is `open`; otherwise `"not_ready_for_payment"`. |
| `currency` | `string` | Always `"usd"`. |
| `line_items` | `array` | Echoes back the requested item (id, quantity). |
| `checkout_url` | `string` | The real Stripe-hosted checkout URL — redirect the buyer (human or agent-driven browser) here to pay. |
| `messages` | `array` | Reserved for ACP protocol messages; empty on success. |

### Error responses

**Empty `items`** → `400 Bad Request`:

```json
{ "detail": "items must be a non-empty list" }
```

**Unknown plan name** (`items[0].id` not a real plan) → `400 Bad Request`:

```json
{
  "detail": {
    "messages": [
      {
        "type": "error",
        "code": "invalid_item",
        "content": "'NotARealPlan' is not a real AINative plan. Valid plans: ['Business', 'Cody — Your Virtual CTO', 'Enterprise', 'Hobbyist', 'Pro', 'Starter']"
      }
    ]
  }
}
```

This is a **live, verified** response — it was captured with a real `curl -X POST` against production, not hand-written. The valid-plans list is read from the same `STRIPE_PRICE_IDS` map the real checkout flow uses, so it cannot drift from what actually exists.

**Missing/malformed body** → `422 Unprocessable Entity` (standard FastAPI/Pydantic validation error), e.g. missing `items`:

```json
{
  "detail": [
    { "type": "missing", "loc": ["body", "items"], "msg": "Field required", "input": {} }
  ],
  "error_code": "VALIDATION_ERROR",
  "next_action": "fix_request"
}
```

**Stripe failure** (e.g. provider outage) → `502 Bad Gateway`:

```json
{ "detail": "Payment provider error: <stripe error message>" }
```

---

## `POST /agentic_commerce/delegate_payment`

Vaults an already-tokenized Stripe payment method into a real, constrained `SetupIntent`, returning a vault token an agent can reference for a later, allowance-limited charge.

:::warning No raw card numbers, ever
`payment_method.id` **must** already be a Stripe-tokenized `PaymentMethod` id (`pm_...`), created client-side via Stripe.js or Stripe Elements — the same PCI-compliance boundary every other checkout flow on this platform respects. This endpoint never accepts or handles a raw card number server-side.
:::

### Request

```http
POST /agentic_commerce/delegate_payment
Content-Type: application/json
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `payment_method.type` | `string` | Yes | Must be exactly `"CARD"` (uppercase) — any other value is rejected. |
| `payment_method.id` | `string` | Yes | A Stripe `PaymentMethod` id (`pm_...`), already tokenized client-side. |
| `allowance.max_amount` | `integer` | Yes | Maximum amount this delegated payment method may later be charged, in minor currency units (cents). Must be `> 0`. |
| `allowance.currency` | `string` | Yes | Currency code, e.g. `"usd"`. |
| `allowance.expires_at` | `string` | Yes | ISO-8601 timestamp the allowance expires. |
| `allowance.reason` | `string` | No (default `"one_time"`) | Freeform reason code. |
| `risk_signals` | `array` | No | Freeform list of risk-signal objects, stored but not currently evaluated server-side. |
| `metadata` | `object` of `string` | No | Freeform key/value metadata, merged into the real Stripe `SetupIntent.metadata`. |
| `billing_address` | `object` | No | Accepted but not currently used. |

```json
{
  "payment_method": {
    "type": "CARD",
    "id": "pm_1AbCdEfGhIjKlMnO"
  },
  "allowance": {
    "max_amount": 4900,
    "currency": "usd",
    "expires_at": "2026-12-31T23:59:59Z",
    "reason": "subscription_renewal"
  },
  "metadata": {
    "agent_id": "agent-42"
  }
}
```

### Response — `201 Created`

```json
{
  "id": "vt_seti_1AbCdEfGhIjKlMnOpQrStUvW",
  "created": 1793577600,
  "metadata": {
    "agent_id": "agent-42"
  }
}
```

| Field | Type | Description |
|-------|------|-------------|
| `id` | `string` | The vault token id — the real Stripe `SetupIntent` id prefixed with `vt_` per the ACP spec (`vt_<setup_intent_id>`). |
| `created` | `integer` | Unix timestamp from the real Stripe `SetupIntent.created`. |
| `metadata` | `object` | Echoes back the `metadata` you sent (not the full, internal Stripe metadata — which also carries `acp_max_amount`/`acp_currency`/`acp_expires_at`/`acp_reason` for later allowance validation). |

### The vault-token concept

A **vault token** (`vt_...`) is not a payment itself — it's a durable, amount-and-currency-constrained *reference* to a tokenized payment method that an agent can hold and present later to authorize a charge, without ever re-collecting card details or exceeding the allowance it was created with. AINative backs this with a real Stripe `SetupIntent` (`usage: "off_session"`, `confirm: false`) rather than an opaque internal record — the allowance's `max_amount`, `currency`, `expires_at`, and `reason` are written into the SetupIntent's own `metadata` (as `acp_max_amount`, `acp_currency`, `acp_expires_at`, `acp_reason`), so a later charge attempt against this vault token can be validated against the real constraint that was actually recorded, not a value that was merely echoed back and never persisted.

### Error responses

**Wrong `payment_method.type`** → `400 Bad Request`:

```json
{
  "detail": {
    "type": "error",
    "code": "unsupported_payment_method_type",
    "message": "payment_method.type must be 'CARD', got 'BANK_ACCOUNT'."
  }
}
```

**Missing required fields** → `422 Unprocessable Entity`. This is a **live, verified** response, captured with a real `curl -X POST {}` against production:

```json
{
  "detail": [
    { "type": "missing", "loc": ["body", "payment_method"], "msg": "Field required", "input": {} },
    { "type": "missing", "loc": ["body", "allowance"], "msg": "Field required", "input": {} }
  ],
  "error_code": "VALIDATION_ERROR",
  "next_action": "fix_request"
}
```

**Stripe failure** → `502 Bad Gateway`:

```json
{ "detail": "Payment provider error: <stripe error message>" }
```

---

## A known gotcha: OPTIONS vs. the real handler

`OPTIONS /checkout_sessions` is answered by the APISIX gateway's CORS plugin before the request ever reaches the backend — a `204` there proves routing/CORS is configured, **not** that the handler, Stripe integration, or validation logic is reachable. To exercise the real FastAPI handler (and get a real Pydantic `422`, or a real `400`/`502` from the business logic), send an actual `POST`, even with a deliberately invalid or empty body.

## curl examples

```bash
# Real validation error (reaches the actual handler)
curl -s -X POST https://ainative-browser-builder.up.railway.app/checkout_sessions \
  -H "Content-Type: application/json" -d '{}'

# Real checkout session for a real plan
curl -s -X POST https://ainative-browser-builder.up.railway.app/checkout_sessions \
  -H "Content-Type: application/json" \
  -d '{"items":[{"id":"Starter","quantity":1}]}'

# Real delegate_payment validation error
curl -s -X POST https://ainative-browser-builder.up.railway.app/agentic_commerce/delegate_payment \
  -H "Content-Type: application/json" -d '{}'
```

## Related

| Document | Purpose |
|----------|---------|
| [UCP Discovery Document](./ucp-discovery) | `GET /.well-known/ucp` — lists ACP's sibling commerce capabilities (Stripe subscriptions, x402, AP2) in one discovery document |
| [Agent402 API](./agent402) | Agent-side x402 wallets and the Cloudflare wire-format payment flow |
| `POST /agentic_commerce/ap2/verify` | [Agent Payments Protocol](https://github.com/google-agentic-commerce/AP2) (Google) mandate verification — AINative's other agentic-commerce surface, registered alongside ACP in `main.py` |
