Skip to content

What is a connector?

A connector is the platform's bridge to a third-party service a tenant uses — Scrive for e-signing, Fortnox for accounting, a BankID provider for identity. It is the one place in Substrat allowed to talk to the outside world.

Connectors are host code, never module code. A vertical cannot call an external API: the boundary lint bans fetch outright, and there is nowhere for a vertical to keep a credential. That prohibition is the whole point — it means "this tenant's Scrive account" lives in one audited place, and a vertical sees only the capability, never the secret.

Where a connector sits

Decision 18's triage rule sorts every outside dependency into three buckets, and a connector is the third:

BucketWhatExample
Kernel-ownedenforcement inputs and contractstenancy, permissions, the event spine, the integrations hub itself
Adapterinfrastructure the kernel consumes, swappable behind a pure interfacethe SQLite/Cloudflare scope host, the SecretBox that seals credentials
Connectora third-party capability tenants use, in the hubScrive, Fortnox, BankID, Swish, Peppol, Kivra, EDI

The hub — the connection store, the runtime, the authority seam — is kernel-owned. Individual connectors are not: they accrete one at a time, as a vertical needs one, and live in connectors/* outside the kernel.

The seam a connector plugs into

A connector never invents its own machinery. Four kernel-owned pieces do the load-bearing work, and a connector is small because they exist:

  1. The connection store. A tenant's authorization for one provider, held by one vertical, keyed (tenant, vertical, provider). Credentials are sealed at rest by a SecretBox adapter — the metadata is readable, the secret never is. See Permissions for how a connection becomes a subject. The hub is not the only way in: a vertical whose users have no dashboard account starts a provider's consent round itself with requestConnectUrl, and the connection still lands in this store, stamped with the principal whose permission check authorized it.

  2. The connector runtime. registerConnector(id, eventType, handler) binds a handler to an event. When a module emits that event, the runtime hands the handler an opened credential and a fetch bound to the connection — so a timeout, egress policy, and per-connection health recording come for free, and module code still cannot reach any of it.

  3. At-least-once delivery with retry. The handler rides the same journal, backoff and dead-letter as every executor. A provider being briefly down is ordinary; the runtime retries with backoff and surfaces a dead letter rather than dropping the effect.

  4. The inbound authority seam. A provider's callback is not a person, so it cannot hold a PrincipalId. Instead a connection is a subject: getConnectorScope(connectionId, scopeId) opens a scope stub whose authority is the connection's own grants, narrowed by construction to that tenant and vertical. Events it causes are stamped { connection } on the spine — the audit trail says "Scrive did this" without naming a human who did not.

durable objectScope DOThe operation committed;the event is in the outbox.no fetch() here — the lint forbids itdelegates the deliverygetConnectorScope()workerConnector runtimeOpens the sealed credentialfor this tenant.fetch bound to the connection · retrysends the documentthe provider calls backoutside the platformThe providerScrive, Fortnox, a bank.never sees a principal
A provider’s callback is not a person, so it cannot hold a principal. The connection itself is the subject: getConnectorScope opens a stub whose authority is that connection’s own grants, narrowed by construction to one tenant and one vertical.

What a connector must answer for itself

The seam supplies the machinery; four answers are the connector's own, and only the last is optional.

  1. What it does at the provider — the handler, or the sweep for a poll-only connector.
  2. Whether this credential is any good — a probe, and this one is not negotiable. Write it as a pair: one that checks a candidate secret at connect time, before anything is stored, and one that checks the credential a live connection already holds. Reach for the cheapest authenticated read the provider offers that names the account — Fortnox's /companyinformation, Planima's /organizations — because the question an operator actually has is not "is this token valid" but "does it see the customer I meant". A valid token from the wrong login is the failure a syntax check cannot catch.
  3. What this connection has been doing — activity, projected from the connector's own ledger into a declared shape, so redaction is structural.
  4. Which credential is loaded — identifiers whole, secrets masked by the connector's own rule.

The probe is mandatory because of what its absence looks like from the outside. Verify answers 501 for a provider with no probe registered — correct for a provider the platform does not operate, and actively misleading for one it does: the tenant pressing Test connection reads "Couldn't reach the provider", and blames the provider for our missing wiring. A connector that skips it also skips its own connect-time gate, so a mistyped credential is stored and looks healthy until the first real dispatch fails.

If a provider genuinely exposes nothing to probe with, the connector still carries a probe that says so, rather than leaving the route to 501. A stated "this provider exposes no verification read" is something a console can render and a person can act on.

What a connector is not

  • Not an engine. An engine owns invariants, operations, events and domain state inside a scope. A connector owns none of those — it consumes an event and effects something outside. It has no tables, no permissions of its own, no domain model.
  • Not swappable infrastructure. The KMS behind SecretBox is an adapter (bucket 2). A connector is a capability a tenant deliberately connects, not plumbing the kernel picks.
  • Not a way around the module rules. A connector cannot read a vertical's tables. If a connector needs a vertical's data, the vertical puts it in the event payload — the connector works from that and nothing else.

Available connectors

Connectors accrete per vertical need, so this list is short by design and grows one entry at a time. Status is honest: a connector can be documented and half-built, because the seam it needs may not exist yet.

ConnectorCategoryStatusProvider
ScriveE-signing & identityPublished (npm, 0.x) — both halves built and running in production; two caveats: the vertical schedules the poll, and BankID is off on the testbedScrive eSign (Swedish BankID)
FortnoxAccountingLive-verified (0.x) — poll-only, reads bookkeeping as SIE4; client-credentials auth, so no refresh token to rotate. The live run corrected the export charset (PC8/CP437, not latin1)Fortnox (Swedish accounting)
PlanimaFacility maintenanceBuilt, not yet live-verified (0.x) — poll-only and read-only, reads a maintenance plan and its costed actions; one static API token, so nothing to rotate. Every claim still rests on the published OpenAPI document and a mock that shares its readingPlanima (Swedish maintenance planning)

Categories, as they will fill in (from the master plan's build list):

  • E-signing & identity — Scrive, BankID, Kivra
  • Accounting — Fortnox, Visma
  • Facility maintenance — Planima
  • Payments — Swish
  • E-invoicing & EDI — Peppol, Ahlsell / Rexel / Sonepar

How these pages are organized

Every connector documents itself the same way — a different shape from an engine's five pages, because a connector is a different thing. One page, these sections:

SectionAnswers
At a glanceprovider, category, status, the npm package
What it consumesthe event that triggers it, and what the payload must carry
The credentialwhat the connection stores, and what must never be stored
The flowwhat it does at the provider, step by step
What's missingthe seams it still needs — stated plainly, because an incomplete connector that pretends otherwise is worse than none

If a connector cannot fill "What's missing" with an empty list, it is not done, and the page says so.

The hard parts, hosted.