Skip to main content

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)

Live & Public

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_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.
Request signing is required

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

HeaderFormatDescription
Signaturesha256=<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_sessions or /agentic_commerce/delegate_payment).
  • TIMESTAMP — the exact same value sent in the Timestamp header, 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​

ConditionResponse
Signature or Timestamp header missing401 — {"code": "missing_signature"}
Timestamp is not an integer401 — {"code": "invalid_timestamp"}
Timestamp more than 5 minutes from server time401 — {"code": "stale_timestamp"}
Signature does not match the expected digest403 — {"code": "invalid_signature"}
ACP_SIGNING_SECRET not configured server-side401 — {"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
Use a current timestamp

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"
caution

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.

FieldTypeRequiredDescription
itemsarrayYesNon-empty list of items to purchase. Only items[0] is used — ACP checkout here is single-line-item.
items[].idstringYesA 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[].quantityintegerNo (default 1)Must be >= 1.
buyerobjectNoOptional buyer info. buyer.email, if present, is passed to Stripe as customer_email.
fulfillment_addressobjectNoAccepted 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": []
}
FieldTypeDescription
idstringThe real Stripe Checkout Session id.
payment_provider.providerstringAlways "stripe".
payment_provider.supported_payment_methodsarrayAlways ["card"] currently.
statusstring"ready_for_payment" if the Stripe session is open; otherwise "not_ready_for_payment".
currencystringAlways "usd".
line_itemsarrayEchoes back the requested item (id, quantity).
checkout_urlstringThe real Stripe-hosted checkout URL — redirect the buyer (human or agent-driven browser) here to pay.
messagesarrayReserved 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.

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​

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.

FieldTypeRequiredDescription
payment_method.typestringYesMust be exactly "CARD" (uppercase) — any other value is rejected.
payment_method.idstringYesA Stripe PaymentMethod id (pm_...), already tokenized client-side.
allowance.max_amountintegerYesMaximum amount this delegated payment method may later be charged, in minor currency units (cents). Must be > 0.
allowance.currencystringYesCurrency code, e.g. "usd".
allowance.expires_atstringYesISO-8601 timestamp the allowance expires.
allowance.reasonstringNo (default "one_time")Freeform reason code.
risk_signalsarrayNoFreeform list of risk-signal objects, stored but not currently evaluated server-side.
metadataobject of stringNoFreeform key/value metadata, merged into the real Stripe SetupIntent.metadata.
billing_addressobjectNoAccepted 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"
}
}
FieldTypeDescription
idstringThe vault token id — the real Stripe SetupIntent id prefixed with vt_ per the ACP spec (vt_<setup_intent_id>).
createdintegerUnix timestamp from the real Stripe SetupIntent.created.
metadataobjectEchoes 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."}}
DocumentPurpose
UCP Discovery DocumentGET /.well-known/ucp — lists ACP's sibling commerce capabilities (Stripe subscriptions, x402, AP2) in one discovery document
Agent402 APIAgent-side x402 wallets and the Cloudflare wire-format payment flow
POST /agentic_commerce/ap2/verifyAgent Payments Protocol (Google) mandate verification — AINative's other agentic-commerce surface, registered alongside ACP in main.py