Commerce API

Authentication

How the Commerce API resolves callers, assigns scopes, and enforces tenant isolation.

Every request to the Commerce API is authenticated and scoped to a tenant (organization). The resolve-caller.ts module determines who is calling and what they can access.

Caller resolution priority

The API checks authentication methods in this order:

PriorityMethodHeader / CookieTenant sourceScopes
1Agent JWTAuthorization: Bearer {jwt}The store it was approved for, or the user's only storePer operation, from capability grants
2API keyx-api-key: {key}The organization that owns the keyKey metadata scopes
3Store hostx-storefront-host: merchant.example.comTenant domain / subdomain lookupstorefront, or the member's role scopes
4Session cookieCookie: better-auth.session=...session.activeOrganizationIdadmin, staff, storefront

If no method resolves a valid caller, the request returns 401 Unauthorized.

Scopes

Three scopes control access:

ScopeEndpointsCallers
storefrontCatalog reads, cart CRUD, place order, customer ordersStorefront (Host), customer sessions, agents with storefront grants
staffRead admin data — products, orders, customers, store, statsEvery member of the store
adminReads and changes catalog, inventory, orders and store settings, and reads customers. Payment accounts, domains, team and keys stay in the dashboard. Starts on Grow. Implies staffSessions of store owners and admins, API keys with admin scope, agents with admin grants acting for an owner or admin

Route handlers enforce scope:

ts
// apps/api/app/v1/products/route.ts
export async function GET(request: Request) {
  const caller = await requireCaller('storefront')
  return listProducts(caller.orgId, params)
}

// apps/api/app/v1/admin/products/route.ts
export async function POST(request: Request) {
  const caller = await requireCaller('admin')
  return adminCreateProduct(caller.orgId, body)
}

Caller type

ts
interface ApiCaller {
  orgId: string
  scopes: ('storefront' | 'staff' | 'admin')[]
  type: 'agent' | 'api-key' | 'session' | 'host'
  userId?: string  // present for session and agent callers
}

Session authentication (dashboard)

The dashboard forwards its Better Auth session cookie to the API:

ts
// apps/dashboard/lib/commerce-api.ts
createCommerceApiClient({
  baseUrl: COMMERCE_API_URL,
  headers: async () => ({
    cookie: (await headers()).get('cookie') ?? '',
  }),
})

The API reads the session, extracts activeOrganizationId, and looks up the user's role in that store on every request. Owners and admins get admin, staff and storefront. Other members get staff and storefront. A session whose user no longer belongs to the store gets nothing from it.

Host-based authentication (storefront)

The storefront forwards the store's host in x-storefront-host. The API never uses its own Host header to find a store. The client sets the header from its host option:

ts
// apps/storefront/lib/commerce-api.ts
const headerList = await headers()
return createCommerceApiClient({
  baseUrl: getCommerceApiBaseUrl(),
  host: headerList.get('host')?.split(':')[0]?.toLowerCase(),
  cookie: headerList.get('cookie') ?? undefined,
})

The API resolves the tenant from the host. A shopper gets storefront scope, which keeps storefront requests away from admin endpoints. A signed-in member of that store gets their role's scopes, and the store being visited wins over the session's active store.

API key authentication

API keys are created in the dashboard (API keys, under Connections, at /settings/api-keys) and stored by Better Auth's apiKey() plugin. Each key has the Storefront scope, the Admin scope or both, and the Admin scope needs Grow:

ts
// Creating a key (dashboard)
await auth.api.createApiKey({
  body: {
    name: 'Integration key',
    prefix: 'pk',
    organizationId: activeOrgId,
    metadata: { scopes: ['admin'] },
  },
})

Better Auth joins the prefix and the random part with no separator, so a key looks like pkAbC123..., with no underscore.

Usage:

curl -H "x-api-key: $PROOD_API_KEY" https://api.prood.com/v1/admin/products

Agent JWT authentication

AI agents authenticate via Better Auth Agent Auth. See Agent Auth for the full flow.

Briefly:

  1. Agent discovers config at /.well-known/agent-configuration
  2. Agent registers and receives capability grants
  3. Agent calls API with Authorization: Bearer {jwt}
  4. API validates JWT, resolves the store, and checks the agent holds the capability for that operation

Webhook authentication

Payment webhook endpoints use a shared secret instead of caller resolution:

plaintext
POST /v1/webhooks/payments/{provider}?org={orgId}
Header: x-checkout-secret: {CHECKOUT_API_SECRET}

This allows the checkout app to forward webhooks without a session or API key.

Error responses

Every error is JSON with a code and a message, from toErrorResponse() in @prood/types. The status is the HTTP status of the response.

json
{
  "code": "UNAUTHORIZED",
  "message": "Invalid API key. It may be mistyped, or it may have expired. Create a new one in your dashboard, under API keys."
}
json
{
  "code": "FORBIDDEN",
  "message": "This API key doesn't have the admin scope. Create a key with it in your dashboard, under API keys. Admin keys start on Grow."
}

A key with the Admin scope on a Free store is refused on every call, whatever the route, with FORBIDDEN and "Write access requires a Grow plan or higher".

Rate limits

Each API key can make 600 requests a minute. Past that the API returns 429 with a Retry-After header saying how many seconds to wait.