Prood
ApplicationsCommerce 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}Agent metadata or user's first org membershipFrom capability grants
2API keyx-api-key: {key}Key metadata organizationIdKey metadata scopes
3Session cookieCookie: better-auth.session=...session.activeOrganizationIdadmin, storefront
4Host headerHost: merchant.example.comTenant domain / subdomain lookupstorefront only

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

Scopes

Two scopes control access:

ScopeEndpointsCallers
storefrontCatalog reads, cart CRUD, place order, customer ordersStorefront (Host), customer sessions, agents with storefront grants
adminFull CRUD — products, orders, customers, store, inventory, statsDashboard sessions, API keys with admin scope, agents with admin grants

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' | '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 grants both admin and storefront scopes.

Host-based authentication (storefront)

The storefront forwards the Host header:

// apps/storefront/lib/commerce-api.ts
headers: async () => ({
  cookie: (await headers()).get('cookie') ?? '',
  host: (await headers()).get('host') ?? '',
})

The API resolves the tenant from the host and grants storefront scope only. This prevents storefront requests from accessing admin endpoints.

API key authentication

API keys are created in the dashboard (Settings → API Keys) and stored by Better Auth's apiKey() plugin:

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

Usage:

curl -H "x-api-key: pk_xxx" http://localhost:3005/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, maps grants to scopes, resolves org from agent metadata

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

All auth failures use consistent error format from @prood/types:

{
  "error": "Unauthorized",
  "code": "UNAUTHORIZED",
  "status": 401
}
{
  "error": "Insufficient scope",
  "code": "FORBIDDEN",
  "status": 403
}

On this page