The backoffice API
Reference for the HTTP API routes inside your generated backoffice: how they are grouped, how they gate access by staff role, and how they validate and respond.
Reference
Your generated backoffice ships an HTTP API under src/app/api/. There are 56 route files, exposing 34 POST, 28 GET, 18 PATCH, and 14 DELETE handlers. The dashboard UI calls these routes; you own the code and can add or change any of them. This page documents how they are structured, not every endpoint.
Where the routes live
Every route is a Next.js App Router handler at src/app/api/<path>/route.ts. The folder path is the URL. A file at src/app/api/products/[id]/route.ts answers /api/products/<id>. Dynamic segments use [id] or [slug] folders, and the handler reads them from ctx.params, which is a Promise you must await:
export async function GET(_req: Request, ctx: { params: Promise<{ id: string }> }) {
await requireStaffRole('viewer');
const { id } = await ctx.params;
const row = await getProductWithRelations(id);
if (!row) return NextResponse.json({ error: 'Not found' }, { status: 404 });
return NextResponse.json(row);
}
Routes are thin. They gate access, validate the body, call a function in src/lib/ (queries for reads, mutations for writes), and return JSON. The business logic lives in src/lib/, not in the route file. That split is why the same operation can be called from a server action and from an API route without duplicated logic.
Route groups
The routes are grouped by the entity they act on. These are the top-level folders under src/app/api/:
| Group | Covers |
|---|---|
products, categories, attributes, collections | Catalog. Includes categories/reorder and collections/[id]/products/reorder for drag-and-drop ordering. |
inventory | Stock locations and the inventory/adjust endpoint that writes stock deltas. |
orders, customers, coupons | Commerce. Includes orders/[id]/transition and orders/[id]/notes. |
shipping, payments | Shipping zones, methods, carriers; payment providers and webhooks. |
blog | Posts, categories, comments. Includes publish, unpublish, schedule, reorder, bulk, and approve-all sub-routes. |
settings, staff, integrations | Store settings, staff roles, third-party integration connections. |
search, ai, gsc | Command palette search, the AI suggest endpoint, and Google Search Console connect flow. |
Access control by staff role
Almost every route calls requireStaffRole() from @/lib/auth/require-staff-role as its first line. 52 of the 56 route files use it. The four that do not are the payment webhooks (covered below), which authenticate by signature instead.
The role argument is the minimum role the caller needs. Roles are ranked viewer (1), editor (2), admin (3), owner (4), defined in src/lib/auth/role-utils.ts. A higher role passes any lower gate. The typical mapping is:
requireStaffRole('viewer')for reads:GET /api/products,GET /api/search,GET /api/coupons/[id]/stats.requireStaffRole('editor')for content writes: creating a product, transitioning an order, adjusting stock, bulk comment actions.requireStaffRole('admin')for configuration:PATCH /api/settings, staff management, integrations, the Search Console connect flow.
Authentication comes from a first-party session, not a Supabase Auth login. The session is a signed httpOnly cookie named mashop_session, minted by the /auth/sso hand-off from MaShop and read from src/lib/auth/session.ts. There is no login form in the backoffice.
The gate redirects, it does not return 401
requireStaffRole() calls Next.js redirect(). A missing or expired session sends the browser to /auth/signed-out; an insufficient role sends it to /auth/signed-out?reason=insufficient_role. Because these routes back the dashboard UI, that redirect is the intended failure mode. If you call a gated route from a non-browser client without a valid session cookie, you get the redirect response, not a JSON error.
Owner-only guards inside a handler
Some actions need a finer check than the entry gate. In src/app/api/staff/route.ts, both POST and PATCH enter at admin, then reject with 403 and the message Only an owner can grant the owner role. if a non-owner tries to assign the owner role. DELETE looks up the target's role and returns Only an owner can remove another owner. for the same reason. When you add sensitive actions, follow this pattern: gate at the entry role, then add the specific check in the body.
Request validation
Write handlers parse the JSON body defensively and validate it with a zod schema before touching the database:
const body = await req.json().catch(() => null);
const parsed = createProductSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
The .catch(() => null) turns malformed JSON into a 400 rather than a crash. A failed schema check returns 400 with error set to zod's flattened issues. Schemas live under src/lib/validations/ (for example createProductSchema, transitionOrderSchema, bulkCommentActionSchema), and some routes define an inline schema, like inventory/adjust, which requires a non-zero integer delta and a reason of 1 to 200 characters.
Query parameters on list routes
List reads take filters and paging as query string parameters, not a body. GET /api/products reads q, status, type, category_id, out_of_stock, low_stock, sort, and dir. Paging is offset and limit. The route clamps them: offset is floored at 0, and limit defaults to 20 and is clamped between 1 and 100. That fixed 20-item default matches the infinite-scroll lists in the dashboard.
Response shape and status codes
Handlers return NextResponse.json(...). The conventions are consistent across the API:
| Status | When | Body |
|---|---|---|
200 | Successful read, or a write with no created id | The resource, or { ok: true } |
201 | Resource created | { id }, or the created row |
207 | Partial success in a bulk action | { ok: false, processed, failed, action, message } |
400 | Malformed JSON or failed validation | { error } (string or zod flatten) |
403 | Role too low for a specific action | { error } with the reason |
404 | Resource not found | { error: 'Not found' } |
409 | Unique-constraint conflict | { error: 'A product with that slug or SKU already exists.' } |
422 | Illegal domain transition | { error } from the RPC |
500 | Unexpected failure | { error } with the caught message |
Two mappings are worth calling out because they come from Postgres error codes:
- Duplicate slug or SKU. Product create and update catch Postgres
23505(unique violation) and return409withA product with that slug or SKU already exists. - Illegal order transition.
POST /api/orders/[id]/transitioncatches Postgres23514(check violation) or a message starting withIllegal transitionand returns422. The state machine that allows or blocks the move lives in the database RPC, not in the route.
Bulk actions return 207
A bulk endpoint runs each item with Promise.allSettled and reports a partial result. POST /api/blog/comments/bulk applies approve, spam, or delete across an array of ids. If any item fails, it returns 207 with a body like { ok: false, processed, failed, action, message } where message reads 2/3 succeeded; 1 failed. If all succeed it returns { ok: true, processed, action }.
Payment webhooks are the exception
The four routes under src/app/api/payments/webhooks/ (stripe, paypal, wave, mpesa) are the only routes that do not call requireStaffRole(). They are called by the payment provider, not by a logged-in user, so they authenticate by verifying the request signature.
The Stripe webhook reads the raw body with req.text() and verifies the stripe-signature header against the merchant's stored webhook_secret. If the provider is not configured yet it returns 401 with { ok: false, error: 'not_configured' }. A missing signature returns 400 with missing_signature; a failed signature returns 400 with signature_failed. It never logs a transaction it cannot authenticate. The mount URL is your deployed domain plus the route path, for example https://<your-domain>/api/payments/webhooks/stripe.
Credentials are read from encrypted storage
The webhook loads the provider secret through getProviderCredentials(), which decrypts values kept encrypted in the database. You configure a provider from the Payments screen, not by hardcoding keys in the route.
The AI suggest route
POST /api/ai/suggest is gated at viewer and validates a union of task shapes (rewrite_title, meta_description, alt_text, product_description, tag_suggestions, translate, and others). It caches responses in-process by a SHA-256 of the request and sets an x-cache header of hit or miss.
In the shipped template this endpoint returns deterministic placeholder text from @/lib/ai/mock-responses, not real model output. It is scaffolding: the route signature is in place so the copy assist surfaces work, and you replace the generator with a real call when you wire your own model. Your prompts and generated code are not sent to OpenAI or Anthropic.
Routes marked dynamic
Most routes are dynamic by default because they read cookies. Five files set export const dynamic = 'force-dynamic' explicitly: search and the four gsc/* routes. Set this on any new route that must never be statically cached, such as one that reads live query parameters or per-request cookies.
Adding your own route
To add an endpoint, create src/app/api/<path>/route.ts and follow the house pattern:
- Call
await requireStaffRole(<min-role>)first, unless the caller is an external service that authenticates another way. - For writes, parse the body with
req.json().catch(() => null)and validate with a zod schema, returning400on failure. - Call a query or mutation in
src/lib/; keep the logic out of the route. - Return
NextResponse.json(...)with the status codes above, mapping known Postgres error codes to409or422where relevant.
For the folder layout these routes sit in, see Code structure. For adding a page that consumes a route, see Add a page. For the tables these routes read and write, see Database schema and Database functions.
