Architecture
n8n-sync is two hook bundles wired into n8n's external-hooks system. There is no server process of its own and no shared database; the publisher pushes events over HTTP and the subscriber applies them through n8n's own repositories.
publisher/index.ts ── export = createPublisherHooks({ emit })
emit = fan out to one createEventSender per SYNC_SUBSCRIBER_URLS entry
each sender = serialized in-memory queue → sendSyncEvent
(fetch POST + retry + HMAC/bearer auth, never throws)
subscriber/index.ts ── export = createSubscriberHooks({ ready })
ready: buildN8nSyncRepositories() → createApplier()
→ createSyncRouteHandler() → mountSyncRoutes()
Publisher side
- Entry file (
publisher/index.ts) ends withexport = createPublisherHooks(...). n8n loads hook files viarequire()and expects the hook map directly (IExternalHooksFileData). emitis a fan-out function that enqueues one event per target URL into onecreateEventSenderper entry inSYNC_SUBSCRIBER_URLS.createEventSender(publisher/sender.ts) maintains a per-target serialized in-memory queue. Events for a given target are delivered one at a time in emitted hook order; a slow target never delays others. Exact queued duplicates of the same semantic operation may be coalesced, but mixed operations stay ordered. Hooks themselves only enqueue (fire-and-forget) so n8n stays responsive.sendSyncEvent(shared/http.ts) performs afetchPOST with timeout and exponential-backoff retry (1s, 2s, 4s, capped at 10s). Every attempt re-signs the request (HMAC mode) so a retried request gets a fresh timestamp.- Event ordering (
publisher/order-state.ts) allocates a unique source-scopedeventIdand monotonic per-entityentityRevision. When delivery is enabled,SYNC_SOURCE_IDis required and persisted with the counters underSYNC_PUBLISHER_STATE_PATH.
Wired hooks → events
| n8n hook | Event |
|---|---|
credentials.create / credentials.update | credentials.upsert |
credentials.delete | credentials.delete |
workflow.afterCreate / workflow.afterUpdate | workflow.upsert |
workflow.activate | workflow.activate |
workflow.afterDelete | workflow.delete |
workflow.afterArchive / workflow.afterUnarchive | workflow.archive |
workflow.postExecute ★ | execution.upsert |
★ workflow.postExecute is opt-in via SYNC_ENTITIES. See Wired Hooks.
Deliberately not wired: workflow.preExecute (fires per execution with no execution-summary counterpart on the subscriber), workflow.create/update/delete pre-hooks (redundant with the after-hooks).
SYNC_ENTITIES gating
All SYNC_ENTITIES access lives in src/shared/config.ts as a ReadonlySet<'workflows' | 'credentials' | 'executions'>. When the env var is absent or blank, it defaults to workflows,credentials (executions are off). Explicit invalid names fail startup, and executions requires workflows.
When an entity is disabled, the corresponding hook handler is not wired at all — the key is absent from the returned hook map. n8n pays zero fan-out overhead for it. For example, with the default value the publisher emits no execution events; workflow.postExecute is re-registered only when executions is in SYNC_ENTITIES.
Subscriber side
The subscriber mounts POST /rest/sync/v1/events plus GET …/health and GET …/ready on n8n's own server inside the n8n.ready hook, and applies events via n8n's internal repositories.
n8n.readyis the only hook the subscriber wires. It resolves n8n's DIContainerand from it pulls only the repository services needed for enabled entity families. Resolving DI earlier crashes; nothing in the bundle touches the container beforereadyfires.createApplier(repos, opts)(subscriber/applier.ts) is the heart of the subscriber. It enforces source-scoped revision ordering before repository writes, then uses row timestamp guards for last-write-wins behavior.createSyncRouteHandler+mountSyncRoutes(subscriber/routes.ts) wire the HTTP entry point. The flow per request is: readiness → authenticate → validate → apply →200 { "ok": true }.
Wire format
The wire payload is the SyncEvent discriminated union in src/shared/types.ts. Inbound payloads are validated with parseSyncEvent (src/shared/validate.ts); anything that does not match the contract is rejected with a 400 before any repository write happens.
Idempotency and ordering
Ordering has two layers:
- Source-scoped
entityRevisionordering is persisted underSYNC_SUBSCRIBER_STATE_PATH, including delete tombstones. - Accepted upserts then use a row timestamp guard to avoid regressing target rows.
| Entity | Invariant column | Note |
|---|---|---|
| Workflows | updatedAt | Stored row is skipped when its updatedAt ≥ incoming. |
| Credentials | updatedAt | Same guard. |
| Executions | stoppedAt | In-flight executions (stoppedAt: null) skip the guard so a later delivery can still converge state. |
Exact duplicate deliveries with the same eventId and entityRevision are no-ops. A distinct eventId that reuses an already-applied entityRevision returns 409 SYNC_REVISION_CONFLICT and does not modify the entity or checkpoint. After revision ordering accepts a newer event, equal timestamps may update the row; older timestamps are rejected without advancing the checkpoint.
Deletes and archives for unknown ids are no-ops. Workflow deletion also removes mapped synced execution rows for that source/workflow before deleting the workflow.
Conventions
- All
process.envaccess lives insrc/shared/config.ts— nowhere else in the bundle. - n8n payload types (
IWorkflowBase,ICredentialsDb,N8nServer) are local minimal copies insrc/shared/types.ts. The package must stay free of n8n dependencies. - The logger is the zero-dep structured JSON logger in
src/shared/logger.ts(createLogger(module)). - The subscriber uses n8n's global
rawBodyReadermiddleware, reading request bodies fromreq.rawBodywhen available, with a zero-dep stream read fallback. HMAC mode fails closed if only a pre-parsedreq.bodyremains, because verification must use the exact raw bytes that are parsed and applied.
Before and after
- Manual script (signal)
- With n8n-sync
// polling script on the target, runs every minute
const workflows = await fetch('https://source.example.com/api/v1/workflows', {
headers: { 'X-N8N-API-KEY': process.env.SOURCE_API_KEY },
}).then(r => r.json());
for (const wf of workflows.data) {
const existing = await localDb.workflow.findOne({ where: { id: wf.id } });
if (!existing || new Date(wf.updatedAt) > new Date(existing.updatedAt)) {
await localDb.workflow.save(wf);
}
}
# source instance
export EXTERNAL_HOOK_FILES=/opt/n8n-sync/publisher.cjs
export SYNC_SUBSCRIBER_URLS=https://target.example.com
export SYNC_SOURCE_ID=prod-source-a
export SYNC_SHARED_SECRET=<secret>
# target instance
export EXTERNAL_HOOK_FILES=/opt/n8n-sync/subscriber.cjs
export SYNC_SHARED_SECRET=<secret>