oRPC
Learn what the narrow oRPC surface at /api/rpc actually exposes, why it is limited to webhook management and a health ping, and how service-key scopes gate it.
oRPC is mounted at /api/rpc and is the external service-key API.
Its scope is deliberately small: a webhook management router, and a health ping. Admin and storefront writes are not here and are not meant to be.
The reason is one transport story per consumer. An integration holding a service key uses oRPC; the admin and the storefront use zsa server actions; the published contract in app/api/openapi.json describes the REST endpoints. A surface that exists in two transports is a surface where the two can disagree.
The router file states the boundary in its own header:
/**
* Scope is deliberately narrow: oRPC is the EXTERNAL service-key API surface
* (AI agents / integrations managing webhooks), plus a health ping. Admin and
* storefront writes are zsa server actions in `server/{admin,web}/<domain>/actions.ts`
* — the one transport story per consumer. A parallel oRPC admin write surface
* was deleted: it had no client in this repo and is not part of the published
* OpenAPI contract (app/api/openapi.json describes the REST endpoints instead).
*/
export const appRouter = {
ping,
web: webRouter,
}What is mounted
webRouter contains exactly one router today.
| Procedure | Scope required | What it does |
|---|---|---|
web.webhooks.list | read | id, url, events, isActive, name, createdAt |
web.webhooks.get | read | One webhook by id, or null |
web.webhooks.create | write:webhooks | Creates one and returns the secret once |
web.webhooks.update | write:webhooks | Updates url, events, name |
web.webhooks.delete | write:webhooks | Removes it |
web.webhooks.rotateSecret | write:webhooks | Issues a new secret and returns it once |
ping | none | { status: "ok", timestamp } |
Create and rotate return the signing secret in the response body, and say so plainly, because it cannot be read back afterwards.
That is worth designing a client around. A caller that discards the create response has no second chance at the secret and has to rotate to get another one.
How a procedure is built
lib/orpc.ts layers three procedures, each adding to the one before it:
baseProcedureinjectsdband arevalidatehelper into context.serviceProcedureresolves a service key and converts anAppErrorinto anORPCError.serviceProcedureWithScopeadds the scope check.
The scope check is the whole of the third layer:
export function serviceProcedureWithScope(scope: ApiScope) {
return serviceProcedure.use(async ({ next, context }) => {
if (!hasScope(context.apiKey.scopes, scope)) {
throw new ORPCError("FORBIDDEN", {
message: `Requires ${scope} scope`,
})
}
return next()
})
}Because the layers stack, a procedure cannot get a database handle without having gone through key resolution first. Authentication is not something a procedure opts into.
There is no admin path through this transport
The session-auth and permission procedures that once mirrored the admin actions were deleted, along with the unused admin router.
So there is no way to reach an admin write through oRPC. Not by convention or by review — the procedures that would carry a session simply do not exist here.
The Webhook entity is admin-owned
The oRPC router imports the webhook lifecycle from server/admin/webhooks/lifecycle
and carries a discipline-ok(no-admin-in-storefront) annotation explaining why:
it is the API-key service surface over the same admin-owned entity, not
storefront code. The discipline check would otherwise reject the import.
Which transport to use
Use oRPC when the caller is an external integration holding a service key and the operation is webhook management or a health check. That is the surface it covers.
Use a zsa server action for an admin or storefront write. That is where those live, and adding an oRPC mirror recreates the parallel surface that was deleted.
Use the REST endpoints when the caller needs the published contract, which is what app/api/openapi.json describes.
Before adding a procedure here, the question is not "would this be convenient over RPC?" It is:
Does this operation belong to a service key, and is it something the published contract does not already cover?
Related
Overview
Learn which of Litestore's three transports to use — zsa server actions for admin and storefront writes, oRPC at /api/rpc for service-key consumers, and the REST endpoints described by /api/openapi.json.
Keys
Learn how service API keys are minted, hashed and revoked, and what each of the six enforced scopes actually permits.
Webhooks
Learn how outbound deliveries are signed and retried, when an endpoint is auto-disabled, and how the inbound Stripe and Resend routes differ.
Actions
Learn how admin and storefront writes are declared as zsa server actions, how permission procedures gate them, and how the CRUD layer generates the standard six.
REST
Learn which REST routes under app/api are published in the OpenAPI contract, which are internal, and how each one is authenticated.