Skip to main content
Services are standalone operation groups that aren’t tied to a single entity’s CRUD lifecycle. Use them for domain services, cross-entity workflows, or any operation that doesn’t fit the entity model. All service routes are prefixed with /api/ by default: POST /api/{serviceName}/{actionName}. The prefix is configurable via apiPrefix in createServer().

Defining a service

Services vs. entity custom actions

Entity custom actionsServices
ScopeTied to a single entity instance (:id)Independent — no parent entity
RoutePOST /api/tasks/:id/archivePOST /api/notifications/send-email
ContextGets the existing record as 3rd argumentNo record — just input and context
Use caseOperations on a specific recordCross-entity workflows, domain services
Use entity custom actions when the operation targets a specific record (archive a task, mark as complete). Use services when the operation spans multiple entities or doesn’t belong to any single entity.
Service actions support an optional path property to override the generated URL segment. When provided, the path replaces /{serviceName}/{actionName} but still respects the API prefix (e.g., path: 'webhooks/stripe' produces POST /api/webhooks/stripe). Prefer the default generated paths — they keep your routes consistent and discoverable. Use path only when you need a different URL structure, such as matching an external webhook callback URL.

Dependency injection

Services access entities through inject, just like entity hooks:

Access rules

Services use the same access control as entities — deny-by-default, with the action name as the key:
The access block is required. If you omit it or leave an action out of it, that action’s route is silently not generated (deny-by-default). This is the most common DX trap with services — you define an action, call the endpoint, and get 404 because the access rule is missing.Always use rules.* descriptors from @vertz/server — not raw callback functions like () => true.

Content descriptors

By default, service actions are JSON-in/JSON-out using s.* schemas. For non-JSON content types (XML, HTML, plain text, binary), use content.* descriptors:

Available descriptors

DescriptorContent-TypeTypeScript type
content.xml()application/xmlstring
content.html()text/htmlstring
content.text()text/plainstring
content.binary()application/octet-streamUint8Array

How it works

Content descriptors implement SchemaLike, so they work in the same body and response properties as s.* schemas. The framework handles everything:
  • Response wrapping — the framework sets the correct Content-Type header based on the descriptor
  • Type inferencecontent.xml() makes handler input/output typed as string, content.binary() as Uint8Array
  • Content-type validation — if a request’s Content-Type doesn’t match the descriptor, the framework returns 415 Unsupported Media Type
  • XML MIME flexibilitycontent.xml() accepts both application/xml and text/xml

Mixing content types

You can mix content.* and s.* in the same action — for example, XML input with JSON output:

Optional body

body is optional for actions that don’t receive a request body (GET, DELETE). When omitted, the handler’s input parameter is unknown:
JSON endpoints must always use s.* schemas for body and response. Content descriptors are for non-JSON content types only. This ensures all JSON endpoints are fully validated and typed.

Custom response headers and status codes

By default, service actions return 200 with application/json. To customize the HTTP response headers or status code, wrap your return value with response():
Plain return values still work — response() is purely opt-in:

Rules

  • content-type is protected and cannot be overridden — the framework always sets it based on the response schema
  • Custom headers are merged onto the response alongside the framework-managed content-type
  • Custom status overrides the default 200. Error responses (4xx/5xx from access rules or validation) are not affected
  • response() works with both JSON and content descriptor responses
Entity custom actions also support response(). The same pattern applies — wrap the return value to set custom headers or status codes.

Request metadata

Service handlers can access raw request metadata via ctx.request:

Atomic request handling

When a service handler performs multiple database writes that must succeed or fail together, wrap them in db.transaction():
If you have many handlers that need transactions, extract a reusable helper:
Use transactions for multi-table writes and financial operations. Read-heavy handlers that only perform a single write typically don’t need them.

Server setup

Pass services alongside entities:

Using services from the UI

Like entities, services generate typed SDK methods via codegen. Each action becomes a callable method on the API client:
NEVER use raw fetch() to call service endpoints. The generated SDK provides type safety, SSR integration, and validation schema metadata that form() uses automatically. See SDK & Fetch Client for details.