# Actions API

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

```bash
# 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:

```json
{
  "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:

```typescript title="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).

```typescript title="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

```bash
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:
  ```ts
  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.

```typescript
// 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:

```typescript title="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`:

```typescript title="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:

```typescript title="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](/api/errors):

```json
{
  "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:

```typescript title="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:

```typescript title="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:

```typescript
{
  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:

```typescript title="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:
```typescript
// 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

| Method | Input Source | Use Case |
|--------|--------------|----------|
| `GET` | Query parameters | Read-only operations, fetching data |
| `POST` | JSON body | Default, state-changing operations |
| `PUT` | JSON body | Full replacement operations |
| `PATCH` | JSON body | Partial updates |
| `DELETE` | JSON body | Deletion with optional payload |

### Response Formats

| Type | Content-Type | Use Case |
|------|--------------|----------|
| `json` | `application/json` | Standard API responses (default) |
| `stream` | `text/event-stream` | Real-time streaming (AI chat, live updates) |
| `file` | Varies | File downloads (reports, exports) |

### Error Codes

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

### Validation Error Response

```json
{
  "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

**Throw an `ActionError`.** The route handler catches it by name and returns
`{ error, code, details }` with your `statusCode`:

```typescript
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`); the generated per-feature helper (`.quickback/define-action`) re-exports the same classes, so importing `ActionError` from the same module as `defineAction` also works.

**Do not return an error response from `execute`.** The generated route wraps whatever `execute` resolves to in the success response — `c.json(result)` — so a returned `c.json({ error }, 400)` is not passed through: the `Response` object itself is JSON-serialized and the client receives `{}` with status **200**. A raw `Response` return is honored only when the action declares `responseType: 'stream'` or `'file'`. Errors leave `execute` by throwing; the declared [`output`](/define/actions#declaring-action-outputs) describes the success body only.

> 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` for clean 4xx responses.


## See Also

- [Defining Actions](/define/actions) — How to define actions in your feature
- [Guards](/define/guards) — Guard conditions reference
- [Error Responses](/api/errors) — Error format reference
