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
| Event | Return | Effect |
|---|---|---|
before-save | An 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-approve | Anything (ignored). | Side-effect only. |
before-post, before-delete, before-approve | Throw to abort the transition. | Returning normally lets it proceed. |
recalculate | A RecalculateResult. | See below. |
Folder name → entity
Folder name converts kebab → PascalCase:
| Folder | Entity |
|---|---|
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.