acp-handler
Version:
Vercel handler for Agentic Commerce Protocol (ACP) - Build checkout APIs that AI agents like ChatGPT can use to complete purchases
608 lines (471 loc) • 17 kB
Markdown
# acp-handler
A TypeScript handler for implementing the [Agentic Commerce Protocol](https://developers.openai.com/commerce) (ACP) in your web application. Handle ACP checkout requests with built-in idempotency, signature verification, and OpenTelemetry tracing.
## What is ACP?
An open standard for programmatic commerce flows between buyers, AI agents, and businesses. This package handles the protocol implementation so you can focus on your business logic.
**Key Features:**
- ✅ Full ACP spec compliance
- ✅ Type-safe TypeScript API
- ✅ Built-in idempotency (prevents double-charging)
- ✅ OpenTelemetry tracing support
- ✅ Web Standard APIs (works with Next.js, Hono, Express, Cloudflare Workers, Deno, Bun, Remix)
- ✅ Production-ready patterns
- ✅ Comprehensive test suite
## Installation
```bash
pnpm add acp-handler
```
### Peer Dependencies
The handler requires a key-value store for session storage. Redis is recommended:
```bash
pnpm add redis
```
Optional dependencies:
```bash
pnpm add next # For Next.js catch-all route helper
```
## Quick Start
### 1. Define Your ACP Handler
Create your ACP handler in a central location (e.g., `lib/acp.ts`) so you can reuse it throughout your app:
```typescript
import { acpHandler, createStoreWithRedis } from 'acp-handler';
// Wire up storage (uses REDIS_URL environment variable)
const { store } = createStoreWithRedis('acp');
// Create ACP handler with business logic
const { handlers, webhooks, sessions } = acpHandler({
// Product pricing logic
products: {
price: async ({ items, customer, fulfillment }) => {
// Fetch products from your database
const products = await db.products.findMany({
where: { id: { in: items.map(i => i.id) } }
});
// Calculate pricing
const itemsWithPrices = items.map(item => {
const product = products.find(p => p.id === item.id);
return {
id: item.id,
title: product.name,
quantity: item.quantity,
unit_price: { amount: product.price, currency: 'USD' }
};
});
const subtotal = itemsWithPrices.reduce(
(sum, item) => sum + item.unit_price.amount * item.quantity,
0
);
return {
items: itemsWithPrices,
totals: {
subtotal: { amount: subtotal, currency: 'USD' },
grand_total: { amount: subtotal, currency: 'USD' }
},
ready: true, // Ready for payment
};
}
},
// Payment processing
payments: {
authorize: async ({ session, delegated_token }) => {
// Integrate with your payment provider (Stripe, etc.)
const intent = await stripe.paymentIntents.create({
amount: session.totals.grand_total.amount,
currency: session.totals.grand_total.currency,
payment_method: delegated_token,
});
if (intent.status === 'requires_capture') {
return { ok: true, intent_id: intent.id };
}
return { ok: false, reason: 'Authorization failed' };
},
capture: async (intent_id) => {
const intent = await stripe.paymentIntents.capture(intent_id);
if (intent.status === 'succeeded') {
return { ok: true };
}
return { ok: false, reason: 'Capture failed' };
}
},
// Storage backend (Redis recommended)
store
});
export { handlers, webhooks, sessions };
```
### 2. Mount Route Handlers
#### Next.js (App Router)
```typescript
// app/checkout_sessions/[[...segments]]/route.ts
import { createNextCatchAll } from 'acp-handler/next';
import { handlers } from '@/lib/acp';
export const { GET, POST } = createNextCatchAll(handlers);
```
#### Hono
Hono natively supports Web Standard APIs, so no adapter needed:
```typescript
// server.ts
import { Hono } from 'hono';
import { handlers } from './lib/acp';
import {
parseJSON,
validateBody,
CreateCheckoutSessionSchema,
UpdateCheckoutSessionSchema,
CompleteCheckoutSessionSchema,
} from 'acp-handler';
const app = new Hono();
app.post('/checkout_sessions', async (c) => {
const parsed = await parseJSON(c.req.raw);
if (!parsed.ok) return parsed.res;
const validated = validateBody(CreateCheckoutSessionSchema, parsed.body);
if (!validated.ok) return validated.res;
return handlers.create(c.req.raw, validated.data);
});
app.get('/checkout_sessions/:id', async (c) => {
const id = c.req.param('id');
return handlers.get(c.req.raw, id);
});
app.post('/checkout_sessions/:id', async (c) => {
const id = c.req.param('id');
const parsed = await parseJSON(c.req.raw);
if (!parsed.ok) return parsed.res;
const validated = validateBody(UpdateCheckoutSessionSchema, parsed.body);
if (!validated.ok) return validated.res;
return handlers.update(c.req.raw, id, validated.data);
});
app.post('/checkout_sessions/:id/complete', async (c) => {
const id = c.req.param('id');
const parsed = await parseJSON(c.req.raw);
if (!parsed.ok) return parsed.res;
const validated = validateBody(CompleteCheckoutSessionSchema, parsed.body);
if (!validated.ok) return validated.res;
return handlers.complete(c.req.raw, id, validated.data);
});
app.post('/checkout_sessions/:id/cancel', async (c) => {
const id = c.req.param('id');
return handlers.cancel(c.req.raw, id);
});
```
#### Express / Node.js
The core handlers use Web Standard `Request`/`Response` objects. For Node.js frameworks like Express, use [`@whatwg-node/server`](https://github.com/ardatan/whatwg-node):
```bash
pnpm add @whatwg-node/server
```
```typescript
// server.ts
import express from 'express';
import { createServerAdapter } from '@whatwg-node/server';
import { handlers } from './lib/acp';
import {
parseJSON,
validateBody,
CreateCheckoutSessionSchema,
UpdateCheckoutSessionSchema,
CompleteCheckoutSessionSchema,
} from 'acp-handler';
const app = express();
// Helper to extract route params
const getId = (req: Request) => req.url.split('/').filter(Boolean)[1];
// POST /checkout_sessions
app.post('/checkout_sessions', createServerAdapter(async (req: Request) => {
const parsed = await parseJSON(req);
if (!parsed.ok) return parsed.res;
const validated = validateBody(CreateCheckoutSessionSchema, parsed.body);
if (!validated.ok) return validated.res;
return handlers.create(req, validated.data);
}));
// GET /checkout_sessions/:id
app.get('/checkout_sessions/:id', createServerAdapter(async (req: Request) => {
const id = getId(req);
return handlers.get(req, id);
}));
// POST /checkout_sessions/:id
app.post('/checkout_sessions/:id', createServerAdapter(async (req: Request) => {
const id = getId(req);
const parsed = await parseJSON(req);
if (!parsed.ok) return parsed.res;
const validated = validateBody(UpdateCheckoutSessionSchema, parsed.body);
if (!validated.ok) return validated.res;
return handlers.update(req, id, validated.data);
}));
// POST /checkout_sessions/:id/complete
app.post('/checkout_sessions/:id/complete', createServerAdapter(async (req: Request) => {
const id = getId(req);
const parsed = await parseJSON(req);
if (!parsed.ok) return parsed.res;
const validated = validateBody(CompleteCheckoutSessionSchema, parsed.body);
if (!validated.ok) return validated.res;
return handlers.complete(req, id, validated.data);
}));
// POST /checkout_sessions/:id/cancel
app.post('/checkout_sessions/:id/cancel', createServerAdapter(async (req: Request) => {
const id = getId(req);
return handlers.cancel(req, id);
}));
app.listen(3000);
```
**Note:** This approach works with Express, Fastify, Koa, and any Node.js HTTP framework.
#### Other Frameworks
The handlers use Web Standard APIs and work natively with:
- Cloudflare Workers
- Deno Deploy
- Bun
- Vercel Edge Functions
- Remix
Just call the handlers directly with `Request` objects!
### 3. Send Webhooks (Optional)
Webhooks notify OpenAI about post-checkout events like shipping or delivery. Since delegated tokens mean OpenAI already knows payment succeeded, webhooks are only needed for lifecycle updates:
```typescript
// warehouse/ship-order.ts
import { webhooks } from '@/lib/acp';
async function handleOrderShipped(sessionId: string, trackingNumber: string) {
// Send webhook notification to OpenAI
await webhooks.sendOrderUpdated(sessionId, {
webhookUrl: process.env.OPENAI_WEBHOOK_URL!,
secret: process.env.OPENAI_WEBHOOK_SECRET!,
merchantName: 'YourStore',
status: 'shipped',
});
}
```
### 4. Access Sessions Anywhere
Use session utilities from admin panels, analytics, or other parts of your app:
```typescript
// app/admin/session/[id]/page.tsx
import { sessions } from '@/lib/acp';
export default async function SessionPage({ params }: { params: { id: string } }) {
const session = await sessions.get(params.id);
if (!session) {
return <div>Session not found</div>;
}
return <div>Session {session.id}: {session.status}</div>;
}
```
### 5. Done!
Your ACP-compliant checkout API is now ready. ChatGPT can create checkout sessions, update cart items, and complete purchases.
## Core Concepts
### Products Handler
Calculates pricing, taxes, and shipping. Called on every create/update.
```typescript
type Products = {
price(input: {
items: Array<{ id: string; quantity: number }>;
customer?: CustomerInfo;
fulfillment?: FulfillmentInfo;
}): Promise<{
items: CheckoutItem[];
totals: Totals;
fulfillment?: Fulfillment;
messages?: Message[];
ready: boolean; // Can checkout proceed to payment?
}>;
};
```
### Payments Handler
Handles payment authorization and capture (two-phase commit).
```typescript
type Payments = {
authorize(input: {
session: CheckoutSession;
delegated_token?: string;
}): Promise<
| { ok: true; intent_id: string }
| { ok: false; reason: string }
>;
capture(intent_id: string): Promise<
| { ok: true }
| { ok: false; reason: string }
>;
};
```
### Webhooks
Send notifications to OpenAI about post-checkout events. With delegated tokens, OpenAI already knows when payment succeeds, so webhooks are only needed for lifecycle updates:
```typescript
// From anywhere in your app
import { webhooks } from '@/lib/acp';
await webhooks.sendOrderUpdated(sessionId, {
webhookUrl: process.env.OPENAI_WEBHOOK_URL!,
secret: process.env.OPENAI_WEBHOOK_SECRET!,
merchantName: 'YourStore',
status: 'shipped', // or 'delivered', 'canceled', etc.
});
```
**Common use cases:**
- Order shipped from warehouse
- Order delivered
- Order canceled after payment
- Refund issued
### Storage
Provides a key-value store for session data and idempotency.
```typescript
type KV = {
get(key: string): Promise<string | null>;
set(key: string, value: string, ttlSec?: number): Promise<void>;
setnx(key: string, value: string, ttlSec?: number): Promise<boolean>;
};
```
**Built-in Redis adapter:**
```typescript
import { createStoreWithRedis } from 'acp-handler';
const { store } = createStoreWithRedis('namespace');
```
## Advanced Features
### Signature Verification
Verify that requests are actually from OpenAI/ChatGPT and haven't been tampered with:
```typescript
import { acpHandler } from 'acp-handler';
const { handlers, webhooks, sessions } = acpHandler({
products,
payments,
store,
signature: {
secret: process.env.OPENAI_WEBHOOK_SECRET, // Provided by OpenAI
toleranceSec: 300 // Optional: 5 minutes default
}
});
export { handlers, webhooks, sessions };
```
**How it works:**
- HMAC-SHA256 signature verification
- Protects against unauthorized requests
- Prevents replay attacks (timestamp must be recent)
- Constant-time comparison (timing attack protection)
**Returns 401 if:**
- Signature header is missing
- Timestamp header is missing
- Signature doesn't match
- Request is too old (replay attack)
- Body has been tampered with
**Optional:** Signature verification is disabled by default for easier development. Enable it in production by providing the `signature` config.
### Idempotency
Automatically handles idempotency for all POST operations to prevent double-charging:
```typescript
// Automatically handled by acp-handler
POST /checkout_sessions/:id/complete
Headers:
Idempotency-Key: idem_abc123
// Retries with same key return cached result
// Payment only charged once!
```
### OpenTelemetry Tracing
Add distributed tracing to monitor performance:
```typescript
import { trace } from '@opentelemetry/api';
import { acpHandler } from 'acp-handler';
const tracer = trace.getTracer('my-shop');
const { handlers, webhooks, sessions } = acpHandler({
products,
payments,
store,
tracer // Add tracer
});
export { handlers, webhooks, sessions };
```
**Spans created:**
- `checkout.create`, `checkout.update`, `checkout.complete`
- `products.price` - See pricing performance
- `payments.authorize`, `payments.capture` - Track payment operations
- `session.get`, `session.put` - Monitor storage
- `webhooks.orderUpdated` - Track webhook delivery
**Attributes:**
- `session_id`, `idempotency_key`, `payment_intent_id`
- `items_count`, `session_status`, `idempotency_reused`
### Testing
The package provides test helpers for integration testing:
```typescript
import {
acpHandler,
createMemoryStore,
createMockProducts,
createMockPayments
} from 'acp-handler/test';
const { handlers, webhooks, sessions } = acpHandler({
products: createMockProducts(),
payments: createMockPayments(),
store: createMemoryStore()
});
// Test complete checkout flow
const res = await handlers.create(req, { items: [...] });
const session = await res.json();
// Test webhook utilities
await webhooks.sendOrderUpdated(session.id, {
webhookUrl: 'https://test.example.com/webhook',
secret: 'test-secret',
status: 'shipped'
});
```
## Examples
See the [`examples/basic`](./examples/basic) directory for a complete Next.js implementation with:
- AI chat demo (simulate ChatGPT)
- Complete checkout flow
- Mock products and payments
- Redis storage
```bash
cd examples/basic
pnpm install
pnpm dev
```
## API Reference
### `acpHandler(config)`
Creates an ACP handler with reusable utilities for checkout, webhooks, and sessions.
**Parameters:**
- `config.products: Products` - Product pricing implementation
- `config.payments: Payments` - Payment processing implementation
- `config.store: KV` - Key-value storage backend
- `config.sessions?: SessionStore` - Custom session storage (optional, defaults to Redis-backed store)
- `config.tracer?: Tracer` - OpenTelemetry tracer (optional)
- `config.signature?: SignatureConfig` - Signature verification config (optional)
**Returns:** Object with:
- `handlers` - Route handlers for checkout API:
- `create(req, body)` - POST /checkout_sessions
- `update(req, id, body)` - POST /checkout_sessions/:id
- `complete(req, id, body)` - POST /checkout_sessions/:id/complete
- `cancel(req, id)` - POST /checkout_sessions/:id/cancel
- `get(req, id)` - GET /checkout_sessions/:id
- `webhooks` - Webhook utilities:
- `sendOrderUpdated(sessionId, config)` - Send order update webhook
- `sessions` - Session utilities:
- `get(id)` - Get checkout session by ID
- `put(session, ttl?)` - Store checkout session
### `createNextCatchAll(handlers, schemas?)`
Creates Next.js catch-all route handlers.
```typescript
import { createNextCatchAll } from 'acp-handler/next';
const { GET, POST } = createNextCatchAll(handlers);
export { GET, POST };
```
### `createStoreWithRedis(namespace)`
Creates a Redis-backed KV store.
```typescript
import { createStoreWithRedis } from 'acp-handler';
// Uses REDIS_URL environment variable
const { store } = createStoreWithRedis('acp');
```
## Project Structure
```
agentic-commerce-protocol-template/
├── packages/
│ └── sdk/ # acp-handler package
│ ├── src/
│ │ ├── checkout/ # Checkout implementation
│ │ │ ├── handlers.ts # Core business logic
│ │ │ ├── next/ # Next.js adapter
│ │ │ ├── hono/ # Hono adapter
│ │ │ ├── storage/ # Storage adapters
│ │ │ ├── webhooks/ # Webhook helpers
│ │ │ └── tracing.ts # OpenTelemetry helpers
│ │ ├── feeds/ # Product feeds (coming soon)
│ │ └── index.ts
│ └── test/ # Test helpers
└── examples/
└── basic/ # Example Next.js app
```
## Resources
- [ACP Checkout Spec](https://developers.openai.com/commerce/specs/checkout)
- [ACP Product Feeds Spec](https://developers.openai.com/commerce/specs/feed)
- [Apply for ChatGPT Checkout](https://chatgpt.com/merchants)
- [Example Implementation](./examples/basic)
## Contributing
Contributions welcome! Please open an issue or PR.
## License
MIT
---
**Questions?** Open an issue or check the [ACP documentation](https://developers.openai.com/commerce).