ACP Checkout & Delegated Payment
Protocol: 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: Every request must include a Signature + Timestamp header pair, verified server-side by acp_signature.py's verify_acp_signature() against the ACP_SIGNING_SECRET shared secret. There is no bearer-token/API-key auth on these two endpoints — the HMAC signature is the auth. See Request Signing below.
Source: acp_checkout.py, acp_delegate_payment.py (Refs core#9247)
Both endpoints are real and live in production today:
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_sessionscreates an actualstripe.checkout.Session(mode="subscription") for one of AINative's real plans, the same plan catalog (plan_catalog_service.py'sSTRIPE_PRICE_IDS) the platform's own pricing page checks out against.POST /agentic_commerce/delegate_paymentvaults an already-tokenized StripePaymentMethodid into a real, single-usestripe.SetupIntent, constrained by the allowance'smax_amount/currency/expires_atrecorded as SetupIntent metadata.
Both endpoints require a valid Signature + Timestamp header pair on every request (Refs core#9346). A request missing either header is rejected with 401 missing_signature before any Stripe call is made — see Request Signing for the exact scheme and a verified example.
Request Signing
Every request to POST /checkout_sessions and POST /agentic_commerce/delegate_payment must include a Signature and Timestamp header, verified server-side before any Stripe call (Refs core#9346, acp_signature.py::verify_acp_signature).
| Header | Format | Description |
|---|---|---|
Signature | sha256=<hex HMAC-SHA256 digest> | HMAC-SHA256 of the canonical string below, keyed by the shared secret ACP_SIGNING_SECRET, hex-encoded. |
Timestamp | <unix seconds> | The time the request was signed. Must be within 5 minutes (REQUEST_TIMESTAMP_TOLERANCE_SECONDS = 300) of server time, in either direction. |
The canonical string that gets signed is:
{METHOD}\n{PATH}\n{TIMESTAMP}\n{RAW_BODY}
METHOD— the HTTP method, uppercased (POST).PATH— the request path only, no query string (/checkout_sessionsor/agentic_commerce/delegate_payment).TIMESTAMP— the exact same value sent in theTimestampheader, as a string.RAW_BODY— the exact raw request body bytes you will send. Sign the bytes as they go on the wire — re-serializing the JSON before signing (e.g. different key order or whitespace) produces a different signature and verification will fail.
The three header-derived parts are joined with \n (including a trailing \n before the body), then the raw body bytes are appended directly — there is no separator between the last \n and the body.
Failure modes
| Condition | Response |
|---|---|
Signature or Timestamp header missing | 401 — {"code": "missing_signature"} |
Timestamp is not an integer | 401 — {"code": "invalid_timestamp"} |
Timestamp more than 5 minutes from server time | 401 — {"code": "stale_timestamp"} |
Signature does not match the expected digest | 403 — {"code": "invalid_signature"} |
ACP_SIGNING_SECRET not configured server-side | 401 — {"code": "signature_not_configured"} (fails closed, never treated as auth-not-required) |
Worked example (verified)
This example was generated and independently re-verified against the real verify_acp_signature() canonical-string logic — it is not hand-written pseudocode.
Request body (the exact bytes to sign and send):
{"items":[{"id":"Starter","quantity":1}]}
Signing with secret = "example_signing_secret", timestamp = "1793577600":
import hashlib
import hmac
method = "POST"
path = "/checkout_sessions"
timestamp = "1793577600"
body = b'{"items":[{"id":"Starter","quantity":1}]}'
secret = "example_signing_secret"
canonical = f"{method}\n{path}\n{timestamp}\n".encode("utf-8") + body
digest = hmac.new(secret.encode("utf-8"), canonical, hashlib.sha256).hexdigest()
signature_header = f"sha256={digest}"
print(signature_header)
# sha256=6caf1dd20fdbd7171eca4a0d537fa99d5678ba984c4768721f65fe7969b2ebc4
Which produces the headers:
Signature: sha256=6caf1dd20fdbd7171eca4a0d537fa99d5678ba984c4768721f65fe7969b2ebc4
Timestamp: 1793577600
1793577600 is fixed only so this example's signature is reproducible on the page. A real request must use the current unix timestamp (within 5 minutes of now) or the server rejects it as stale_timestamp — recompute the signature with timestamp = str(int(time.time())) for any request you actually send.
Signing in curl
SECRET="your_acp_signing_secret"
BODY='{"items":[{"id":"Starter","quantity":1}]}'
TIMESTAMP=$(date +%s)
SIGNATURE="sha256=$(printf 'POST\n/checkout_sessions\n%s\n%s' "$TIMESTAMP" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')"
curl -s -X POST https://ainative-browser-builder.up.railway.app/checkout_sessions \
-H "Content-Type: application/json" \
-H "Signature: $SIGNATURE" \
-H "Timestamp: $TIMESTAMP" \
-d "$BODY"
Always use a timing-safe comparison on the verifying side (the server already does, via hmac.compare_digest) and never log the raw ACP_SIGNING_SECRET.
POST /checkout_sessions
Creates a real Stripe checkout session for one line item.
Request
POST /checkout_sessions
Content-Type: application/json
Signature: sha256=<hex HMAC-SHA256 digest>
Timestamp: <unix seconds>
See Request Signing above for exactly how to compute Signature.
| 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). |
{
"items": [
{ "id": "Pro", "quantity": 1 }
],
"buyer": {
"email": "agent-buyer@example.com"
}
}
Response — 201 Created
{
"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:
{ "detail": "items must be a non-empty list" }
Unknown plan name (items[0].id not a real plan) → 400 Bad Request:
{
"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. (Captured before request signing was enforced — reproducing it now requires a valid Signature/Timestamp pair, or the response is 401 missing_signature instead; see Request Signing.)
Missing/malformed body → 422 Unprocessable Entity (standard FastAPI/Pydantic validation error), e.g. missing items:
{
"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:
{ "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.
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
POST /agentic_commerce/delegate_payment
Content-Type: application/json
Signature: sha256=<hex HMAC-SHA256 digest>
Timestamp: <unix seconds>
See Request Signing above for exactly how to compute Signature.
| 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. |
{
"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
{
"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:
{
"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. (Captured before request signing was enforced — reproducing it now requires a valid Signature/Timestamp pair, or the response is 401 missing_signature instead; see Request Signing.):
{
"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:
{ "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
Every example below needs a valid Signature + Timestamp pair (see Request Signing) — without them you'll get 401 missing_signature instead of the handler's own validation/business-logic response.
SECRET="your_acp_signing_secret"
sign() {
# $1 = path, $2 = raw body
local path="$1" body="$2"
TIMESTAMP=$(date +%s)
SIGNATURE="sha256=$(printf 'POST\n%s\n%s\n%s' "$path" "$TIMESTAMP" "$body" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')"
}
# Real validation error (reaches the actual handler, signature verified first)
BODY='{}'
sign "/checkout_sessions" "$BODY"
curl -s -X POST https://ainative-browser-builder.up.railway.app/checkout_sessions \
-H "Content-Type: application/json" -H "Signature: $SIGNATURE" -H "Timestamp: $TIMESTAMP" \
-d "$BODY"
# Real checkout session for a real plan
BODY='{"items":[{"id":"Starter","quantity":1}]}'
sign "/checkout_sessions" "$BODY"
curl -s -X POST https://ainative-browser-builder.up.railway.app/checkout_sessions \
-H "Content-Type: application/json" -H "Signature: $SIGNATURE" -H "Timestamp: $TIMESTAMP" \
-d "$BODY"
# Real delegate_payment validation error
BODY='{}'
sign "/agentic_commerce/delegate_payment" "$BODY"
curl -s -X POST https://ainative-browser-builder.up.railway.app/agentic_commerce/delegate_payment \
-H "Content-Type: application/json" -H "Signature: $SIGNATURE" -H "Timestamp: $TIMESTAMP" \
-d "$BODY"
# Unsigned request — demonstrates the signature gate itself
curl -s -X POST https://ainative-browser-builder.up.railway.app/checkout_sessions \
-H "Content-Type: application/json" -d '{}'
# {"detail":{"code":"missing_signature","message":"Request must include 'Signature' and 'Timestamp' headers."}}
Related
| Document | Purpose |
|---|---|
| UCP Discovery Document | GET /.well-known/ucp — lists ACP's sibling commerce capabilities (Stripe subscriptions, x402, AP2) in one discovery document |
| Agent402 API | Agent-side x402 wallets and the Cloudflare wire-format payment flow |
POST /agentic_commerce/ap2/verify | Agent Payments Protocol (Google) mandate verification — AINative's other agentic-commerce surface, registered alongside ACP in main.py |