Custom fields
Store custom field values from your extension on entity records and lines.
Custom fields are extension-owned values attached to Backfill entity records and lines. Use entity fields for values that belong to a whole record, such as a customer or invoice. Use line fields for provider-specific values that belong to one line on a line-bearing document.
File location
Declare fields in one file per parent entity:
src/fields/
├── Customer.ts
└── Invoice.ts
The filename is the canonical entity name. Field keys live inside the declaration config, so exported constants can follow normal TypeScript naming conventions.
// src/fields/Customer.ts
import { defineEntityField } from "@backfill-io/sdk";
export const stripeCustomerId = defineEntityField({
key: "stripe_customer_id",
label: "Stripe customer",
type: "string",
});
// src/fields/Invoice.ts
import { defineEntityField, defineLineField } from "@backfill-io/sdk";
export const stripeInvoiceId = defineEntityField({
key: "stripe_invoice_id",
label: "Stripe invoice",
type: "string",
});
export const stripeTaxBehavior = defineLineField("line_items", {
key: "stripe_tax_behavior",
label: "Stripe tax behavior",
type: "select",
options: ["inclusive", "exclusive", "unspecified"],
});
line_items is the only supported line collection today, on every line-bearing canonical document (Invoice, CreditMemo, SalesReceipt, RefundReceipt, VendorBill, Expense, PurchaseOrder, Quote, VendorCredit, SalesOrder, and Deposit). Additional line collections will be added as new line-bearing entities ship. Field keys must be lower snake case.
The equivalent normalized deployment manifest is generated by the CLI. You do not write this block by hand:
fields: {
entity: {
Customer: {
stripe_customer_id: {
label: "Stripe customer",
type: "string",
},
},
Invoice: {
stripe_invoice_id: {
label: "Stripe invoice",
type: "string",
},
},
},
line: {
Invoice: {
line_items: {
stripe_tax_behavior: {
label: "Stripe tax behavior",
type: "select",
options: ["inclusive", "exclusive", "unspecified"],
},
},
},
},
}
src/fields/<EntityName>.ts declarations apply to Backfill standard entities. Use defineEntity.fields for the schema of extension-owned custom entities.
Field types
type FieldType =
| "string"
| "number"
| "boolean"
| "date"
| "datetime"
| "select"
| "multi_select"
| "currency"
| "json";
type FieldOption = string | { label: string; value: string };
interface FieldConfig {
key: string;
label: string;
type: FieldType;
nullable?: boolean;
options?: FieldOption[];
currency?: string;
currencyField?: string;
}
defineEntityField(config: FieldConfig);
defineLineField(lineCollection: "line_items", config: FieldConfig);
string, number, boolean, date, and datetime use their standard JSON-shaped values. select, multi_select, currency, and json have extra rules covered below.
Select and multi-select
select and multi_select fields must declare a non-empty options list. Options can be plain strings, or { label, value } objects when you want a friendlier UI label without changing the stored value:
export const stripeTaxBehavior = defineLineField("line_items", {
key: "stripe_tax_behavior",
label: "Stripe tax behavior",
type: "select",
options: [
{ label: "Inclusive", value: "inclusive" },
{ label: "Exclusive", value: "exclusive" },
{ label: "Unspecified", value: "unspecified" },
],
});
multi_select stores an array of declared option values:
export const fulfillmentFlags = defineLineField("line_items", {
key: "fulfillment_flags",
label: "Fulfillment flags",
type: "multi_select",
options: ["backordered", "split_shipment", "manual_review"],
});
Currency
Currency fields are amount-shaped values for financial extension data. Use decimal strings for values — never JavaScript floating-point numbers — to avoid rounding errors.
A currency field must declare either a static ISO 4217 currency code, or a currencyField that names another field on the same record where the currency code lives:
// Static currency: every value is in USD.
export const stripeFeeUsd = defineEntityField({
key: "stripe_fee_usd",
label: "Stripe fee (USD)",
type: "currency",
currency: "USD",
});
// Dynamic currency: read the code from a sibling field on the same line.
export const stripeFeeAmount = defineLineField("line_items", {
key: "stripe_fee_amount",
label: "Stripe fee",
type: "currency",
currencyField: "currency",
});
JSON
json stores arbitrary JSON-shaped values. Use it sparingly for structured payloads that don’t fit the other types:
export const stripeMetadata = defineEntityField({
key: "stripe_metadata",
label: "Stripe metadata",
type: "json",
});
Reserved keys
Custom field keys cannot collide with native attributes on the parent entity or line. The platform validates these reserved keys at deploy time against a parity-tested list, and a colliding key fails the deploy with a validation error pointing at the declaration.
For entity fields, reserved keys include native attributes such as email on Customer or status on Invoice.
For line fields, reserved native line keys include amount, quantity, tax_amount, account_id, account_code, item_id, line_key, and line_number.
Runtime reads and writes
Reads require entity read permission, writes require entity write. Values are scoped to the calling extension — reads only see fields your extension declared.
Entity fields
After declaring fields with defineEntityField, read and write values through the same entity APIs you use for native fields. Declared field keys sit at the top level next to native fields; unknown keys fail validation.
import { Customer } from "@backfill-io/sdk";
Customer.create({
name: "Acme, Inc.",
email: "billing@acme.example",
stripe_customer_id: "cus_123",
});
Customer.update(customerId, {
name: "Acme, Inc.",
stripe_customer_id: "cus_123",
});
const customer = Customer.get(customerId);
const stripeCustomerId = customer?.stripe_customer_id;
For field-only writes, use the generated entity field namespace:
Customer.fields.set(customerId, "stripe_customer_id", "cus_123");
Customer.fields.setAll(customerId, {
stripe_customer_id: "cus_123",
revenue_schedule_id: "rs_abc123",
});
const all = Customer.fields.getAll(customerId);
// => { stripe_customer_id: "cus_123", revenue_schedule_id: "rs_abc123" }
Customer.fields.delete(customerId, "stripe_customer_id");
Generic code can use Entity:
import { Entity } from "@backfill-io/sdk";
Entity.update("Invoice", invoiceId, {
stripe_invoice_id: "in_123",
revenue_schedule_id: "rs_abc123",
});
Line fields
Line fields target a document ID and a line_key. When you are already creating or updating an invoice, include declared line field keys directly inside line_items next to native line attributes:
import { Invoice } from "@backfill-io/sdk";
Invoice.update(invoiceId, {
status: "sent",
stripe_invoice_id: "in_123",
line_items: [
{
line_key: "line_1",
quantity: 2,
stripe_tax_behavior: "exclusive",
stripe_balance_transaction_id: "txn_123",
},
],
});
For field-only writes, use the line-collection fields namespace. Get line_key from the hook context (ctx.entity.line_items[i].line_key) or from the invoice you just read or wrote. The generated SDK accessor is camelCase: Invoice.lineItems.
Invoice.lineItems.fields.set(
invoiceId,
lineKey,
"stripe_tax_behavior",
"exclusive",
);
Invoice.lineItems.fields.setAll(invoiceId, lineKey, {
stripe_tax_behavior: "exclusive",
stripe_balance_transaction_id: "txn_123",
});
Invoice.lineItems.fields.setMany(invoiceId, {
"stripe:il_123": {
stripe_tax_behavior: "exclusive",
stripe_fee_amount: "1.23",
},
"stripe:il_124": {
stripe_tax_behavior: "inclusive",
stripe_fee_amount: "0.42",
},
});
Invoice.lineItems.fields.delete(invoiceId, lineKey, "stripe_tax_behavior");
const value = Invoice.lineItems.fields.get(
invoiceId,
lineKey,
"stripe_tax_behavior",
);
const valuesByLine = Invoice.lineItems.fields.getAll(invoiceId);
// => { [lineKey]: { stripe_tax_behavior: "exclusive" } }
Merge, null, and delete
Writes merge declared keys instead of replacing the whole field set. setAll — for both entity and line fields — only touches the keys you pass, leaving unset keys at their existing values.
To clear a field, use delete. set(..., null) stores JSON null, which is a real value, not a delete. Fields are nullable by default; if a declaration sets nullable: false, writes with null are rejected.
Transactions and hook timing
Inline line field writes and the native document update are one logical transaction. If any line field fails validation — wrong type, oversized value, unknown line_key — the whole write fails and nothing is persisted.
Before-save hooks cannot write line fields, because new line keys do not exist yet at that point. Either include the line field values in the create/update call itself, or write them from an after-save hook once line keys are stable.
Generated types
Generated SDK reads are typed from discovered field declarations, so Customer.get(...) returns the right shape for the fields you declared. Generic Entity calls may still return unknown when the entity type or field key is not statically known.
API responses
SDK reads are scoped to the calling extension. Backfill UI surfaces can combine active values from every enabled extension for a rendered document.
Backfill REST API responses exclude extension fields by default and accept a single include query parameter to opt in:
extension_fields.entity— surface entity-level extension fields on the parent record underextensionFields.extension_fields.line— surface line-level extension fields on each projected line under anextensionFieldskey on the line.
Both tokens may be combined (?include=extension_fields.entity,extension_fields.line); unknown tokens return 400 invalid_include.
GET /api/v1/invoices/:id?include=extension_fields.entity,extension_fields.line
{
"data": {
"id": "...",
"documentNumber": "INV-1",
"extensionFields": {
"stripe": { "stripe_invoice_id": "in_123" }
},
"lines": [
{
"lineKey": "line_1",
"amount": "100.00",
"extensionFields": {
"stripe": { "stripe_tax_behavior": "exclusive" }
}
}
]
}
}
Keys are always immutable extension_key → field_key; internal extension_id UUIDs never appear in responses. Only enabled extensions contribute values, and only field keys declared in the extension’s active manifest are projected. The opt-in is supported on every line-bearing canonical document REST endpoint: Invoice, CreditMemo, SalesReceipt, RefundReceipt, VendorBill, Expense, PurchaseOrder, Quote, VendorCredit, SalesOrder, and Deposit.
Custom fields vs. entities
Fields and custom entities both use file-based declarations. Choose based on the lifecycle of the data:
| Use fields when | Use custom entities when |
|---|---|
| Data lives 1:1 on an existing entity or document line. | Data has its own lifecycle, often many records per parent. |
| You want it to disappear with the parent. | You want to query, sort, filter, or permission it independently. |
| You need declared field validation without a new resource. | You need a first-class extension-owned entity type. |
For 1:N data attached to an invoice, such as reconciliation matches, use a custom entity with displayOn instead.