Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions packages/api/src/acp/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Agentic Commerce Protocol

The ACP transport is mounted at `/acp` by both the Express and Fastify API
adapters. It implements the `2026-04-17` checkout-session contract:

- `POST /acp/checkout_sessions`
- `POST /acp/checkout_sessions/:id`
- `GET /acp/checkout_sessions/:id`
- `POST /acp/checkout_sessions/:id/complete`
- `POST /acp/checkout_sessions/:id/cancel`
- `GET /acp/feed.jsonl`
- `GET /.well-known/acp.json`

Required checkout configuration:

```text
UNCHAINED_ACP_API_KEY=<inbound bearer token>
UNCHAINED_ACP_PAYMENT_PROVIDER_ID=<GENERIC provider id>
ACP_CHECKOUT_CONTINUE_URL=https://shop.example.com/orders
```

The configured payment provider must be a `GENERIC` provider using the existing
Stripe adapter key `shop.unchained.payment.stripe`. ACP Shared Payment Token
charges use `STRIPE_SECRET` and Stripe's `2026-04-22.preview` API version for
that single charge request.

Product-feed configuration:

```text
ACP_SELLER_NAME=Example Store
ACP_SELLER_URL=https://shop.example.com
ACP_SELLER_PRIVACY_POLICY=https://shop.example.com/privacy
ACP_SELLER_TOS=https://shop.example.com/terms
ACP_PRODUCT_URL_BASE=https://shop.example.com/products
ACP_TARGET_COUNTRIES=US,CH
```

Webhook configuration:

```text
ACP_WEBHOOK_URL=https://example.openai.com/agentic_checkout/webhooks/order_events
ACP_WEBHOOK_SECRET=<shared signing secret>
ACP_WEBHOOK_RETRIES=5
ACP_WEBHOOK_EVENT_TENSE=past
```

`OPENAI_WEBHOOK_URL` and `OPENAI_WEBHOOK_SECRET` are accepted as aliases.
`ACP_WEBHOOK_EVENT_TENSE=present` emits the canonical repository values
`order_create` and `order_update`; the default emits the OpenAI certification
values `order_created` and `order_updated`.

Every request requires `Authorization: Bearer`, `API-Version: 2026-04-17`, and
every POST also requires `Idempotency-Key`.

The current idempotency cache is process-local with a 24-hour TTL. It provides
the ACP wire behavior for a single process, but production multi-instance
deployments need a shared persistent implementation behind the same helper.
66 changes: 66 additions & 0 deletions packages/api/src/acp/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { timingSafeStringEqual } from '@unchainedshop/utils';
import { ACP_API_VERSION, acpConfig } from './config.ts';
import { ACPError } from './error.ts';

export type ACPHeaders = Record<string, string | string[] | undefined>;

export const getHeader = (headers: ACPHeaders, name: string) => {
const value = headers[name.toLowerCase()];
return Array.isArray(value) ? value[0] : value;
};

export const verifyACPRequest = async ({ headers, method }: { headers: ACPHeaders; method: string }) => {
if (!acpConfig.apiKey) {
throw new ACPError(
503,
'api_error',
'acp_not_configured',
'UNCHAINED_ACP_API_KEY is not configured',
);
}

const authorization = getHeader(headers, 'authorization');
const [scheme, token] = authorization?.split(' ') || [];
if (
scheme?.toLowerCase() !== 'bearer' ||
!token ||
!(await timingSafeStringEqual(token, acpConfig.apiKey))
) {
throw new ACPError(
401,
'invalid_api_key_error',
'invalid_api_key',
'A valid Bearer token is required',
);
}

const apiVersion = getHeader(headers, 'api-version');
if (!apiVersion) {
throw new ACPError(
400,
'invalid_request',
'missing_api_version',
`API-Version is required. Supported versions: ${ACP_API_VERSION}`,
'$.headers.API-Version',
);
}
if (apiVersion !== ACP_API_VERSION) {
throw new ACPError(
400,
'invalid_request',
'unsupported_api_version',
`Unsupported API-Version. Supported versions: ${ACP_API_VERSION}`,
'$.headers.API-Version',
);
}

if (method === 'POST' && !getHeader(headers, 'idempotency-key')) {
throw new ACPError(
400,
'invalid_request',
'idempotency_key_required',
'Idempotency-Key is required for POST requests',
'$.headers.Idempotency-Key',
);
}
};
41 changes: 41 additions & 0 deletions packages/api/src/acp/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
export const ACP_API_VERSION = '2026-04-17';

const {
ACP_API_PATH = '/acp',
UNCHAINED_ACP_API_KEY,
UNCHAINED_ACP_PAYMENT_PROVIDER_ID,
ACP_CHECKOUT_CONTINUE_URL,
ACP_IDEMPOTENCY_CONFLICT_STATUS = '422',
ACP_SELLER_NAME,
ACP_SELLER_URL,
ACP_SELLER_PRIVACY_POLICY,
ACP_SELLER_TOS,
ACP_PRODUCT_URL_BASE,
ACP_TARGET_COUNTRIES,
ACP_WEBHOOK_URL,
OPENAI_WEBHOOK_URL,
ACP_WEBHOOK_SECRET,
OPENAI_WEBHOOK_SECRET,
ACP_WEBHOOK_RETRIES = '5',
ACP_WEBHOOK_EVENT_TENSE = 'past',
} = process.env;

export const acpConfig = {
apiPath: ACP_API_PATH,
apiKey: UNCHAINED_ACP_API_KEY,
paymentProviderId: UNCHAINED_ACP_PAYMENT_PROVIDER_ID,
continueUrl: ACP_CHECKOUT_CONTINUE_URL,
sellerName: ACP_SELLER_NAME,
sellerUrl: ACP_SELLER_URL,
sellerPrivacyPolicy: ACP_SELLER_PRIVACY_POLICY,
sellerTerms: ACP_SELLER_TOS,
productUrlBase: ACP_PRODUCT_URL_BASE,
targetCountries: ACP_TARGET_COUNTRIES?.split(',')
.map((country) => country.trim().toUpperCase())
.filter(Boolean),
webhookUrl: ACP_WEBHOOK_URL || OPENAI_WEBHOOK_URL,
webhookSecret: ACP_WEBHOOK_SECRET || OPENAI_WEBHOOK_SECRET,
webhookRetries: Math.max(0, Number.parseInt(ACP_WEBHOOK_RETRIES, 10) || 0),
webhookEventTense: ACP_WEBHOOK_EVENT_TENSE === 'present' ? 'present' : 'past',
idempotencyConflictStatus: ACP_IDEMPOTENCY_CONFLICT_STATUS === '409' ? 409 : 422,
} as const;
33 changes: 33 additions & 0 deletions packages/api/src/acp/error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
export type ACPErrorType =
| 'invalid_request'
| 'authentication_error'
| 'permission_error'
| 'not_found_error'
| 'conflict_error'
| 'invalid_api_key_error'
| 'api_error'
| 'api_connection_error';

export class ACPError extends Error {
status: number;
type: ACPErrorType;
code: string;
param?: string;

constructor(status: number, type: ACPErrorType, code: string, message: string, param?: string) {
super(message);
this.status = status;
this.type = type;
this.code = code;
this.param = param;
}

toJSON() {
return {
type: this.type,
code: this.code,
message: this.message,
...(this.param ? { param: this.param } : {}),
};
}
}
118 changes: 118 additions & 0 deletions packages/api/src/acp/feed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { ProductStatus } from '@unchainedshop/core-products';
import type { Context } from '../context.ts';
import normalizeMediaUrl from '../mcp/utils/normalizeMediaUrl.ts';
import { acpConfig } from './config.ts';
import { ACPError } from './error.ts';

const formatPrice = (amount: number, currencyCode: string, decimals = 2) =>
`${(amount / 10 ** decimals).toFixed(decimals)} ${currencyCode.toUpperCase()}`;

export const buildACPProductFeed = async (context: Context) => {
if (!acpConfig.sellerName || !acpConfig.sellerUrl || !acpConfig.productUrlBase) {
throw new ACPError(
503,
'api_error',
'feed_not_configured',
'ACP_SELLER_NAME, ACP_SELLER_URL, and ACP_PRODUCT_URL_BASE are required',
);
}

const targetCountries = acpConfig.targetCountries?.length
? acpConfig.targetCountries
: [context.countryCode.toUpperCase()];
const rows: Record<string, unknown>[] = [];
const limit = 250;

for (let offset = 0; ; offset += limit) {
const products = await context.modules.products.findProducts(
{ includeDrafts: false, limit, offset },
{},
);
if (!products.length) break;

for (const product of products) {
const text = await context.modules.products.texts.findLocalizedText({
productId: product._id,
locale: context.locale,
});
if (!text?.title || !text.description) continue;

const pricing = await context.services.products.simulateProductPricing({
product,
countryCode: context.countryCode,
currencyCode: context.currencyCode,
quantity: 1,
discounts: [],
});
const unitPrice = pricing?.unitPrice({ useNetPrice: false });
if (!unitPrice) continue;

const currency = await context.modules.currencies.findCurrency({
isoCode: unitPrice.currencyCode,
});
const medias = await context.modules.products.media.findProductMedias({
productId: product._id,
});
const normalizedMedia = await normalizeMediaUrl(medias, context);
const imageUrl = (normalizedMedia[0] as any)?.file?.url;
if (!imageUrl) continue;

const inventory = await context.services.products.simulateProductInventory({ product });
const knownStock = inventory
.map(({ quantity }) => quantity)
.filter((quantity): quantity is number => typeof quantity === 'number');
const availability = knownStock.length
? knownStock.some((quantity) => quantity > 0)
? 'in_stock'
: 'out_of_stock'
: 'unknown';
const checkoutEligible = Boolean(
acpConfig.paymentProviderId &&
acpConfig.sellerPrivacyPolicy &&
acpConfig.sellerTerms &&
availability !== 'out_of_stock',
);
const slug = text.slug || product.slugs[0] || product._id;
const productUrl = `${acpConfig.productUrlBase.replace(/\/$/, '')}/${slug}`;

rows.push({
item_id: product._id,
title: text.title,
description: text.description,
url: productUrl,
image_url: imageUrl,
...(normalizedMedia.length > 1
? {
additional_image_urls: normalizedMedia
.slice(1)
.map((media) => (media as any).file?.url)
.filter(Boolean)
.join(','),
}
: {}),
brand: text.brand || text.vendor || acpConfig.sellerName,
price: formatPrice(unitPrice.amount, unitPrice.currencyCode, currency?.decimals ?? 2),
availability,
is_eligible_search: product.status === ProductStatus.ACTIVE,
is_eligible_checkout: checkoutEligible,
seller_name: acpConfig.sellerName,
seller_url: acpConfig.sellerUrl,
...(checkoutEligible
? {
seller_privacy_policy: acpConfig.sellerPrivacyPolicy,
seller_tos: acpConfig.sellerTerms,
}
: {}),
target_countries: targetCountries,
store_country: context.countryCode.toUpperCase(),
group_id: product._id,
listing_has_variations: Boolean(product.proxy?.assignments?.length),
mpn: product.warehousing?.sku,
});
}

if (products.length < limit) break;
}

return rows.map((row) => JSON.stringify(row)).join('\n') + (rows.length ? '\n' : '');
};
Loading