Actions API
Call custom business logic endpoints defined with defineActions.
Actions provide custom endpoints beyond CRUD operations. They support input validation, access conditions, and multiple response types including streaming.
Record-Based Actions
Record-based actions operate on a specific record identified by ID.
POST /api/v1/{resource}/:id/{action-name}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
{
"success": true,
"data": {
"id": "app_123",
"stage": "offer",
"previousStage": "interview",
"notes": "Strong technical interview, moving to offer stage"
}
}Security Flow
- Authentication — User must be logged in (401 if not)
- Firewall — Record must exist and be accessible to the user (404 if not found or not owned)
- Access — User must have the required role (403 if insufficient)
- Input validation — Request body validated against the action's Zod schema (400 if invalid)
- Access conditions — Record must match access conditions (400 if not met)
- Masking — Applied to the response data
Access Conditions
Actions can require the record to be in a specific state:
// In actions.ts
reject: {
access: {
roles: ["owner", "hiring-manager", "recruiter"],
record: { stage: { notEquals: "rejected" } },
},
}If the record doesn't match (stage is already rejected), the action returns 400.
Standalone Actions
Standalone actions don't require a record ID. They're for operations that aren't tied to a specific record.
POST /api/v1/{resource}/{action-name}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
- Authentication — Required (401)
- Organization check — For org-scoped resources, user must have an active organization (403)
- Access — Role-based check (403)
- Scoped DB — The
dbpassed to the handler is auto-scoped based on table columns (org isolation, owner filtering, soft delete) - 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) INSERToperations auto-injectorganizationIdandownerIdfrom context
// 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 platform admin operations), declare unsafe on the action:
adminReport: {
unsafe: {
reason: 'Platform support report',
adminOnly: true,
crossTenant: true,
targetScope: 'all',
},
execute: async ({ db, rawDb, ctx, input }) => {
// rawDb bypasses scoped filters
const allOrgs = await rawDb.select().from(applications);
return allOrgs;
},
}Cross-tenant unsafe actions are generated with:
- Better Auth authentication required
- platform admin gate (
ctx.userRole === 'admin') - mandatory audit logging on denial/success/error
Without unsafe mode, rawDb 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.
Input Validation
Actions use Zod schemas for input validation. The schema is defined in your actions.ts file:
import { z } from "zod";
import { defineActions } from "@quickback/compiler";
export default defineActions("applications", {
"advance-stage": {
type: "record",
input: z.object({
notes: z.string().optional(),
}),
access: {
roles: ["hiring-manager", "recruiter"],
record: { stage: { notEquals: "hired" } },
},
},
reject: {
type: "record",
input: z.object({
reason: z.string().min(1),
}),
access: {
roles: ["hiring-manager", "recruiter"],
record: { stage: { notEquals: "rejected" } },
},
},
});When validation fails:
{
"error": "Validation failed",
"layer": "guards",
"code": "GUARD_VALIDATION_FAILED",
"details": {
"issues": [
{ "path": ["reason"], "message": "String must contain at least 1 character(s)" }
]
}
}Response Types
Actions support three response types:
JSON (Default)
Returns a JSON object wrapped in { success: true, data: ... }. Masking is applied to the data.
Streaming
For long-running operations, actions can return a streaming response:
generateReport: {
type: "standalone",
responseType: "stream",
// ...
}The action handler returns a ReadableStream directly — no JSON wrapper.
File
For file downloads, actions can return a raw Response object:
exportCsv: {
type: "record",
responseType: "file",
// ...
}Action Handler
The compiler generates a handler skeleton. Your action receives:
{
db, // Scoped database (auto-enforces org/owner/soft-delete filters)
rawDb, // 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
auditFields, // { createdAt, modifiedAt } timestamps
}HTTP Methods
Actions default to POST but can use any HTTP method:
getStatus: {
type: "record",
method: "GET",
// GET actions receive input from query parameters instead of body
}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_123Generated 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-levelON DELETE CASCADE - Cross-feature: No cascade — only within the same feature's tables
See Also
- Defining Actions — How to define actions in your feature
- Guards — Guard conditions reference
- Error Responses — Error format reference