Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.vertz.dev/llms.txt

Use this file to discover all available pages before exploring further.

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

import { service } from '@vertz/server';
import { rules } from '@vertz/server';
import { s } from '@vertz/schema';

const notifications = service('notifications', {
  inject: {
    users: usersEntity,
    tasks: tasksEntity,
  },

  access: {
    sendEmail: rules.public,
    sendBulk: rules.public,
  },

  actions: {
    sendEmail: {
      body: s.object({
        to: s.string().email(),
        subject: s.string(),
        body: s.string(),
      }),
      response: s.object({
        id: s.string().uuid(),
        status: s.literal('sent'),
      }),
      handler: async (input, ctx) => {
        await emailService.send(input);
        return { id: crypto.randomUUID(), status: 'sent' as const };
      },
    },

    sendBulk: {
      body: s.object({
        userIds: s.array(s.string().uuid()),
        message: s.string(),
      }),
      response: s.object({ sent: s.number() }),
      handler: async (input, ctx) => {
        return { sent: input.userIds.length };
      },
    },
  },
});

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:
const reports = service('reports', {
  inject: {
    tasks: tasksEntity,
    users: usersEntity,
  },
  actions: {
    generate: {
      body: s.object({ month: s.number(), year: s.number() }),
      response: s.object({ totalTasks: s.number(), completionRate: s.number() }),
      handler: async (input, ctx) => {
        const tasks = await ctx.entities.tasks.list({
          where: { /* filter by month/year */ },
        });
        return {
          totalTasks: tasks.total,
          completionRate: /* calculate */,
        };
      },
    },
  },
});

Access rules

Services use the same access control as entities — deny-by-default, with the action name as the key:
import { rules } from '@vertz/server';

access: {
  sendEmail: rules.public,
  sendBulk: rules.public,
  // generate: undefined → route not created
}
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:
import { content, service } from '@vertz/server';

const saml = service('saml', {
  actions: {
    // XML GET — no body, returns XML
    metadata: {
      method: 'GET',
      response: content.xml(),
      handler: async () => '<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata"/>',
    },

    // HTML response
    sso: {
      method: 'GET',
      response: content.html(),
      handler: async (_input, ctx) => {
        return `<html><body onload="document.forms[0].submit()">
          <form method="POST" action="${acsUrl}">
            <input type="hidden" name="SAMLResponse" value="${response}" />
          </form>
        </body></html>`;
      },
    },
  },

  access: {
    metadata: rules.public,
    sso: rules.public,
  },
});

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:
const importer = service('import', {
  actions: {
    spreadsheet: {
      method: 'POST',
      body: content.xml(),
      response: s.object({ imported: s.number() }),
      handler: async (input) => {
        const rows = parseSpreadsheetXml(input); // input: string
        return { imported: rows.length };
      },
    },
  },
  access: { spreadsheet: rules.public },
});

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:
actions: {
  healthCheck: {
    method: 'GET',
    response: content.text(),
    handler: async () => 'OK',
  },
}
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():
import { response, service } from '@vertz/server';
import { s } from '@vertz/schema';

const cloud = service('cloud', {
  access: { jwks: rules.public, createToken: rules.public },
  actions: {
    jwks: {
      method: 'GET',
      response: s.object({ keys: s.array(jwkSchema) }),
      handler: async (_input, ctx) => {
        const keys = await getPublicKeys(ctx);
        return response(
          { keys },
          {
            headers: { 'Cache-Control': 'public, max-age=3600' },
          },
        );
      },
    },

    createToken: {
      body: tokenRequestSchema,
      response: s.object({ token: s.string() }),
      handler: async (input) => {
        const token = await generateToken(input);
        return response({ token }, { status: 201 });
      },
    },
  },
});
Plain return values still work — response() is purely opt-in:
// These two are equivalent:
handler: async () => ({ token: 'tok' });
handler: async () => response({ token: 'tok' });

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:
handler: async (_input, ctx) => {
  const authHeader = ctx.request.headers.get('authorization');
  const requestUrl = ctx.request.url;
  const httpMethod = ctx.request.method;
  // ctx.request.body contains the pre-parsed body
};

Atomic request handling

When a service handler performs multiple database writes that must succeed or fail together, wrap them in db.transaction():
import { service } from '@vertz/server';

const transfers = service('transfers', {
  actions: {
    transfer: {
      body: s.object({
        fromId: s.string().uuid(),
        toId: s.string().uuid(),
        amount: s.number().positive(),
      }),
      response: s.object({ success: s.boolean() }),
      handler: async (input, ctx) => {
        return db.transaction(async (tx) => {
          await tx.accounts.update({
            where: { id: input.fromId },
            data: { balance: { decrement: input.amount } },
          });
          await tx.accounts.update({
            where: { id: input.toId },
            data: { balance: { increment: input.amount } },
          });
          return { success: true };
        });
      },
    },
  },
  access: { transfer: rules.public },
});
If you have many handlers that need transactions, extract a reusable helper:
import type { TransactionClient } from '@vertz/db';

function withTransaction<T>(
  fn: (tx: TransactionClient<typeof models>) => Promise<T>,
): Promise<T> {
  return db.transaction(fn);
}

// Then in any handler:
handler: async (input) => withTransaction(async (tx) => {
  await tx.orders.create({ data: { ... } });
  await tx.inventory.update({ ... });
  return { orderId: '...' };
}),
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:
createServer({
  entities: [users, tasks],
  services: [notifications, reports],
  db,
}).listen({ port: 3000 });

Using services from the UI

Like entities, services generate typed SDK methods via codegen. Each action becomes a callable method on the API client:
import { api } from '../client';
import { form } from '@vertz/ui';

// Call a service action directly
const result = await api.notifications.sendEmail({
  to: 'user@example.com',
  subject: 'Hello',
  body: 'World',
});

// Use with form() for form submissions
const contactForm = form(api.notifications.sendEmail, {
  onSuccess: () => {
    /* handle success */
  },
});
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.