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/orders/ord_123/refund \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{ "reason": "Customer request", "amount": 50.00 }'The action's input schema validates the request body using Zod. Invalid input returns a 400 error.
Response
{
"success": true,
"data": {
"refundId": "ref_456",
"amount": 50.00,
"status": "processed"
}
}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
complete: {
access: {
roles: ["owner", "admin", "member"],
record: { completed: { equals: false } },
},
}If the record doesn't match (completed is already true), 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/reports/generate-summary \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{ "startDate": "2025-01-01", "endDate": "2025-01-31" }'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(claims).where(eq(claims.status, 'active'));
// Inserts automatically include organizationId and ownerId
await db.insert(claims).values({ title: input.title });
return items;
}If you need unscoped access (e.g., cross-org admin operations), declare unsafe: true on the action:
adminReport: {
unsafe: true,
execute: async ({ db, rawDb, ctx, input }) => {
// rawDb bypasses all security — only available with unsafe: true
const allOrgs = await rawDb.select().from(invoices);
return allOrgs;
},
}Without unsafe: true, rawDb is undefined.
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("orders", {
refund: {
type: "record",
input: z.object({
reason: z.string().min(1),
amount: z.number().positive(),
}),
access: {
roles: ["admin"],
record: { status: { equals: "completed" } },
},
},
});When validation fails:
{
"error": "Validation failed",
"layer": "guards",
"code": "GUARD_VALIDATION_FAILED",
"details": {
"issues": [
{ "path": ["amount"], "message": "Number must be greater than 0" }
]
}
}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: true, otherwise undefined)
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 projects and projectMembers tables where projectMembers has a .references(() => projects.id), deleting a project will also soft-delete all its members:
DELETE /api/v1/projects/proj_123Generated code:
// Soft delete parent
await db.update(projects).set({ deletedAt: now, modifiedAt: now })
.where(and(buildFirewallConditions(ctx), eq(projects.id, id)));
// Cascade soft delete to child tables
await db.update(projectMembers).set({ deletedAt: now, modifiedAt: now })
.where(eq(projectMembers.projectId, 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