Build a connector
A walkthrough using Stripe as the canonical example — manifest, settings schema, sync route, webhook handler.
Walk through the core files a real connector ships: manifest, field declarations, test-connection route, poll sync route, and webhook handler.
1. Manifest with defineConnector
// backfill.config.ts
import { defineConnector } from "@backfill-io/sdk";
export default defineConnector({
key: "stripe",
name: "Stripe",
provider: "stripe",
kind: "source",
version: "0.2.0",
permissions: {
http: ["https://api.stripe.com/*"],
entities: {
Customer: ["write"],
Invoice: ["write"],
Payment: ["write"],
// ...the rest
},
secrets: {
api_key: ["read"],
webhook_secret: ["read"],
},
},
connection: {
testConnection: { route: "/setup/test_connection", method: "POST" },
setup: {
webhookUrlTemplate:
"/api/v1/extensions/<tenant_id>/stripe/routes/webhooks/stripe?connection_id=<connection_id>",
instructions:
"Create a Stripe webhook endpoint forwarding accounting events to the generated URL, then paste the signing secret here.",
},
settingsSchema: {
type: "object",
required: ["api_key"],
properties: {
api_key: {
type: "string", minLength: 1,
title: "Secret API key",
"x-secret": true,
"x-placeholder": "sk_live_... or sk_test_...",
},
webhook_secret: {
type: "string", minLength: 1,
title: "Webhook signing secret",
"x-secret": true,
"x-placeholder": "whsec_...",
},
},
},
},
webhooks: { endpoint: "/webhooks/stripe", auth: "stripe_signature" },
streams: [
{ key: "events", mode: "webhook" },
{ key: "customers", mode: "poll", schedule: "*/15 * * * *" },
{ key: "invoices", mode: "poll", schedule: "*/15 * * * *" },
{ key: "payments", mode: "poll", schedule: "*/15 * * * *" },
{ key: "payouts", mode: "poll", schedule: "*/15 * * * *" },
],
ui: {
lineColumns: [
{
id: "stripe-tax-behavior",
label: "Stripe tax",
entity: "Invoice",
lineCollection: "line_items",
field: "stripe_tax_behavior",
width: 140,
order: 60,
visibleWhen: { field: "source_system", equals: "stripe" },
},
],
},
});
See Settings schema, Webhooks, Streams, Connection setup for the deeper dive on each block.
API-key vs OAuth starter
Use one setup path in the first version of a connector:
// API-key connector: declare the key in settingsSchema and read it with Secrets.get("api_key").
connection: {
settingsSchema: {
required: ["api_key"],
properties: {
api_key: { type: "string", "x-secret": true },
},
},
}
// OAuth connector: declare connection.oauth and read provider tokens with OAuth.accessToken().
connection: {
oauth: {
type: "authorization_code",
authorizationUrlTemplate: "https://provider.example.com/oauth/authorize",
tokenUrlTemplate: "https://provider.example.com/oauth/token",
scopes: ["orders.read"],
client: { credentialKey: "provider_public_app" },
},
settingsSchema: { type: "object", required: [], properties: {} },
}
If the provider expects normal SaaS “Connect” behavior, use OAuth from day one. Do not ask admins to paste provider OAuth access tokens into secret fields. See Build an OAuth connector for the OAuth-specific route and test pattern.
2. Field declarations
Stripe carries useful provider-specific IDs and classifications that should stay extension-owned. Declare those fields in src/fields/Invoice.ts; the CLI discovers the file and normalizes it into the deployment manifest.
// src/fields/Invoice.ts
import { defineLineField } from "@backfill-io/sdk";
export const stripeBalanceTransactionId = defineLineField("line_items", {
key: "stripe_balance_transaction_id",
label: "Balance transaction",
type: "string",
});
export const stripeTaxBehavior = defineLineField("line_items", {
key: "stripe_tax_behavior",
label: "Stripe tax behavior",
type: "select",
options: ["inclusive", "exclusive", "unspecified"],
});
export const stripeFeeAmount = defineLineField("line_items", {
key: "stripe_fee_amount",
label: "Stripe fee",
type: "currency",
currencyField: "currency",
});
The ui.lineColumns[] entry in backfill.config.ts references the field key stripe_tax_behavior.
3. The test-connection route
The dashboard calls this when an admin enters credentials.
// src/api/setup/test_connection.ts
import { api, Http } from "@backfill-io/sdk";
export const config = { auth: "api_key" };
export const POST = api(async (request) => {
const apiKey = request.body.api_key;
const res = Http.get("https://api.stripe.com/v1/balance", {
auth: { bearer: apiKey },
});
if (!res.ok) {
return api.badRequest("Invalid API key", { status: res.status });
}
return api.json({ ok: true });
});
4. A poll sync route
// src/api/sync/customers.ts
import { api, ingest } from "@backfill-io/sdk";
export const config = { auth: "api_key" };
export const POST = api(async (request) => {
const stream = "customers";
const apiKey = Secrets.get("api_key");
if (!apiKey) return api.badRequest("Missing api_key");
// Read cursor / page from request, fetch a page from Stripe,
// and emit each customer:
const res = Http.get("https://api.stripe.com/v1/customers?limit=100", {
auth: { bearer: apiKey },
});
if (!res.ok) return api.error(`Stripe ${res.status}`, { status: 502 });
const results = (res.body.data || []).map((c) =>
ingest.canonical("customer", { customer_id: c.id, name: c.name }, {
stream,
idempotencyKey: `stripe:customer:${c.id}`,
}),
);
return api.json({ stream, imported: results.length, hasMore: res.body.has_more });
});
The real connector uses lib/stripe.ts helpers for cursor management and pagination. See Sync routes for the full pattern.
5. The webhook handler
// src/api/webhooks/stripe.ts
import { api, ingest } from "@backfill-io/sdk";
// ...transforms imported from src/lib/transforms/webhooks.ts
export const config = { auth: "stripe_signature" };
export const POST = api(async (request) => {
const event = request.body;
// dispatch on event.type, transform to canonical, emit:
ingest.canonical("payment", transformPayment(event), {
stream: "payments",
idempotencyKey: `stripe:event:${event.id}`,
});
return api.json({ ok: true }, { status: 202 });
});
The platform verifies the signature before invoking the handler — see Webhooks.
6. Line annotations
Stripe invoice lines often carry provider-specific facts that are useful in the Backfill UI but should not become native accounting fields. Declare those with defineLineField, render them with read-only ui.lineColumns[], and include declared line field keys directly in the canonical invoice payload.
// src/api/sync/invoices.ts
import { api, ingest } from "@backfill-io/sdk";
export const config = { auth: "api_key" };
export const POST = api(async (request) => {
const stripeInvoice = request.body;
const payload = transformStripeInvoice(stripeInvoice);
payload.line_items = (payload.line_items ?? []).map((line) => ({
...line,
stripe_tax_behavior: line.metadata?.stripe_tax_behavior ?? "unspecified",
stripe_balance_transaction_id: line.metadata?.balance_transaction_id,
stripe_fee_amount: line.metadata?.stripe_fee_amount,
}));
const result = ingest.canonical("invoice", payload, {
stream: "invoices",
idempotencyKey: `stripe:invoice:${stripeInvoice.id}`,
});
return api.json(result);
});
Backfill splits the payload into native invoice fields, native line fields, and declared extension fields. The native document write and inline line field write are one logical operation: if a declared line field fails validation, the whole ingest fails. Line fields require stable line_key values on the canonical lines. Line fields are extension-owned and excluded from reports, exports, public API projections, MCP responses, and outbound sync payloads by default.
7. Route tests
Connector route modules can be tested locally with
@backfill-io/sdk/testing. The test runtime installs the SDK globals that
Backfill normally provides at runtime and captures provider HTTP, checkpoints,
logs, and canonical ingest calls.
import assert from "node:assert/strict";
import test from "node:test";
import { connectorTestRuntime } from "@backfill-io/sdk/testing";
test("customers sync emits canonical customers", async () => {
const runtime = connectorTestRuntime({
secrets: { api_key: "sk_test_123" },
});
try {
runtime.install();
const { POST } = await import("../src/api/sync/customers");
runtime.http.getOnce({
status: 200,
body: { data: [{ id: "cus_123", name: "Ada Lovelace" }] },
});
const response = await runtime.call(POST);
assert.equal(response.status, 200);
assert.equal(runtime.ingest.calls[0].documentType, "customer");
assert.equal(runtime.ingest.calls[0].sourceId, "cus_123");
} finally {
runtime.restore();
}
});
See Connector testing for mocked Http, Secrets,
Settings, sync, ingest, and partial failure examples.
What’s left as an exercise
- The transform layer (
src/lib/transforms/) — provider-specific shape → canonical shape. - Per-stream pagination state (the Stripe connector has
lib/stripe.tsfor cursor management). - Per-event idempotency keying for events that explode into multiple emissions.
These are still moving targets. Read the Stripe connector source as the reference.