Quickback Docs

Actions

Create custom API endpoints for business logic beyond CRUD. Build workflows, integrations, and complex operations with type-safe action handlers.

Actions are custom API endpoints for business logic beyond CRUD operations. They enable workflows, integrations, and complex operations.

Overview

Quickback supports two types of actions:

AspectRecord-BasedStandalone
Route{METHOD} /:id/{actionName}Custom path or /{actionName}
Record fetchingAutomaticNone (record is undefined)
Firewall appliedYesNo
PreconditionsSupported via access.recordNot applicable
Response typesJSON onlyJSON, stream, file
Use caseAdvance application, reject candidateAI chat, bulk import from job board, webhooks

Defining Actions

Actions are defined in a separate actions.ts file that references your table:

// features/applications/actions.ts
import { applications } from './applications';
import { defineActions } from '@quickback/compiler';
import { z } from 'zod';

export default defineActions(applications, {
  'advance-stage': {
    description: "Move application to the next pipeline stage",
    input: z.object({ stage: z.enum(["screening", "interview", "offer", "hired"]), notes: z.string().optional() }),
    access: { roles: ["owner", "hiring-manager"] },
    execute: async ({ db, record, ctx }) => {
      // Business logic
      return record;
    },
  },
});

Configuration Options

OptionRequiredDescription
descriptionYesHuman-readable description of the action
inputYesZod schema for request validation
accessYesAccess control (roles, record conditions, or function)
executeYes*Inline execution function
handlerYes*File path for complex logic (alternative to execute)
standaloneNoSet true for non-record actions
pathNoCustom route path (standalone only)
methodNoHTTP method: GET, POST, PUT, PATCH, DELETE (default: POST)
responseTypeNoResponse format: json, stream, file (default: json)
sideEffectsNoHint for AI tools: 'sync', 'async', or 'fire-and-forget'
allowRawSqlNoExplicit compile-time opt-in for raw SQL in execute/handler code
unsafeNoUnsafe raw DB mode. Use true (legacy) or object config (reason, adminOnly, crossTenant, targetScope)

*Either execute or handler is required, not both.

Record-Based Actions

Record-based actions operate on an existing record. The record is automatically loaded and validated before your action executes.

Route pattern: {METHOD} /:id/{actionName}

POST /applications/:id/advance-stage
POST /applications/:id/reject
POST /applications/:id/schedule-interview

Runtime Flow

  1. Authentication - User token is validated
  2. Record Loading - The record is fetched by ID
  3. Firewall Check - Ensures user can access this record
  4. Access Check - Validates roles and preconditions
  5. Input Validation - Request body validated against Zod schema
  6. Execution - Your action handler runs
  7. Response - Result is returned to client

Example: Advance Application Stage

// features/applications/actions.ts
import { applications } from './applications';
import { defineActions } from '@quickback/compiler';
import { eq } from 'drizzle-orm';
import { z } from 'zod';

export default defineActions(applications, {
  'advance-stage': {
    description: "Move application to the next pipeline stage",
    input: z.object({
      stage: z.enum(["screening", "interview", "offer", "hired"]),
      notes: z.string().optional(),
    }),
    access: {
      roles: ["owner", "hiring-manager"],
      record: { stage: { notEquals: "rejected" } },  // Precondition
    },
    execute: async ({ db, ctx, record, input }) => {
      const [updated] = await db
        .update(applications)
        .set({
          stage: input.stage,
          notes: input.notes ?? record.notes,
        })
        .where(eq(applications.id, record.id))
        .returning();

      return updated;
    },
  },
});

Request Example

POST /applications/app_123/advance-stage
Content-Type: application/json

{
  "stage": "interview",
  "notes": "Strong technical background, moving to interview"
}

Response Example

{
  "data": {
    "id": "app_123",
    "stage": "interview",
    "notes": "Strong technical background, moving to interview"
  }
}

Standalone Actions

Standalone actions are independent endpoints that don't require a record context. Use standalone: true and optionally specify a custom path.

Route pattern: Custom path or /{actionName}

POST /chat
GET /reports/summary
POST /webhooks/stripe

Example: AI Chat with Streaming

export default defineActions(sessions, {
  chat: {
    description: "Send a message to AI",
    standalone: true,
    path: "/chat",
    method: "POST",
    responseType: "stream",
    input: z.object({
      message: z.string().min(1).max(2000),
    }),
    access: {
      roles: ["recruiter", "hiring-manager"],
    },
    handler: "./handlers/chat",
  },
});

Streaming Response Example

For actions with responseType: 'stream':

Content-Type: text/event-stream

data: {"type": "start"}
data: {"type": "chunk", "content": "Hello"}
data: {"type": "chunk", "content": "! I'm"}
data: {"type": "chunk", "content": " here to help."}
data: {"type": "done"}

Example: Report Generation

export default defineActions(applications, {
  generateReport: {
    description: "Generate hiring pipeline report",
    standalone: true,
    path: "/applications/report",
    method: "GET",
    responseType: "file",
    input: z.object({
      startDate: z.string().datetime(),
      endDate: z.string().datetime(),
    }),
    access: { roles: ["owner", "hiring-manager"] },
    handler: "./handlers/generate-report",
  },
});

Actions-Only Features

You can create feature directories that contain only standalone actions — no table definitions required. This is useful for utility endpoints like reports, integrations, or webhooks that don't map to a specific resource.

quickback/features/
└── reports/
    ├── actions.ts          # Standalone actions only (no table file)
    └── handlers/
        ├── trial-balance.ts
        └── profit-loss.ts
// features/reports/actions.ts
export default defineActions(null, {
  trialBalance: {
    description: "Generate trial balance report",
    standalone: true,
    path: "/reports/trial-balance",
    method: "GET",
    input: z.object({
      startDate: z.string(),
      endDate: z.string(),
    }),
    access: { roles: ["admin", "accountant"] },
    handler: "./handlers/trial-balance",
  },
});

Pass null as the schema argument since there is no table. All actions in a tableless feature must have standalone: true — record-based actions require a table to operate on.

Access Configuration

Access controls who can execute an action and under what conditions.

Role-Based Access

access: {
  roles: ["hiring-manager", "recruiter"]  // OR logic: user needs any of these roles
}

Public Access

Use roles: ["PUBLIC"] for unauthenticated endpoints (contact forms, webhooks, public APIs). Every invocation is mandatory audit logged with IP address, input, result, and timing.

// features/contact/actions.ts
export default defineActions(null, {
  submit: {
    description: "Public contact form submission",
    standalone: true,
    path: "/submit",
    input: z.object({
      name: z.string().min(1),
      email: z.string().email(),
      message: z.string().min(10),
    }),
    access: { roles: ["PUBLIC"] },
    handler: "./handlers/submit",
  },
});

The wildcard "*" is not supported and will throw a compile-time error. Use "PUBLIC" explicitly.

Record Conditions

For record-based actions, you can require the record to be in a specific state:

access: {
  roles: ["hiring-manager"],
  record: {
    stage: { equals: "screening" }  // Precondition
  }
}

Supported operators:

OperatorDescription
equalsField must equal value
notEqualsField must not equal value
inField must be one of the values
notInField must not be one of the values

Context Substitution

Use $ctx to reference the current user's context:

access: {
  record: {
    ownerId: { equals: "$ctx.userId" },
    orgId: { equals: "$ctx.orgId" }
  }
}

OR/AND Combinations

access: {
  or: [
    { roles: ["hiring-manager"] },
    {
      roles: ["recruiter"],
      record: { ownerId: { equals: "$ctx.userId" } }
    }
  ]
}

Function Access

For complex logic, use an access function:

access: async (ctx, record) => {
  return ctx.roles.includes('admin') || record.ownerId === ctx.userId;
}

Scoped Database

All actions receive a scoped db instance that automatically enforces security:

OperationOrg ScopingOwner ScopingSoft Delete FilterAuto-inject on INSERT
SELECTWHERE organizationId = ?WHERE ownerId = ?WHERE deletedAt IS NULLn/a
INSERTn/an/an/aorganizationId, ownerId from ctx
UPDATEWHERE organizationId = ?WHERE ownerId = ?WHERE deletedAt IS NULLn/a
DELETEWHERE organizationId = ?WHERE ownerId = ?WHERE deletedAt IS NULLn/a

Scoping is duck-typed at runtime — tables with an organizationId column get org scoping, tables with ownerId get owner scoping, tables with deletedAt get soft delete filtering.

execute: async ({ db, ctx, input }) => {
  // This query automatically includes WHERE organizationId = ? AND deletedAt IS NULL
  const items = await db.select().from(applications).where(eq(applications.stage, 'interview'));

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

  return items;
}

Not enforced in scoped DB (by design):

  • Guards — actions ARE the authorized way to modify guarded fields
  • Masking — actions are backend code that may need raw data
  • Access — already checked before action execution

Unsafe Mode

Actions that need to bypass scoped DB filters (for example, platform-level support operations) can enable unsafe mode.

Use object form for explicit policy and mandatory audit metadata:

export default defineActions(applications, {
  adminReport: {
    description: "Generate cross-org hiring report",
    unsafe: {
      reason: "Support investigation for enterprise customer",
      adminOnly: true,      // default true
      crossTenant: true,    // default true
      targetScope: "all",   // "all" | "organization"
    },
    input: z.object({ startDate: z.string() }),
    access: { roles: ["owner"] },
    execute: async ({ db, rawDb, ctx, input }) => {
      // db is still scoped (safety net)
      // rawDb bypasses scoped filters
      const allOrgs = await rawDb.select().from(applications);
      return allOrgs;
    },
  },
});

Unsafe cross-tenant actions are generated with:

  • Better Auth authentication required (no unauthenticated path)
  • platform admin gate (ctx.userRole === "admin")
  • mandatory audit logging on deny/success/error

PUBLIC actions also receive mandatory audit logging (same audit table), since unauthenticated endpoints are high-risk by nature.

Without unsafe mode, rawDb is undefined in the executor params.

Legacy unsafe: true is still supported, but object mode is recommended so audit logs include an explicit reason.

Raw SQL Policy

By default, the compiler rejects raw SQL in action code and handler files. Use Drizzle query-builder syntax whenever possible.

If a specific action must use raw SQL, opt in explicitly:

reconcileLedger: {
  description: "Run custom reconciliation query",
  allowRawSql: true,
  input: z.object({}),
  access: { roles: ["owner"] },
  execute: async ({ db }) => {
    // Allowed because allowRawSql: true
    return db.execute(sql`select 1`);
  },
}

Without allowRawSql: true, compilation fails with a loud error pointing to the action and snippet.

The detector checks for SQL keywords in string arguments, so non-SQL method calls like headers.get("X-Forwarded-For") or map.get("key") will not trigger false positives.

Handler Files

For complex actions, separate the logic into handler files.

When to Use Handler Files

  • Complex business logic spanning multiple operations
  • External API integrations
  • File generation or processing
  • Logic reused across multiple actions

Handler Structure

// handlers/generate-report.ts
import type { ActionExecutor } from '@quickback/types';

export const execute: ActionExecutor = async ({ db, ctx, input, services }) => {
  const apps = await db
    .select()
    .from(applicationsTable)
    .where(between(applicationsTable.createdAt, input.startDate, input.endDate));

  const pdf = await services.pdf.generate(apps);

  return {
    file: pdf,
    filename: `hiring-report-${input.startDate}-${input.endDate}.pdf`,
    contentType: 'application/pdf',
  };
};

Importing Tables

Handler files can import tables from their own feature or other features. The compiler generates alias files for each table, so you import by the table's file name:

// handlers/advance-stage.ts
import type { ActionExecutor } from '@quickback/types';
import { eq, and } from 'drizzle-orm';

// Same feature — import from parent directory using the table's file name
import { applications } from '../applications';
import { interviewScores } from '../interview-scores';

// Cross-feature — go up to the features directory, then into the other feature
import { candidates } from '../../candidates/candidates';
import { jobs } from '../../jobs/jobs';

export const execute: ActionExecutor = async ({ db, ctx, input, c }) => {
  const [job] = await db
    .select()
    .from(jobs)
    .where(eq(jobs.id, input.jobId))
    .limit(1);

  if (!job) {
    return c.json({ error: 'Job not found', code: 'NOT_FOUND' }, 404);
  }

  // ... continue with business logic
};

Path pattern from features/{name}/handlers/:

  • Same feature table: ../{table-file-name} (e.g., ../applications)
  • Other feature table: ../../{other-feature}/{table-file-name} (e.g., ../../candidates/candidates)
  • Generated lib files: ../../../lib/{module} (e.g., ../../../lib/realtime, ../../../lib/webhooks)

Executor Parameters

interface ActionExecutorParams {
  db: DrizzleDB;           // Scoped database (auto-applies org/owner/soft-delete filters)
  rawDb?: DrizzleDB;       // Raw database (only available when unsafe mode is enabled)
  ctx: AppContext;         // User context (userId, roles, orgId)
  record?: TRecord;        // The record (record-based only, undefined for standalone)
  input: TInput;           // Validated input from Zod schema
  services: TServices;     // Configured integrations (billing, notifications, etc.)
  c: HonoContext;          // Raw Hono context for advanced use
  auditFields: object;     // { createdAt, modifiedAt } timestamps
}

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
// GET action - input comes from query params
getStageHistory: {
  method: "GET",
  input: z.object({ format: z.string().optional() }),
  // Called as: GET /applications/:id/getStageHistory?format=detailed
}

// POST action (default) - input comes from JSON body
'advance-stage': {
  // method: "POST" is implied
  input: z.object({ stage: z.enum(["screening", "interview", "offer", "hired"]) }),
  // Called as: POST /applications/:id/advance-stage with JSON body
}

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
401Not authenticated
403Access check failed (role or precondition)
404Record not found (record-based actions)
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:

execute: async ({ 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';

execute: async ({ 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, details?).

Protected Fields

Actions can modify fields that are protected from regular CRUD operations:

// In resource.ts
guards: {
  protected: {
    stage: ["advance-stage", "reject"],  // Only these actions can modify stage
  }
}

This allows the advance-stage action to set stage = "interview" even though the field is protected from regular PATCH requests.

Examples

Application Stage Advance (Record-Based)

export default defineActions(applications, {
  'advance-stage': {
    description: "Move application to the next pipeline stage",
    input: z.object({
      stage: z.enum(["screening", "interview", "offer", "hired"]),
      notes: z.string().optional(),
    }),
    access: {
      roles: ["owner", "hiring-manager"],
      record: { stage: { notEquals: "rejected" } },
    },
    execute: async ({ db, ctx, record, input }) => {
      const [updated] = await db
        .update(applications)
        .set({
          stage: input.stage,
          notes: input.notes ?? record.notes,
        })
        .where(eq(applications.id, record.id))
        .returning();
      return updated;
    },
  },
});

AI Chat (Standalone with Streaming)

export default defineActions(sessions, {
  chat: {
    description: "Send a message to AI assistant",
    standalone: true,
    path: "/chat",
    responseType: "stream",
    input: z.object({
      message: z.string().min(1).max(2000),
    }),
    access: { roles: ["recruiter", "hiring-manager"] },
    handler: "./handlers/chat",
  },
});

Bulk Import (Standalone, No Record)

export default defineActions(candidates, {
  bulkImport: {
    description: "Import candidates from job board CSV",
    standalone: true,
    path: "/candidates/import",
    input: z.object({
      data: z.array(z.object({
        email: z.string().email(),
        name: z.string(),
      })),
    }),
    access: { roles: ["hiring-manager", "recruiter"] },
    execute: async ({ db, input }) => {
      const inserted = await db
        .insert(candidates)
        .values(input.data)
        .returning();
      return { imported: inserted.length };
    },
  },
});

On this page