Integrations
Everything outside the Flow core is an integration behind one contract, the
integration port (server/integrations/port.js). VISION.md, "The core and
its integrations", says why. This page says how an adapter is built, how an
installation turns it on and off, and how it owns its tables.
An adapter
An adapter is a plain object that defineAdapter checks:
import { fileURLToPath } from 'node:url';
export const exampleAdapter = {
id: 'example', // lower case, letters, digits and dashes
name: 'Example',
bundled: false, // true: ships on in a preset
schema: {
migrationsFolder: fileURLToPath(new URL('./example/migrations', import.meta.url)),
tables: ['example_cases', 'example_sync_state'],
},
capabilities: {
health: async () => ({ ok: true }), // required
pullWorkItems: async (cursor) => { /* … */ },
},
start: async ({ db, log }) => { /* optional */ },
stop: async () => { /* optional */ },
};
The capabilities are pullWorkItems, pullHistoryJobs, pushInvoice, pushBill,
pullPayments, checkInvoice, listCustomers, createCustomer, findInvoices, listWork,
billableJobs, listPriceItems, jobIssues, moveWork, cancelMove, personSettings,
setPersonSetting, captureError, browserMonitoring, traceSpan, traceRequest, alerts and
health. alerts({ organisationId }) answers what only a person can clear,
for the quiet alert on the Integrations page (docs/ALERTS.md).
listPriceItems({ organisationId }) answers the system's own price list from
the adapter's copy, never a live call: { kind, syncedAt, items }, kind
accounting or work, each item { id, code, name, unitAmountCents, taxCode, sold } (docs/PRICE_LIST.md). listWork({ organisationId })
answers the work system's jobs for the Jobs board from the adapter's own copy,
never a live call: { items, statuses }, each item with its sourceSystem,
sourceJobRef, status, clientName, practitioner, dates, lines and
total (Seazona: open orders and orders finished in the last 30 days, never
one deleted in Seazona). An item may carry link, { url, exact }, which
opens the job in the work system's own web app (exact is false for only its
home page), and pendingMove, { to, from, requestedAt, expiresAt, openUrl, openExact }: a stage move the work system has not made yet.
jobLink({ sourceJobRef }) answers that same link for one
job, or null; it never calls the system.
moveWork({ sourceJobRef, toStatus, personId }) takes a person's move of a
job to another stage on the Jobs board and answers what happens; an
adapter whose API cannot change a stage answers a hand-off, { kind: 'hand_off', from, to, openUrl, expiresAt } (Seazona). cancelMove({ sourceJobRef }) takes back a move not yet made. personSettings({ personId }) answers each person's own choices in the integration, [{ key, label, hint, kind, value }], and setPersonSetting({ personId, key, value })
changes one (docs/JOB_BOARD_MOVES.md). jobIssues({ refs }) answers the integration's
open issues for those job refs, { [ref]: [{ integration, phrase, href }] }:
one short phrase and the page where a person fixes it; the Jobs board shows
them on each job (Seazona: history jobs a person must decide).
billableJobs({ organisationId, from, to }) answers the billable jobs in the
adapter's own copy whose job day is in that range, never a live call:
{ jobs }, each { ref, day, subtotalCents } (subtotalCents null when a
line has no amount); the daily reconcile counts them (docs/RECONCILE.md). captureError({ message, source, error }) takes each error Flow logs, and browserMonitoring()
answers the public settings a browser needs. traceSpan({ name, op, attributes }, work) runs work inside a span of the current performance
trace, and traceRequest() answers a function that names the request's
trace by its route (server/core/tracing.js). The Sentry adapter has all
four (docs/SENTRY.md).
listCustomers({ organisationId }) answers the system's customers as
{ id, name }. createCustomer({ organisationId, contactId, actor })
creates that system's customer from the Flow contact and answers
{ outcome, contact }. Xero does this when Create missing clients is on
(docs/CONTACTS.md). findInvoices({ organisationId, invoiceIds, numbers, flowInvoiceIds, contactId, from, to }) answers sales invoices from the
adapter's own copy, never a live call, with the copy's state (complete,
fresh, changedAt). The pipeline uses both (docs/JOB_INVOICE_LINKS.md).
checkInvoice({ organisationId, obligation }) answers whether the accounting system holds an invoice for a
billable job, in the ledger's evidence shape; it is the live check before
Flow drafts a job's invoice (POST /api/invoicing/jobs/:id/draft).
deleteInvoice({ organisationId, invoiceId, actor }) reads that system's
invoice live and answers { state, status }: deleted, gone, approved
or unavailable. The core voids an issued Flow invoice only after deleted
or gone (docs/XERO.md, "Deleting a draft"). An adapter declares only the ones it has, and
must declare health.
An adapter may also declare routes(context): a function that returns an
Express router, with context { db, personAuth, log }. Flow mounts it at
/api/<id> (registry.routes in server/integrations/port.js), after the
/api rate limit. The router is built only for an adapter that is on with
its tables in place; for one that is off, every request under /api/<id>
gets the turned-off answer (below) with status 503. The router does its own
sign-in checks with personAuth.
Two optional functions give an adapter a lifecycle:
start({ db, log })runs once at server start, only while the adapter is on, after its tables are in place. Keep the database handle here and start background work (a scheduler, a poller). When a person saves the adapter's settings or turns it on, it runs again withreconfigured: true, so an adapter can clear a stop that waits for a person (for example after its API client was refused).stop()ends that work.registry.stop()calls it for every adapter that started, newest first. Tests call it so they end cleanly.
To ship an adapter, add it to the list in server/integrations/adapters.js.
Core code never imports an adapter; it asks the registry.
Turn an integration on or off
An owner turns an integration on in Flow, in Integrations (below). A
host can also turn one on with FLOW_INTEGRATIONS: the adapter ids,
separated by commas (seazona,xero). Unset or empty: none. An unknown id
stops boot, so a typo never leaves an integration silently off.
Set up in Flow
flowchart LR owner[Owner: Integrations] -- settings and credentials --> api[PUT /api/integrations/:id] api -- seal credentials<br/>with the secrets key --> db[(integration_settings)] api --> apply[Stop the adapter<br/>create its tables<br/>start it with the new settings] boot[Flow starts] --> read[Read integration_settings] --> merge[Host environment over<br/>stored settings] --> start[Start each adapter that is on]
- An adapter declares
setup: the settings an owner may enter (fields, each named by its environment setting), a short guide, andderivedKeys. Xero and Seazona declare theirs in theirindex.js. - Only an owner sees the settings (
GET /api/integrations). A change needs a verified owner sign-in (PUT /api/integrations/:id). It applies at once: no restart and no host secret to edit. - A credential is write-only. Flow seals it with AES-256-GCM under its
secrets key, bound to the integration and the setting's name, and stores
only the sealed value. The screen shows whether it is set, never the value.
Every change writes an
integration_setting_eventsrow with the setting names and who changed them, never a value. - The host's own environment wins: a setting set there shows as "Set by the host" and cannot be changed in Flow.
- A field with
advanced: true, and every setting the host sets, shows in the Advanced fold under Settings. The fold is open while the integration is off, so Connect shows every setting. - An integration turned on in Flow that cannot start (a setting is missing) shows the reason and Flow keeps running. One turned on by the host stops boot, as before.
derivedKeys(Xero'sXERO_TOKEN_KEY) come from the secrets key, so no one makes them by hand.
The secrets key
FLOW_SECRETS_KEY is the key: 32 bytes, as 64 hex characters or base64
(openssl rand -hex 32). When it is unset, Flow derives the key from
SESSION_SECRET when that is at least 32 characters. With neither, Flow
stores no credential and the screen says so. Changing the key, or its
source, makes stored credentials and Xero's stored sign-in unreadable: an
owner enters the credentials again and connects Xero again. A copy of the
database alone opens no credential, because the key is never stored there.
Tables
An installation's database holds only the tables of the integrations it has turned on (issue #13).
flowchart LR
boot[Flow starts] --> core[Core migrations]
core --> each{For each adapter}
each -- on --> migrate[Run its migrations<br/>check its tables exist]
each -- off --> skip[Touch nothing]
migrate --> ready[ready]
skip --> off[turned off answer]
The rules:
-
The adapter owns its schema. Its tables are not in
server/db/schema.js. It keeps its own drizzle migrations inschema.migrationsFolder(ameta/_journal.jsonand SQL files, asnpx drizzle-kit generatewrites them) and its own migration journal,drizzle.__drizzle_migrations_<id>. -
Every table starts with the adapter id, dashes as underscores (
my-labownsmy_lab_…).defineAdapterrefuses any other name, so an adapter never touches a core table or another adapter's. -
schema.tableslists every table the migrations create. After it migrates, Flow checks each one exists and stops boot if one is missing. -
Tables are created only while the adapter is on. At start, after the core migrations,
registry.start(db)runs the migrations of each enabled adapter, and of no other. -
Turning an adapter on later creates its tables at the next start. There is no manual step. Until that start it answers as turned off.
-
Turning an adapter off never drops its tables. Its rows stay, and turning it on again finds them.
-
Removing the tables is a separate, explicit step. It refuses while the integration is in
FLOW_INTEGRATIONSand while any of its tables holds a row, and removes everything in one transaction:DATABASE_URL=postgres://… node server/integrations/remove-tables.js <id>
Read an adapter's tables
Go through the registry, never straight to the tables:
const result = await registry.whenEnabled('example', (adapter) => adapter.capabilities.pullWorkItems(cursor));
whenEnabled runs the function only when the adapter is on and its tables
are in place. Otherwise it returns the adapter's turned-off answer, and code
shows that answer instead of crashing:
{ "ok": false, "code": "integration_off", "integration": "example", "reason": "disabled", "error": "Example is turned off on this installation." }
reason |
When |
|---|---|
disabled |
The integration is off. |
restart_needed |
It was turned on after Flow started. Its tables come at the next start. |
tables_missing |
Its tables were removed by hand. A query that finds no table gives this answer, not an error. |
registry.with(capability) (the core's fan-out) also returns only adapters
that are on with their tables in place. registry.list() gives each
adapter's state: disabled, ready, restart_needed, or not_started
before the first start.
Outbound calls
A provider can cut off an installation's API access after many failed
calls, even inside its rate limit. So every adapter that calls an outside
system sends each call through the call guard
(server/integrations/call-guard.js, issues #101 and #111). The guard names
no vendor. Each adapter brings its own tables and its own policy.
call ──> guard.check ──> rate limit ──> provider ──> guard.record
│ │
└── stopped: CallsStopped, no call └── failure? update circuit,
budget, halt; report once
The guard stops calls in four ways:
| Stop | Starts when | Ends when |
|---|---|---|
halted |
a refused credential (401/403, or a token refresh refused for good) | a person saves the settings or connects again (resume()) |
resource_stopped |
a 401/403 on one resource, when authStops is resource |
a probe call succeeds (one probe after 6 hours, doubling after each refused probe, at most 24 hours), or a person acts (resume()). It is a warning alert, not a stop alert: other calls still go |
circuit_open |
5 failed calls in 10 minutes | the pause passes: 5 minutes, doubling after each failed probe, at most 6 hours; a success closes it |
budget_spent |
50 failed calls in one UTC day | the next UTC day |
A failed call is no answer, a 5xx, a 429, or any other 4xx except 404 (a
404 on one record means it was deleted). A stopped call throws
CallsStopped at once (kind: 'calls_stopped', code
<id>_api_halted, <id>_api_resource_stopped, <id>_api_circuit_open or
<id>_api_failure_budget_spent, retryAfterMs null for a stop only a
person ends). Each stop is reported once with log.error, so the connected
error service gets one report per problem; a repeat is a warning. Reports
carry the resource and the HTTP status, never a host, a query, a token or a
response body.
To follow the pattern, an adapter:
- Creates two tables in its own migrations, with the columns in
CALL_GUARD_COLUMNS: a state table (one row per credential or tenant:key,circuit_open_until,circuit_opens,halted_reason,halted_at,stopped_resources,updated_at) and a calls table (one row per call:key,at,resource,request_path,status_code,failure_kind,duration_ms). The state is in PostgreSQL, so every process on one database shares it and a restart does not clear it. - Declares
callGuard: { integration, name, tables: { state, calls }, policy }on the adapter.defineAdapterchecks that the tables are the adapter's own. - Calls
guard.check(key, { resource })before each call andguard.record(key, { resource, path, statusCode | failureKind, durationMs })after it, andguard.resume()fromstart()whenreconfiguredis true. - Treats
CallsStoppedas "wait", not as a failure: a background job does not spend its attempts on it, and a batch stops at the first one. - Shows
guard.status(key)in itshealth(), and answersguardAlerts(name, await guard.status(key))fromalerts().
test/integration-call-guard.test.js fails when a shipped adapter has no
call guard and is not listed as exempt with a reason, and runs the same
contract suite (test/support/call-guard-contract.js) against each guarded
adapter's tables and policy. Xero and Seazona use the guard. Sentry is
exempt: its SDK follows Sentry's own limits.
An adapter that admits each call in its own transaction (Seazona's rate
governor writes the call row when it admits a call) passes lockName so the
guard takes the same lock, calls guard.stoppedNow(tx, key) inside that
transaction, and passes the row's id as callId to record. Its existing
tables keep their names: columns maps key and at (Seazona:
client_key, admitted_at), and callDefaults fills other required
columns of a new row. report: false is for an adapter that already reports
each stop once at a higher level (Seazona's lanes).
Button presses
A person's button press that calls an outside system (a copy, a recheck, a
send) is one action. Pressed again and again, it spends the provider's
rate limit, and can trip the call guard. So each such route also goes
through the action guard (createActionGuard in
server/integrations/call-guard.js), after sign-in and before the handler.
press ──> sign-in ──> action guard ──> handler ──> call guard ──> provider
│ │
└── too soon: 429 + wait, no call └── answer sets the next wait
| Rule | Answer |
|---|---|
| One run at a time per action, organisation and key | 429 action_running |
A wait after each run (cooldownMs) |
429 action_cooldown |
After a failed run (5xx, 429, a stopped call): twice the wait each time, at most maxCooldownMs; a good run resets it. A longer Retry-After from the call guard wins |
429 action_cooldown |
One budget per integration: 30 runs in 10 minutes for all its actions, except an action with budget: false |
429 action_budget |
| A refused request (another 4xx) | no wait, no budget spent |
Every 429 carries Retry-After and retryAfterMs. A good run says its
wait in the Flow-Cooldown-Ms header. The page shows the wait on the button
(holdButton in server/ui/app.js), so a person sees when it is ready.
The waits each adapter declares:
| Route | Action | Wait |
|---|---|---|
POST /api/xero/sync |
sync |
5 minutes |
POST /api/xero/mirror |
mirror |
2 minutes |
POST /api/xero/payments/sync |
paymentsSync |
5 minutes |
POST /api/xero/invoices/:id/recheck (Mark resolved) |
recheck |
30 seconds per invoice |
POST /api/xero/contacts (Create) |
createContact |
30 seconds per contact |
POST /api/xero/obligations/:id/check |
obligationCheck |
30 seconds per job |
POST /api/xero/invoices/push (Send to Xero) |
push |
one at a time; backs off after a failure; no budget |
POST /api/invoicing/invoices/send-ready, send-again |
sendReady, sendAgain |
one at a time; backs off; no budget |
POST /api/invoicing/jobs/:id/draft |
draftJob |
10 seconds per job; no budget |
Check prices (POST /api/invoicing/price-list/check) reads Flow's copies
only, so it has no action. The Seazona routes call nothing at a press (a backfill is paced by the rate
governor), so they have no action. A new route that calls an outside system
at a press declares an action. The state is in memory: one Flow process
serves one lab, as for the request limit (server/rate-limit.js); the call
guard still protects every process. createApp({ buttonCooldowns: false })
turns the waits off for tests only (test/action-guard.test.js tests them).
Health
GET /health lists every adapter under integrations with enabled and
state. When an adapter is ready its health() answer is there too, so it
must read Flow's own data and answer fast: an answer slower than 5 seconds,
or an error, shows as { ok: false, error }. An integration problem never
fails Flow's own health check (server/integrations/health.js).
Tests
test/integration-schema.test.js proves the rules against PostgreSQL with a
synthetic adapter (test/fixtures/sample-adapter/): a start with it off
creates none of its tables, a start with it on creates all of them, turning
it off keeps them and their rows, and removal refuses while a table holds a
row. An adapter's own tests follow the same pattern: boot it off and assert
none of its tables exist, boot it on and assert all of them do.