Skip to content

Action Endpoints

Actions are custom API endpoints that go beyond standard CRUD operations. They allow you to implement business logic, workflows, and complex operations.

Types of Actions

Quickback supports two types of actions:

TypeRoute PatternDescription
Record-based{METHOD} /:id/:actionNameOperates on a specific record
StandaloneCustom pathIndependent endpoint, no record required

Default method is POST. Actions can use GET, POST, PUT, PATCH, or DELETE.

Record-Based Actions

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

Endpoint Format

POST /rooms/:id/activate      # Default method
POST /invoices/:id/approve
GET /orders/:id/status        # GET for read-only actions
DELETE /items/:id/archive

Example Request

POST /invoices/inv_123/approve
Content-Type: application/json

{
  "notes": "Approved for Q1 budget"
}

Example Response

json
{
  "data": {
    "id": "inv_123",
    "status": "approved",
    "approvedBy": "user_456",
    "approvedAt": "2024-01-15T14:30:00Z"
  }
}

How It Works

  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. Guard Check: Validates preconditions (e.g., status must be "pending")
  5. Input Validation: Request body is validated against Zod schema
  6. Execution: Your action handler runs
  7. Response: Result is returned to client

Preconditions

Actions can specify record conditions that must be met:

typescript
guard: {
  roles: ["admin", "finance"],
  record: { status: { equals: "pending" } }  // Precondition
}

If the precondition fails, the API returns a 403 Forbidden error.

Standalone Actions

Standalone actions are independent endpoints that don't require a record context.

Endpoint Format

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

Example Request

POST /chat
Content-Type: application/json

{
  "message": "Hello, how can I help?"
}

Example Response (Streaming)

For actions with responseType: '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"}

Defining Standalone Actions

typescript
export default defineActions(sessions, {
  chat: {
    standalone: true,
    path: "/chat",
    method: "POST",
    responseType: "stream",
    input: z.object({
      message: z.string().min(1).max(2000),
    }),
    guard: {
      roles: ["member", "admin"],
    },
    handler: "./handlers/chat",
  },
});

Input Validation

All actions use Zod schemas for input validation. Invalid requests return a 400 Bad Request error.

Example Schema

typescript
input: z.object({
  notes: z.string().optional(),
  amount: z.number().positive(),
  dueDate: z.string().datetime(),
})

Validation Error Response

json
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Invalid request data",
    "details": [
      {
        "field": "amount",
        "message": "Expected positive number"
      }
    ]
  }
}

Response Types

Actions can return different response types:

JSON (default)

typescript
responseType: 'json'

Standard JSON response:

json
{
  "data": {
    "success": true,
    "result": { ... }
  }
}

Stream (Server-Sent Events)

typescript
responseType: 'stream'

For real-time streaming responses (AI chat, live updates):

Content-Type: text/event-stream

data: {"chunk": "Hello"}
data: {"chunk": " world"}
data: [DONE]

File Download

typescript
responseType: 'file'

For file downloads (reports, exports):

Content-Type: application/pdf
Content-Disposition: attachment; filename="report.pdf"

<binary data>

Access Control

Actions use the same guard system as CRUD operations:

Role-Based

typescript
guard: {
  roles: ["admin", "manager"]
}

Combined Conditions

typescript
guard: {
  or: [
    { roles: ["admin"] },
    { roles: ["member"], record: { ownerId: { equals: "$ctx.userId" } } }
  ]
}

Custom Guard Function

typescript
guard: async (ctx, record) => {
  // Custom logic
  return ctx.roles.includes('admin') || record.ownerId === ctx.userId;
}

Error Handling

Standard Errors

StatusDescription
400Invalid input / validation error
401Not authenticated
403Guard check failed (role or precondition)
404Record not found (record-based actions)
500Handler execution error

Throwing Custom Errors

In your action handler:

typescript
execute: async ({ ctx, record, input }) => {
  if (record.balance < input.amount) {
    throw new ActionError('INSUFFICIENT_FUNDS', 'Not enough balance', 400);
  }
  // ... continue
}

Protected Fields

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

typescript
// In resource.ts
guards: {
  protected: {
    status: ["approve", "reject"],  // Only these actions can modify status
    amount: ["reviseAmount"],
  }
}

This allows the approve action to set status = "approved" even though the field is protected from regular PATCH requests.

Handler Files

For complex actions, use separate handler files:

Definition

typescript
// actions.ts
export default defineActions(invoices, {
  generateReport: {
    description: "Generate PDF report",
    standalone: true,
    path: "/invoices/report",
    method: "GET",
    responseType: "file",
    input: z.object({
      startDate: z.string().datetime(),
      endDate: z.string().datetime(),
    }),
    guard: { roles: ["admin", "finance"] },
    handler: "./handlers/generate-report",
  },
});

Handler Implementation

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

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

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

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

Executor Context

Action handlers receive these parameters:

typescript
interface ActionExecutorParams {
  db: DrizzleDB;           // Database instance
  ctx: AppContext;         // User context (userId, roles, orgId)
  record?: TRecord;        // The record (record-based only)
  input: TInput;           // Validated input from request
  services: TServices;     // Configured integrations
  c: HonoContext;          // Raw Hono context for advanced use
}

Backend security, simplified.