Commerce RPC API
Authenticated RPC endpoint for SQL backends: POST /api/commerce/[service]/[method].
The Commerce RPC endpoint is an RPC-style bridge from the client to a server-side commerce adapter (Postgres/Prisma, SQLite, or Supabase). It exists only when the store runs on a SQL backend: the client httpCommerce adapter (src/lib/commerce/http.ts) posts one HTTP request per commerce call, and this route dispatches it to the resolved server backend.
On the default Firebase/Firestore setup the client talks to the backend directly and this route is not used. It is the network boundary for the SQL backends described in SQL backends & the commerce RPC bridge. Who the caller is stays a separate choice from where the data lives — see authentication.
Endpoint
POST /api/commerce/<service>/<method>
Authorization: Bearer <firebase-id-token>
Content-Type: application/json
{ "args": [ ... ] }- Path —
<service>and<method>name a whitelisted commerce call, e.g.products.getById→/api/commerce/products/getById. - Credential — whatever the configured identity provider reads. On the Firebase default that is an ID token in
Authorization: Bearer <token>, as shown above; onAUTH_PROVIDER=authjsit is a session cookie and there is no header at all. Public methods ignore it;userandadminmethods require it. - Body — strictly
{ "args": unknown[] }: an object whoseargsis an array holding the method's positional arguments.
The route pins the Node.js runtime:
export const runtime = "nodejs";firebase-admin, the pg pool, and the Supabase client cannot run on the Edge runtime. It declares no caching mode — route handlers are dynamic by default, and every RPC call resolves live data.
Response
On success the route returns HTTP 200 with the method's return value under result (a null/undefined return serializes as null):
{ "result": <return value> }On any failure it returns a 4xx/5xx status with a plain error string:
{ "error": "Authentication required." }Date values in result become ISO 8601 strings on the wire (via JSON.stringify / Date.prototype.toJSON). The client adapter revives the known timestamp fields back into real Dates. In the other direction, the route revives Date-bearing arguments (see Date handling).
Access model
Every whitelisted method carries one of three access levels. Public methods skip auth entirely; the others resolve the caller through getAuthUser() (src/lib/auth) and then apply an ownership or role check. The route does not know which provider answered, and must not: it needs a uid, nothing more.
| Access | Requirement |
|---|---|
public | No authentication. Catalog reads and discount lookup. |
user | A valid token is required, and the method's uid argument (args[0]) must equal the token's uid — so a signed-in user can only act on their own cart, quotes, orders, and profile. |
admin | A valid token is required, and admin.isAdmin(uid) must return true for the backend in use. |
quotes.submit is the one special case: it is a user method but allows a null userId for guest submissions (no token needed in that case). If the userId argument is non-null it must still match the token's uid.
admin.isAdmin is intentionally exposed as a user method so the client can check its own admin status.
Status codes
| Status | When |
|---|---|
200 | Success — { result }. |
400 | Body is not valid JSON, or is not { args: unknown[] }. |
401 | A non-public method was called without a valid token ("Authentication required."). |
403 | Token valid but the ownership/admin check failed (e.g. "You may only act on your own account.", "Admin privileges required.", "You may only submit quotes as yourself."). |
404 | <service>.<method> is not in the whitelist ("Unknown commerce method: <service>.<method>"). |
500 | Backend not configured ("Commerce backend is not configured.") or the adapter method threw ("<service>.<method> failed."). |
The request is processed in order: whitelist lookup → body parse → backend resolution → access control → dispatch. An unknown service/method is deliberately indistinguishable from a non-existent route (both are 404).
Method whitelist
Only the methods below are callable. Anything else responds 404. The whitelist covers all eight services of CommerceServices except for four deliberate omissions:
carts.listen(a live subscription) andcarts.mergeCarts(a pure local function) have no HTTP surface and are implemented client-side inhttp.ts.orders.recordPaidSessionwrites a paid order and decrements stock with no payment check of its own — the signature verification lives in the Stripe webhook, which calls the server adapter directly. Exposed here it would be an order-minting endpoint, so it404s even for an admin.audit.recordis withheld for the same class of reason (below).
products
| Method | Access |
|---|---|
list | public |
listByCategory | public |
countByCategory | public |
getById | public |
search | public |
listFeatured | public |
create | admin |
update | admin |
delete | admin |
feature | admin |
unfeature | admin |
carts
| Method | Access |
|---|---|
get | user |
set | user |
updateItem | user |
clear | user |
carts.listen and carts.mergeCarts are not callable over HTTP — they live only in the client adapter.
quotes
| Method | Access |
|---|---|
submit | user (allows null userId for guests) |
listForCustomer | user |
listAll | admin |
updateStatus | admin |
listPage | admin |
countByStatus | admin |
orders
| Method | Access |
|---|---|
listForCustomer | user |
createFromQuote | admin |
listAll | admin |
updateStatus | admin |
listPage | admin |
countByStatus | admin |
discounts
| Method | Access |
|---|---|
findUsable | public |
listAutomatic | public |
list | admin |
save | admin |
remove | admin |
listAutomatic is public where the full list is not: an automatic promotion
needs no secret code — the cart applies it to everyone — so there is nothing in
it to withhold. list returns every code, including the ones that are secret.
customers
| Method | Access |
|---|---|
get | user |
create | user |
update | user |
listAddresses | user |
addAddress | user |
updateAddress | user |
deleteAddress | user |
list | admin |
listPage | admin |
count | admin |
admin
| Method | Access |
|---|---|
isAdmin | user |
audit
| Method | Access |
|---|---|
listPage | admin |
audit.record is not on this list, and answers 404 even to an admin.
The trail exists to record what an admin did. An HTTP-callable write taking a
caller-supplied actorUid would let that same admin forge entries naming
someone else, or bury a real one under noise — so the method is never exposed,
and the route calls the server adapter in-process after a privileged mutation
succeeds, with the actor taken from the verified token and the timestamp from
the database clock.
The audited set is the admin mutations only — products.create / update /
delete / feature / unfeature, quotes.updateStatus,
orders.createFromQuote, orders.updateStatus, discounts.save, and
discounts.remove. Reads are excluded: logging every list call would bury the
record of who actually changed something.
Entries are paginated newest-first with an opaque cursor. Order is
created DESC, id DESC — the id tiebreaker matters, because created is not
unique and a keyset on a non-unique key skips or repeats rows at page
boundaries. As everywhere else in this seam, cursor is null only when the
page came back empty: a full final page still returns one, and the caller
learns the trail is exhausted from the next, empty page.
Date handling
Some methods take arguments that carry Dates. Because those Dates crossed the wire as ISO strings, the route revives them before handing the args to the adapter (which expects real Dates). This is targeted per method — not a blind deep-walk — so schemaless string fields on products/customers are never misread as timestamps.
| Service.method | Argument field(s) revived (on args[0]) |
|---|---|
discounts.save | created, expiresAt (when non-null) |
quotes.updateStatus | created |
orders.createFromQuote | created |
orders.updateStatus | created |
Examples
Public call — products.getById
No Authorization header needed for a public method:
curl -X POST https://your-store.example.com/api/commerce/products/getById \
-H "Content-Type: application/json" \
-d '{ "args": ["prod_123"] }'{
"result": {
"id": "prod_123",
"name": "Widget",
"featured": false
}
}Authenticated user call — carts.get
A user method: send the Firebase ID token and pass the caller's uid as args[0]. It must equal the token's uid or the request is rejected with 403.
curl -X POST https://your-store.example.com/api/commerce/carts/get \
-H "Authorization: Bearer $FIREBASE_ID_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "args": ["uid_of_signed_in_user"] }'{ "result": { "items": [], "updated": "2026-07-03T12:00:00.000Z" } }If the token is missing or invalid:
{ "error": "Authentication required." }If the uid in args[0] does not match the token:
{ "error": "You may only act on your own account." }Guest quote submission — quotes.submit
quotes.submit accepts a null userId with no token, so guests can submit a quote request:
curl -X POST https://your-store.example.com/api/commerce/quotes/submit \
-H "Content-Type: application/json" \
-d '{ "args": [null, { "items": [], "contact": { "email": "guest@example.com" } }] }'A signed-in customer submits the same way but passes their own uid as args[0] and includes the token — a non-null userId that does not match the token yields 403 ("You may only submit quotes as yourself.").