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:
| Priority | Method | Header / Cookie | Tenant source | Scopes |
|---|---|---|---|---|
| 1 | Agent JWT | Authorization: Bearer {jwt} | The store it was approved for, or the user's only store | Per operation, from capability grants |
| 2 | API key | x-api-key: {key} | The organization that owns the key | Key metadata scopes |
| 3 | Store host | x-storefront-host: merchant.example.com | Tenant domain / subdomain lookup | storefront, or the member's role scopes |
| 4 | Session cookie | Cookie: better-auth.session=... | session.activeOrganizationId | admin, staff, storefront |
If no method resolves a valid caller, the request returns 401 Unauthorized.
Scopes
Three scopes control access:
| Scope | Endpoints | Callers |
|---|---|---|
storefront | Catalog reads, cart CRUD, place order, customer orders | Storefront (Host), customer sessions, agents with storefront grants |
staff | Read admin data — products, orders, customers, store, stats | Every member of the store |
admin | Reads 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 staff | Sessions of store owners and admins, API keys with admin scope, agents with admin grants acting for an owner or admin |
Route handlers enforce scope:
// 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
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:
// 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:
// 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:
// 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/productsAgent JWT authentication
AI agents authenticate via Better Auth Agent Auth. See Agent Auth for the full flow.
Briefly:
- Agent discovers config at
/.well-known/agent-configuration - Agent registers and receives capability grants
- Agent calls API with
Authorization: Bearer {jwt} - 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:
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.
{
"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."
}{
"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.