Handover documentation · Version 5 · 2026-08-23

MetaCompliance: Maxio → Upflow PDF integration

Takeover manual · four legal entities · invoice and credit note PDFs from Maxio (SaaSOptics) into Upflow

Written for whoever takes the system over next, including someone who has never seen it: understanding it, running it, fixing it and extending it should be possible from this document alone. All IDs, schedules and counts describe the production state as of 2026-08-23.

ACTIVE: running GATED / OFF: deployed, not running OPEN: pending decision

About this document

This document describes the production system delivered to bring invoice and credit note PDFs from Maxio (SaaSOptics) into Upflow, across MetaCompliance's four legal entities.

It is written for whoever takes the system over next, including someone who has never seen it before. Understanding it, running it, fixing it and extending it should be possible from this document alone, with no side explanation from anyone who worked on it earlier. Where a fact is unverified or unmeasured, it is labelled as such rather than smoothed over.

Each section starts with a plain-language summary. Technical detail follows in the same section, clearly separated.

Anyone about to change something starts at section 9. Sections 1 to 8 describe what the system is and how it behaves. Section 9 is how to touch it: the rules the work follows, the verification gates a change has to pass before it reaches production, and the full procedure for adding a new organization.

Table of contents

  1. Executive summary
  2. What the system does
  3. System architecture at a glance
  4. The three workflow families
  5. Reliability by design
  6. Operating the system day to day
  7. Known limitations and roadmap
  8. Reference appendix
  9. Making changes safely

1. Executive summary

The system bridges a gap in MetaCompliance's invoicing stack: invoices and customers arrive in Upflow through the native Sage Intacct → Upflow sync, but the invoice PDF files are generated by Maxio (SaaSOptics) and do not flow natively to Upflow. This integration ferries every relevant PDF from Maxio to its matching invoice in Upflow, automatically.

4
Legal entities covered
Limited, DACH, Ireland, Denmark
5 / day
Cycles per business day
Monday to Friday, nothing at weekends
2026-05-15
Live in production since
weekly recap live since 2026-05-27
8
Sync workflows
6 active, 2 gated (Denmark)

Key facts:

Item Value
Entities coveredMetaCompliance Limited (GBP), DACH (EUR), Ireland (EUR), Denmark (DKK, gated)
ScheduleFive cycles per business day, Monday to Friday, nothing at weekends. Each org runs at its own minute inside the cycle. Full per-workflow reference in section 8.4
HostingMetaCompliance's own n8n cloud instance, Starter plan, Frankfurt (EU)
Production statusLive in production since 2026-05-15. All six active syncs run the chunked architecture with redirect resolution, v10.1 (section 3). Denmark deployed but gated (waiting on MC's Maxio billing migration). Weekly recap live since 2026-05-27; independent heartbeat watchdog since 2026-08-22
Upflow Test ModeAs of 2026-08-20, MetaCompliance is out of Test Mode on Limited only. Ireland, DACH and Denmark are still in Test Mode, which accepts data normally while blocking all outbound dunning to customers. That state changes by an action inside Upflow, so each organization's current mode is read in its own Upflow settings (section 5.7)
Data exposureAll processing happens inside MC's own n8n instance in Frankfurt. PDFs move between MC's Maxio account and MC's Upflow account, and are not stored or copied anywhere else
Manual interventionNone in normal operation, with one standing exception: nothing detects a workflow that has stopped, so the Active-toggle check in section 5.8 has to be run by hand. Beyond that, a human is needed when n8n disables a workflow after repeated crashes (section 5.8), when the n8n API key expires every 90 days (section 8.3), when a new failure category surfaces in Slack (section 6.3), and when a specific amended invoice needs re-pushing (section 7.5)

Important

The system absorbs its own failures, but it does not restart itself. Every workflow can crash, be re-run, or skip a cycle without losing data, duplicating PDFs, or requiring manual cleanup: that part holds, and section 5 explains how. What it does not do is guarantee that it is still running. When a workflow crashes repeatedly, n8n disables it, and nothing in the system announces that. Section 5.8 covers what this looks like and how to spot it, section 6.4 covers the wider point that a green run is not proof that work is happening.

2. What the system does

In one paragraph:

In one paragraph

Every few hours during business days, the system fetches the latest invoice list from Upflow, narrows it down to those that are still unpaid and need a PDF, looks up the matching record in Maxio, downloads the PDF, validates it, and uploads it to Upflow. Each step is logged. Most failures are retried automatically, either on the next run or on a weekly re-check (section 5.6). Operational alerts are sent to Slack when something requires human attention.

Business outcome

  • MetaCompliance customers receive PDF-attached invoices in Upflow without any manual export-import work.
  • Dunning campaigns in Upflow carry the underlying invoice as a downloadable attachment, for every invoice the sync has covered. Section 6.4 explains how to tell whether coverage is real rather than assumed.
  • Finance team retains a complete audit trail of every PDF push (who, when, status, error if any). One class of invoice never reaches that trail, and section 6.4 says which.

What the system does NOT do

To prevent any ambiguity, the system explicitly does not:

  • Create customers or invoices in Upflow. Those arrive via MC's existing Sage Intacct → Upflow sync.
  • Modify any invoice data, billing logic, or subscription state in Maxio or Upflow.
  • Generate or transform PDFs. It only ferries them as-is.
  • Send any customer-facing communication. Upflow's own dunning engine handles that.

3. System architecture at a glance

Figure 1. The six movements of data, in order, and the alert that follows
1. Maxio (SaaSOptics) → Maxio cache Cache builder pulls invoice metadata into the n8n data table
2. Upflow → sync workflows Sync pulls unpaid invoices
3. Maxio cache → sync workflows Sync looks up the PDF URL
4. Maxio (SaaSOptics) → sync workflows Sync downloads the PDF binary
5. Sync workflows → Upflow Sync pushes the PDF to its matching invoice
6. Sync workflows → monitoring workflows Sync logs every step
Slack #metacompliance-n8n-sync Only what needs human attention

Node styles: teal = start of the chain · green = terminal state.

Three layers, three jobs:

  1. Cache builders prepare the data. They keep a fresh local index of Maxio invoice metadata, so the main workflow does not have to call Maxio repeatedly.
  2. Sync workflows do the work. They pull unpaid invoices from Upflow, match them against the cache, fetch the PDF, validate it, and upload it to Upflow.
  3. Monitoring workflows observe and alert. They post operational summaries to Slack and aggregate failures for weekly review.

All three layers run independently on schedules. They share state through n8n data tables (think of them as small SQL tables built into n8n).

One architecture everywhere: the chunked pull

In plain language. Upflow has no way to ask "give me only the unpaid invoices", so the sync has to read the whole invoice history of the org and sort it out itself. The original design read that history inside one single workflow run. For the largest org, that single run was measured at around 37 MB on an instance published with 320 MiB of RAM, and it was killed. What that establishes is not a documented limit per run, it is that a run of that size does not survive on this tier. The current design splits the reading into slices and hands each slice to a helper workflow, which throws away everything irrelevant before handing back a short list. The history is still read in full, it is simply never held in one place at one time.

Since 2026-08-20, every active sync on this instance uses the chunked design. The pull-all generation was retired in two waves that day, and the retired workflows are kept inactive as rollback references (section 8.1). The comparison below is the reason the migration happened, and it stays here because the rollback references still carry the old profile:

Pull-all (retired 2026-08-20) Chunked pull (all six active syncs)
How the list is readOne workflow pulled every page of the org's invoice list, held it all, then filtered client-sideThe parent asks Upflow for a count, builds a plan of chunks, and calls a sub-workflow once per chunk
Where the filtering happensIn the parent, after everything was loadedIn the sub-workflow, before anything is returned. Each invoice is trimmed to 4 fields and only outstanding ones come back
Peak size of the parent runLimited: around 37 MBLimited: 1.4 to 1.8 MB. DACH: 360 KB
Sub-executions per runNoneLimited: 2. The others: 1 at current volumes
Run duration5 to 30 seconds on a steady-state orgLimited measured at around 65 seconds

Which workflows use which: the six active syncs — three INVOICE, three credit note, across Limited, Ireland and DACH — are all chunked (v10.1). The only workflows still carrying the pull-all design are the two gated Denmark syncs, which are switched off and due to be rebuilt on the chunked pattern before Denmark is ever switched on (section 7.1).

Figure 2. The chunked pull: the history is read in full, never held in one place at one time
Parent sync workflow
Ask Upflow for a count build a chunk plan
Sub-workflow, chunk 1 Fetch pages, keep outstanding only, trim to 4 fields
Sub-workflow, chunk 2 Fetch pages, keep outstanding only, trim to 4 fields
Parent receives short lists only
Match cache, download PDF, push to Upflow

Node styles: teal = entry point · green = terminal state.

4. The three workflow families

Tip

Each workflow is internally documented for autonomy. Node names are explicit (e.g. HTTP: Upflow pull outstanding, Code: Filter & match chain, DT: Upsert mc-sync-log), and a sticky note explains every important or non-trivial node directly inside the workflow. Anyone opening a workflow in the n8n UI can read along the flow without external context. This is the second layer of documentation, complementing this handover.

4.1 Sync workflows: the main job

What they do: for each org, every few hours, push the missing PDFs to Upflow.

How the unpaid list is built: the Upflow API does not expose a server-side filter for invoice state. The sync therefore reads the entire invoice list for the org (paginated, all states) and narrows it down to invoices whose state is DUE or OVERDUE. That narrowing happens inside the chunk sub-workflow, before anything returns to the parent (section 3). This is the input set of the filtering logic described further down.

Variants: two per org, eight in total.

Variant What it processes
INVOICE syncRegular invoices (the majority of volume)
CM syncCredit notes (separate Upflow endpoint, smaller volume)

Schedule: five cycles per day Mon-Fri. Inside each cycle the orgs are staggered, so no two syncs hit the same Upflow org at the same moment. The exact minute now differs per workflow and section 8.4 is the reference. The two Denmark syncs carry a schedule but are switched off (see section 7).

How a PDF is attached, and which identifier is used. This matters for anyone reading the workflow or reproducing a call by hand. The sync is driven by Upflow's own invoice listing, so every object it processes already carries both identifiers it needs, and there is no lookup call:

  • customId is the invoice reference as MetaCompliance knows it. It is the key used to match the invoice against the Maxio cache.
  • id is Upflow's own primary key, a UUID. It is what goes in the push URL.

The push itself is POST https://api.upflow.io/v1/invoices/{id}/pdf for invoices, and POST https://api.upflow.io/v1/credit_notes/{id}/pdf for credit notes.

Configuration knobs, identical on all six active syncs. They are set in Set: Sync config at the top of the workflow and are stored as text, not numbers, which is worth knowing before editing them. Since 2026-08-23 the two values that used to live buried in code — the re-check window and the reference-format rule — sit in the same node, and the code reads them fail-loud: a missing or unparseable value stops the run instead of silently compiling an empty rule.

Knob Current value What it controls
upflowPageSize500How many invoices are requested per page from Upflow
pagesPerChunk10How many pages one sub-workflow call handles
batchCap10How many PDFs a single run is allowed to push
recheckAfterDays7How long a Maxio cache-miss keeps an invoice out of the pool (section 5.6)
nonSoPatternper org, INVOICE syncs onlyThe reference-format rule (section 8.5)

Warning

batchCap = 0 means dry run, on every active sync. The run scans everything, computes its statistics, pushes nothing, and reports itself as a dry run in its summary. This used to differ between generations, and the difference was a trap: on the retired pull-all syncs, 0 was coerced to 5 and quietly kept pushing. That trap left production with the last pull-all sync on 2026-08-20; the two gated Denmark workflows still carry it, which is one more reason they are rebuilt before use (section 7.1).

Logic flow (simplified):

Figure 3. A sync run, end to end. The flow continues down the “Yes” branch of each decision
Pull all unpaid invoices from Upflow
Filter: keep only candidates needing a PDF
Any candidates?
No → Log COVERED, exit terminal state for a run with nothing to do
Yes → For each candidate, look up Maxio cache
PDF available?
No → Log skip with reason
Yes → Download PDF from Maxio
Validate PDF: size, magic bytes
Valid?
No → Log failure with error code
Yes → Upload PDF to Upflow
Log success

Node styles: teal = trigger · dashed = decision · peach = exception path · green = terminal state.

Filtering logic:

The sync does not push every unpaid invoice every time. It excludes:

  • Invoices that have already been pushed successfully (tracked in mc-sync-log). Since 2026-08-23 this exclusion is no longer strictly permanent: when Maxio re-emails an amended version of an already-pushed invoice, the cache builders detect it and re-open exactly that invoice (section 7.5).
  • Nothing is excluded for failing validation any more. A downloaded file that is not a PDF is logged, stays visible in mc-sync-failures, and is retried at every tick. The permanent exclusion that used to exist here was removed with the redirect fix (section 5.9); the 5 Limited invoices that had been frozen under it since 2026-05-17 were all recovered on 2026-08-20.
  • Invoices whose Maxio record does not exist or has no PDF URL yet. These come back into the pool after 7 days and are re-checked weekly (section 5.6).
  • Invoices whose reference does not match the Maxio numbering convention of that org. This rule is org-specific, and it has to be, because the orgs do not number their invoices the same way. Section 8.5 is the table of which rule each invoice sync carries, and section 6.4 explains what this exclusion costs in visibility.

These are the exclusions that decide whether a given invoice gets a PDF. The filter node applies five tests in all, including two that separate invoices from credit notes; section 4.4 lists them in the order they are tested.

This filtering means each run does the minimum work necessary. A steady-state run takes seconds on the smaller orgs; Limited was measured at around 65 seconds, because it always reads the full history through its sub-workflow before concluding there is nothing to do.

Tip

The same sync workflow handles both backfill (catching up on old invoices) and ongoing operation (new invoices). There is no separate "backfill mode" to manage, and a sync that has been stopped for a while catches up on its own once it runs again.

4.2 Cache builders: the prep work

What they do: keep a local index of all Maxio invoices for each org, refreshed every few hours.

Why this matters: Maxio's API is slow and rate-limited. Without a cache, the sync would have to query Maxio individually for every Upflow invoice, every run. The cache turns hundreds of API calls into a single lookup against an in-memory data table.

Variants: one per org, four in total.

Schedule: five runs per day, at :45, ahead of the syncs that read them. That is 15 minutes before the syncs that run on the hour (Ireland and DACH invoices), 30 minutes before the credit note syncs at :15, and 45 minutes before Limited's invoice sync at :30. The ordering is deliberate: the cache is always refreshed before anything reads it.

All four cache builders are active, Denmark included. The Denmark cache has been refreshing five times a day throughout, even though the two Denmark syncs are switched off. The practical effect is that the Denmark cache is warm and no cache work is pending on the day MC decides to switch that org on (section 7.1).

Logic flow:

Figure 4. A cache builder run: incremental by design, most runs process zero new records
Read watermark: last refresh time
Pull all Maxio invoices modified since watermark
Any new or updated records?
No → Update watermark, log COVERED, exit
Yes → Upsert into cache table
Update watermark to now
Log row count, exit

Node styles: teal = trigger · dashed = decision · green = terminal state.

Incremental design: the cache builder only pulls what has changed since the last refresh. Most runs process zero new records. This keeps the system fast and well under Maxio's rate limits.

Since 2026-08-23 the builders carry a second job: amendment detection. Each refreshed Maxio record now also stores two timestamps in the cache, last_emailed and auditentry_modified. For every record it refreshes, the builder looks up the sync log, and when the fresh last_emailed is newer than the one recorded at push time, it re-opens that invoice by flipping its log row to amended_repush_pending — section 7.5 is the full mechanism, including the guard that keeps pre-existing history from re-opening en masse. The flip is verified by re-reading the row, and a flip that cannot be verified stops the run before the watermark advances, so a detection is never silently lost.

Cache freshness: at the moment a sync reads the cache, it is between 15 and 45 minutes old, depending on which sync it is. That range covers the six active syncs; the two gated Denmark syncs sit outside it as their schedules currently stand, and section 7.1 covers what to do about that before switching Denmark on. Between two cycles the cache can be up to three hours old, which does not matter because nothing reads it in that window. The Maxio URLs used to download PDFs carry tokens that Maxio rotates, so the cache also keeps the freshest URLs available. Section 5.9 covers what happens when a token rotates anyway.

4.3 Monitoring workflows: observe and alert

What they do: track what the sync workflows did, classify any failure, and surface only what needs human attention.

This is intentionally a separate layer from the sync workflows themselves. The syncs focus on doing the work; the monitoring layer focuses on telling you about it. The monitoring sub-workflows are wired so that a failure inside one of them does not break the sync that called it. The reverse does not hold in the same way: most of the monitoring only runs when a sync calls it, so a sync that stops running takes its own reporting with it. Section 6.4 is the consequence of that asymmetry.

Four components

1. Independent liveness watchdog. MC - Heartbeat (added 2026-08-22) runs at 12:00 and 21:00 London on weekdays and checks the one thing the rest of the monitoring cannot: that every producer is still writing at all. It reads the age of the newest mc-sync-stats row for each of the six syncs and the newest mc-so-cache-progress row for each of the four cache builders. A sync writes one statistics row per tick even when it has nothing to do, so a stale timestamp means the workflow stopped running, not that it was idle. More than 7 hours without a write, an org missing from the table entirely, or a table that cannot be read at all: each posts a red alert to #n8n-alerts, and an inconclusive check is treated as red rather than as silence. Every Monday around noon it also posts one green proof-of-life message whether or not anything is wrong, so that the watchdog's own death is visible: if the Monday green stops arriving, the watchdog itself is down. Its one structural limit is that it runs on the n8n instance it watches, so it catches a silently disabled workflow — the failure that actually happened here — but not the whole instance going down. The Monday green is what bounds that residual case.

2. Operational Slack notifier. At the end of every sync run, a dedicated sub-workflow (MC - Slack notify risks) computes summary statistics and decides whether to post a message:

  • Compares the current run against the previous run for the same org and entity type.
  • Detects meaningful changes: new failure categories, error spikes, completion of catch-up phases.
  • Posts to #metacompliance-n8n-sync only when something is worth attention.
  • Since 2026-08-23, the delta-based thresholds are suspended on runs whose candidate batch is saturated (candidates_count >= batchCap): a full batch measures the batch refilling, not a business change. The critical threshold — a failed push — is evaluated on every run, unconditionally.
  • Silent runs are intentional. Silence means no threshold was crossed on a run that reported. It does not mean a run happened; that question belongs to the heartbeat above.

3. Failure deduplication, and self-clearing. Every failed push triggers a sub-workflow (MC - Upsert failure to mc-sync-failures) that upserts a row into a deduplicated catalog:

  • Composite key: invoice_id + org. The same failing invoice retried multiple times produces one row updated, not N rows.
  • Stores last failure code, last attempt date, retry count.
  • Used as the source of truth for "what currently needs attention", and as the input for the weekly recap.

A second sub-workflow (MC - Clear resolved failure from mc-sync-failures) does the opposite: when an invoice that had failed is later pushed successfully, its row is deleted. It is wired on all six syncs and on the manual re-push tool. mc-sync-failures is therefore a list of what is currently unresolved, not a history of everything that ever failed. An invoice that fixed itself leaves no trace there, and mc-sync-log is where its history lives.

4. Weekly recap. Every Monday at 08:00 London (09:00 France time), the scheduled workflow MC - Weekly failures recap:

  • Reads mc-sync-failures for the past seven days, and mc-sync-stats for each sync's last recorded run.
  • Groups failures by category (error_code) and by org.
  • Posts a structured Slack message in #metacompliance-n8n-sync.

Since version 1.1.0 (2026-08-22) the recap verifies sync activity instead of assuming it, and it uses three output shapes:

  • Stopped-sync alert (:red_circle:) when any sync recorded no activity at all during the window, or when the statistics table cannot be read. An empty failure list produced by a stopped sync used to read as good news; it no longer can.
  • All-clear (:large_green_circle:) only when nothing was skipped and all six syncs ran during the window. The old wording that claimed "the sync system processed all eligible invoices successfully" on an empty failure list is gone: it asserted something the workflow never measured.
  • Info digest (:bar_chart:) when invoices were skipped. Includes a breakdown by org, a detail per category with up to 5 sample customIds, and an action item: "no action required by default. If any listed invoice should be processed by the sync, please ping the integration owner with the customId."

Every message, whatever its shape, now ends with a Sync activity section giving the last recorded run per org — so an org with nothing to report is never simply absent from the message, because absence is exactly what a dead org used to look like.

The action item is deliberately framed around intent (should-it-be-in-scope), not around technical errors, because the skipped invoices are by design (see section 7.2 catalog A/B/C).

Severity levels and Slack behaviour

Level Slack action Trigger example
noneSilentRun completed cleanly, nothing notable
infoPosted in #metacompliance-n8n-syncA new failure category appeared, or catch-up phase ended
warningPosted with @here mentionFailure count spiked vs previous run, or a previously-stable category started failing
criticalPosted with @channel mentionA failed push (HTTP non-204) on the run. Evaluated on every run, never suspended

What to do when you receive an alert

The alert tells you which category of failure occurred. From there:

  1. Open the n8n UI, navigate to Data Tables, select mc-sync-failures.
  2. Filter by org and error_code matching the alert.
  3. Each row carries a workflow_execution_id linking back to mc-sync-log for the full attempt history.
  4. If the failure category is one of the three documented in section 7.2 (untreated cases), no action is required from MetaCompliance, these are awaiting product-team validation.
  5. If the category is new, or growing, or unexpected: forward the Slack message to the integration owner.

Centralised exception capture: the MC Error Handler

A dedicated workflow, MC - Error Handler, was built specifically for this delivery to catch any runtime exception that escapes a workflow's own error branches. n8n provides the underlying errorWorkflow hook (the plumbing); everything that decides what to capture, how to format it, and where to post it is custom.

Coverage, verified on 2026-08-23. Every one of the 22 active workflows except the error handler itself names MC - Error Handler as its errorWorkflow. The handler is the expected exception, since it cannot be its own safety net.

How a sub-workflow failure propagates is no longer an open question: it was measured on this instance by a controlled test on 2026-08-23. A throwaway parent and sub-workflow were made to fail on a real scheduled tick, and n8n raised two independent error events: the sub-workflow's errorWorkflow fired carrying the root cause — the failing node and its message — and the parent's fired carrying only a generic wrapper error (Error executing workflow with item at index 0). The practical consequence is one rule: keep settings.errorWorkflow set on every workflow, sub-workflows included. Remove it from a sub-workflow and the only alert left is the parent's generic one, which names neither the node nor the reason.

When a covered workflow throws an uncaught exception, the handler fires automatically and posts a structured message to Slack channel #n8n-alerts. The post includes:

  • Stack trace (truncated to 2500 characters)
  • Failing node name and type
  • Workflow name and id
  • Execution URL (clickable, opens directly in the n8n UI)
  • Execution mode (production / manual / retry)
  • Retry info if applicable
  • ISO timestamp

#n8n-alerts is intentionally separate from #metacompliance-n8n-sync so business signals (recap, info, warning) and technical exceptions never collide. Subscribers to #n8n-alerts see only events that need a developer's eyes; subscribers to #metacompliance-n8n-sync see only events that need an operator's eyes.

Important

The accurate phrasing is "exceptions are captured", not "failures are captured". The two are not the same, and the difference decides what you will and will not hear about.

The native errorWorkflow fires on runtime exceptions raised during automatic executions. It does not fire in three cases:

  • A manual run started from the n8n editor. No error execution is created, so no handler runs and no Slack post appears. Testing a workflow by hand and seeing no alert proves nothing.
  • A container-level crash, for example memory pressure on the shared cloud instance. These leave a crashed execution in n8n's log and produce no Slack alert from the handler. The heartbeat closes this loop one level up: repeated crashes end in a disabled workflow, a disabled workflow stops writing, and a producer that stops writing is exactly what the heartbeat detects (section 5.8).
  • Work that is silently not done. A run that completes without exception is green whether it pushed 40 PDFs or zero. Section 6.4 covers this at length.

The counterweight is that no data is lost when a run dies mid-flight: the next run re-reads Upflow, re-checks the log, and processes whatever still needs processing. What is not guaranteed is that a next run happens at all.

Important

Slack is the primary operational channel. Subscribe to #metacompliance-n8n-sync (business signals) and #n8n-alerts (technical exceptions) and you see everything the system reports about itself. Since 2026-08-22 that includes the system stopping: the heartbeat posts red when a producer goes quiet, and its Monday green proof-of-life is the message whose absence is itself a signal. The manual Active-toggle check in section 5.8 remains as the fallback, and the one case Slack still cannot cover is the whole instance going down, watchdog included — which is what the Monday green exists to bound.

4.4 Anatomy of a sync workflow

In plain language. Opening a sync for the first time is intimidating: there are around sixty boxes on the canvas. Roughly four in ten of them are sticky notes, the yellow panels that document the flow and execute nothing, so the part that actually runs is closer to thirty-five nodes. Those nodes group into fourteen stages that execute in a fixed order, front to back, with one loop in the middle that walks the candidate invoices one at a time. This section is the map of those stages. Read it beside an open editor and the canvas resolves into a line.

Node counts, read from the workflow definitions on 2026-08-23:

Workflow Nodes on the canvas Nodes that execute
Each of the six active syncs (INVOICE and CM)6637
Each of the six chunk sub-workflows63
Each of the four cache builders3425

Note

The sticky notes are thorough, and a 2026-08-20 audit found four places where they had fallen behind the code. Several of those places have since been rewritten by the 2026-08-23 changes, and new stickies were added with the amendment detector; no full sticky re-audit has been done since. The principle the audit left behind stands unchanged: where a sticky and the code disagree, the code is the answer.

Three n8n terms are used below, because each one decides behaviour and each is easy to change by accident in the editor:

  • onError is a per-node setting that says what happens when that node throws. The default, stopWorkflow, turns the whole run red. continueRegularOutput passes the failed item down the normal output anyway. continueErrorOutput sends it down a second output reserved for errors.
  • alwaysOutputData is a per-node setting that makes a node emit one empty item when it would otherwise emit nothing at all. It is used here so that a lookup returning zero rows does not stop its branch dead.
  • $('Node name') is n8n expression syntax for reading the output of another node by name. The name is matched as a literal string, which is what makes the rename hazard at the end of this section real.

The fourteen stages, in execution order

Stage What it does Nodes
A. TriggerStarts the run, either on the cron or by handSchedule: 5x/day …, Manual run
B. ConfigurationSets the handful of variables the rest of the workflow reads: the org label, the Upflow base URL, the page size, the batch cap. Full list in section 8.6Set: Sync config
C. PullReads the org's whole invoice list from Upflow through the chunk sub-workflow, one sub-execution per chunkHTTP: Upflow count (limit=1), Code: Build chunk plan, Execute Sub-workflow: fetch + filter invoice chunk, Code: Aggregate Upflow pages
D. Already-processed lookupReads back every row this org has ever written to mc-sync-log, so the next stage can exclude what is already doneData Table: Get already-processed rows
E. Filter and matchThe decision engine. Applies five exclusions in order, stops once it has batchCap candidates, and emits one item per candidateCode: Filter & match chain, IF: no work needed?
F. LoopWalks the candidates strictly one at a time. No batch size is configured, so the default of one appliesSplitInBatches
G. Cache lookupFor the current candidate, finds the matching Maxio row and derives the PDF URL from itData Table: Get cache row (per item), Code: Enrich pdfUrl from cache, IF: cache match valid?
H. Resolve and downloadWrites the write-ahead row (section 5.2), then downloads the PDF from Maxio. No credential is used: the Maxio link is a public tokenised URLData Table: Insert pre-attempt row, HTTP: SaaSOptics download PDF
I. ValidationThree checks on the downloaded file, first failure wins: the binary is readable, it is at least 1 024 bytes, its first five bytes are %PDF-. Then base64-encodes itCode: Encode PDF base64, IF: PDF valid
J. PushPosts the encoded PDF to Upflow. Success is HTTP 204 exactlyHTTP: Upflow push PDF
K. LoggingFive mutually exclusive builders, one per outcome, all converging on a single insert into mc-sync-logCode: Build log row, Code: Build error log row, Code: Build cache skip log row, Code: Build js encode error log row, Code: Build push http error log row, Data Table: Insert row
L. StatisticsComputes the run verdict, re-counts each exclusion, and writes one row to mc-sync-statsCode: Final batch summary, Data Table: Get this run logs, Code: Compute stats from filter inputs, Data Table: Get previous stats, Data Table: Insert stats row, Code: Build notify payload
M. NotificationHands the run's statistics to the shared Slack sub-workflow, which decides whether anything is worth posting (section 4.3)Execute Workflow: MC - Slack notify risks
N. Failure trackingRecords or clears the failure row for this candidate, then loops back to stage F for the next oneIF: is failure?, Execute Workflow: MC - Upsert failure, Execute Workflow: MC - Clear resolved failure

Stages A to N are identical across the six active syncs. Same node names, same wiring, same onError and alwaysOutputData settings, same log-row shape. A maintainer who has read one sync has read all six. (The two gated Denmark workflows still carry the retired generation and are rebuilt before use, section 7.1.)

The five exclusions applied in stage E, in the order they are tested, each one skipping to the next invoice:

  1. The invoice state is not DUE or OVERDUE. Credit note syncs skip this test, because Upflow's credit notes carry no state field at all (section 8.8).
  2. The reference matches the credit note pattern. Invoice syncs use this to exclude credit notes; credit note syncs invert it and require it.
  3. The invoice is already logged with status = 'success'. Re-opened only by the amendment detector, which flips the row out of success when Maxio re-emails the invoice (section 7.5).
  4. The invoice is logged with a cache-miss code less than recheckAfterDays (7) days ago.
  5. The reference does not match the Maxio numbering convention of that org. This rule is per organization and section 8.5 is the table.

A sixth exclusion used to sit in this list: an invoice once flagged invalid_pdf_magic was frozen for life. It was removed family-wide with the redirect fix (section 5.9); a bad download now retries every tick and stays visible in mc-sync-failures.

If no candidate survives, stage E emits a single placeholder item so that stages L and M still fire. A run with nothing to do still produces statistics and still reports. That is why a COVERED verdict proves the run happened and proves nothing about work being done (section 6.4).

Stage C in detail, and the scan invariants

All six syncs read the list the same way: ask Upflow for a count, build a chunk plan (page size 500, 10 pages per chunk), call the sub-workflow once per chunk, aggregate. The aggregate throws if the number of chunks returned differs from the plan, or if fewer invoices came back than the count said there were while the list was not genuinely exhausted. A partial scan — the failure mode where pagination stops early and the unfetched invoices are simply never considered — cannot complete green. This guard used to exist on two syncs only; it has covered all six since the family alignment of 2026-08-20.

One nuance was added when the credit note syncs joined the pattern. On /credit_notes, Upflow's announced total can overstate what the API will actually deliver (a measured quirk, currently under investigation on Limited — section 7.6). The sub-workflow therefore treats a final short or empty page as the legitimate end of the list and reports it as listExhausted; the parent tolerates the shortfall only in that case, and counts it openly as phantomTotalDeficit in the run's verdict instead of hiding it. A non-empty page arriving after a short one is still a hole in the pagination, and still throws.

Inside the sub-workflow (three nodes, one per chunk call): When Executed by Another Workflow receives the chunk descriptor, HTTP: Upflow list invoice chunk fetches that chunk's pages, and Code: Filter outstanding + trim chunk verifies the pagination, keeps only the outstanding invoices, trims each one to four fields, and returns a single item. Its pagination checks throw rather than warn. The six sub-workflows are byte-identical in their code within their family; the only differences between them are the workflow name, the endpoint, and the Upflow credential attached to the HTTP node.

The redirect resolution runs on all six syncs. Between the write-ahead row and the download, HTTP: SaaSOptics resolve redirect probes the cached URL without following redirects, and Code: Resolve final PDF URL rebuilds the address when the answer is a 302. Section 5.9 explains why. HTTP: SaaSOptics download PDF then reads its URL from that node instead of from the cache node.

Settings that look cosmetic and are not

Each of these is one click away from being changed and none of them announces what it costs.

Where Setting What it holds up
Execute Sub-workflow: fetch + filter invoice chunkMode "Run once for each item"This is what makes each chunk a separate sub-execution whose memory is released on return. Switching it to "Run once with all items" reintroduces the memory problem the chunked generation exists to solve
Data Table: Get already-processed rows, Data Table: Get cache row (per item), Data Table: Get this run logs, Data Table: Get previous statsalwaysOutputData: trueOn the cache lookup, the empty placeholder item is the cache-miss signal. Turning it off converts a logged, retryable skip into a branch that stops without a trace. On the others it is what lets a first-ever run proceed with no history to read
HTTP: SaaSOptics download PDFonError: continueRegularOutputA download failure has to reach the validation node so it gets logged as a failure. Restoring the default turns every transient Maxio hiccup into a red run that abandons the rest of the batch
Code: Encode PDF base64onError: continueErrorOutputIts error output feeds Code: Build js encode error log row. Remove the setting and that branch is orphaned, and a JavaScript exception becomes a red run
HTTP: Upflow push PDFfullResponse: trueThis is what makes the HTTP status code visible to the logging node. Turn it off and every push logs as failed while actually succeeding, because the status test finds nothing to test
Code: Build chunk plan, Code: Aggregate Upflow pages, Code: Filter outstanding + trim chunkNo onError at allThese nodes throw on purpose. A silent no-op scan is exactly what they exist to prevent, so adding error tolerance to them removes the guard
The five log-row buildersOne shared 19-column shapeAdding a column means adding it in all five, or rows coming from some paths will be missing it. The 19th, so_last_emailed, was added this way on 2026-08-23
Code: Filter & match chain and Code: Compute stats from filter inputsThe same exclusion logic, written twiceThe second is a read-only mirror of the first, used for counting. Change one without the other and the coverage verdict stops describing what the workflow did. Nothing enforces the mirror mechanically. Since 2026-08-23 the two org-specific values they share — the re-check window and the reference rule — are read from Set: Sync config instead of being written twice; the logic is still mirrored

One asymmetry is worth knowing because it is consistent across all six active syncs rather than being drift: the call that clears a resolved failure row carries onError: continueRegularOutput, so a cleanup problem never breaks a tick whose push already succeeded. The call that records a failure carries no such protection, so if it errors, the run goes red and the remaining candidates in the batch are abandoned. Whether that asymmetry is intentional cannot be established from the workflow definitions.

The rename hazard

This is the shortest way to break a sync, and it produces no warning at edit time. When one node reads another's output through $('Node name'), the name is a literal string. Renaming the target leaves the editor perfectly happy, saves without complaint, and fails at runtime on the next scheduled tick.

The nodes below are referenced by name somewhere in their workflow. Do not rename any of them. If one must be renamed, every expression pointing at it has to be found and updated in the same edit.

Node name How many nodes read it by name Present in
Set: Sync config14All six
Code: Enrich pdfUrl from cache4All six
Code: Aggregate Upflow pages3All six
Code: Encode PDF base642All six
Code: Compute stats from filter inputs2All six
Data Table: Get already-processed rows2All six
Code: Filter & match chain1All six
Data Table: Get previous stats1All six
SplitInBatches1All six
Code: Build chunk plan1All six
Code: Resolve final PDF URL1All six
When Executed by Another Workflow1All six chunk sub-workflows

Set: Sync config is the one to be most careful with. Renaming it breaks the workflow everywhere at once, and the failure will look like a configuration problem rather than a rename.

5. Reliability by design

The system has been engineered so that a failed, crashed or half-finished run never loses a PDF, never duplicates one, and never leaves state that has to be cleaned up by hand. That property has held in production. It is a different property from "the system keeps running", which section 5.8 addresses separately.

Each mechanism below is explained with the failure it protects against.

5.1 Idempotent uploads

What it means: uploading the same PDF twice has the same effect as uploading it once. Upflow's PDF endpoint is overwrite-on-write. The second push replaces the first, byte-for-byte identical, no duplicate.

What it protects against: any case where the system "thinks it might not have pushed" (network blip, log write failure, worker restart mid-push). The next run will simply re-push, and Upflow ends up in the correct state.

5.2 Write-Ahead Log (WAL)

What it means: before any PDF push, the system writes a row in mc-sync-log with status attempted. The actual push happens next. After the push, the outcome is written as a second row, not as an update to the first: mc-sync-log is append-only (section 8.2), so a completed attempt leaves two rows, the attempted marker and the outcome. Both carry the same workflow_execution_id, which is what pairs them.

Since 2026-08-23 the outcome row also records so_last_emailed, the Maxio re-email timestamp at push time — the baseline the amendment detector compares against (section 7.5).

What it protects against: a crash between the moment the PDF goes out and the moment that fact is recorded. Even if the crash happens mid-push, there is always a trace of the attempt in the log table. The next run will see the attempted-but-not-success row and re-process it.

5.3 Stateless across runs

What it means: each sync run starts from scratch. It does not rely on "where the last run stopped". It re-reads the org's whole invoice list from Upflow, narrows it to the unpaid ones on its own side because Upflow offers no server-side state filter (section 4.1), then checks the log to skip already-pushed ones.

What it protects against: any failure that leaves the system in an unclear state. There is no "pointer to restart from" that could be corrupted, lost, or out of sync. The truth is always: read Upflow, read the log, compute the delta.

Note

This is a deliberate design choice. A watermark-based incremental design is not used, because Upflow's updatedAt field is not reliable enough to drive one. The current architecture trades a small amount of redundant work (re-reading the full unpaid list every run) for a state that cannot drift out of sync.

5.4 Multi-org isolation

What it means: each of the four MetaCompliance entities has its own dedicated workflows, credentials, cache tables, and log entries. A failure or anomaly on one org does not propagate to the others.

What it protects against: any per-org problem (credential rotation, Maxio rate limit on one entity, schema oddity) cannot cascade. The Limited team's bad day does not affect the Ireland team.

Where the isolation stops. It is logical, not physical. All four orgs run on the same n8n instance, so they share its memory and its monthly execution allowance. An org that runs heavy or crash-loops consumes both at the expense of the others, which is the subject of section 7.3.

5.5 Failure classification

What it means: every failed push is logged with a specific error_code. Codes include things like no_so_match_in_cache, no_einvoicing_url, invalid_pdf_magic_*, js_exception_encode, and HTTP error codes for push failures.

What it protects against: "something failed and nobody knows why". With classified failures, the weekly recap can tell you "10 invoices failed for reason X (action: ask MC to refresh Maxio), 2 invoices failed for reason Y (action: investigate)". Every failure becomes actionable.

5.6 Permanent vs transient failure handling

The system distinguishes failures it should retry from failures it should stop retrying.

Failure type Retry policy
Transient error on the two GET requests (timeout, HTTP 429, HTTP 5xx)Up to 3 attempts, 5 seconds apart, inside the same run. If all three fail, the invoice falls through to the next tick
Maxio cache miss (invoice not found in the cache)Comes back into the pool after 7 days, then re-checked weekly
No PDF URL on the Maxio record yetSame: back after 7 days, re-checked weekly
Downloaded file is not a valid PDFRetried at every tick on all six syncs, and the invoice stays visible in mc-sync-failures. The permanent freeze that used to apply was removed with the redirect fix (section 5.9)
Invoice not in Maxio at allMarked permanent (likely a Sage-only invoice). Reviewed weekly
PDF amended in Maxio after a successful pushDetected by the cache builders through the re-email timestamp; the invoice re-enters the pool automatically (section 7.5). Pushes made before 2026-08-23 are out of the detector's reach and go through the manual re-push tool

The in-run retry, and what it does not cover. The two GET requests each sync makes, the Upflow invoice listing call and the Maxio PDF download, retry automatically 3 times at 5-second intervals. Stated plainly: this absorbs roughly 10 to 15 seconds of instability, and nothing more. A two-minute outage at either provider still fails the tick. The real safety net for anything longer is the next tick, which re-reads everything and picks up what was missed. That is by design, and it is why the idempotence described in section 5.1 matters more than the retry count.

Why the split matters: a naive retry-everything design would burn resources re-attempting hopeless cases. A naive give-up design would silently drop failures that would have healed on their own. The 7-day re-check is the compromise: bounded effort, no permanent give-up on anything that Maxio might populate later.

5.7 Test Mode protection

What it means: the system pushes PDFs even when an Upflow organization is in Test Mode. Test Mode blocks customer-facing communication (no dunning emails, no payment reminders), but does not block API data ingestion. This was validated empirically during build.

What it protects against: accidentally driving customer-facing activity before MC is ready. While an org is in Test Mode, customers see nothing, even though PDFs are accumulating correctly behind the scenes. When that org goes live, every invoice the sync has covered already has its PDF attached.

Where each organization stands, as of 2026-08-23:

Organization Mode as of 2026-08-23 What that means for customers
LimitedLiveUpflow dunning runs normally. Outbound communication reaches real customers
IrelandTest ModeData is accepted, no dunning leaves Upflow
DACHTest ModeData is accepted, no dunning leaves Upflow
DenmarkTest ModeData is accepted, no dunning leaves Upflow. Its two syncs are also gated (section 7.1)

Important

The table above is a snapshot dated 2026-08-23, reported by the integration owner. It is not a property of the system and nothing here updates when it moves. That state changes by an action inside Upflow, and it is not visible from the integration. Read the current state where it is authoritative: the settings of each Upflow organization. Treating a handover document as the source of truth for a live/test flag is how a team ends up believing an org is silent when it is not.

Caution

Going live is irreversible and it applies to the whole workspace, not to one invoice or one customer. Upflow does not document a path back to Test Mode. The decision belongs to MetaCompliance, and so does the click. Plan it on a quiet day, with awareness that the next Upflow scheduler tick after the flip (default 08:30 org-time) will trigger automatic dunning actions on every eligible invoice. A step-by-step checklist is in section 7.4.

5.8 What happens if a run crashes

A single crashed run costs nothing. Repeated crashes do.

A single crash. The run is marked crashed in n8n's execution log, the next scheduled run re-reads Upflow, re-checks the log and processes whatever still needs processing. No PDF is lost, none is duplicated, no cleanup is required. The only side effect is a missing statistics row in mc-sync-stats for that tick, which is cosmetic.

Repeated crashes. When a workflow crashes repeatedly, n8n stops scheduling it and sets it to inactive. That is not recovery, it is a silent outage: the workflow disappears from the running set, no exception is raised, no alert is posted, and the weekly digest keeps arriving as normal because it reports on failures rather than on absence.

What the chunked architecture changes, and what it does not. A pull-all run holds the org's entire invoice history in one execution. On the largest org that run was measured at around 37 MB, on an instance published with 320 MiB of RAM, and it crashed. The plan does not define a memory ceiling per execution; what is established is that a run of that size does not survive on this tier. The chunked architecture (section 3) has removed that exposure on all six active syncs since 2026-08-20; only the two gated Denmark workflows still carry the old profile, and they are rebuilt before use. The auto-disable behaviour itself is unchanged and is part of how n8n works. Any workflow that crashes repeatedly for any future reason will be switched off the same way, with the same silence.

How it is spotted. Since 2026-08-22 there are two automatic signals, and the manual check remains as the fallback:

  1. MC - Heartbeat posts a red alert to #n8n-alerts within at most half a working day of a producer going quiet, and its Monday green proof-of-life bounds the failure of the watchdog itself (section 4.3).
  2. The Monday recap turns red when a sync recorded no activity over the whole window (section 4.3).
  3. The manual fallback: open the n8n workflow list and look at the Active toggle on each of the workflows listed in section 8.4, Table 1. All twelve should be active. Any workflow from that table showing as inactive, without someone having deliberately switched it off, is an outage in progress. Cross-check against the executions list: a workflow that stopped running leaves a gap, and the last execution before the gap is usually crashed.

If you find a sync switched off. Finding it is not by itself a reason to switch it straight back on.

  • Establish why it stopped, before touching the toggle. A workflow that n8n disabled after repeated crashes will crash again if the cause is still there, and each attempt spends executions from the monthly allowance (section 7.3).
  • Check which architecture that workflow carries. The memory exposure described above is addressed by the chunked architecture, but only on the two invoice syncs that use it. Section 4.1 describes the split and the registry in section 8.1 names them workflow by workflow. A sync still on the older architecture is still exposed to it.
  • Repeatedly re-enabling a workflow that keeps being disabled is not a fix, and it burns allowance each time. Escalate instead (section 6.3).

5.9 Expiring Maxio links, and the redirect that hides the PDF

In plain language. The download links Maxio gives out contain a token that Maxio rotates. When a token has rotated, the old link no longer serves the PDF: it answers with a redirect to a different address. Following that redirect lands on Maxio's HTML viewer page, which is a web page, not a PDF. On a sync without the fix below, the system downloads that page, notices it is not a PDF, and records a failure.

What actually happens, measured. The redirect target drops the /invoice.pdf segment from the address. That single missing segment is the whole problem. On one invoice, following the redirect as-is returned 25 804 bytes of HTML. Re-applying /invoice.pdf to the redirect target returned a real PDF of 125 069 bytes.

The fix. Before downloading, the sync probes the URL without following redirects. If the answer is a 302, it reads the new location, re-applies /invoice.pdf, and downloads from there. It costs one extra HTTP request per invoice. Bundled with it: a file that genuinely is not a PDF is no longer excluded for life, so it is retried and stays visible instead of disappearing from the working set.

Where it is live: on all six active syncs, since the family alignment of 2026-08-20.

Note

One limitation to be aware of. The 302 branch has been proven outside n8n, by issuing the requests directly, but as of 2026-08-23 it has not yet executed inside n8n, because no token in the current pool has rotated since the fix went in. It is untested in production, not unverified in principle. If it ever misbehaves, it fails the way everything else here fails: with a logged error and a visible failure row, never silently.

6. Operating the system day to day

6.1 Where to look, and how to diagnose one invoice with no PDF

Most questions have one right place to look, and looking in the wrong one gives an answer that is confidently wrong. The first row is the one that matters most.

You want to know... Look here
Are all the syncs still scheduled and running?#n8n-alerts: the heartbeat posts red when a producer goes quiet, and posts one green proof-of-life every Monday noon. No red plus a present Monday green means everything is writing. The n8n Active-toggle check against section 8.4 Table 1 remains the manual confirmation
Did anything fail in the last run?Slack channel #metacompliance-n8n-sync, non-none messages
Has a runtime exception occurred?Slack channel #n8n-alerts (separate channel, technical exceptions only)
What is the success rate over the last 7 days?mc-sync-stats table in n8n UI, filter by org and date
What is currently unresolved?mc-sync-failures table, or the weekly recap on Monday 08:00 London. Rows disappear once the invoice succeeds, so this is a live list, not a history
Why did a specific invoice fail?mc-sync-log table, filter invoice_id on the invoice reference, the value Upflow calls customId (section 8.2)
Has this specific invoice ever been seen?mc-sync-log table, same filter. An invoice excluded by the reference-format rule never appears here at all (section 6.4)
Is a given Upflow organization live or in Test Mode?That organization's own settings in Upflow, not n8n. Section 5.7 records the position as of 2026-08-23, and after that date the settings are the only authoritative answer

One invoice shows no PDF in Upflow: the order to check it in

This is the most common question the system will be asked, and answering it out of order wastes time. Start with the invoice reference (its Upflow customId) and the organization it belongs to.

  1. Is the sync for that organization still running at all? The Active toggle, checked against section 8.4 Table 1. A stopped sync explains every invoice at once and nothing below is worth doing until this is answered (section 5.8).
  2. Does the reference pass that organization's format rule? Section 8.5 has the rule each sync carries and how to check a reference by eye. If it does not pass, the sync never treated the invoice as a candidate: there is nothing in any table to find, and section 7.2 is where that case belongs.
  3. Look the reference up in mc-sync-log, filtering invoice_id on it. No row, with the format rule passing, means the invoice was never outstanding at a moment a sync ran: read its state in Upflow, since only DUE and OVERDUE are candidates (section 4.4).
  4. A row with status = success means the PDF was pushed and Upflow accepted it. If the document in Upflow is not the current version of the invoice, section 7.5 is the reason: either the amendment has not been re-emailed yet (the detector's trigger), or the push predates 2026-08-23 and sits outside the detector's reach — the manual re-push tool covers both.
  5. A row with status = failed carries an error_code. Section 8.7 says what produces each one and whether it clears itself. no_so_match_in_cache and no_einvoicing_url re-enter the pool on their own after 7 days; most of the rest are retried on every run.
  6. A row stuck at attempted, with no outcome row from the same workflow_execution_id, means the run died part-way through that invoice (section 5.2). The next tick re-processes it.
  7. The invoice is in mc-sync-failures only while it is still unresolved. An invoice that failed and later succeeded leaves no row there, so an empty result is not evidence that nothing ever went wrong (section 4.3).

6.2 Healthy steady-state signature

For each active org, a healthy day looks like:

  • 5 INVOICE sync runs and 5 CM sync runs, all status: success in n8n executions list
  • 4 cache builder workflows, each running 5 times, so 20 cache builder executions across the instance, all status: success
  • Alongside every sync run, one or two sub-executions of its chunk sub-workflow (2 for Limited, 1 elsewhere at current volumes). These are normal and are described in section 3
  • 2 heartbeat runs (12:00 and 21:00 London), silent except for the Monday-noon green proof-of-life
  • Slack: zero or one operational message per day, severity none or info
  • mc-sync-stats: one row per sync run, coverage_verdict starting with COVERED (steady state) or PARTIAL (catch-up in progress)
  • mc-sync-log: continued append of new rows, all recent rows with status: success

The shared helper sub-workflows in section 8.4 Table 2 appear in the executions list as well, once per call, so the raw execution count on a healthy day is higher than the number of scheduled runs above.

Slack will surface most deviations from this, including — since 2026-08-22 — a sync that stopped running (heartbeat red, or a red Monday recap). What it still cannot surface is a run that completed green while a defective filter excluded everything. Section 6.4 covers that residual case.

6.3 When to escalate

The system is designed to operate autonomously. Reach out when:

  • A failure category appears that is not documented (new error_code in mc-sync-failures).
  • A heartbeat red lands in #n8n-alerts, or a Monday passes without the heartbeat's green proof-of-life. Either means a producer — or the watchdog itself — has stopped. Confirm with the Active-toggle check against section 8.4 Table 1.
  • The Monday weekly recap is red, or fails to land in #metacompliance-n8n-sync at all. Since version 1.1.0 the recap checks sync activity itself, so a green recap now is a clearance for the week it covers; a missing one remains a signal about the recap itself.
  • A message lands in #n8n-alerts (technical exception channel). These are rare and worth forwarding.
  • Upflow or Maxio breaks backward compatibility on the APIs used (rare, but possible).
  • You need to extend the system (new org, new entity type, new alert).

Escalate to the integration owner. That is a role, not a person: it belongs to whoever currently maintains this integration, and it moves when maintenance moves. The role is currently held at Upflow, at arthur.pomies@upflow.io. Confirm who holds it before escalating, and update this line when it changes hands. This is the route for anything in this list, and for any other question about the integration.

6.4 What the monitoring does not see

In plain language. The alerting in this system reports on things that went wrong. It does not report on things that did not happen. Those are different, and every blind spot below is an instance of the same gap. A green run and a COVERED verdict are not proof that work is happening. If you take one thing from this document, take that.

The blind spots — one closed on 2026-08-22, three still open:

Blind spot Status
A stopped sync goes undetectedClosed 2026-08-22. MC - Heartbeat watches the age of every producer's last write and posts red when one goes quiet (section 4.3). The residual: the watchdog shares the instance it watches, so a whole-instance outage is bounded only by the absence of the Monday green
COVERED can mean zero workStill open, structurally. coverage_verdict = COVERED - 0 eligible remaining is computed after filtering. If the filter wrongly excluded everything, the verdict is COVERED and the run pushed nothing. Both are true at once. The heartbeat does not help here: the run did happen
Format-excluded invoices are invisibleStill open. An invoice skipped by the reference-format rule is dropped before anything is written to mc-sync-log. It appears in no log, no failures table, and no weekly digest: as far as the system is concerned, it was never a candidate
A resolved failure leaves no traceStill open, by design. mc-sync-failures self-clears (section 4.3). Useful for "what is open now", useless for "what has been flaky lately"

The third blind spot has a mechanism, and it is worth stating as a live property of the system. Every invoice sync carries a rule that excludes references not matching the Maxio numbering convention of its own organization. The organizations do not number their Maxio invoices the same way, so the rule is per organization, and section 8.5 is the table of exactly which rule each sync carries today.

A rule calibrated for one organization and applied to another excludes that organization's entire normal traffic. Not a subset of it, all of it. And nothing in the system says so: the run stays green, the coverage verdict still reads COVERED - 0 eligible remaining, nothing is written to mc-sync-log, no failure row appears in mc-sync-failures, no Slack message is posted, and the Monday digest keeps arriving as an all-clear. The exclusion happens before the first log write, so the excluded invoices are not failures the system is being quiet about. They are candidates the system never had.

Nothing in the alerting can tell that state apart from an organization that has genuinely caught up. Where an organization is still in Test Mode, that setting is the only thing standing between the state and the customer: it accepts data while blocking outbound dunning, and it stops protecting anyone the moment that organization goes live (section 5.7).

As of 2026-08-23 that protection covers three organizations of the four. Ireland, DACH and Denmark are in Test Mode, so a filter defect on any of them stays inside Upflow and reaches no customer. Limited is live, so on Limited there is nothing in between. A rule that wrongly excluded Limited's traffic would produce the same green run, the same COVERED - 0 eligible remaining verdict and the same silence in Slack, and Upflow would go on dunning those customers with no invoice attached. Each organization's current mode is read in its own Upflow settings, not here.

What remains open. Denmark's two syncs carry the reference rule written for Limited, and section 8.5 flags them for it. Denmark's Maxio numbering has to be checked before that org is switched on, or the sync will exclude every invoice, run green, report COVERED, and push nothing, with no alert of any kind. This is the first item on the Denmark checklist in section 7.1.

The practical rule this leaves you with: when you want to know whether the system is working, do not ask whether anything failed. Ask whether anything was pushed. mc-sync-log growing over the last few days answers that question, COVERED does not.

7. Known limitations and roadmap

7.1 Denmark: currently gated

Status: infrastructure deployed identically to the other three orgs. Two workflows are off: the Denmark INVOICE sync and the Denmark CM sync, both tagged [GATED] in their names. The Denmark cache builder is active and has been refreshing the Denmark cache five times a day all along, so no cache catch-up is pending.

Reason: MetaCompliance does not currently bill the Denmark entity from Maxio. PDFs in Denmark Maxio are not relevant. MC plans to migrate Denmark billing to Maxio.

Before switching Denmark on, four things need doing. Each of the four below has a concrete failure attached to skipping it:

  1. Check how Denmark numbers its Maxio invoices. The two Denmark syncs carry the reference-format rule written for Limited (YYYY-XXXXX), and section 8.5 states that rule alongside the one every other sync carries. If Denmark's numbering does not match it, the sync will exclude every invoice, run green, report COVERED, and push nothing, with no alert of any kind. Section 6.4 is the mechanism. This is the single highest-risk item on the list, because it fails invisibly.
  2. Set the timezone on both Denmark workflows, and re-slot them. They are the only two scheduled workflows on the instance with no timezone declared. Left as they are, their three-slots-a-day schedule resolves against the instance default rather than Europe/London, so they may not fire at the times listed in section 8.4 Table 3. The slots themselves also need moving: the crons they carry, 10:00 and 10:15 and the same pattern at 13 and 16, fall 45 minutes before the :45 cache refresh of their own cycle rather than after it, so Denmark would read a cache last refreshed in the previous cycle, two and a half hours old at the credit note slot. That is not a breakage, it is a source of no_so_match_in_cache rows that would clear themselves a week later (section 8.7). Re-slot both syncs behind a cache refresh, using the clock view in section 8.4 and gate G9.
  3. Check the execution quota first. Denmark adds executions to a plan allowance that is already substantially consumed. Section 7.3 has the measured numbers. Switching Denmark on without resolving the hosting question is how the instance runs out of executions mid-month.
  4. Decide what Denmark's Upflow organization does at go-live. Same irreversibility as any other org, section 5.7.

Items 3 and 4 are checks. The other two carry work: item 2 always, since the timezone and the slots both have to be set, and item 1 only if Denmark turns out to number its Maxio invoices differently, in which case the reference rule has to be changed on both Denmark syncs. Do them in the order above, because item 1 decides whether there is anything to fix at all.

7.2 Three categories of untreated invoices

During backfill, three categories of unpaid invoices were observed in Upflow that the sync does not process. They require MC's product team to confirm intent:

Category Example Why excluded
Reference outside the org's Maxio conventionARADJ-12345Format does not match how that organization numbers its Maxio invoices. Likely manual adjustments or non-Maxio invoices.
Standard format but absent from Maxio2024-10687.CM.01customId looks Maxio-shaped but no matching record in Maxio. Likely Sage-only invoices that were never replicated.
DACH legacy customIdFree-form text with FR dates, UUID fragmentsPre-Maxio invoices imported with non-standard identifiers.

Warning

There is no single instance-wide "correct" reference shape. A bare integer is foreign on Limited and normal on DACH. The convention is read per organization, and when judging whether an exclusion is legitimate, always ask against which org's convention. Section 8.5 is the per-organization table, section 6.4 is the mechanism.

Each category has been catalogued. MC's product team should validate whether these are expected to have PDFs (in which case a fix is needed) or expected to remain PDF-less (in which case the system is correct). The first category is worth checking with more care than the other two, because a wrongly excluded reference produces no signal anywhere.

7.3 Hosting: the execution allowance is the near-term constraint, and the decision is open

Memory is no longer the binding constraint. The chunked architecture (section 3) cuts the largest parent run from around 37 MB to under 2 MB, and it has covered all six active syncs since 2026-08-20. The next memory ceiling is a different one: the mc-sync-log read that every run performs grows with the table itself (section 7.6).

What binds now: execution volume. The Starter plan allows 2 500 executions per month:

Configuration Executions per weekday Share of the monthly allowance
Measured 2026-08-21, the first full weekday with all six syncs on the chunked architecture, sub-executions included, read from the execution list2104 557 per month at 21.7 weekdays — above the 2 500 allowance, if sub-executions count toward it
Structural floor, independent of invoice volume: 30 sync parents + 20 cache-builder runs + ~35 chunk sub-executions + ~30 notifier calls~115≈ 2 496 per month — the schedule alone consumes the allowance before a single PDF moves
Earlier baseline (2026-08-18), taken with the Limited invoice sync inactive~65≈ 57 %
With Denmark also switched onHigher again, not measuredUnknown

Warning

Two things decide whether the 210-per-weekday figure breaches the allowance, and neither is settled here.

  • Whether n8n counts sub-executions toward the plan allowance at all. The vendor does not document the point, and no test run from here can answer it authoritatively. The 210-per-weekday figure includes them, which is why it is stated as conditional.
  • How many PDFs each run actually pushes, since the per-push helper workflows are themselves executions.

MetaCompliance can settle both in one look, and that is the recommended next step. The authoritative counter is the usage gauge in MC's own n8n Cloud account, which reports what the vendor is actually billing against. It gives the real number and answers the sub-execution question at the same time. Read it before acting on this section. It takes less time than reading this paragraph.

What n8n does once the allowance is exhausted is not recorded here, so it is a third thing to confirm with the vendor rather than discover at the ceiling.

Switching Denmark on adds its own runs on top of whatever that gauge shows, so read the gauge first.

Current recommendation: upgrade the n8n Cloud plan to Pro. It clears the execution ceiling, keeps the instance managed, and requires no migration, no re-deployment, and no change to any workflow.

Self-hosting stays on the table, but as an option rather than the default. It becomes the better answer if MC's scale changes substantially, or if MC decides it wants to own the infrastructure for reasons of its own. It carries an operational cost that the Cloud plan does not: patching, backups, uptime, and the alerting that goes with running a server. That cost is worth paying for a reason, not by default.

Important

No decision has been made. MetaCompliance has not chosen between the Pro upgrade, self-hosting, or staying on Starter with Limited switched off. This section states what has been measured, what has not, and the open questions, nothing more.

7.4 Go-live checklist

Going live flips an Upflow org from Test Mode to production. The next scheduler tick after the flip (default 08:30 org-time) will trigger automatic dunning actions on every eligible invoice. The decision is a MetaCompliance business decision, the click is a MetaCompliance admin action.

This procedure has been carried out once and is still ahead three times. As of 2026-08-23, Limited is live and Ireland, DACH and Denmark are still in Test Mode (section 5.7). What follows applies to each organization that has not gone live yet, one organization at a time.

Start by reading the org's current mode in Upflow. The date above is a snapshot and the state changes by an action inside Upflow, so check the target org's settings first and confirm it is actually still in Test Mode before working through the steps below.

Per-org checklist before clicking "Go live":

  1. Confirm coverage is real, not just declared. Two checks, and the second is the one that counts:
    • Check the latest mc-sync-stats row for the org. coverage_verdict should start with COVERED, not PARTIAL. If PARTIAL, review what remains and whether it falls under section 7.2 (catalog A/B/C, expected) or something new (escalate).
    • Then check that mc-sync-log actually contains successful pushes for that org, over a period that makes sense. COVERED on its own is compatible with the sync having pushed nothing at all (section 6.4), so a verdict without a populated log is not evidence of coverage.
  2. Spot-check 3 to 5 invoices in Upflow UI. Confirm the PDF is attached and downloadable, that it opens correctly, that the content matches the customer record.
  3. Confirm internal alignment. Inside MetaCompliance: finance team aware of the date, dunning policy reviewed, customer service ready for inbound questions.
  4. Pick a quiet day and an early time. A weekday morning (not Friday, not before a holiday) gives the most reaction window if anomalies appear.
  5. Stagger across orgs. Where more than one organization is still in Test Mode, take them one at a time rather than together. Ireland is the smallest and the lowest blast radius, so it is the natural first, then DACH. Limited is already live and is out of that sequence. Observe 24 to 48 hours between them. Denmark stays gated until MC migrates its billing to Maxio.
  6. Subscribe to both Slack channels. #metacompliance-n8n-sync (business signals) and #n8n-alerts (technical exceptions). Keep them in front of you for the first day post-go-live.
  7. Click "Go live" in Upflow UI on the target org. The flip is irreversible.
  8. Watch the next scheduler tick. The first automated dunning batch will fire at the next 08:30 org-time. Confirm volume and recipient list match expectations.
  9. Reach out to the integration owner if anything looks off. Within the first 48 hours, treat any unfamiliar Slack message as worth a forward.

7.5 An amended PDF is re-pushed automatically, within stated limits

In plain language. Once an invoice's PDF has been pushed, the invoice is excluded from future runs, which is what keeps each run cheap. Until 2026-08-23 that exclusion was permanent: a PDF amended in Maxio after its push — a PO number added after the invoice was first sent, say — never reached Upflow, and nothing signalled that the two versions differed. That gap was demonstrated end-to-end on a live invoice. Since 2026-08-23 the system detects the common form of it and re-pushes on its own.

How detection works. The cache builders already refresh every Maxio record whose modification timestamp moved. They now also store the record's last_emailed timestamp in the cache, and every successful push records the same timestamp in its log row (so_last_emailed). When a builder refreshes a record whose fresh last_emailed is newer than the one recorded at push time, it flips that invoice's log row from success to amended_repush_pending. The invoice thereby leaves the already-pushed exclusion on its own, and the next sync tick re-pushes the current PDF and records a new baseline. Both sides of the comparison are Maxio's own timestamp — no mixed clocks. The flip is verified by re-reading the row; a flip that cannot be verified stops the builder before its watermark advances, so a detection cannot be silently lost.

Why the trigger is the re-email date and not the modification date. Measured on 2026-08-20 on a 40-invoice sample: Maxio's auditentry.modified moves in system-wide e-billing batches — 18 of the 40 invoices carried a modified inside the same 1.2 seconds. As a trigger it would have re-pushed 22.5 % of the sample, almost all of it batch noise. The re-email date isolated exactly the one invoice in the sample whose amendment was proven by reading both PDFs.

The limits, stated plainly:

  • Pushes made before 2026-08-23 are out of the detector's reach. Their log rows carry no baseline, and the guard treats an empty baseline as "never flip" — deliberately, because without it the entire pushed history would have re-opened at once. Anything older goes through the manual tool below.
  • An amendment that is never re-emailed is not detected. The re-email is the trigger. Same channel: the manual tool.
  • Latency. The detector sees a re-emailed invoice when the cache builder next refreshes that record, which happens when Maxio's e-billing system next touches its modification timestamp: hours to a few days. The sync is idempotent, so late is safe.

The backstop: MC - Manual re-push by invoice number. An operator workflow, kept inactive and run by hand, that takes an organization and a list of invoice references, reads the live Maxio record, resolves the fresh PDF through the same redirect-and-validation chain as the syncs, and pushes it — deliberately ignoring the already-pushed exclusion, since bypassing it is its purpose. dryRun is on by default: the full chain runs and reports what it would push, without writing anything anywhere. Limited, Ireland and DACH invoices only; Denmark and credit-note references are refused loudly. Every real push it makes lands in mc-sync-log like any other, with its own marker.

7.6 Open items

Everything on this list is known and none of it is done as of 2026-08-23. It is here so that nobody rediscovers these the hard way.

Item Where it is described Consequence if left as is
Denmark's numbering is unverified and both gated syncs still carry the retired pull-all architectureSections 7.1 and 8.5Switching Denmark on without the numbering check reproduces the invisible-exclusion failure; the rebuild onto the chunked pattern is planned together with the un-gating
The hosting decision is open, and the measured volume sits above the Starter allowance if sub-executions countSection 7.3The authoritative read is the usage gauge in MetaCompliance's own n8n Cloud account, and it has not been read
mc-sync-log grows without bound and is re-read whole by every sync runSection 8.2It is the next memory ceiling after the one the chunked pull removed. About 40 % of it is write-ahead rows that the filter never uses
Upflow announces more credit notes than it delivers on Limited (1 404 announced, 1 310 delivered)Section 4.4, scan invariantsUnder investigation with a read-only probe. Until settled, credit-note coverage on Limited cannot be declared complete
Upflow has agreed to ship a server-side state filter on the invoice listing, with no dateSection 4.1When it lands, the reason the chunked pull exists disappears; adopting it simplifies the architecture substantially
The manual re-push tool has not yet had its positive-control runSection 7.5Its dry run passed a negative control (an unknown reference is reported, not pushed); a positive control on a known invoice is the remaining step before it is called proven

As of 2026-08-23, Limited is the one organization out of Test Mode (section 5.7), so it is the one where Upflow dunning reaches real customers. The two Limited-specific items that stood here in the previous version — five invoices frozen under the old validation rule, and the redirect resolution being live on one org only — were both resolved on 2026-08-20.

8. Reference appendix

Note

All IDs below reflect the production state as of 2026-08-23. All workflows live in n8n project 4hgIgCtYqkhCE7Ab on MC's n8n cloud instance (https://metacompliance.app.n8n.cloud).

8.1 Workflow registry

Sync workflows (8 total: 6 active + 2 gated). All six active syncs are v10.1, chunked with redirect resolution (section 3). The two gated Denmark workflows still carry the retired v9 architecture and are rebuilt before use.

Name ID Status
MC - Sync v10.1 - chunked pull all outstanding + redirect resolve (Limited)JG6mI8OcCb4tXr6factive, INVOICE
MC - Sync v10.1 - chunked pull all outstanding + redirect resolve (Ireland)n7d1kAq17HNxRakuactive, INVOICE
MC - Sync v10.1 - chunked pull all outstanding + redirect resolve (DACH)zOD0fHLklBbiLhCtactive, INVOICE
MC - Sync v10.1-CM - chunked pull all outstanding + redirect resolve (Limited)cj9xM1f3K5rpekIQactive, CM
MC - Sync v10.1-CM - chunked pull all outstanding + redirect resolve (Ireland)Qp5QD04EqXV4SvrHactive, CM
MC - Sync v10.1-CM - chunked pull all outstanding + redirect resolve (DACH)vcRMzE7C691unWs1active, CM
[GATED] MC - Sync v9 - pull all outstanding (Denmark)csYbjQbTH8WEKWLrinactive, INVOICE, retired architecture
[GATED] MC - Sync v9-CM - pull all outstanding (Denmark)1HILgxzf71N91jPvinactive, CM, retired architecture

Chunk sub-workflows (6, active, no schedule of their own):

Name ID Called by
MC - Sync v10 - fetch + filter Upflow invoice chunk (Limited) [sub]GwM2NsjoiK2pBT76JG6mI8OcCb4tXr6f
MC - Sync v10.1 - fetch + filter Upflow invoice chunk (Ireland) [sub]fxjHsFgrwmcQOgE0n7d1kAq17HNxRaku
MC - Sync v10.1 - fetch + filter Upflow invoice chunk (DACH) [sub]SOBXkatshYC2F5oTzOD0fHLklBbiLhCt
MC - Sync v10.1-CM - fetch + filter Upflow credit-note chunk (Limited) [sub]a5Yc1DKAVyF4O7PAcj9xM1f3K5rpekIQ
MC - Sync v10.1-CM - fetch + filter Upflow credit-note chunk (Ireland) [sub]nMp3B5wF1UBXxHLvQp5QD04EqXV4SvrH
MC - Sync v10.1-CM - fetch + filter Upflow credit-note chunk (DACH) [sub]HAqe3W63cbOaO7iivcRMzE7C691unWs1

Cache builder workflows (4 total, all active, Denmark included):

Name ID
MC - SO Cache builder v4 - incremental (Limited)gKCqvweXcEMQOLNl
MC - SO Cache builder v4 - incremental (DACH)ATcQ7Oel1z6XKFwH
MC - SO Cache builder v4 - incremental (Ireland)MgBPYfdW9cai7ecJ
MC - SO Cache builder v4 - incremental (Denmark)puwhviDXHGGLPhdO

Sub-workflows and utilities:

Name ID Role
MC - Slack notify risks (sub-workflow)yQTaVe3bNkpoO3hYOperational alerts on threshold crossings, called from every sync at end-of-cycle. Posts to #metacompliance-n8n-sync.
MC - Upsert failure to mc-sync-failures (sub-workflow)7mAEjEQw7VZbFXzLRecords or updates a failure row in mc-sync-failures. Called from every sync on every failure branch.
MC - Clear resolved failure from mc-sync-failures (sub-workflow)teiTgpo0VG0YDLI3Removes a failure row once that invoice succeeds. Wired on all six syncs and on the manual re-push tool. This is what makes mc-sync-failures a live list rather than a history.
MC - Weekly failures recapKDTX4bWccD336qQNMonday 08:00 London. Aggregates mc-sync-failures over the last 7 days AND verifies per-org sync activity against mc-sync-stats; red when an org went silent (section 4.3). Posts to #metacompliance-n8n-sync.
MC - Error HandlerVHCl42sKOjFvmM6jCatches runtime exceptions and posts to #n8n-alerts. Named as errorWorkflow on every active workflow except itself (section 4.3; sub-to-parent propagation verified by controlled test 2026-08-23).
MC - Heartbeat - sync + cache liveness (independent watchdog)KD98wgE9kr8RspEI12:00 + 21:00 London weekdays. Watches the age of every producer's last write; red alert to #n8n-alerts on staleness, green proof-of-life on Mondays (section 4.3).
MC - Manual re-push by invoice numbermvSXCD8913bv6mSpInactive, manual only. Operator backstop: re-pushes the current Maxio PDF for explicit references, bypassing the already-pushed exclusion. dryRun on by default (section 7.5).

Deployed but switched off (11). Every retired workflow now carries a [DEPRECATED x.y.z], [GATED] or [PARKED] prefix — since 2026-08-23 none of them displays as a normal workflow:

Name ID Why it is off
[GATED] MC - Sync v9 - pull all outstanding (Denmark)csYbjQbTH8WEKWLrMC does not yet bill Denmark from Maxio (section 7.1)
[GATED] MC - Sync v9-CM - pull all outstanding (Denmark)1HILgxzf71N91jPvSame
[DEPRECATED 1.1.0] MC - Sync v10 - chunked pull all outstanding (Limited)ux5Ua9vHwgw1z13jImmediate predecessor of the Limited v10.1, first rollback rung
[DEPRECATED v9.3.0] MC - Sync v9 - pull all outstanding (Limited)5CWix9kevwoHZoKUPull-all generation, second rollback rung
[DEPRECATED 9.3.0] MC - Sync v9 - pull all outstanding (Ireland)kBlOqctR7rUiRDadPull-all generation, Ireland rollback
[DEPRECATED v9.4.0] MC - Sync v9 - pull all outstanding (DACH)IDt0wsiGrNtgiFo0Pull-all generation, DACH rollback
[DEPRECATED 9.3.0] MC - Sync v9-CM - pull all outstanding (Limited)f10YZs4ZbwGGUqIMPull-all generation, CM rollback
[DEPRECATED 9.3.0] MC - Sync v9-CM - pull all outstanding (Ireland)cKtWk0D0wEESRFs1Same
[DEPRECATED 9.3.0] MC - Sync v9-CM - pull all outstanding (DACH)Xz3lMPUdw7Fo5xwKSame
[DEPRECATED 9.5.0] MC - Sync v9.5 - pull all outstanding + redirect resolve (DACH)TD7H7Jj4fKJFY6xdSuperseded build, never ran in production. Kept as the redirect-fix reference
MC - Sync (Limited) [PARKED …]sLaBNTvTvnGZ5rUOParked copy

What else is on the instance. The n8n interface reports 81 workflows in total, which is a larger number than anything above, so here is the reconciliation:

Group Count What they are
Active22The 12 scheduled workflows in section 8.4 Table 1, plus the 10 that only run when called (the six chunk sub-workflows and the four shared helpers)
Deployed but switched off11The table immediately above, and section 8.4 Table 3
Inactive, manual only14The manual re-push tool, read-only diagnostics and probes, and remaining smoke tests. None carries an active schedule
Archived34Soft-deleted earlier iterations, plus the five one-shot end-to-end push tests archived on 2026-08-23 — they were inactive but one click away from a real production push, which is not a property a takeover instance should keep

8.2 Data tables

Name ID Schema
mc-sync-logLtFm7i89QCTGgph8Append-only log of every push attempt. invoice_id holds the invoice reference, the same value Upflow calls customId; the Upflow UUID is in a separate column, upflow_invoice_uuid. 19 columns: invoice_id, org, invoice_type, status (attempted / success / failed / amended_repush_pending), attempted_at, completed_at, attempt_count, error_code, error_details, pdf_size_bytes, workflow_execution_id, upflow_invoice_uuid, http_status_push, invoice_state, workflow_id, saasoptics_invoice_id, invoice_source, pdf_url_host, so_last_emailed (added 2026-08-23, the amendment-detection baseline). Common across orgs, filter by org. Note that an invoice excluded by the reference-format rule never reaches this table (section 6.4).
mc-sync-statsf0eCEzZcvgF5fgIEOne row per sync run, aggregated metrics. 16 columns including org, run_at, coverage_verdict, candidates_count, pushed_success, pushed_failed, skipped counts per category.
mc-sync-failuresDTyI89rKIfAyy0HTDeduplicated failure catalog. 12 columns. Composite key: composite_key = invoice_id + "|" + org. One row per unique failing invoice, attempt_count increments on retry, first_attempted_at preserved.
mc-so-cache-limitedmMyB0D6rZYH0BzpmMaxio invoice metadata cache for Limited. 8 columns (last_emailed and auditentry_modified added 2026-08-23).
mc-so-cache-dachnD39Zprlmcvt1xbxMaxio invoice metadata cache for DACH. 8 columns (same two added).
mc-so-cache-irelandKVI38innuYbkA4AIMaxio invoice metadata cache for Ireland. 8 columns (same two added).
mc-so-cache-denmarkVZs19v5RKscuUvMTMaxio invoice metadata cache for Denmark. 8 columns (same two added).
mc-so-cache-progressGOQ3z2pSu6RWhYdPWatermark per cache builder. 9 columns including window_key (PK), status, last_pulled_count. Singleton rows per org: __incremental_watermark_<org>__.

8.3 Credentials

All credentials live in MC's n8n cloud instance under the credentials list. None are stored outside. Listed below by ID for traceability. Rotation is handled by MetaCompliance (Upflow and Maxio admin sides); updating the credentials in n8n after rotation falls to the integration owner.

Name ID Type
[PROD] Upflow - MetaCompliance LimitedibYsABgbGCN8Y0sIHTTP Custom Auth
[PROD] Upflow - MetaCompliance DACHw9sCJIqwz6gIIx80HTTP Custom Auth
[PROD] Upflow - MetaCompliance Ireland3mPiseCtRt8efCsRHTTP Custom Auth
[PROD] Upflow - MetaCompliance DenmarkC2tSVx2zgdE5oOm6HTTP Custom Auth
[PROD] Maxio - MetaCompliance GBP (Limited)nRLEEuR4XOBWXmECHTTP Header Auth
[PROD] Maxio - MetaCompliance DACH (EUR)U8P7oQHJWIa1iURNHTTP Header Auth
[PROD] Maxio - MetaCompliance Ireland (EUR)XUGbUIldt82DIlxrHTTP Header Auth
[PROD] Maxio - MetaCompliance MOCH (DKK Denmark)MNvma2c24jxGqA1BHTTP Header Auth
Slack account (notifications)iD4Yua9z4pcY09LXSlack OAuth

Warning

MC's n8n public API key expires every 90 days, and it expires silently. This is separate from the credentials above. It is the key used to reach the instance through the n8n API rather than through the browser, and nothing warns anyone when it lapses: calls simply start being refused.

Current expiry: 2026-11-16. Regenerating it takes a minute, in n8n under Settings → n8n API.

Worth putting a calendar reminder a week before that date. This is the kind of trap that costs an afternoon for no reason.

8.4 Schedule reference

This reference is per workflow, exhaustively, and it includes the workflows that carry a schedule but are switched off.

Note

All times below are read from the live cron expressions, not from the display names of the schedule nodes. The habit stays even though its motivating case is gone: the two rollback workflows whose schedule labels contradicted their own cron were renamed on 2026-08-23, so the labels now tell the truth. The callout under Table 3 records what they used to say.

Timezone, once, so it does not need repeating. Every active scheduled workflow declares Europe/London. London and Paris both follow the EU daylight-saving rule, and that rule switches both zones at the same instant (01:00 UTC, on the last Sunday of March and the last Sunday of October). Paris is therefore London + 1 hour, permanently, with no seasonal exception. A Paris reader never has to adjust for the time of year.

Cron format. n8n uses six fields: [Second] [Minute] [Hour] [Day-of-month] [Month] [Day-of-week]. So 0 45 7,10,13,16,19 * * 1-5 reads as "at second 0, minute 45, in hours 7, 10, 13, 16 and 19, on any day of any month, Monday to Friday".

The daily rhythm, in plain language

One cycle repeats five times a day, Monday to Friday. Nothing runs at weekends.

Each cycle spans from :45 to :30 of the following hour, and the order inside it follows the data dependency: the cache is always refreshed before anything reads it.

  1. :45 the four cache builders refresh the Maxio index for all four orgs.
  2. :00 the invoice syncs for Ireland and DACH run.
  3. :15 the three credit note syncs run (Limited, Ireland, DACH).
  4. :30 Limited's chunked invoice sync runs.

Cycles start at 07:45, 10:45, 13:45, 16:45 and 19:45 London (08:45, 11:45, 14:45, 17:45 and 20:45 Paris). On top of that: the heartbeat at 12:00 and 21:00 London — a few seconds each, inside otherwise free windows — and one weekly job, the failures digest on Monday morning.

The day by the clock, and where the free windows are

The three tables that follow this view are organised by workflow, and they are the exhaustive per-workflow reference. This view is the same schedule organised by time, and it answers a different question: is right now a safe moment to launch a workflow by hand or to deploy a change?

What the hosting plan actually says, since the answer rests on it. n8n publishes two figures for the Starter tier: five concurrent executions and 320 MiB of RAM, the RAM being a resource of the instance rather than an allowance held open for each execution separately (n8n pricing page and n8n Cloud subscription features per tier, both read 2026-08-20). Anything started beyond the concurrency limit is not rejected: n8n queues it and runs it in FIFO order once capacity frees up (n8n Cloud concurrency documentation, same date). So runs on this instance are not serialised, and up to five of them can be in flight together, drawing on the same published memory figure. What n8n does not document is whether one execution's memory is held apart from another's. The schedule in this section was laid out on the assumption that it is not, so a manual run started inside an occupied slot competes with the scheduled one for the instance allowance. That assumption has not been verified against the vendor, and the free windows below are what keeps the question from mattering.

How long a slot is treated as busy. Each slot below is drawn as occupied for 2 minutes. That is a safety margin, not a measured maximum: measured runs take 5 to 30 seconds, and the longest measured, Limited's chunked invoice sync, around 65 seconds. Every free window below is computed from the slot plus that 2-minute margin.

One weekday, 07:45 to 20:32 London, drawn to scale. One character is roughly 15 minutes:

Wide diagram: scroll it sideways.

07:45       10:45       13:45       16:45       19:45
███░░░░░░░░░███░░░░░░░░░███░░░░░░░░░███░░░░░░░░░███
 ↑             ↑
 │             └─ 133 min free, the long windows are the gaps BETWEEN cycles
 └─ one cycle: 4 slots packed into 47 min

███ cycle, 4 scheduled slots in 47 min      ░░░ free window, 133 min

The full clock, all 20 slots. London on the left, Paris beside it.

Wide diagram: scroll it sideways.

  LONDON  PARIS   SLOT  /  FREE WINDOW                          x = workflows firing
  ──────  ──────  ────────────────────────────────────────────────────────────────

                  ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒»  673 min free   20:32 (prev) → 07:45
  ── CYCLE 1 ─────────────────────────────────────────────────────────────────────
  07:45   08:45   █ CACHE    x4   Limited · Ireland · DACH · Denmark
                  ▒   13 min free   07:47 → 08:00
  08:00   09:00   █ INVOICE  x2   Ireland · DACH
                  █ DIGEST   x1   Weekly failures recap   ← MONDAYS ONLY
                  ▒   13 min free   08:02 → 08:15
  08:15   09:15   █ CREDIT   x3   Limited · Ireland · DACH
                  ▒   13 min free   08:17 → 08:30
  08:30   09:30   █ INVOICE  x1   Limited (chunked, ~65 s measured)
                  ▒▒▒▒▒▒▒▒▒▒  133 min free   08:32 → 10:45
  ── CYCLE 2 ─────────────────────────────────────────────────────────────────────
  10:45   11:45   █ CACHE    x4   Limited · Ireland · DACH · Denmark
                  ▒   13 min free   10:47 → 11:00
  11:00   12:00   █ INVOICE  x2   Ireland · DACH
                  ▒   13 min free   11:02 → 11:15
  11:15   12:15   █ CREDIT   x3   Limited · Ireland · DACH
                  ▒   13 min free   11:17 → 11:30
  11:30   12:30   █ INVOICE  x1   Limited (chunked)
                  ▒▒▒▒▒▒▒▒▒▒  133 min free   11:32 → 13:45
  ── CYCLE 3 ─────────────────────────────────────────────────────────────────────
  13:45   14:45   █ CACHE    x4   Limited · Ireland · DACH · Denmark
                  ▒   13 min free   13:47 → 14:00
  14:00   15:00   █ INVOICE  x2   Ireland · DACH
                  ▒   13 min free   14:02 → 14:15
  14:15   15:15   █ CREDIT   x3   Limited · Ireland · DACH
                  ▒   13 min free   14:17 → 14:30
  14:30   15:30   █ INVOICE  x1   Limited (chunked)
                  ▒▒▒▒▒▒▒▒▒▒  133 min free   14:32 → 16:45
  ── CYCLE 4 ─────────────────────────────────────────────────────────────────────
  16:45   17:45   █ CACHE    x4   Limited · Ireland · DACH · Denmark
                  ▒   13 min free   16:47 → 17:00
  17:00   18:00   █ INVOICE  x2   Ireland · DACH
                  ▒   13 min free   17:02 → 17:15
  17:15   18:15   █ CREDIT   x3   Limited · Ireland · DACH
                  ▒   13 min free   17:17 → 17:30
  17:30   18:30   █ INVOICE  x1   Limited (chunked)
                  ▒▒▒▒▒▒▒▒▒▒  133 min free   17:32 → 19:45
  ── CYCLE 5 ─────────────────────────────────────────────────────────────────────
  19:45   20:45   █ CACHE    x4   Limited · Ireland · DACH · Denmark
                  ▒   13 min free   19:47 → 20:00
  20:00   21:00   █ INVOICE  x2   Ireland · DACH
                  ▒   13 min free   20:02 → 20:15
  20:15   21:15   █ CREDIT   x3   Limited · Ireland · DACH
                  ▒   13 min free   20:17 → 20:30
  20:30   21:30   █ INVOICE  x1   Limited (chunked)   ← last slot of the day
                  ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒»  673 min free   20:32 → 07:45 next day

  Bar scale: 1 ▒ ≈ 13 min.  » = bar capped, the figure is the truth.
  Not drawn: the heartbeat at 12:00 and 21:00 — it reads two tables and finishes in
  seconds, so the free windows above remain usable as stated.

Three short gaps inside a cycle, four long ones between cycles. A cycle holds 4 slots, so it contains 3 internal gaps of 13 minutes each. The 4 windows of 133 minutes are the gaps between the five cycles, and the fifth long window is the overnight one.

The week. Monday to Friday are identical, one extra job aside. Weekends are empty. That is the whole weekly picture, so it does not need a grid:

Wide diagram: scroll it sideways.

       07:45       10:45       13:45       16:45       19:45
  Mon  ███░░░░░░░░░███░░░░░░░░░███░░░░░░░░░███░░░░░░░░░███   ★ + weekly digest 08:00
  Tue  ███░░░░░░░░░███░░░░░░░░░███░░░░░░░░░███░░░░░░░░░███
  Wed  ███░░░░░░░░░███░░░░░░░░░███░░░░░░░░░███░░░░░░░░░███
  Thu  ███░░░░░░░░░███░░░░░░░░░███░░░░░░░░░███░░░░░░░░░███
  Fri  ███░░░░░░░░░███░░░░░░░░░███░░░░░░░░░███░░░░░░░░░███
  Sat  ················ nothing scheduled ················
  Sun  ················ nothing scheduled ················

  ███ cycle (4 slots, 47 min)   ░░░ free (133 min)   ★ Monday-only job

Three facts carry the week:

  1. Every weekday is the same shape. Five cycles, four 133-minute windows between them.
  2. Monday 08:00 is the one exception. The weekly failures recap (0 0 8 * * 1) fires on top of the two invoice syncs already scheduled at 08:00, which makes it the busiest :00 slot of the week, three workflows on the same second. Not a good moment for a manual run.
  3. Saturday and Sunday are completely free. No cron on this instance carries a weekend day in its day-of-week field. The window from Friday 20:32 to Monday 07:45 is 59 h 13 min, and it is the right place for anything long or risky.

The free windows, with their durations:

Wide table: scroll it sideways.

Free window (London) Paris Length What it is good for
08:32 → 10:4509:32 → 11:45133 minDeploys, manual runs. Denmark would split this one
11:32 → 13:4512:32 → 14:45133 minDeploys, manual runs. Denmark would split this one
14:32 → 16:4515:32 → 17:45133 minDeploys, manual runs. Denmark would split this one
17:32 → 19:4518:32 → 20:45133 minStays 133 min even after Denmark goes on
20:32 → 07:45 next day21:32 → 08:45673 min (11 h 13 min)Anything long
Fri 20:32 → Mon 07:45Fri 21:32 → Mon 08:453 553 min (59 h 13 min)Migrations, backfills, risky changes
:47:00, :02:15, :17:30 inside any cycle+1 h13 min eachOne short manual run. Not a deploy window

Rule of thumb. Between cycles there is just over two hours. Inside a cycle there are thirteen minutes. If it is not clear which of the two the moment falls in, it is not a free window.

Peak concurrency, counted as workflows firing on the same second:

Slot Workflows on the same second When
:45 (07:45, 10:45, 13:45, 16:45, 19:45)4, the four cache buildersEvery weekday
:15 (08:15, 11:15, …)3, the three credit note syncsEvery weekday
Monday 08:003, Ireland invoice, DACH invoice, weekly digestMondays only
:00 (other days)2, Ireland invoice and DACH invoiceTue to Fri
:301, Limited's chunked invoice syncEvery weekday

On that ranking. The busiest instant of the week is not Monday 08:00. It is each :45 cache-builder slot, four workflows on the same second, five times a day. Monday 08:00 is the busiest :00 slot and the week's only asymmetry, which is a different claim and the accurate one.

The two Denmark syncs are absent from everything above, and their times carry a caveat. They are switched off, so they do not fire at all today. They are also the only two scheduled workflows on the instance with no timezone declared, so the 10:00 / 13:00 / 16:00 shown for them in Table 3 is where Denmark would land once a timezone is explicitly set, not where it would land as the workflows currently stand. Table 3 carries the same warning underneath it. If Denmark is switched on with Europe/London set, three of the four 133-minute windows split into 88 minutes, then 13, then 28. The 17:32 → 19:45 window is the only one left whole, before and after.

Table 1: running on a schedule (12 workflows)

Wide table: scroll it sideways.

Workflow ID What it does Cron Ticks (London) Ticks (Paris) State
MC - SO Cache builder v4 - incremental (Limited)gKCqvweXcEMQOLNlRefreshes the Maxio PDF-link cache for Limited0 45 7,10,13,16,19 * * 1-507:45, 10:45, 13:45, 16:45, 19:4508:45, 11:45, 14:45, 17:45, 20:45Active
MC - SO Cache builder v4 - incremental (Ireland)MgBPYfdW9cai7ecJSame, Ireland0 45 7,10,13,16,19 * * 1-507:45, 10:45, 13:45, 16:45, 19:4508:45, 11:45, 14:45, 17:45, 20:45Active
MC - SO Cache builder v4 - incremental (DACH)ATcQ7Oel1z6XKFwHSame, DACH0 45 7,10,13,16,19 * * 1-507:45, 10:45, 13:45, 16:45, 19:4508:45, 11:45, 14:45, 17:45, 20:45Active
MC - SO Cache builder v4 - incremental (Denmark)puwhviDXHGGLPhdOSame, Denmark0 45 7,10,13,16,19 * * 1-507:45, 10:45, 13:45, 16:45, 19:4508:45, 11:45, 14:45, 17:45, 20:45Active, although both Denmark syncs are off
MC - Sync v10.1 - chunked pull all outstanding + redirect resolve (Ireland)n7d1kAq17HNxRakuInvoice sync, Ireland0 0 8,11,14,17,20 * * 1-508:00, 11:00, 14:00, 17:00, 20:0009:00, 12:00, 15:00, 18:00, 21:00Active
MC - Sync v10.1 - chunked pull all outstanding + redirect resolve (DACH)zOD0fHLklBbiLhCtInvoice sync, DACH0 0 8,11,14,17,20 * * 1-508:00, 11:00, 14:00, 17:00, 20:0009:00, 12:00, 15:00, 18:00, 21:00Active
MC - Sync v10.1-CM - chunked pull all outstanding + redirect resolve (Limited)cj9xM1f3K5rpekIQCredit note sync, Limited0 15 8,11,14,17,20 * * 1-508:15, 11:15, 14:15, 17:15, 20:1509:15, 12:15, 15:15, 18:15, 21:15Active
MC - Sync v10.1-CM - chunked pull all outstanding + redirect resolve (Ireland)Qp5QD04EqXV4SvrHCredit note sync, Ireland0 15 8,11,14,17,20 * * 1-508:15, 11:15, 14:15, 17:15, 20:1509:15, 12:15, 15:15, 18:15, 21:15Active
MC - Sync v10.1-CM - chunked pull all outstanding + redirect resolve (DACH)vcRMzE7C691unWs1Credit note sync, DACH0 15 8,11,14,17,20 * * 1-508:15, 11:15, 14:15, 17:15, 20:1509:15, 12:15, 15:15, 18:15, 21:15Active
MC - Sync v10.1 - chunked pull all outstanding + redirect resolve (Limited)JG6mI8OcCb4tXr6fInvoice sync, Limited0 30 8,11,14,17,20 * * 1-508:30, 11:30, 14:30, 17:30, 20:3009:30, 12:30, 15:30, 18:30, 21:30Active
MC - Heartbeat - sync + cache liveness (independent watchdog)KD98wgE9kr8RspEILiveness watchdog (section 4.3)0 0 12,21 * * 1-512:00, 21:0013:00, 22:00Active
MC - Weekly failures recapKDTX4bWccD336qQNWeekly Slack digest of unresolved failures0 0 8 * * 1Mondays 08:00Mondays 09:00Active

On the Denmark cache builder. It runs on the full schedule while the two Denmark syncs are switched off, so it refreshes a cache that nothing currently reads. The effect is that the cache stays warm and Denmark has no catch-up to do on the day it is switched on.

Table 2: no schedule of their own (10 workflows)

These are active but cannot start themselves. They run only when something calls them, which is why they never appear in the schedule above.

Wide table: scroll it sideways.

Workflow ID What it does Runs when
MC - Sync v10 - fetch + filter Upflow invoice chunk (Limited) [sub]GwM2NsjoiK2pBT76Fetches one chunk of Upflow invoice pages, returns outstanding invoices onlyCalled by JG6mI8OcCb4tXr6f
MC - Sync v10.1 - fetch + filter Upflow invoice chunk (Ireland) [sub]fxjHsFgrwmcQOgE0Same, for IrelandCalled by n7d1kAq17HNxRaku
MC - Sync v10.1 - fetch + filter Upflow invoice chunk (DACH) [sub]SOBXkatshYC2F5oTSame, for DACHCalled by zOD0fHLklBbiLhCt
MC - Sync v10.1-CM - fetch + filter Upflow credit-note chunk (Limited / Ireland / DACH) [subs]a5Yc1DKAVyF4O7PA / nMp3B5wF1UBXxHLv / HAqe3W63cbOaO7iiSame, for credit notesCalled by their CM parents
MC - Upsert failure to mc-sync-failures (sub-workflow)7mAEjEQw7VZbFXzLRecords or updates a failure rowCalled by every sync, on a failure
MC - Clear resolved failure from mc-sync-failures (sub-workflow)teiTgpo0VG0YDLI3Removes a failure row once the invoice succeedsCalled by every sync, on a success
MC - Slack notify risks (sub-workflow)yQTaVe3bNkpoO3hYEvaluates a run's statistics and posts to Slack if a threshold is crossedCalled by every sync, at end of run
MC - Error HandlerVHCl42sKOjFvmM6jPosts to the technical alert channelFires when a workflow that names it throws an exception

Table 3: deployed but switched OFF (11 workflows)

Caution

Everything in this table carries a working cron expression and is not running. Switching any of them on makes it start firing at the times shown, immediately. Read the two warnings underneath the table before reactivating anything here.

Wide table: scroll it sideways.

Workflow ID Cron it carries Ticks if switched on (London / Paris) Why it is off
[GATED] MC - Sync v9 - pull all outstanding (Denmark)csYbjQbTH8WEKWLr0 0 10,13,16 * * 1-510:00, 13:00, 16:00 / 11:00, 14:00, 17:00MC does not yet bill Denmark from Maxio
[GATED] MC - Sync v9-CM - pull all outstanding (Denmark)1HILgxzf71N91jPv0 15 10,13,16 * * 1-510:15, 13:15, 16:15 / 11:15, 14:15, 17:15Same
[DEPRECATED 1.1.0] MC - Sync v10 - chunked pull all outstanding (Limited)ux5Ua9vHwgw1z13j0 30 8,11,14,17,20 * * 1-508:30, 11:30, 14:30, 17:30, 20:30 / 09:30 onwardsImmediate predecessor of Limited v10.1, first rollback rung
[DEPRECATED v9.3.0] MC - Sync v9 - pull all outstanding (Limited)5CWix9kevwoHZoKU0 30 8,11,14,17,20 * * 1-508:30, 11:30, 14:30, 17:30, 20:30 / 09:30 onwardsPull-all generation, second rollback rung
[DEPRECATED 9.3.0] MC - Sync v9 - pull all outstanding (Ireland)kBlOqctR7rUiRDad0 0 8,11,14,17,20 * * 1-508:00, 11:00, 14:00, 17:00, 20:00 / 09:00 onwardsPull-all generation, Ireland rollback
[DEPRECATED v9.4.0] MC - Sync v9 - pull all outstanding (DACH)IDt0wsiGrNtgiFo00 0 8,11,14,17,20 * * 1-508:00, 11:00, 14:00, 17:00, 20:00 / 09:00 onwardsPull-all generation, DACH rollback
[DEPRECATED 9.3.0] MC - Sync v9-CM - pull all outstanding (Limited / Ireland / DACH)f10YZs4ZbwGGUqIM / cKtWk0D0wEESRFs1 / Xz3lMPUdw7Fo5xwK0 15 8,11,14,17,20 * * 1-508:15, 11:15, 14:15, 17:15, 20:15 / 09:15 onwardsPull-all generation, CM rollbacks
[DEPRECATED 9.5.0] MC - Sync v9.5 - pull all outstanding + redirect resolve (DACH)TD7H7Jj4fKJFY6xd0 0 8,11,14,17,20 * * 1-508:00, 11:00, 14:00, 17:00, 20:00 / 09:00 onwardsSuperseded build, never ran in production. Kept as the redirect-fix reference
MC - Sync (Limited) [PARKED …]sLaBNTvTvnGZ5rUO0 30 8,11,14,17,20 * * 1-508:30, 11:30, 14:30, 17:30, 20:30 / 09:30 onwardsParked copy

Warning

1. The two Denmark workflows have no timezone set. They are the only two scheduled workflows on this instance without one. Every other workflow declares Europe/London. Switched on as they are, their 10:00 / 13:00 / 16:00 would resolve against the instance-wide default rather than London, so they would not necessarily run when this table says. Set the timezone explicitly on both before switching Denmark on.

2. A trap that existed here was defused on 2026-08-23. Two rollback candidates, 5CWix9kevwoHZoKU and sLaBNTvTvnGZ5rUO, used to carry a schedule node named as though it ran at :00 while its cron actually said :30 — reactivating either on the strength of its label would have landed it at 08:30, on top of the currently active Limited sync. Both nodes were renamed to state the real times. The habit the trap taught survives it: the times in the table above are the cron values, and those are the ones that apply.

8.5 Per-organization reference formats

In plain language. Before a sync goes looking for an invoice in Maxio, it checks whether the invoice reference even looks like a Maxio reference. What "looks like" means is not the same in every organization, because the organizations do not number their Maxio invoices the same way. Each sync therefore carries its own rule. This is the table of what each one carries. Section 6.4 is why getting one of them wrong is invisible.

The rules are built from three patterns. The notation on the left is the regular expression as written in the workflow; the right-hand column is the same thing in a form anyone can check a reference against by eye.

Pattern In plain language
/\.CM\.\d+$/The reference ends with .CM. followed by one or more digits. Example: 2024-10687.CM.01. The invoice syncs use it to exclude credit notes; the credit note syncs invert it and require it.
/^ARADJ-\d+$/The whole reference is ARADJ- followed by one or more digits, and nothing else. Example: ARADJ-12345. This is the accounting system's adjustment marker, not a Maxio invoice.
/^ARADJ-\d+$|^\d{1,6}$/Either the whole reference is ARADJ- plus digits, or the whole reference is nothing but digits, at most six of them. A reference of seven or more digits is not caught, and neither is anything carrying a prefix and a hyphen.

The rule in force, per sync (the two gated Denmark rows included):

Wide table: scroll it sideways.

Workflow Org Entity Rule in force What it does, in plain language
JG6mI8OcCb4tXr6fLimitedINVOICE/^ARADJ-\d+$|^\d{1,6}$/, excludeSkip anything that is entirely ARADJ- plus digits, and anything that is nothing but digits up to six of them. Everything else is treated as a Maxio reference
zOD0fHLklBbiLhCtDACHINVOICE/^ARADJ-\d+$/, excludeSkip only what is entirely ARADJ- plus digits. Plain numbers are processed
n7d1kAq17HNxRakuIrelandINVOICE/^ARADJ-\d+$|^\d{1,6}$/, excludeSame as Limited
csYbjQbTH8WEKWLrDenmark, gatedINVOICE/^ARADJ-\d+$|^\d{1,6}$/, excludeSame as Limited. Inherited, and unverified against Denmark's numbering. See the flag below
cj9xM1f3K5rpekIQLimitedCM/\.CM\.\d+$/, requireProcess only references ending .CM. plus digits. Everything else is skipped
Qp5QD04EqXV4SvrHIrelandCM/\.CM\.\d+$/, requireSame
vcRMzE7C691unWs1DACHCM/\.CM\.\d+$/, requireSame
1HILgxzf71N91jPvDenmark, gatedCM/\.CM\.\d+$/, requireSame. No org-specific rule is involved, so this one carries no format risk

Checking a reference by hand, without reading any code. Entirely ARADJ- and digits: skipped everywhere. Nothing but digits: skipped on Limited, Ireland and Denmark, processed on DACH. Ends in .CM. and digits: only the credit note syncs will touch it, and the invoice syncs will not. Anything else: handed to that org's Maxio cache for matching.

Warning

Three invoice syncs carry the identical rule, and their organizations do not all number alike. That is the shape section 6.4 describes, and here is where it currently stands.

  • Limited and Ireland. The rule fits them: their Maxio invoices are numbered as a four-digit prefix, a hyphen, then digits (YYYY-XXXXX), so a bare integer there really is a record that never came from Maxio. That numbering convention is recorded in a code comment inside the workflows. It has not been verified independently against Maxio data.
  • DACH. DACH numbers its Maxio invoices as plain integers, no prefix and no hyphen, which is why its rule is the narrower one. Limited's rule applied to DACH excludes DACH's entire normal traffic.
  • Denmark, both gated syncs. The rule is inherited from Limited and is unverified against Denmark's actual numbering. Nothing in the workflows records how Denmark numbers its Maxio invoices, and neither does this document. If Denmark numbers like DACH rather than like Limited, switching those syncs on reproduces the shape exactly: every real invoice excluded, every run green, nothing logged, no alert. Comparing a handful of Denmark references in Maxio against the two shapes settles it in minutes, and it is the first item on the checklist in section 7.1.

The credit note syncs need no org-specific rule and carry none. Requiring .CM. plus digits already excludes ARADJ- references and bare integers by construction. That is why this hazard can only ever affect the four invoice syncs.

Where the rule lives. Since 2026-08-23, on the three active invoice syncs the pattern is a visible value — nonSoPattern in Set: Sync config — read by both the filter and its statistics mirror, fail-loud: a missing or invalid value stops the run instead of silently compiling a rule that excludes everything. The old trap of the value being written twice in code, and the two copies drifting apart, no longer applies to the value; the mirrored logic remains, so a change to how the rule is applied still has to land in both Code: Filter & match chain and Code: Compute stats from filter inputs in the same edit.

The two gated Denmark syncs predate the hoist and still carry the pattern inside their code, twice each, with the inline-versus-named-constant inconsistency that used to affect the whole family. They are rebuilt on the current pattern before use (section 7.1), which retires that inconsistency with them.

On where the digit rule came from. A code comment on Limited and Ireland records its justification as pending, which means the origin of the "at most six digits" test was never established even by whoever wrote it. The rationale that supports it today, that Maxio numbering there is YYYY-XXXXX, was written afterwards. Whether it was checked against Limited's and Ireland's real data or inferred cannot be established from the workflows. Treat it as an assumption that has held in practice, not as a verified rule.

8.6 Configuration reference

In plain language. Almost everything adjustable in a sync sits in one node at the top of the workflow, called Set: Sync config. It holds a handful of variables and every other node reads them from there. This section is what each variable is, what it is set to on each sync, and what to know before touching it. Two things are worth reading before the tables: batchCap = 0 now means the same thing — a dry run — on every active sync, and a few tunable values still live in code rather than in that node, a list that shrank on 2026-08-23.

Everything in Set: Sync config is stored as text, including the values that are obviously numbers. That is why every consumer parses them before use, and why a value typed with a stray character still looks perfectly fine in the editor.

Values per sync, read on 2026-08-23 (the gated Denmark pair included for completeness):

Wide table: scroll it sideways.

Knob Limited INV DACH INV Ireland INV Limited CM Ireland CM DACH CM Denmark INV (gated) Denmark CM (gated)
upflowBaseUrlhttps://api.upflow.io/v1samesamesamesamesamesamesame
orgMetaCompliance LimitedMetaCompliance DACHMetaCompliance IrelandMetaCompliance Limited CMMetaCompliance Ireland CMMetaCompliance DACH CMMetaCompliance DenmarkMetaCompliance Denmark CM
entityTypeINVOICEINVOICEINVOICECMCMCMINVOICECM
batchCap1010101010101010
upflowPageSize500500500500500500200200
pagesPerChunk101010101010absentabsent
recheckAfterDays777777in codein code
nonSoPattern^ARADJ-\d+$|^\d{1,6}$^ARADJ-\d+$^ARADJ-\d+$|^\d{1,6}$in codein code

What each one controls, and what to know before changing it:

Knob What it controls Before you change it
upflowBaseUrlThe base URL for every Upflow call: the count, the listing, and the pushIdentical everywhere and there is no reason for it to diverge. It is also passed into the sub-workflow through the chunk plan, so a change has to survive that hop
orgTwo jobs at once. It is the label written into the org column of every mc-sync-log and mc-sync-stats row, and it is the filter key used to read those tables backThe highest-risk knob here. Changing it does not migrate history, it makes the workflow blind to everything it has already done under the old label. Every invoice it has ever pushed becomes a fresh candidate and the whole organization is re-pushed on the next run. Note that each credit note sync deliberately uses a different org value from its invoice sibling, with a CM suffix. That suffix is what keeps the two histories apart in a shared table. Do not tidy it away
entityTypeNothing. It is declared and never readNo node, no expression and no code in any of the eight workflows reads it. The real invoice-versus-credit-note distinction is carried by three other things: the endpoint path in the HTTP nodes (/invoices against /credit_notes), the hard-coded invoice_type value in the pre-attempt node and in all five log-row builders, and the direction of the .CM. test (section 8.5). Changing entityType alone changes nothing at all, which is a trap in the opposite direction from the usual one: it lets a maintainer believe a workflow has been re-pointed when it has not
batchCapThe maximum number of PDFs one run will process. The filter stops collecting candidates once it has this manyThe value 0 is a dry run on every active sync. See below. Note also that this caps work, not scanning: the full organization is read every run regardless. Raising it lengthens the run roughly in proportion, because the loop handles one candidate at a time with one call to Maxio and one to Upflow each. Daily throughput is this value multiplied by the number of ticks the sync gets, so changing the schedule (section 8.4) changes throughput as well
upflowPageSizeHow many invoices are requested per page from UpflowCoupled to pagesPerChunk: one chunk is the two multiplied together, and the number of sub-executions is the org's invoice count divided by that product, rounded up. Code: Build chunk plan throws if this is not a positive whole number, which is a deliberate fail-loud guard
pagesPerChunkHow many pages one sub-execution fetches. It is wired straight into the HTTP node's pagination limitThe memory held by one sub-execution is roughly upflowPageSize multiplied by pagesPerChunk invoices. Raising it means fewer sub-executions and more memory each, and that trade-off is the entire point of the chunked generation. Code: Build chunk plan throws if it is not a positive whole number

Warning

batchCap = 0 is a dry run on every active sync. The workflow scans the organization, computes its statistics, pushes nothing, and reports the run as a dry run in the summary. The historical trap — on the retired pull-all generation, 0 was coerced to 5 and pushed five real PDFs — left production on 2026-08-20 and survives only on the two gated Denmark workflows, one more reason they are rebuilt rather than switched on as they stand (section 7.1).

Tunable constants that live in code, not in Set: Sync config. This is the part a maintainer will go looking for in the configuration node and will not find. It shrank on 2026-08-23: the re-check window and the reference rule moved into Set: Sync config.

Wide table: scroll it sideways.

Constant Value Where it lives What it controls Before you change it
Outstanding statesDUE, OVERDUECode: Filter & match chain and Code: Compute stats from filter inputs on the invoice syncs, and again in Code: Filter outstanding + trim chunk inside their chunk sub-workflowsThe definition of "outstanding"Deliberately absent from the credit note syncs, because Upflow's credit notes carry no state field at all (section 8.8). Restoring it there would exclude every credit note. The same list exists in the sub-workflow and in the parent's filter, redundantly, and the two have to agree
PDF minimum size1024 bytesCode: Encode PDF base64, all syncsBelow this the download is rejected as too smallIdentical everywhere
PDF magic bytes%PDF-, first five bytesCode: Encode PDF base64, all syncsThe file-type checkIdentical everywhere. This is the check that catches an HTML page arriving in place of a PDF (section 5.9)
Push success status204 exactlyCode: Build log row, all syncsWhat counts as a successful pushIt is an exact test, not a range test over 2xx. If Upflow ever answered 200 or 201 to a successful push, every push would log as failed while actually succeeding
batchCap fallback5Code: Filter & match chain and Code: Compute stats from filter inputsThe value used when batchCap does not parse as a whole number at all0 itself parses and means dry run; the fallback only covers a genuinely broken value
PDF URL rewrite/?no_track becomes /invoice.pdf?no_trackCode: Enrich pdfUrl from cache and again in Code: Resolve final PDF URL, all syncsHow the download URL is derived from the viewer URL held in the cacheIf Maxio ever changes this URL shape, every organization breaks at once, and the symptom is a failed magic-bytes check rather than a 404 (section 8.9)
Notification thresholds5 and 10A configuration node inside MC - Slack notify risks, which is a different workflowThe delta thresholds that decide whether a run is worth a Slack postShared by all syncs. Changing them changes alerting for every organization at once. Since 2026-08-23 the delta thresholds are suspended on saturated batches — the parents pass batch_cap in the payload for that purpose — while the critical failed-push threshold stays unconditional
Cache table nameOne per orgThe data table node's own configuration, not a variableWhich Maxio cache the sync readsmc-so-cache-limited for both Limited syncs, mc-so-cache-ireland for both Ireland syncs, mc-so-cache-dach for both DACH syncs, mc-so-cache-denmark for both Denmark syncs. Note the asymmetry: an organization's invoice and credit note syncs share a cache table while carrying different org labels

8.7 Status values and error codes

In plain language. Every attempt the system makes on an invoice ends up as one row in mc-sync-log, and two fields on that row say what happened: status says whether it worked, and error_code says why it did not. Both are closed sets. This section is the complete list of what can appear in them. Section 6.3 makes an operational decision depend on whether a code is one you have seen before or a new one, so the value of this list is that it is complete.

Exhaustive as measured on 2026-08-23, against the definitions of the six active sync workflows, the cache builders and the manual re-push tool. Two rows carry a caveat on the word "exact", and they are marked.

status takes exactly four values.

Literal What produces it What it means
attemptedData Table: Insert pre-attempt row, before the PDF is fetchedThe write-ahead marker (section 5.2). A row that stays at attempted, with no later row for the same invoice from the same execution, means the run died mid-invoice
successCode: Build log row, when the push returned HTTP 204The PDF is in Upflow
failedCode: Build log row on any non-204 answer, plus the four other log-row buildersEverything else
amended_repush_pendingThe cache builders' amendment detector (2026-08-23), flipping a former success rowThe pushed invoice was re-emailed by Maxio after its push. It has left the already-pushed exclusion, and the next tick re-pushes the current PDF; the flipped row then stays as history beside the new success row (section 7.5)

Note

There is no skipped status, and that is deliberate. A Maxio cache miss is logged as failed, not as a separate category. The code records the reason: keeping the schema consistent means the invoice is naturally retried on a later run. A maintainer looking for a skipped status will not find one.

invoice_type is closed in the same way: INVOICE on the invoice syncs, CM on the credit note syncs, hard-coded in the pre-attempt node and in all five log-row builders.

error_code, the complete list.

Read one column with care. Permanent and retryable are not properties declared anywhere in the workflows. No node classifies a code. The behaviour is emergent: it follows entirely from which exclusions Code: Filter & match chain builds when it decides what is a candidate. The column below states that emergent behaviour, not a setting anyone can find.

Wide table: scroll it sideways.

# Exact literal What produces it Permanent or retryable Which workflows
1empty stringCode: Build log row, when the push returned HTTP 204. Paired with status = 'success'Excludes the invoice from future runs until the amendment detector re-opens it (section 7.5)All six
2no_so_match_in_cacheCode: Build cache skip log row, when the outstanding Upflow invoice has no matching row in that org's Maxio cacheTime-bounded. Excluded only while the log row is under 7 days old, then the invoice re-enters the poolAll six
3no_einvoicing_urlCode: Build cache skip log row, when the cache row exists but carries no PDF link. An invoice that predates e-billing, or one dated in the futureTime-bounded, 7 days, same as aboveAll six
4invalid_pdf_magic_got_"…"Code: Build error log row, from the check in Code: Encode PDF base64, when the first five bytes of the downloaded file are not %PDF-. The observed bytes are written into the literal, so the string varies with every occurrenceRetryable everywhere since the redirect fix went family-wide on 2026-08-20 (section 5.9): the failure stays visible in mc-sync-failures and retries every tickAll six
5pdf_too_small_<n>_bytesSame node, when the downloaded file is under 1 024 bytes. <n> is the actual byte count, so this string also varies with every occurrenceRetried on every run. It is in no exclusion setAll six
6so_download_failed: <message>Same node, when the binary could not be read at all. The download node's retries were exhausted and it passed the item through. The message is the underlying exception text, or no_binary_data. Varies with every occurrenceRetried on every runAll six
7invalid_pdfCode: Build error log row, as a fallback when an item reaches the invalid branch with no validation error attached. Should not occur in normal operationRetried on every runAll six
8js_exception_encodeCode: Build js encode error log row, when the encoding node threw a JavaScript exception. The exception text is stored separately, truncated to 500 charactersRetried on every runAll six
9An HTTP status as a string, for example 400, 401, 404, 422, 500Code: Build log row, when the push returned any status other than 204Retried on every runAll eight. The set of values here is whatever Upflow returns and cannot be enumerated from the workflows
10The manual_… familyMC - Manual re-push by invoice number, one row per real (non-dry) attemptOperator-initiated; the operator is watching, so these rows do not feed mc-sync-failuresThe manual tool only (section 7.5)
10UNKNOWNCode: Build log row, when the push response carried no status code at allRetried on every runAll six
11An HTTP status as a string, from the error branchCode: Build push http error log row, when the push node threw rather than answeringRetried on every runAll eight. Same caveat as row 9
12HTTP_UNKNOWNCode: Build push http error log row, when the push threw and no status could be read from the errorRetried on every runAll six

Four things to take from that table:

  1. Three codes are families, not fixed strings. Rows 4, 5 and 6 embed observed data in the literal itself. Any tooling that matches on exact equality will miss them. The workflows themselves match on the prefix for row 4 and never match on rows 5 or 6 at all.
  2. Rows 9 and 11 are closed in form and open in value. The literal is always an HTTP status turned into a string, so the shape is exhaustive; the set of statuses Upflow can return on a PDF push is not documented and cannot be established from here. That is the one place where "exhaustive" needs its qualifier.
  3. Only four retry behaviours exist, and together they are the whole retry policy: permanent on success, permanent on a magic-bytes failure (seven syncs of eight), a 7-day window on the two cache codes, and retry-on-every-run for everything else. There is no back-off, no dead-letter state, and no attempt counter that grows. The attempt_count column in mc-sync-log is written as a literal 1 by every builder, so despite its name it is not a running total. The count that does increment lives on the mc-sync-failures row.
  4. The taxonomy is uniform across the six active syncs, plus the manual_… family that only the operator tool emits. Since 2026-08-20 no code is treated differently by one sync than by another.

Important

The workflows themselves make no distinction between a known code and a new one. Only three tests exist anywhere in the code: the magic-bytes prefix, and exact matches on the two cache codes. Every other code, seen a thousand times or never seen before, is treated identically: retried on the next run and on every run after it. The failure-tracking sub-workflow stores the code verbatim without interpreting it.

The consequence is that the known-versus-new judgement in section 6.3 is a human one, and this table is what it rests on. A code in mc-sync-failures that is not on this list is by definition new, and that is the signal worth escalating.

8.8 The Upflow API

In plain language. Upflow is where the PDFs go. The integration reads a list of invoices from it and posts a file back, and that is the whole of the contract. What follows is that contract as this integration uses it, plus the handful of places where the API does not behave the way its shape suggests. Several of the architectural choices described earlier in this document are forced by the limitations listed here rather than chosen.

Base URL. Production is https://api.upflow.io/v1. A separate sandbox exists at https://api.sandbox.upflow.io/v1, fully isolated: separate database, separate test payment keys, separate outbound email domain, and no data crossing between the two. Both are declared in the vendor's OpenAPI schema.

There is no public machine-readable copy of that schema. The vendor's developer portal is a JavaScript application that cannot be fetched, and the obvious schema URL answers 404. The only queryable endpoint reference is a copy obtained from the vendor and held locally, so whoever takes this over should ask the vendor for one rather than assume the statements below are still current. Everything said here about the schema was read from such a copy at the date on this document.

Authentication. Two headers, both mandatory on every request. Omitting either returns 401.

X-Api-Key:    <key>
X-Api-Secret: <secret>

Two properties of a key pair are worth knowing before anyone debugs one:

  • Keys are issued per organization, in that organization's own settings. Four organizations mean four independent pairs, and there is no tenant selector on the request, so routing between them has to be explicit. This is why the integration carries four separate Upflow credentials (section 8.3).
  • A pair is sealed to one environment. The same pair returns 401 against the sandbox host and works against production. A 401 on the sandbox host is therefore not evidence that a key is dead, it may simply be a production key. Probe both hosts before declaring a credential invalid, and never let a credential labelled sandbox hold a production key.
  • There is no endpoint that says which organization a key belongs to. The only known way to attribute a pair is to fetch one customer and read the organization slug out of the URL field on the returned record. On an organization with no customers there is nothing to read, so the check comes back inert rather than failing.

Pagination.

Wide table: scroll it sideways.

Query parameter Meaning Default Maximum
offsetZero-based index of the first item returned0none stated
limitItems per page50500
pageOne-based page number, an alternative to offsetnonenone stated

If both page and offset are sent, offset wins. Response bodies carry the offset, page, limit and total alongside the array, whichever paging mode was used.

Warning

Three pagination traps, all of which fail quietly.

  1. The schema's internal parameter names are not the query-string names. In the OpenAPI document the parameters are catalogued under keys like paginationLimit. Those are the schema's own reference names. Sending ?paginationLimit=500 is silently ignored: no error, no warning, and the default page size of 50 applies. When wiring a client from the schema, read each parameter's name field, never the catalogue key.
  2. The array is not always under the same key. The invoice listing returns it under invoices and the customer listing under customers, but credit notes and payments return it under the generic key items. A parser that hard-codes a key returns zero results on those collections and raises nothing.
  3. The reported total can over-count. One collection has been observed reporting a total one higher than the number of records actually reachable at any offset, most likely a deleted or hidden record still being counted. That is a hypothesis, not a verified explanation. Do not treat "fewer items than the total" as proof of a pagination bug without probing the offsets.

The four endpoints this integration calls. Nothing else is called against these organizations.

Method Path Purpose
GET/v1/invoicesList the organization's invoices, paginated. The source of the invoice UUID, its state, and its reference
POST/v1/invoices/{invoiceId}/pdfAttach the PDF to an invoice. The only write this integration performs
GET/v1/credit_notesList the organization's credit notes, paginated
POST/v1/credit_notes/{creditNoteId}/pdfAttach the PDF to a credit note

Creating or updating customers and invoices is deliberately never done from here. Those records belong to the ERP sync, and writing them from this integration would risk duplicating or overwriting data it does not own.

The identifier model, and the shortcut that does not work. This is the part most likely to be got wrong, because an Upflow invoice carries three identifiers and only one of them is the key this integration matches on.

Wide table: scroll it sideways.

Identifier What it is Who populates it Shape
idUpflow's own primary keyUpflow, when the record is createdA UUID
customIdThe human-readable document numberThe ERP sync, from the source invoice numberWhatever the source system numbers the document. On Limited and Ireland that is a four-digit prefix, a hyphen, then digits; on DACH it is a plain integer (section 8.5). A credit note carries the same reference followed by .CM. and digits, for example 2024-10687.CM.01
externalIdThe ERP's own internal record identifierThe ERP syncA short numeric string, unrelated to the document number

The integration matches on customId. It is the one value that is identical in Maxio, in the ERP, and in Upflow. The push uses id. Both arrive together on the invoice listing, which is why the sync performs no lookup call at all (section 4.1).

Warning

The external: path prefix resolves externalId, not customId. Upflow accepts external:{value} in place of a UUID on many paths, which makes it tempting to post straight to /invoices/external:{document number}/pdf and skip the listing. That fails: the prefix looks up the ERP's internal record identifier, which is a value the billing system does not know. It has been measured returning 404 on every document tried. The same asymmetry applies to credit notes, where the prefix additionally cannot be used for matching at all. The UUID is the only usable path parameter here.

What cannot be filtered server-side, and what that forces.

  • There is no filter on invoice state. The complete filter set on the invoice listing is the three pagination parameters, two customer filters, an updatedAt comparison, customId, a dunning plan filter, and a sort order. There is no way to ask the server for the outstanding invoices only. Narrowing to outstanding has to happen after the pull, on the client side, which makes the read-everything-then-filter shape an incompressible baseline of the design rather than a choice. It is also the direct cause of the memory exposure described in sections 3 and 5.8.
  • There is no field anywhere that says whether a PDF is attached. The schema carries no such flag on the invoice or on any sibling object, there is no attachments sub-resource for invoices, and the /pdf sub-path exposes a POST only, with nothing to read or probe with. The consequence for the design is direct: the integration has to keep its own record of what it has already pushed, which is what mc-sync-log is for.
  • The field that looks like it answers that question does not. In production every invoice carries a populated PDF URL field regardless of whether a PDF was ever uploaded, and those URLs do not resolve. The vendor's own clarification is that the field means the record has a URL value, possibly a placeholder or a derived one, not that a PDF is stored and downloadable. The URL shape is identical in both cases, so there is no signal to tell them apart. The sandbox behaves differently, populating the field only after an upload, which means logic validated in sandbox produces a design that fails in production.
  • customId filters invoices and is silently ignored on credit notes. The same parameter that returns a single match on the invoice listing is not a declared parameter on the credit note listing, so it is discarded and the whole pool comes back paginated. Code that assumes symmetry takes the first record of that pool and treats it as the match. Matching a credit note has to be done in memory.
  • Credit notes carry no state field at all. Any outstanding-state test copied over from the invoice path evaluates against nothing and skips every credit note. That is why the four credit note syncs deliberately do not carry one (section 8.6).
  • The documented state values are incomplete. The schema lists five. A full production scan found a sixth in live data, VOIDED. Write state tests as allow-lists over known-good values, and treat the schema's list as incomplete rather than closed.
  • There are no webhooks. The vendor has answered this consistently for more than two years, most recently reconfirmed in January 2026. Polling is the official pattern, which is why this system is built on schedules.

Rate limits, and where the number actually comes from. The stated figures are 600 requests per minute and a cap of 10 simultaneous requests, with the vendor's own guidance for bulk work being to run sequentially, to cap at 5 concurrent if parallelising, and never to run simultaneous operations against entities linked to the same customer.

Note

Provenance matters here. Those figures come from the vendor's prose integration guide, not from the OpenAPI schema. The copy of the schema read for this document contains no rate-limit statement of any kind: no request ceiling, no mention of 429, no quota, no concurrency. The figure is well established but it is not contractual from the schema, and anyone who needs it contractually should re-confirm it with the vendor rather than look for it in the specification.

Observed behaviour does not match a naive reading of it either. A 429 has arrived on the very first request of a run, a run that had issued exactly one request and therefore cannot have caused it. At the same moment a different organization's first request, on a different key, timed out at the edge after two minutes. Three other syncs firing on the same tick succeeded, and every tick before and after was clean. The likeliest reading is transient degradation on the vendor's side rather than client burst, and that remains a hypothesis. Two things follow regardless of the cause: a 429 here is not proof that the client misbehaved, and the real scope of the limit, whether per key, per organization or per source address, is undocumented.

One non-obvious rejection. Requests carrying a default Python HTTP library User-Agent receive 403 from the production host even with entirely valid credentials, reproduced with the User-Agent as the only variable while the same headers pass from a normal command-line client. It is a web application firewall rule. Any script written against this API has to set its own User-Agent.

The PDF upload endpoint specifically.

  • Path parameter: the UUID, for the reason given above.
  • Two body formats are accepted, both validated. A regular file upload with a single file field, and a JSON body carrying the PDF base64-encoded. This integration uses the JSON form, because in a chain of HTTP steps each upstream call would otherwise overwrite the binary slot with its own response. Base64 inside a JSON field survives that. The cost is roughly a third more bytes on the wire.
  • The only success answer is 204 with no body. It is the only response code the schema declares and the only one observed, which is why the workflows test for exactly 204 (section 8.6).
  • Overwriting is last-write-wins, verified byte for byte. Pushing a first PDF onto an invoice with none returns 204 and downloads back identical. Pushing a different PDF onto the same invoice returns 204 and downloads back as the second file, with the first gone. This is what makes re-pushing safe, and it is the property section 5.1 rests on: the integration never has to check whether a PDF is already there.
  • This endpoint is the only channel that surfaces a PDF in the interface for these organizations. A workspace-level setting restricts visible PDFs to those uploaded through it, and PDFs arriving by any other route are hidden. That setting is on for the organizations in scope. There is no fallback path: if this endpoint stops working, no PDF reaches the interface.

One last property, for anyone extending the integration beyond PDFs. Every monetary amount in this API is expressed in minor units, on read and on write, across every amount field. Nothing in the field naming says so, and the consequence for anyone comparing figures across systems is a uniform hundredfold discrepancy that looks like a data corruption and is not.

8.9 The Maxio (SaaSOptics) API

Caution

Read this before opening any documentation. Maxio SaaSOptics is not Maxio Advanced Billing, formerly Chargify. They are sold under one brand and they are entirely separate products, with different authentication, different endpoints, different pagination and different data models. The public Maxio developer documentation covers Advanced Billing, which is the wrong product for this integration. Following it leads to a different authentication scheme, a different URL structure and a different object hierarchy, and nothing will work.

SaaSOptics, this integration Advanced Billing, not this
Instance URL containssaasoptics.comchargify.com or ebilling.maxio.com
AuthenticationAuthorization: Token <token>HTTP Basic
Object hierarchyCustomer, then contract, then transaction, then invoice, then line itemsSite, then subscription, then invoice
PaginationA count, a next link, a previous link, a results arrayPage-based, different envelope

How to tell which one you are on: read the instance URL. If it contains saasoptics, it is SaaSOptics. That check costs seconds and it prevents a full day of debugging the wrong product.

In plain language. Maxio is where the PDFs come from. The integration reads invoice metadata out of it, mostly to find one field: the link to the invoice PDF. It never writes anything, so a read-only token is enough.

Hosts and account paths. The four entities do not share one host.

Wide table: scroll it sideways.

Entity Currency Host Account path
LimitedGBPz93.saasoptics.commetacompliance
DACHEURs12.saasoptics.commetacompliance_dach
IrelandEURm12.saasoptics.commetacompliance_ireland
DenmarkDKKm12.saasoptics.commetacompliance_moch

The base URL is built as the host, then the account path, then the API version segment. Two consequences for routing. First, a client has to switch on the host and account path together, not on the account path alone: Limited is on one host, DACH on a second, Ireland and Denmark share a third. Store the four base URLs explicitly, paired with their credential, and never derive a host from an entity name. Second, the Denmark entity is internally labelled metacompliance_moch, a legacy naming artefact on MetaCompliance's side. The API recognises only that path, so it has to be preserved in the URL even though every human-facing reference calls the entity Denmark.

More generally, the entity names in this system are inconsistent and contain typographical errors. Do not string-match on entity names to build the mapping. Pair entities by currency and by their Upflow counterpart, and read the entity list from the API rather than from a screenshot.

Authentication. One header, using the literal scheme word Token. Not Bearer, and not HTTP Basic.

Authorization: Token <token>

Tokens are issued per entity in the administration settings, with a write-permission checkbox. This integration only reads, so a read-only token is sufficient and preferable.

Warning

Do not set this header by hand on a node that already carries a credential setting it. The two are concatenated into one malformed value, and the API rejects it with a message complaining about the token containing spaces. That message reads exactly like a corrupted token and sends the reader off to regenerate a perfectly good one. The fix is to delete the manual header and keep the credential.

Pagination. Responses carry a total count, a next link, a previous link, and a results array. The count is the total number of records matching the query and comes back on the first page without iterating, so a request for a single record is a cheap count-only probe. The largest page size observed is 100. The next and previous links are full absolute URLs, or null.

Warning

On the last page the next link is null and the response is not empty. It still carries the count, the results and the previous link. Any pagination loop whose stop condition is "the response came back empty" never fires, resolves the next URL to null, and then issues a request against the literal string null. The stop condition has to be the absence of a next link, not the emptiness of the body.

The invoice object, fields that matter here.

Field Type Notes
idIntegerInternal to Maxio. Not a cross-system key: it exists in no other system
numberStringThe human document number. This is the cross-system matching key, identical in the ERP and in Upflow's customId
dateDateThe invoice date. It may be far in the future, see below
is_paidBoolean
committedBooleanPresent on the object and not usable as a filter, see below
einvoicing_urlString or nullThe PDF link with click tracking. Not the one to use
einvoicing_url_no_click_trackingString or nullThe PDF link without tracking. This is the field the integration reads
line_itemsArraySizeable, and the main contributor to response weight
auditentryObjectCarries the creation and modification timestamps. The modification timestamp is the only reliable filtering axis

Two properties of number that mislead. First, the four-digit prefix is not the issue year: an invoice carrying a prefix several years old has been measured as issued recently, with a due date a month later. The prefix is most likely a fixed contract or template identifier, and that is a hypothesis. Never infer a document's age or its relevance from its number. Second, the invoice collection contains credit memos as well as invoices. Maxio keeps both in the same collection, and they are distinguishable only by the shape of the number: a credit memo ends in .CM. and digits, for example 2024-10687.CM.01. Credit memos do have their PDF link populated, so any "first record with a PDF" selection accepts one happily, and it then has to go to Upflow's credit note collection rather than to invoices. This is why the reference-shape test (section 8.5) runs before the lookup.

Future-dated invoices exist and distort sorting. Multi-year contracts are pre-booked, which produces invoices dated years ahead. Sorting by date descending therefore surfaces those first rather than the recently actionable ones. Sort by the modification timestamp descending for any query meaning "recent". Future-dated invoices normally have no PDF link, because they have not been e-billed yet.

The PDF link, and how a usable URL is built from it. The field is einvoicing_url_no_click_tracking. It is a public tokenised URL: no authentication is needed to fetch it, which is why the download node in every sync carries no credential.

Its base shape is https://<subdomain>.bills.saasoptics.com/p/i/{token}/?no_track, and that base URL serves the HTML viewer page, not the PDF. To get the PDF, the trailing /?no_track is replaced by /invoice.pdf?no_track. That single substitution is the rewrite rule listed in section 8.6, and it is applied in every sync.

When the field is empty. It is populated only once the invoice has actually been e-billed. Invoices predating e-billing adoption have it null, and so do future-dated pre-booked ones. Coverage measured on the largest entity, restricted to invoices modified since the start of 2025, was around 86 % on a 100-record sample, which is a sample-based figure and not a population measurement. Never assume full coverage; check the field before attempting a download. An empty field is what produces the no_einvoicing_url code in section 8.7.

What a good response looks like: content type application/pdf, the bytes %PDF- at the very start, and a size in the low tens of kilobytes for a typical one-page invoice. Validate both the magic bytes and a minimum size, because some tooling infers the content type from the .pdf in the URL rather than from the response header and will cheerfully report a PDF content type for an HTML error page. That is exactly what the validation stage does (section 4.4).

Token rotation is the operationally significant behaviour on this side, and it is fully characterised in section 5.9 rather than repeated here. The short version: the tokens in these links rotate silently, a rotated link answers with a redirect whose target drops the /invoice.pdf segment, and a client that follows redirects automatically downloads the viewer page and stores it as if it were the invoice.

Caution

Filters that are accepted and then silently ignored. This is the single most dangerous property of this API, because a query that has been silently unfiltered returns results that look entirely plausible: the right shape, a count in the right range, and no error anywhere.

Filters confirmed working on the invoice collection:

Filter Notes
Modification timestamp, greater-or-equalThe primary scoping axis for both backfill and incremental refresh
Modification timestamp, strictly-less-thanAn exclusive upper bound. Prefer it for windowing, because half-open windows do not double-count at the joins
Modification timestamp, less-or-equalAn inclusive upper bound. Verified to return the same count as the exclusive form on a test window
contractAn integer foreign key
customerAn integer foreign key

Sorting is available on the number, the date, the contract number and the modification timestamp.

Filters that are accepted, dropped, and answered as though nothing was asked:

Parameter What actually happens
Date range filters on the invoice date fieldIgnored. Returns exactly the same set as no filter at all
A null test on the e-invoicing URL fieldIgnored. That lookup is not exposed on this field
The modified-by name inside the audit entryIgnored. Only the timestamp is honoured through that traversal
committed=trueIgnored. The boolean is not exposed as a filter. A timestamp variant is documented as the alternative and has not been tested
number=<document number>Ignored. The request returns a full page of invoices, none of them the one asked for

That last one deserves the emphasis, because it is the one a maintainer is most likely to write. The number is a top-level attribute and it is the cross-system key, so filtering on it is the obvious first instinct. It is not exposed as a filter. There is no server-side way to fetch one invoice by its document number. Either paginate and match in memory, or use the internal integer identifier if it happens to be known. This is precisely why the system maintains a local cache keyed on the document number (section 4.2).

The working rule that follows: after adding any filter to a query against this API, verify that it actually filtered. Run it against a case whose answer is already known and compare counts. Never treat "the parameter was accepted" as "the parameter was applied".

Two consequences of all of the above that are visible in the architecture. First, there is no API-side way to scope to "invoices that have a PDF", so the cache builder fetches every invoice in the modification window and filters on the link being present, on its own side. Second, since the document number cannot be used as a filter, the cache is the only thing that turns an Upflow reference into a Maxio PDF link in one step. A cache miss is therefore a lookup failure, not proof that the invoice does not exist in Maxio: a document last modified outside the refresh window is absent from the cache while existing perfectly well upstream.

9. Making changes safely

In plain language. Everything before this section describes what the system is and how it behaves. This section is about touching it. It has three parts: 9.1 is how work is done here, and why each rule exists; 9.2 is the set of checks a change passes before and after it reaches production, each written so that it can be answered yes or no; 9.3 is the full procedure for adding a new organization, which is the extension this architecture was built to absorb.

Nothing here repeats the reference material. Where a rule or a gate depends on a fact, it points at the section that holds it.

9.1 The change discipline

Five rules. Each one exists because of something in this system that does not announce itself at edit time.

1. A change of shape is never made in place on a live workflow. A change of value is. The line runs between the two, and it is the line the system actually supports.

  • Editing a value is applied in place, on the live workflow, and does not call for a clone. That covers a field in Set: Sync config, a cron expression, and a schedule. The workflow keeps its identity, its history and its settings, and reducing a schedule across the eleven scheduled workflows is eleven edits rather than eleven clones.
  • Changing the shape is built on a clone and switched over. That covers adding or removing a node, rewiring a branch, and editing the source of a Code node. Clone the workflow, build the change on the clone, test the clone, then switch over: activate the clone and deactivate the original in the same sitting.

One boundary case, because the system has it and it is the one that gets read the wrong way. Some values are not in a configuration node at all, they are written inside Code node source: the mirrored exclusion values of rule 2 and the constants listed in rule 3. Changing one of those means editing source, so it sits on the shape side and goes on a clone, even though what is being changed looks like a value. The test is where the edit lands, not what the thing is called.

The reason the second case is held this firmly is that a shape change is the one that cannot be undone by reversing the edit. A saved workflow on this hosting model has no per-workflow version history to fall back on (rule 5), so the original left intact and switched off is the rollback, and gate G14 is what makes sure someone can still name it afterwards. A value edit is reversible by typing the old value back, which is why it does not carry the same weight.

Rename the superseded workflow at the moment of the switch, not when the clone is made. The [DEPRECATED v9.3.0] and [DEPRECATED v9.4.0] prefixes in section 8.1 are what a completed switch looks like. Applying that prefix earlier, while the workflow is still the one serving production, mislabels the instance for everyone who looks at it afterwards: the workflow list then says the live sync is retired and the retired one is live, which is exactly backwards at the moment someone is trying to work out what is actually running.

2. The exclusion logic is written twice inside every sync, and nothing keeps the copies in step. Code: Filter & match chain decides what is excluded; Code: Compute stats from filter inputs counts the same exclusions for the statistics. Since 2026-08-23 the two org-specific values they used to duplicate — the reference rule and the re-check window — are read from Set: Sync config by both, fail-loud, so the value can no longer drift between them. A change to the logic still has to land in both nodes in the same edit: changing only the filter leaves the coverage verdict describing something the run did not do; changing only the statistics node leaves the report wrong while behaviour is unchanged. The outstanding-state list also exists a third time, inside the chunk sub-workflow, and it has to agree with the parent (section 8.6).

3. Some values live inside Code node source, not in the configuration node. A maintainer who opens Set: Sync config, does not find a value there, and concludes it is not adjustable will be wrong every time. The second table in section 8.6 lists each of those constants, its value, and the node it lives in. One of them is not even in the same workflow: the Slack notification thresholds sit in a configuration node inside MC - Slack notify risks, and changing them changes alerting for every organization at once.

4. Renaming a node is not a cosmetic edit. Several nodes are read by name from expressions elsewhere in the same workflow, the name is matched as a literal string, and renaming one leaves the editor perfectly happy while breaking the workflow at runtime on the next scheduled tick. Section 4.4 carries the list of protected names and how many expressions point at each. Set: Sync config is the one to be most careful with: renaming it breaks the workflow everywhere at once, and the failure reads like a configuration problem rather than a rename.

5. The project keeps a versioned changelog, one section per workflow, held alongside this documentation rather than inside n8n. This is where the version numbers in the workflow names come from, and where the deprecated prefixes are explained. It is a separate file and it has to be handed over with this document, because the v9, v9-CM, v10 and v10.1 markers in the workflow names have no key without it.

The convention:

Element Rule
VersionEach workflow carries its own MAJOR.MINOR.PATCH, independent of the others. MAJOR for a trigger change, a target change, a business-logic change or a rewrite. MINOR for an added node, branch or protection. PATCH for a fix with no behaviour change
Version prefix0.x.x while under construction and not yet active, 1.x.x once in production
Change-type tagOne of [CREATE], [FIX], [FEAT], [REFACTOR] (no functional change), [AUDIT] (review, no change), [DEPRECATE] (deactivation or archival)
Entry formatVersion, absolute date, tag, then one or two lines saying what changed and why. The execution id that motivated a fix belongs in the entry
Forcing ruleNo workflow is created, modified, fixed or audited without an entry. One entry per concrete change, never a batched "several small changes"

A successor should keep this going, and the reason is mechanical rather than tidy-minded. The n8n licence in use on this instance provides no built-in per-workflow change history, so this convention is the only record that exists of what changed on a workflow, when, and why. Without it, a sync that starts behaving differently after an edit is diagnosed by reading the current definition and guessing at the previous one.

9.2 The verification gates

In plain language. Each gate below is a check with a stated pass criterion, phrased so that the answer is yes or no rather than a matter of judgement. They are grouped by when they apply: before building, while building, before the change touches production, and after it is running. Every one of them corresponds to a way this system can fail quietly, and the failures they guard against are properties of the system as it stands today, not hypothetical ones.

A gate that cannot be answered yes has not been passed. "Probably fine" is not a pass, and neither is "the run was green", which is the specific answer several of these gates exist to reject.

Before building

G1Numbering convention measured

The target organization's Maxio reference format is read from that organization's own Maxio data, never inherited from a sibling organization because the workflow being cloned happened to carry a rule.

Pass

you can state the format in words, for example "a four-digit prefix, a hyphen, then digits" or "plain integers", you can name the references you read it from, and it matches what the rule you are about to write will accept.

A rule calibrated on one organization and applied to another excludes that organization's entire normal traffic, not a subset of it, and nothing anywhere says so: the run is green, the coverage verdict reads COVERED, nothing is written to mc-sync-log, no failure row appears, no Slack message is posted, and the Monday digest keeps arriving as an all-clear. Sections 6.4 and 8.5 carry the mechanism and the per-organization table. Section 8.5 also records that this exact shape is still in place, untriggered, on the gated Denmark invoice sync, which carries a rule inherited from another organization and unverified against Denmark's own numbering.

G2Upflow organization ready

The organization exists in Upflow, its key pair works, and the workspace-level setting that restricts visible PDFs to those uploaded through the PDF endpoint is on. That setting is what makes a pushed PDF appear in the interface at all, and it is on for the organizations in scope today (section 8.8).

Pass

a read-only listing call limited to one record, using that organization's credentials, returns 200 with a plausible total; and the PDF-visibility setting has been confirmed in the organization's own Upflow settings rather than assumed from the other organizations.

Two properties make credential debugging misleading here, both in section 8.8: keys are issued per organization with no tenant selector on the request, and a pair is sealed to one environment, so a 401 against the sandbox host is not evidence that a production key is dead.

G3Execution allowance headroom checked

New workflows spend runs against a monthly allowance, and the authoritative counter is the usage gauge in MetaCompliance's own n8n Cloud account, not an estimate made from the schedule.

Pass

the gauge has been read this month, and the headroom it shows covers what the change adds.

An organization added on the standard rhythm is three workflows firing five times a weekday, in the region of 330 runs a month before anything else. On top of that, a chunked sync creates one execution per chunk on its sub-workflow, and every sync creates executions on the shared helper sub-workflows it calls. Whether n8n counts those sub-executions toward the allowance is not settled here (section 7.3), which is a second reason to read the gauge rather than compute a figure from the schedule.

While building

G4Clone, never edit in place, for a change of shape

Rule 1 of section 9.1, stated as a gate. It applies to a node added, removed or rewired, and to Code node source. A value edit, a cron or a schedule does not trigger it and is applied in place.

Pass

either the change is a value edit under rule 1, or the workflow carrying it is a different workflow from the one currently serving production and the switch-over is a pair of toggles rather than a save on a live workflow.

G5Mirrored values changed in both copies, and read back from both

Rule 2 of section 9.1, stated as a gate.

Pass

after saving, Code: Filter & match chain and Code: Compute stats from filter inputs have each been re-opened and the new value read on screen. "Both were edited" is not the criterion, "both were read back" is.

G6No protected node renamed, or every reference updated with it

Rule 4 of section 9.1, stated as a gate.

Pass

the clone contains no rename of any node listed in the table in section 4.4, or, if one was renamed, every expression that referred to it was updated in the same edit and the workflow validates without an unresolved node reference.

A node read by another node through $('Node name') is matched as a literal string, so a rename saves without complaint and fails at runtime on the next scheduled tick. The editor raises nothing, which is what makes this the shortest way to break a sync. Set: Sync config is the one to be most careful with: fourteen expressions point at it, so renaming it breaks the workflow everywhere at once, and the failure reads like a configuration problem rather than a rename. Section 4.4 carries the full list and the count per node.

G7Timezone set explicitly on any scheduled workflow
Pass

the workflow declares a timezone in its own settings, Europe/London unless there is a reason to differ, matching the six live syncs.

Two workflows on this instance declare none. Their crons resolve against an instance-wide default that is not visible in the workflow itself, so the times shown for them in section 8.4 Table 3 are where they would land once a timezone is set, not where they would land as they stand.

G8Error workflow wired and verified, not assumed
Pass

the workflow's settings name MC - Error Handler, read back on the clone after saving.

Do not assume a duplicate made in the editor carries the setting across. Coverage today is every active workflow except the handler itself (section 4.3). Without the setting, an uncaught exception in a workflow that runs on its own schedule posts nowhere. For a sub-workflow the answer is now measured, not assumed: its failure raises the parent's generic error too, but only the sub's own errorWorkflow names the failing node — so set it on every clone, sub-workflows included (section 4.3).

G9Schedule slot chosen against the timeline
Pass

the chosen minute sits in a free window of the clock view in section 8.4, and the workflow has not been dropped onto a second that is already occupied when an empty one would do.

The Starter tier allows five concurrent executions and publishes 320 MiB of RAM as a resource of the instance, not as an allowance per execution, and n8n does not document whether one execution's memory is held apart from another's (section 8.4 carries the sources and the date they were read). The schedule was laid out on the assumption that concurrent runs draw on the same allowance, which is why slots are spread rather than stacked. Four cache builders already fire on the same second at :45 and three credit note syncs at :15, so the :45 slot is the one closest to the five-execution concurrency limit before anything is added to it. One ordering constraint is not negotiable: a cache builder fires before the syncs that read its cache, in the same cycle (section 4.2).

Before it touches production

G10Sub-workflow published before the parent is activated

On this hosting model a workflow is a draft until it is published, and a parent that calls an unpublished sub-workflow cannot be activated at all.

Pass

the sub-workflow shows as active in the workflow list before the parent's toggle is touched.

G11Dry run

Note

Setting batchCap to 0 is a genuine dry run on every active sync: the organization is scanned, statistics are computed, nothing is pushed, and the run reports itself as a dry run in its summary. The generation-dependent trap this gate used to warn about left production on 2026-08-20 and survives only on the two gated Denmark workflows (section 8.6), which are rebuilt before use.

Pass

The run's summary reports it as a dry run and the statistics row shows zero pushed.

G12Smoke with eyes on the result
Pass

the PDF is open on the invoice in the Upflow interface, downloaded, and readable, and its content matches the customer record.

A green execution status is not the criterion. A run can complete cleanly having pushed nothing at all, and the file a run downloads from Maxio is not always the invoice it should be, which is the reason the validation stage in section 4.4 exists. The only thing that settles the question is the document itself, open in Upflow.

After activation

G13Confirm work actually happened
Pass

new rows attributable to the new or changed workflow appear in mc-sync-log, with status = success, filtered on its own org value.

A coverage verdict reading as complete is not evidence. coverage_verdict = COVERED - 0 eligible remaining is computed after filtering, so a rule that excluded everything produces the same verdict as an organization that is genuinely up to date. The two states are indistinguishable in every alerting channel this system has (section 6.4). Rows in the log are the evidence; the verdict is not.

G14Rollback target identified, and its schedule read from the cron rather than the node label
Pass

you can name the workflow you would switch back on, and you have read the cron expression it actually carries rather than the display name of its schedule node.

The motivating case is historical but instructive: until 2026-08-23, two rollback candidates carried a schedule node whose display name disagreed with its own cron, and reactivating one on the strength of its label would have put it on top of the currently active Limited sync. The labels were fixed; the gate stays, because a label is one rename away from lying again. Section 8.4, Table 3, states the cron values, and those are the ones that will apply.

9.3 Adding a new organization

In plain language. The architecture extends by cloning. A new legal entity gets its own credentials, its own cache table and its own three workflows, and nothing that already runs is edited. The work is mostly mechanical. The two places it goes wrong are the reference-format rule, which fails invisibly if it is inherited rather than measured, and the clone that keeps pointing at the source organization's data while carrying the new organization's name.

Prerequisites, and this is a stop rather than a list to skim

If any of these is missing, the work does not start. Cloning first and filling gaps afterwards means editing the same workflows twice, and the second edit is where mirrored values drift apart.

What is needed Detail
Upflow key pair for the new organizationIssued in that organization's own Upflow settings. One pair per organization, and there is no tenant selector on the request (section 8.8). The base URL is the same production URL for every organization (section 8.6), so nothing per-organization is needed there
Maxio token for the new entity, with its host and its account pathRead-only is sufficient and preferable, since the integration never writes to Maxio. The four entities do not share one host, and the account path is not derivable from the entity name (section 8.9)
The entity's Maxio numbering convention, measuredGate G1. This is the one prerequisite that is not paperwork, and it is the one that fails silently if it is skipped
A short name and a slug for the entityUsed in the cache table name, in the workflow names and in the org labels. Pick them once, before anything is created
Whether the entity is live on Maxio at launchIf MetaCompliance has not flipped billing for the entity, the two syncs are deployed switched off with a [GATED] prefix in their names, exactly as Denmark stands today (section 7.1)
The entity's currencyNothing technical depends on it. It goes in the credential label, so the credential list stays readable at a glance (section 8.3)

The steps, in order

Step 1. Measure the numbering convention. Read a handful of that entity's invoice references from its own Maxio account and compare them against the two shapes in force today: a four-digit prefix, a hyphen, then digits, which is how Limited and Ireland number; or plain integers, which is how DACH numbers (section 8.5). Write the answer down. It decides the rule in step 7, and it is the only step whose omission produces no symptom at all.

Verify: gate G1.

Step 2. Provision the two credentials. In the instance credential list, create an HTTP Custom Auth credential for Upflow and an HTTP Header Auth credential for Maxio. The Maxio authentication scheme word is the literal Token, not Bearer and not HTTP Basic (section 8.9). Follow the naming pattern already in use in section 8.3 so the list stays grouped by purpose and entity.

Verify: gate G2 for the Upflow side. On the Maxio side, a single count-only request against that entity's host and account path returns a total.

Step 3. Create the cache table. Create a data table named mc-so-cache-<slug> with the same eight columns as the four existing caches (section 8.2), types included — the two timestamp columns are deliberately string, because the data-table date type silently shifts timestamps under a non-UTC workflow timezone:

Column Type
saasoptics_numberstring
saasoptics_idstring
einvoicing_url_no_click_trackingstring
fetched_atdate
window_keystring
saasoptics_entity_idstring
last_emailedstring
auditentry_modifiedstring

Verify: the column names and types match an existing cache exactly. The cache builder writes by column name, so a near-miss on a name is a write that goes nowhere.

Step 4. Seed the watermark row. The cache builders read their incremental watermark, the timestamp they last refreshed up to, from the shared mc-so-cache-progress table. Add one row for the new entity before the first run:

  • window_key = __incremental_watermark_<slug>__
  • status = pending
  • every other column empty or zero

With the row present and empty, the first run falls back to an initial lookback window instead of failing on a missing row. The length of that fallback window is not stated in this document. Read it from the cache builder's own configuration before relying on it to decide whether a wider one-off backfill is needed.

Verify: the row exists, and its window_key matches, character for character, the value that goes into the cache builder configuration in step 5.

Step 5. Clone the cache builder. Source: MC - SO Cache builder v4 - incremental (Limited), which is active and current (section 8.1). Duplicate it and rename the duplicate MC - SO Cache builder v4 - incremental (<Entity>). Four edits on the clone:

  1. Its configuration node: the org label becomes MetaCompliance <Entity>, the watermark row key becomes the value seeded in step 4, and the Maxio account path becomes the entity's own.
  2. Its Maxio HTTP nodes: attach the new Maxio credential and set the host, because the host is per entity (section 8.9).
  3. Its data table nodes: point them at mc-so-cache-<slug>.
  4. Its schedule: pick the slot under gate G9, remembering that the cache has to be refreshed before the syncs that read it in the same cycle.

Verify: walk every data table node and every credential on the clone. A renamed workflow that still points at the source organization's cache table or credential looks entirely correct in the workflow list and writes to the wrong place.

Step 6. Clone the invoice sync. Clone from a current, active source only. Never clone from a name carrying [DEPRECATED], [GATED] or [PARKED] in section 8.1: those are kept as rollback references and they are behind the current generation.

Two cases, and they differ in how much there is to clone:

  • Clone any of the three active invoice syncs (MC - Sync v10.1 - chunked pull all outstanding + redirect resolve, Limited / Ireland / DACH — section 8.1 has the IDs). The chunk sub-workflow has to be cloned too. On the sub-workflow clone, attach the new organization's Upflow credential: the credential and the name are the only things that differ between the sub-workflows that exist today (section 4.4). On the parent clone, point the Execute Sub-workflow: fetch + filter invoice chunk node at the sub-workflow clone, and leave its mode set to "Run once for each item", which is what makes each chunk a separate execution whose memory is released on return (section 4.4). The sub-workflow is published before the parent is activated (gate G10).

Edits on the parent clone, in both cases:

  1. Set: Sync config: org becomes MetaCompliance <Entity>. Leave upflowBaseUrl alone. Set entityType to INVOICE for consistency, while knowing that nothing reads it: re-pointing a workflow by changing that value alone changes nothing at all (section 8.6).
  2. Upflow HTTP nodes: attach the new credential on the count, the listing and the push, and on the sub-workflow's listing node if one was cloned.
  3. The Maxio download node: no edit and no credential. Its URL comes from the cache row, and the link is a public tokenised URL (section 8.9).
  4. The cache data table node: point it at mc-so-cache-<slug>.
  5. The reference-format rule: step 7.
  6. The schedule: gate G9.

Verify: the org value is exactly MetaCompliance <Entity> and is used by no other workflow on the instance; every Upflow credential on the clone is the new one; the cache node names the new table.

Step 7. Set the reference-format rule. Take the measurement from step 1 and write the matching pattern into nonSoPattern in Set: Sync config — since 2026-08-23 that one visible value is read by both the filter and its statistics mirror, fail-loud (section 8.5). There is no second place to edit and no code to open; an empty or invalid value stops the run instead of silently excluding everything.

One search trap on the way in: three of the four live filters write the pattern inline inside the test, while one assigns it to a named constant first, so searching the workflows for that constant name finds one rule and misses the others (section 8.5).

Verify: gate G5.

Step 8. Clone the credit note sync. Source: any of the three live ones, MC - Sync v10.1-CM - chunked pull all outstanding + redirect resolve (Limited / Ireland / DACH). They are identical apart from org labels and the cache table name; the CM chunk sub-workflow is cloned along with it, exactly as in step 6. Same edits as step 6, with two differences:

  • The org value takes the CM suffix, so MetaCompliance <Entity> CM. That suffix is what keeps the invoice history and the credit note history apart in a shared log table, and it is deliberate rather than untidy (section 8.6).
  • No organization-specific reference rule is involved. The credit note syncs require a reference ending in .CM. plus digits, which excludes the other shapes by construction (section 8.5). Do not add an outstanding-state test either: Upflow credit notes carry no state field, and a copied-over state test skips every credit note (section 8.8).

Verify: the org value carries the CM suffix, and the HTTP nodes point at the credit note paths rather than the invoice paths (section 8.8).

Step 9. Set the workflow settings on every clone. Cache builder, invoice sync, credit note sync, and the chunk sub-workflow if one was cloned. Two settings, read back after saving:

  • The error workflow, gate G8. Sub-workflows included: the controlled test of 2026-08-23 established that the sub's own errorWorkflow is the only alert that names the real failure (section 4.3).
  • The timezone, gate G7.

Step 10. Choose the schedule slots. Gate G9, for all three workflows at once, since they have to sit in the right order inside a cycle: cache builder first, then the invoice sync, then the credit note sync, or any order that keeps the cache ahead of its readers. Section 8.4 has the clock view and the free windows, including what a new organization does to them.

Step 11. Warm the cache before any sync reads it. Activate the cache builder, then trigger one manual run, and let it finish. This ordering is not a preference: a sync that runs against an empty cache logs every candidate as no_so_match_in_cache, which is time-bounded rather than permanent (section 8.7), so it heals on its own, but it fills the failure table and the weekly digest with a week of noise that means nothing.

Verify: mc-so-cache-<slug> has rows after the manual run, and einvoicing_url_no_click_tracking is populated on at least some of them. That field is only ever populated once an invoice has actually been e-billed, so a cache full of rows with an empty link column is a fact about the entity, not a defect (section 8.9).

Step 12. Rehearse, then activate the syncs.

If the entity is gated at launch, stop here. Leave both syncs inactive, with the [GATED] prefix in their names and the reason recorded, exactly as Denmark stands (section 7.1). The cache builder stays active, which is what keeps the cache warm for the day the entity is switched on.

Otherwise, in this order: gate G10 if a sub-workflow is involved, then gate G11 for the rehearsal, then gate G12 on the first pushes that rehearsal produced, and only then activate the invoice sync and the credit note sync.

Step 13. The observation window. The first cycle, the first day, and the first week each answer a different question.

When Pass criterion
First cycleThe cache builder, the invoice sync and the credit note sync each show a success execution at their own slot. Section 6.2 is the healthy signature to compare against
First daymc-sync-log carries rows with status = success under the new org value, and under the CM value if the entity has credit notes. Rows, not a verdict (gate G13)
First dayThe coverage verdict reads PARTIAL with a decreasing count of eligible remaining. A new organization starts with its whole outstanding history to push, and a run is capped at ten pushes by default (section 8.6), so COVERED on the first run is a reason to re-check the reference rule, not a success
First dayBoth Slack channels watched. An error_code that is not in the table in section 8.7 is new by definition, and that is the thing worth escalating (section 6.3)
First weekThe Monday digest arrives. Whether it mentions the new organization or not is uninformative: the digest reports failures, not absence (section 6.3)

Then close the loop on the documentation: add the new workflows, the new data table and the new credentials to the registry tables in section 8, add the new workflows to the schedule reference in section 8.4 (Table 1 for the three scheduled ones, Table 2 for a chunk sub-workflow), and open a changelog entry per new workflow under the convention in section 9.1.

Pitfalls that still apply

  • The Maxio host varies per entity, and the account path is not derivable from the entity name. Section 8.9 has the table. Never build a base URL by pattern from an entity name, and store the host and the account path together with the credential they belong to.
  • Entity naming in Maxio is inconsistent and contains typographical errors, and one entity is internally labelled with a legacy name that the API recognises and no human uses (section 8.9). Do not string-match on entity names to pair an entity with its credential. Pair by currency and by Upflow counterpart, and put the entity's identity in the credential label so the list stays readable.
  • A duplicate made in the editor does not reliably carry the workflow settings across. Read the error workflow and the timezone back on the clone rather than assuming them, which is what gates G7 and G8 are for.
  • The first run has a cold cache and a bounded lookback window. Outstanding invoices older than that window are absent from the cache, and each one logs as a cache miss until it re-enters the pool. The condition is time-bounded, not permanent (section 8.7), but a one-off wider backfill run of the cache builder avoids a week of failure rows that mean nothing.
  • Test Mode is per Upflow organization and the flip out of it is irreversible. Read the new organization's current mode in its own Upflow settings rather than inferring it from the others, and treat going live as its own decision with its own checklist (sections 5.7 and 7.4).
  • A COVERED verdict on a brand-new organization is a warning sign rather than a success. It is the same verdict an organization produces when its reference rule excluded everything (section 6.4).

Questions on any of this?

Grab a slot in my calendar and we go through it together.

Book a call →