Hooks

React to entity lifecycle events with side effects, mutations, or rejected writes.

File location

Hooks live at src/hooks/<entity-kebab>/<event>.ts and use a default export — drop the file in place and it’s registered on the next deploy. The folder name converts kebab → PascalCase (invoice/Invoice, sales-receipt/SalesReceipt); the filename must be one of the nine valid events.

// ✅
export default async function (ctx) { ... }

// ❌ won't be picked up — runtime looks for a default export, not a named one
export async function handler(ctx) { ... }

Anatomy

// src/hooks/invoice/before-save.ts
import { Settings, Log } from "@backfill-io/sdk";
import type { HookContext } from "@backfill-io/sdk";

export default async function (ctx: HookContext) {
  const settings = Settings.getAll();
  if (!settings.enableTaxOnInvoices) return null;

  const total = computeTax(ctx.entity);
  Log.info("Tax calculated", { total });

  return { line_items: updatedItems, total_amount: total };
}

Hook context

interface HookContext<T = any> {
  entity: T;             // entity AFTER the change (or proposed entity, in before-*)
  entityType: string;
  operation: 'create' | 'update' | 'delete';
  previous: T | null;    // entity before; null on create
  changes: string[];     // names of fields modified
}

Use ctx.changes to bail early when an after_save only cares about specific fields:

if (!ctx.changes.includes("status")) return;

Return contract by event

EventReturnEffect
before-saveAn object of fields to change, or null.Object is merged into the entity before commit. null means no changes. Throw to reject the write.
after-save, after-post, after-delete, after-approveAnything (ignored).Side-effect only.
before-post, before-delete, before-approveThrow to abort the transition.Returning normally lets it proceed.
recalculateA RecalculateResult.See below.

Folder name → entity

Folder name converts kebab → PascalCase:

FolderEntity
invoice/Invoice
sales-receipt/SalesReceipt
journal-entry/JournalEntry

If the entity isn’t a standard entity and isn’t declared via defineEntity in src/entities/, deploy fails with unknown entity type 'X'.

Recalculate

Recalculate is different. Instead of returning changes or being side-effect-only, the hook returns components that the platform composes into the draft total:

// src/hooks/invoice/recalculate.ts
import type { RecalculateContext, RecalculateResult } from "@backfill-io/sdk";

export default async function (ctx: RecalculateContext): Promise<RecalculateResult> {
  return {
    components: [
      {
        key: "tax",
        type: "tax",
        label: "Sales tax",
        amount: computeTax(ctx.calculation).toFixed(2),
        provider: "my-tax-engine",
      },
    ],
  };
}

Shared helpers

Hook files can import extension-local helpers and browser-compatible npm packages. The CLI bundles each hook entrypoint before deploy, leaving @backfill-io/sdk external for the Backfill runtime and inlining relative modules such as ../../lib/tax plus pure JavaScript packages such as zod, dayjs, or lodash.

Unsupported imports fail at build time: CSS, SVG, images, Node builtins such as fs/net/tls, native addons, dynamic require, dynamic import, and packages that require Node process-level globals. Shared helpers are bundled per hook in v1, so a helper used by multiple hooks is duplicated in each deployed hook bundle.