Supercommerce API Docs
Admin API

Customer Module — Admin

HTTP surface for the admin customer-management portal (list, inspect, ban/unban, set-role, delete, set-password, session revocation, and storefront impersonation of non-staff accounts) plus the customer picker and lifetime-stats recompute. The storefront address book lives in the same module but is documented in store/customer.md.

HTTP surface for platform staff to manage customer accounts — every non-staff user (shoppers, including shoppers who are also vendors). Covers the management portal (list/detail + lifecycle actions + impersonation), the lightweight customer picker for form selects, and the lifetime-stats recompute job. The customer's storefront address book lives in the same module but is out of scope here (see store/customer.md).

Sources: api-modules/customer/src/controllers/admin-customer-management.controller.ts, admin-customer-actions.controller.ts, admin-customer-impersonation.controller.ts, store-impersonation-redeem.controller.ts, admin-customer.controller.ts.

The customer identity lives in Better-Auth's user table; this module owns the shopping-related customer data and the admin lifecycle surface on top of it.


Scope & enforcement model

  • Who is a "customer": any account whose role is not admin/superAdmin, has no dynamicRole, and is not an anonymous guest. Staff accounts are excluded from the list and from every mutation (their ids resolve to 404 there — existence is never revealed).
  • Reads for a promoted shopper still resolve. Staff and customers share one user table, so granting a shopper staff access (POST /admin/staff/grant-access) moves the same row out of "customer". The detail endpoint and its sub-resources deliberately stay readable for those ids, or a promoted shopper's orders, addresses and stats would become permanently unreachable. Only anonymous guests 404 on read.
  • Vendor-linked customers are included. A shopper who also became a vendor is the same user row; they appear here, badged isVendor. Banning/deleting/impersonating affects their seller access too — surfaced as a warning in the UI.
  • Authorization is dynamicRole-driven. The built-in admin role grants nothing on its own; superAdmin retains full access. Every action below is independently grantable to staff via a dynamicRole and enforced by PermissionsGuard (built-in OR dynamic). delete, set-password, and impersonate are intentionally grantable (no superAdmin-only tier).
  • Target protection: all mutations refuse staff targets server-side (404), independent of the caller's grants. set-role additionally refuses assigning admin/superAdmin (the portal cannot mint staff).
  • Audit: ban/unban/set-role/delete/set-password/session-revoke/impersonate-start/impersonate-stop are written to user_admin_audit (actor, target, before→after, source, reason) in the same transaction as the change.

Authentication & permissions

EndpointPermission
GET /admin/customersuser: list
GET /admin/customers/:iduser: list
GET /admin/customers/:id/ordersuser: list
GET /admin/customers/:id/sessionsuser: list
GET /admin/customers/:id/addressesuser: list
GET /admin/customers/:id/audituser: list
POST /admin/customers/:id/banuser: ban
POST /admin/customers/:id/unbanuser: ban
POST /admin/customers/:id/set-roleuser: set-role
POST /admin/customers/:id/set-passworduser: set-password
DELETE /admin/customers/:iduser: delete
POST /admin/customers/:id/sessions/revoke-allsession: revoke
DELETE /admin/customers/:id/sessions/:tokensession: revoke
POST /admin/customers/:id/impersonateuser: impersonate
POST /store/impersonation/redeemnone (guarded by the single-use handoff token)
POST /admin/customers/impersonate/stopnone (must be in an impersonation session)
GET /admin/customers/pickeruser: list
POST /admin/customers/recalculate-statsuser: update
GET /admin/customers/recalculate-stats/statususer: update

Response envelope

Successful responses are wrapped by ResponseInterceptor:

{ "data": <payload>, "message": "Success", "statusCode": 200, "metadata": { /* pagination if present */ } }

Paginated lists use the canonical metadata: { total, limit, offset, hasMore } shape.

Error envelope

statusCodeerrorCode examples
400BAD_REQUEST, VALIDATION_ERROR
401UNAUTHORIZED
403FORBIDDEN
404NOT_FOUND (unknown id, a guest account, or a staff target on any mutation)
500INTERNAL_SERVER_ERROR, DATABASE_ERROR

Management portal

GET /admin/customers — Customer list

Paginated. Substring ILIKE search across email, name, phone (searchValue). Facets: banned, emailVerified, isVendor, hasOrdered (each true/false, omit for any). Sort: sortBycreatedAt | totalSpent | lastOrderedAt, sortDirectionasc | desc. Offset pagination (limit, offset). Excludes admin/superAdmin and anonymous guests.

Row shape (CustomerSummary): id, name, email, emailVerified, image, phoneNumber, phoneNumberVerified, banned, isVendor, createdAt, totalSpent, deliveredOrderCount, lastOrderedAt.

GET /admin/customers/:id — Customer detail

Returns CustomerDetail: profile + status (banned, banReason, banExpires) + isVendor + vendorMemberships[] ({ vendorId, vendorName, vendorSlug, memberRole }) + stats (lifetime aggregates, null until first order). 404 for unknown ids and guest accounts; an account promoted to staff still resolves here.

GET /admin/customers/:id/orders — Orders (paginated)

limit/offset. Items: { id, orderNumber, status, paymentStatus, grandTotal, placedAt } (money in subunits), newest-first.

GET /admin/customers/:id/sessions — Active sessions

Array of { id, token, ipAddress, userAgent, isImpersonation, createdAt, expiresAt }.

GET /admin/customers/:id/addresses — Saved addresses

Array of the storefront address shape (see store/customer.md).

GET /admin/customers/:id/audit — Admin-action audit trail (paginated)

limit/offset. Items: { id, action, actorId, source, changes, createdAt }, newest-first.

POST /admin/customers/:id/ban — Ban

Body: { reason?: string, banExpiresInDays?: number } (omit banExpiresInDays for a permanent ban). Sets banned, revokes all the customer's sessions, audit-logged. Returns the refreshed CustomerDetail.

POST /admin/customers/:id/unban — Lift ban

Clears banned/banReason/banExpires. Returns refreshed CustomerDetail.

POST /admin/customers/:id/set-role — Set role

Body: { role: string | null } (null clears). Rejects admin/superAdmin with 403. Returns refreshed CustomerDetail.

POST /admin/customers/:id/set-password — Set password

Body: { newPassword: string (≥8), revokeSessions?: boolean (default true) }. Hashes with Better-Auth's configured hasher and updates the customer's credential account; 400 if the account has no password login (OAuth-only). Returns refreshed CustomerDetail.

DELETE /admin/customers/:id — Delete

Permanently deletes the account (cascades child rows; order.customerId set null). The deletion's audit row survives. Returns { success: true }. If the customer signed in with Apple, their Apple tokens are revoked after the delete commits, as App Store review requires (best-effort, never blocks the delete; see Social Sign-In).

POST /admin/customers/:id/sessions/revoke-all & DELETE /admin/customers/:id/sessions/:token

Revoke all or one session. Return the customer's remaining sessions.


Customer impersonation

Signs the admin into the storefront as the customer, so they see exactly what that shopper sees. Mirrors vendor impersonation (vendor.md) in storefront context; there is no activeVendorId to scope, since a customer session needs none.

The session is minted natively by us behind PermissionsGuard (never via Better-Auth's static-role check), and impersonatedBy + the admin_session cookie point at the real acting admin, so stopping restores that admin's own session. The redemption and the stop are both written to user_admin_audit with source admin-portal.

Why this is a two-step handoff

Each frontend proxies the API through its own origin (/bff/* on the dashboards, /api/v1/* on the storefront). That is deliberate: Better-Auth writes its session cookie onto whatever origin the request lands on, so routing through per-app origins keeps admin, vendor and store sessions isolated instead of sharing one cookie on a common API host.

The direct consequence is that the admin app cannot be given a customer session. A Set-Cookie on the admin's impersonate call is scoped to the admin origin — it would replace the caller's own admin session inside the admin app (locking them out of the dashboard until it expires), and the storefront would still see nothing.

So the start call returns a single-use token instead, and the storefront redeems it through its own origin:

admin app ──POST /admin/customers/:id/impersonate──▶ API   (validates, mints token)
          ◀──────────────── handoffUrl ─────────────

          └─▶ opens  https://shop.example.com/impersonate?token=…

                        └─▶ POST /store/impersonation/redeem  (via the STORE origin's proxy)
                              ◀── Set-Cookie: session scoped to the store origin

POST /admin/customers/:id/impersonate — Issue a handoff link

Required permission: user: impersonate. Validates the target up front, then mints a token — no session and no cookie are created here. Body: none. Vendor-context impersonation is not here — it lives in vendor management.

Response 200

{
  "impersonatedUserId": "…",
  "impersonatedUserEmail": "rita@example.test",
  "impersonatedUserName": "Rita Shopper",
  "storeUrl": "https://shop.example.com", // store.storefront_urls.store_url; null when unset
  "handoffPath": "/impersonate?token=…",  // for callers that know their own storefront origin
  "handoffUrl": "https://shop.example.com/impersonate?token=…", // null when store URL unset
  "expiresAt": "2026-08-01T12:01:00.000Z" // TOKEN expiry (60s), not the session's
}

Errors404 NOT_FOUND (unknown id, a guest, or a staff account — the customer scope makes those indistinguishable, so ids can't be probed); 400 BAD_REQUEST (the customer is banned — lift the ban first); 403 FORBIDDEN (caller lacks user:impersonate).

POST /store/impersonation/redeem — Redeem the token

Public by necessity — the browser has no session on the store origin yet; creating one is the point of the call. Authority is the token itself: 32 random bytes, single-use (consumption is atomic, so a replay finds nothing) and valid for 60 seconds. The target is re-checked on redemption, so a ban or a promotion to staff inside that window still takes effect.

Body{ "token": "…" }

Response 200 — Set-Cookie establishes the session on the calling origin:

{
  "impersonatedUserId": "…",
  "impersonatedUserEmail": "rita@example.test",
  "impersonatedUserName": "Rita Shopper",
  "expiresAt": "2026-08-01T13:00:00.000Z" // SESSION expiry, 1h default
}

Errors400 BAD_REQUEST (token missing, already used, or expired); the same 404/400 target checks as above if the customer changed in the meantime.

The storefront serves this at /impersonate, which redeems the token, strips it from the URL and history, and redirects to the home page.

POST /admin/customers/impersonate/stop — Stop impersonating

No permission required, by design: while impersonating, the caller's session is the (permissionless) customer's. Authority comes from the admin_session cookie, so it can only restore the admin who started this session. This is what the storefront's impersonation banner calls.

Response 200{ "stopped": true }, with Set-Cookie restoring the admin's session.


Customer picker

GET /admin/customers/picker — Customer picker (form selects)

Required permission: user: list. Substring ILIKE search across email and name. Pass selectedIds=<csv> to receive a pinned block of already-applied customers. Uses the picker envelope (data[] + pinned[]) — see Picker responses in catalog.md.

CustomerListItem: { id, email, name, image, createdAt, totalSpent, averageSpent, deliveredOrderCount, lastDeliveredAt, lastOrderedAt }. Lifetime stats live in customer_order_stats (maintained from source on order.placed/order.delivered) and are embedded via one batched lookup (no N+1).


Lifetime-stats recompute

POST /admin/customers/recalculate-stats — Recompute lifetime stats

Required permission: user: update. Enqueues a background BullMQ job that rebuilds customer_order_stats for all customers from source and backfills order.delivered_at. Deduped on a stable jobId. Response 202: { status: "queued" | "running", jobId }.

GET /admin/customers/recalculate-stats/status — Recompute status

Required permission: user: update. Poll until state is completed/failed. Returns { state, result, error, finishedAt }.


  • admin-rbac — gates every endpoint; the built-in admin role grants nothing, so staff access flows through dynamicRole. The current admin's effective permissions are served by GET /admin/rbac/me/permissions (used by the UI to gate actions). See admin-rbac.md.
  • auth — owns the user/session/account tables and Better-Auth's impersonation primitives; the impersonation session is minted via @sc/auth's mintImpersonationSession and stopped via Better-Auth's native stopImpersonating.
  • order, reviews — admin flows that consume the picker to attach a customer onto an admin-side action.

On this page