# Authentication (https://prood.com/docs/apps/api/authentication)

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 [#caller-resolution-priority]

The API checks authentication methods in this order:

| Priority | Method | Header / Cookie | Tenant source | Scopes |
| --- | --- | --- | --- | --- |
| 0 | Agent Auth proxy | `x-prood-agent-proxy: {token}` | Signed by Agent Auth's execute endpoint, valid for a minute | The one operation it names, from capability grants |
| 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 [#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:

```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 [#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) [#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) [#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-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:

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

## Agent JWT authentication [#agent-jwt-authentication]

AI agents authenticate via Better Auth Agent Auth. See [Agent Auth](/docs/apps/api/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 [#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 [#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 [#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. A store's AI agents share one allowance of the same size.

## Related pages [#related-pages]

<Cards>
  <Card title="Agent Auth" href="/docs/apps/api/agent-auth" description="AI agent registration and JWT access." />
  <Card title="API keys" href="/docs/apps/dashboard" description="Creating keys in the dashboard." />
  <Card title="Multi-tenant platform" href="/docs/architecture/multi-tenant" description="Tenant isolation model." />
</Cards>
