Quickback Docs

Actions API

Call custom business logic endpoints defined with defineAction.

Actions provide custom endpoints beyond CRUD operations. They support input validation, access conditions, and multiple response types including streaming.

Each action is one file under quickback/features/<feature>/actions/, default-exporting a single defineAction({ … }) call. Whether it is record-based or standalone is decided by one key: path:.

Record-Based Actions

An action with no path: binds to :id and receives the fetched record in its handler params.

POST /api/v1/{resource}/:id/{action-name}

The URL segment is the kebab-cased action name — advanceStage.ts mounts at /advance-stage.

Request

# With JSON body
curl -X POST /api/v1/applications/app_123/advance-stage \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{ "notes": "Strong technical interview, moving to offer stage" }'

The action's input schema validates the request body using Zod. Invalid input returns a 400 error.

Response

The response body is whatever execute returns, masked — there is no { success: true, data: … } wrapper:

{
  "id": "app_123",
  "stage": "offer",
  "previousStage": "interview",
  "notes": "Strong technical interview, moving to offer stage"
}

The one exception is a compiler-applied transition (an action that declares transition: and omits execute). The compiler synthesizes the result for you, and it looks like { "success": true, "stage": "offer" } — the flag plus the transitioned field and its stamp column.

Security Flow

  1. Authentication — User must be logged in (401 if not)
  2. Firewall — Record must exist and be inside the caller's scope (403 by default; 404 when the resource sets firewallErrorMode: 'hide')
  3. Access — User must have the required role (403 if insufficient)
  4. Access conditions — Record must match access.record conditions (409 if not met)
  5. Input validation — Request body validated against the action's Zod schema (400 if invalid)
  6. Masking — Applied to the response data

Access Conditions

Actions can require the record to be in a specific state:

quickback/features/applications/actions/reject.ts
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";

export default defineAction({
  description: "Reject an application.",
  input: z.object({ reason: z.string().min(1) }),
  access: {
    roles: ["owner", "hiring-manager", "recruiter"],
    record: { stage: { notEquals: "rejected" } },
  },
  async execute({ record, input, db }) {
    // ...
  },
});

If the record doesn't match (stage is already rejected), the action returns 409 Conflict with ACCESS_ACTION_NOT_ALLOWED_FOR_STATE and details.conditions echoing the declared predicate.

Standalone Actions

An action with a path: is standalone: it doesn't bind to :id, and its handler receives no record. The presence of path: is the only signal — there is no standalone: true flag (it is rejected at compile time).

quickback/features/candidates/actions/bulkImport.ts
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";

export default defineAction({
  description: "Import candidates from an external source.",
  path: "/candidates/bulk-import",   // ← makes it standalone
  input: z.object({ /* ... */ }),
  access: { roles: ["recruiter"] },
  async execute({ db, input }) { /* ... */ },
});

path must be absolute (/…) or area-relative (./… inside a mounted feature area). It is mounted under the contract prefix:

POST /api/v1/candidates/bulk-import

Request

curl -X POST /api/v1/candidates/bulk-import \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{ "source": "LinkedIn", "candidates": [{"name": "Alice Johnson", "email": "alice@example.com"}, {"name": "Bob Martinez", "email": "bob@example.com"}] }'

Response

Same format as record-based actions. The difference is no record lookup or firewall check.

Security Flow

  1. Authentication — Required (401)
  2. Organization check — For org-scoped resources, user must have an active organization (403)
  3. Access — Role-based check (403)
  4. Scoped DB — The db passed to the handler is auto-scoped based on table columns (org isolation, owner filtering, soft delete)
  5. Input validation — Zod schema validation (400)

Scoped Database

All actions (both standalone and record-based) receive a scoped db instance that automatically enforces security based on the table's columns:

  • Tables with organizationId — org isolation (WHERE organizationId = ?)
  • Tables with ownerId — owner isolation (WHERE ownerId = ?)
  • Tables with deletedAt — soft delete filter (WHERE deletedAt IS NULL)
  • INSERT operations auto-inject organizationId and ownerId from context

userId vs ownerId on feature tables

Use ownerId (not userId) for the row-owner column on a feature table. The compiler:

  • Auto-firewalls reads on ownerId (WHERE ownerId = ctx.userId).
  • Auto-stamps ownerId on insert from ctx.userId.
  • Treats a userId column on a feature table as informational — almost always "a user this row references" (member, contact, invitee), not "the row owner."

A userId column on a feature table emits a compile-time warning. To resolve it:

  • Rename to ownerId if the column controls access (most common case).
  • Rename to linkedUserId (or similar) if the column is informational — explicit naming prevents future confusion.
  • Declare explicit firewall if userId really is the access column on that table:
    firewall: [{ field: 'userId', equals: 'ctx.userId' }]

Better Auth's own internal tables (session, account, member, passkey, subscriptions) use userId by convention. Those are not feature tables and don't pass through the auto-firewall — the warning only fires on resources you define in your quickback/ folder.

// Your handler receives a scoped db — no manual filtering needed
execute: async ({ db, ctx, input }) => {
  // This query automatically includes WHERE organizationId = ? AND ownerId = ? AND deletedAt IS NULL
  const items = await db.select().from(applications).where(eq(applications.stage, 'interview'));

  // Inserts automatically include organizationId and ownerId
  await db.insert(applications).values({ candidateId: input.candidateId, jobId: input.jobId, stage: 'applied' });

  return items;
}

If you need unscoped access (for example, audited cross-tenant sysadmin operations), declare unsafe on the action:

quickback/features/applications/actions/adminReport.ts
import { z } from "zod";
import { defineAction, applications } from "../.quickback/define-action";

export default defineAction({
  description: 'Cross-tenant platform support report.',
  path: '/applications/admin-report',
  input: z.object({}),
  access: { roles: ['admin'] },
  unsafe: {
    reason: 'Platform support report',
    adminOnly: true,
    crossTenant: true,
    targetScope: 'all',
  },
  async execute({ db, unsafeDb, ctx, input }) {
    // unsafeDb bypasses scoped filters
    const allOrgs = await unsafeDb.select().from(applications);
    return allOrgs;
  },
});

Cross-tenant unsafe actions are generated with:

  • Better Auth authentication required
  • sysadmin gate (ctx.userRole === 'sysadmin')
  • mandatory audit logging on denial/success/error

Without unsafe mode, unsafeDb is undefined.

Raw SQL in action code is blocked by default at compile time. If you need it for a specific action, set allowRawSql: true on that action explicitly.

Explicit scope with unsafeDb.scopeTo

Unsafe handlers often need to derive the tenant from somewhere other than the ambient request context — an inbound webhook reads it from a mailbox record, a cron job iterates over every organization, a background worker acts on behalf of a specific user. unsafeDb.scopeTo({ organizationId?, userId?, teamId? }) returns a scoped db that applies the firewall with your explicit scope instead of ctx:

quickback/features/mail/actions/ingestInbound.ts
// Inbound mail webhook — PUBLIC + unsafe
import { z } from "zod";
import { eq } from "drizzle-orm";
import { defineAction, mailboxes, messages } from "../.quickback/define-action";

export default defineAction({
  description: 'Ingest an inbound mail webhook.',
  path: '/mail/ingest-inbound',   // standalone
  access: { roles: ['PUBLIC'] },
  unsafe: {
    reason: 'Inbound mail webhook — tenant derived from mailbox record',
    targetScope: 'organization',
  },
  input: z.object({ mailboxId: z.string(), body: z.string() }),
  async execute({ unsafeDb, input }) {
    // Look up the mailbox without a tenant in ctx (unsafe handler, unscoped client)
    const [mailbox] = await unsafeDb.select().from(mailboxes)
      .where(eq(mailboxes.id, input.mailboxId));
    if (!mailbox) return { ok: false };

    // Pin scope to the mailbox's organization — every downstream query gets
    // WHERE organizationId = mailbox.organizationId, inserts get it injected.
    const db = unsafeDb.scopeTo({ organizationId: mailbox.organizationId });
    await db.insert(messages).values({ mailboxId: mailbox.id, body: input.body });
    return { ok: true };
  },
});

Why this is safer than hand-rolling organizationId on every query:

  • The firewall still runs. scopeTo just tells it which scope to use.
  • insert().values() auto-injects organizationId / ownerId / teamId per the scope.
  • Soft-delete and team-isolation filters continue to apply unchanged.
  • No chance of forgetting a WHERE organizationId = ? clause on a single query.

x-unsafe: true is emitted on each unsafe operation in openapi.json, and MCP tool lists prefix the description with [UNSAFE] and set annotations.destructiveHint so agents and reviewers can see at a glance which endpoints cross trust boundaries.

Input Validation

Actions use Zod schemas for input validation. input is a required field on every action, and each action lives in its own file:

quickback/features/applications/actions/advanceStage.ts
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";

export default defineAction({
  description: "Move an application forward in the pipeline.",
  input: z.object({
    notes: z.string().optional(),
  }),
  access: {
    roles: ["hiring-manager", "recruiter"],
    record: { stage: { notEquals: "hired" } },
  },
  async execute({ record, input, db }) {
    // ...
  },
});

The import source is the generated per-feature helper (../.quickback/define-action), not @quickback/compiler. It bakes in the record's row type, so record.createdAt / record.organizationId and the feature's Drizzle tables are typed without as any.

When validation fails you get an RFC 9457 Problem Details document (application/problem+json) — see Errors:

{
  "type": "https://quickback.dev/problems/validation-error",
  "title": "Validation error",
  "status": 400,
  "detail": "Request body validation failed.",
  "layer": "validation",
  "code": "VALIDATION_ERROR",
  "details": {
    "fields": {
      "reason": "String must contain at least 1 character(s)"
    }
  },
  "pointer": "/reason"
}

Malformed JSON returns INVALID_JSON (400); a body over the 1 MiB cap returns PAYLOAD_TOO_LARGE (413).

Response Types

Actions support three response types:

JSON (Default)

Returns the value your execute returned, serialized directly. Masking is applied to it first. There is no envelope.

Streaming

For long-running operations, actions can return a streaming response:

quickback/features/reports/actions/generateReport.ts
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";

export default defineAction({
  description: "Stream a generated report.",
  path: "/reports/generate",
  responseType: "stream",
  input: z.object({ /* ... */ }),
  access: { roles: ["admin"] },
  async execute() { /* return a ReadableStream */ },
});

The action handler returns a ReadableStream directly — no JSON wrapper.

File

For file downloads, actions can return a raw Response object:

quickback/features/applications/actions/exportCsv.ts
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";

export default defineAction({
  description: "Export matching applications as a CSV download.",
  path: "/applications/export.csv",
  responseType: "file",
  input: z.object({ status: z.string().optional() }),
  access: { roles: ["recruiter"] },
  async execute({ db, input }) { /* return a Response */ },
});

responseType is only valid on a standalone action — one that declares path:. A record-bound action (no path:) always returns JSON, and declaring responseType on it is a compile error (parser/action-file-parser.ts"responseType is only valid on standalone actions").

Action Handler

Your execute function receives:

{
  db,           // Scoped database (auto-enforces org/owner/soft-delete filters)
  unsafeDb,     // Raw database (only available when unsafe mode is enabled)
  ctx,          // Auth context (user, session, org)
  record,       // The existing record (record-based only, undefined for standalone)
  input,        // Validated input from Zod schema
  services,     // Injected services
  c,            // Hono context
  env,          // Runtime bindings (Cloudflare env / process env). Alias for c.env.
}

HTTP Methods

Actions default to POST but can use any HTTP method:

quickback/features/applications/actions/getStatus.ts
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";

export default defineAction({
  description: "Read the current pipeline status.",
  method: "GET",   // GET actions receive input from query parameters instead of body
  input: z.object({}),
  access: { roles: ["recruiter"] },
  async execute({ record }) { /* ... */ },
});

When using GET, input is parsed from query parameters instead of the request body.

Cascading Soft Delete

When soft-deleting a parent record, Quickback automatically soft-deletes related records in child/junction tables within the same feature. The compiler detects foreign key references at build time.

For example, if your feature has jobs and applications tables where applications has a .references(() => jobs.id), deleting a job will also soft-delete all its applications:

DELETE /api/v1/jobs/job_123

Generated code:

// Soft delete parent
await db.update(jobs).set({ deletedAt: now, modifiedAt: now })
  .where(and(buildFirewallConditions(ctx), eq(jobs.id, id)));

// Cascade soft delete to child tables
await db.update(applications).set({ deletedAt: now, modifiedAt: now })
  .where(eq(applications.jobId, id));

Cascade rules:

  • Soft delete (default): Application-level cascade via generated UPDATE statements
  • Hard delete (mode: 'hard'): No compiler cascade — relies on DB-level ON DELETE CASCADE
  • Cross-feature: No cascade — only within the same feature's tables

HTTP API Reference

Request Format

MethodInput SourceUse Case
GETQuery parametersRead-only operations, fetching data
POSTJSON bodyDefault, state-changing operations
PUTJSON bodyFull replacement operations
PATCHJSON bodyPartial updates
DELETEJSON bodyDeletion with optional payload

Response Formats

TypeContent-TypeUse Case
jsonapplication/jsonStandard API responses (default)
streamtext/event-streamReal-time streaming (AI chat, live updates)
fileVariesFile downloads (reports, exports)

Error Codes

StatusDescription
400Invalid input / validation error (also GUARD_FIELD_PROTECTED for direct protected-field writes)
401Not authenticated
403Access role check failed (ACCESS_ROLE_REQUIRED)
404Record not found (record-based actions)
409Record is in the wrong state for this action — access.record predicate or transition policy failed (ACCESS_ACTION_NOT_ALLOWED_FOR_STATE)
500Handler execution error

Validation Error Response

{
  "error": "Invalid request data",
  "layer": "validation",
  "code": "VALIDATION_FAILED",
  "details": {
    "fields": {
      "amount": "Expected positive number"
    }
  },
  "hint": "Check the input schema for this action"
}

Error Handling

Option 1: Return a JSON error response (recommended for most cases)

Since action handlers receive the Hono context (c), you can return error responses directly:

async execute({ ctx, record, input, c }) {
  if (record.stage === 'hired') {
    return c.json({
      error: 'Cannot modify a hired application',
      code: 'ALREADY_HIRED',
      details: { currentStage: record.stage },
    }, 400);
  }
  // ... continue
}

Option 2: Throw an ActionError

import { ActionError } from "@quickback/compiler";

async execute({ ctx, record, input }) {
  if (record.stage === 'hired') {
    throw new ActionError('Cannot modify a hired application', 'ALREADY_HIRED', 400, {
      currentStage: record.stage,
    });
  }
  // ... continue
}

The ActionError constructor signature is (message, code, statusCode = 400, details?). The compile step rewrites the @quickback/compiler import to the generated runtime class (src/lib/types.ts), and the route handler catches it by name and returns { error, code, details } with your statusCode.

Do not throw Hono's HTTPException from an action — the route handler doesn't unwrap it, so it surfaces as an unhandled 500 ACTION_EXECUTION_FAILED. Use ActionError (or return c.json(...) directly) for clean 4xx responses.

See Also

On this page