Case studiesReference integration: ERP ledger to database

Epicor general ledger to Postgres: one request per partition, checked by count

How a scheduled flow copies the Epicor Kinetic general ledger journal detail into Postgres without silent loss: one request per fiscal year, period and book, a count check that refuses a short partition, an upsert that makes a re-run harmless, retries on 429 and 503, dead letters for 400 and 401, an alert when a pull fails, and a nightly comparison of Epicor and Postgres totals.

Why a ledger pull needs a count check

An Epicor BAQ is a stored query exposed over REST. A general ledger BAQ returns tens of thousands of rows, and the obvious way to copy it is $top and $skip paging. Epicor does not promise a row order for an unordered BAQ, and in probes of a real Epicor test server the rows came back in a different order on every call. Paging then drops some rows and repeats others at the page boundaries, and no request fails. Paging the FY2025 trial balance that way lost 12 of its 17,160 rows.

The pattern that came out of those probes is one request per partition with a count check: ask Epicor how many rows a fiscal year, period and book holds, fetch that partition in a single request, and accept it only if the rows received equal the count. This article builds that pattern as a flow and runs it.

The probes were not repeated here. The mock server that stands in for Epicor keeps a stable row order, so it cannot show the loss. What the test does show is the rest of the flow: a partition that comes back short, Epicor answering 429, 503, 401 or 400, a re-run, and the two sides drifting apart.

What was real, recorded and injected

The parts of the test and what each one was
PartWhat it was
Epicor dataRecorded. Responses captured from Epicor Kinetic’s demo company EPIC06 and replayed by epicor-mock, a Cloudflare Worker run locally. 14,207 journal detail rows in fiscal years 2021, 2022, 2024, 2025 and 2026.
Epicor behaviourCopied by the mock from probes of a real server: API key, Basic or Bearer login with an empty 401, Epicor error envelopes, OData $filter, $top, $skip and $count, and $top=0 returning every row. Unlike the real server, its row order is stable.
OpexFlow and PostgresReal. OpexFlow 2.2.0 and Postgres 16 on one machine. Every number below was counted from them.
FaultsInjected. 429 and 503 are the mock’s own fault options, switched on per request by a small proxy between OpexFlow and the mock. A missing login and a wrong column name make the mock answer a real 401 and a real 400. The short partition comes from a second mock instance that caps every answer at 1,000 rows.
Alert emailReal SMTP delivery to a local test mailbox.
Not testedA live Epicor server, response times at production size, the trial balance BAQ, and the credentials of a real instance.

The flow

Four flow tabs on one OpexFlow instance. Two are sub-flows that the other two call: one plans the partitions, one makes a single Epicor request with retries.

Nodes in the pull flow, in order

  1. schedule 02:00
  2. plan: books x periods
  3. map: one per partition
  4. http: count
  5. http: all rows
  6. sql: upsert + control row
  1. The schedule fires at 02:00 (Asia/Jakarta) with a fiscal year. By hand, POST /api/ingest/schedule/epicor-gl-pull with a fiscal_year and a _schedule.previous_date.
  2. Read the active books from OFPA-Book and check that @odata.count equals the rows received. Make one partition for each book and fiscal period 1 to 12: 120 partitions per fiscal year with the 10 books of EPIC06.
  3. Fan out with a map node, one record per partition.
  4. Ask Epicor for the partition’s row count ($count=true, $top=1, one column). A count of 0 ends the record there.
  5. Fetch the partition in one request ($top=0 returns every row, as Epicor does) with the 23 columns the table keeps.
  6. Run one Postgres statement: upsert the rows by journal key and write a control row with the expected and the loaded row count.
The base URL is an instance variable; the credentials are environment variables.
GET /BaqSvc/OFPA-GLJrnDtl/Data?$filter=GLJrnDtl_FiscalYear eq 2025 and GLJrnDtl_FiscalPeriod eq 3
    and GLJrnDtl_BookID eq 'MAIN'&$count=true&$top=1&$select=GLJrnDtl_JournalNum
  -> {"@odata.count": 1251, "value": [ one row ]}

GET /BaqSvc/OFPA-GLJrnDtl/Data?$filter=<same partition>&$count=true&$top=0&$select=<23 columns>
  -> {"@odata.count": 1251, "value": [ 1,251 rows ]}

The count check lives in Postgres. The control row has CHECK (loaded_rows = expected_rows) and is written by the same statement as the journal rows. When a partition arrives short, Postgres refuses the control row and rolls back the whole statement, so a short partition leaves no rows at all. The failure is permanent, so the record is parked in Dead letters and raises the failure alert.

One sql node. The rows travel as one JSON parameter; there is no statement per row.
WITH src AS (SELECT * FROM jsonb_to_recordset(${metadata.http_response.value}::jsonb) AS x(...)),
     up AS (INSERT INTO gl_journal_detail (...) SELECT ... FROM src
            ON CONFLICT (fiscal_year, book_id, journal_code, journal_num, journal_line)
            DO UPDATE SET ... WHERE (old values) IS DISTINCT FROM (new values)
            RETURNING (xmax = 0) AS inserted),
     ctl AS (INSERT INTO epicor_partition_load
               (fiscal_year, fiscal_period, book_id, expected_rows, loaded_rows, run_id)
             VALUES (${fy}, ${period}, ${book}, ${expected}, (SELECT count(*) FROM src), ${run_id})
             ON CONFLICT (fiscal_year, fiscal_period, book_id) DO UPDATE SET ... RETURNING 1)
SELECT ... FROM up
The pull flow in the OpexFlow editor: a schedule source, then plan partitions, per partition, count request, Epicor count, expected rows, rows request, Epicor rows and the Postgres upsert node, in two rows.
The pull flow in the editor, cropped to the nodes. The red line under the Postgres node is the last error of the test run, the refused short partition.

One request, three attempts

Every Epicor call goes through one sub-flow. Its http node sends HTTP Basic and an X-API-Key header, both read from environment variables that the configuration lists, so no secret is stored in the flow. The mock accepts any one credential; the node sends both.

{
  "id": "g1", "type": "http", "method": "GET",
  "url": "${var:EPICOR_BASE}/${req.baq}/Data?$filter=${req.filter}&$count=true&$top=${req.top}&$select=${req.select}",
  "headers": { "X-API-Key": "${EPICOR_API_KEY}", "Accept": "application/json" },
  "auth": { "type": "basic", "username": "${EPICOR_USER}", "password": "${EPICOR_PASSWORD}" },
  "merge": "merge", "timeout_ms": 120000, "error_on": "all"
}

OpexFlow 2.2.0 has no retry setting on the http node, no back-off and no Retry-After handling. The sub-flow builds retries from what exists. A transient failure (429, 5xx, a timeout or a transport error, reported as stage “retry”) goes down the node’s error wire to a branch, waits one second, and tries again, up to three attempts. Any other failure (400, 401, 404) goes to the last node, which throws, so the record is parked in Dead letters and the failure alert goes out. The wait is a one second answer from a mock service that OpexFlow serves itself, because the flow has no delay node.

The request sub-flow: request in, GET attempt 1, 2 and 3 in the top row, a Transient branch and a Wait 1 s node under each of the first two attempts, a Fail the record node at the bottom, and response out. Red dashed lines are error wires.
The request sub-flow. Red dashed lines are error wires: a failed attempt goes to the transient check, then waits and tries again, or fails the record.
The 1 MiB default response cap is below the largest answer of the test, 5.29 MB.
{
  "transform": {
    "timeout_ms": 5000, "memory_limit_mb": 128,
    "executor": {
      "http_host_allowlist": ["<proxy host>", "<wait service host>"],
      "http_env_allowlist": ["EPICOR_USER", "EPICOR_PASSWORD", "EPICOR_API_KEY"],
      "http_max_response_bytes": 67108864, "http_timeout_ms": 60000,
      "total_deadline_ms": 1800000, "max_node_steps": 200000
    }
  },
  "telemetry": { "alerts": { "on_failure": true, "window_secs": 60, "email": { "...": "local test mailbox" } } }
}

A clean load and a re-run

Five pulls, one per fiscal year recorded in the mock, into an empty database. The expected figures were counted separately, straight from the recorded file.

Clean load, one pull per fiscal year
Fiscal yearRows in PostgresRows in the filePartitions loadedRun time
202110,80510,805159.9 s
2022303018.9 s
202415415439.8 s
20253,0623,0621611.5 s
2026156156138.1 s
Total14,20714,20748

The book debit total of each fiscal year in Postgres also equals the total in the file, to the cent. Epicor was asked 653 times: 5 for the book list, 600 count requests (120 per fiscal year, most of them for empty partitions) and 48 requests for rows. The answers added up to 10.2 MB. The largest was one partition of 7,517 rows (2021, period 9, MAIN) at 5.29 MB.

A terminal listing the clean load, one line per fiscal year with rows in Postgres equal to rows in the file, 14,207 in total across 48 partitions, followed by the re-run with 14,207 rows before and after and 0 rows rewritten.
The clean load and the re-run, from the scenario log.

Then the same five pulls again with nothing changed in Epicor. Postgres held 14,207 rows before and 14,207 after. The upsert only touches a row whose values differ, and no row was rewritten: none has an updated_at later than its first_loaded_at. The control table shows what each partition was expected to hold and what it holds.

A terminal with two psql queries: rows per fiscal year (10,805, 30, 154, 3,062 and 156) with the book debit total of each, and the control table with 48 partitions, 14,207 expected rows and 14,207 loaded.
Postgres after the load: rows per fiscal year, and the control table.

Retries on 429 and 503

Two transient faults on fiscal year 2025, which was already loaded: the row request of period 3, MAIN answered 429 twice, and the count request of period 5, MAIN answered 503 once. Each fault is a response from the mock itself, with Epicor’s error body.

  • Period 3, MAIN: 429, 429, then 200 with the rows, on the third attempt.
  • Period 5, MAIN: 503, then 200 on the second attempt.
  • The pull ended done in 15.3 s. Postgres still held 3,062 rows, there were no dead letters and no alert.
  • A retry came 1 to 8 seconds after its failure, because the one second wait step queues behind the other work of the run.
A terminal showing the requests for two partitions with times: partition 2025-03-MAIN count 200, full 429, full 429, full 200; partition 2025-05-MAIN count 503, count 200, full 200. The pull finished done in 15.3 seconds with 3,062 rows in Postgres.
The requests for the two partitions, from the proxy log.

Failures that are parked: 401, 400 and retries used up

Three faults at once on three partitions of fiscal year 2025. The three partitions were first deleted from Postgres (1,410 rows), so the pull had to load them.

  • Period 3, MAIN: the login was removed from the count request. The mock answered a real 401 with an empty body. One request, no retry.
  • Period 4, MAIN: the filter named a column that does not exist. The mock answered 400 with Epicor’s message, “Could not find a property named ‘GLJrnDtl_BookCode’ on type ‘Epicor.QueryItem’”. One request, no retry.
  • Period 5, MAIN: 503 on every attempt. Three attempts one to two seconds apart, then the record was failed.

The other 13 partitions loaded and the run ended failed after 10.8 s, with 1,652 rows in Postgres: 3,062 less the 1,410 rows of the three partitions. Three dead letters were open, each holding the record the failing node received, so its request can be repeated. Two alert emails went out. The limiter sends one alert per failing node per window, and the second failure of the same node was folded into a later summary mail.

A terminal listing the three faults: partition 2025-03-MAIN one request answered 401; 2025-04-MAIN a count request 200 then a full request 400; 2025-05-MAIN three full requests answered 503. Three open dead letters follow, and 2 alert mails sent.
The three failures, the requests behind them and the dead letters they left. Host addresses are blurred.
The Dead letters tab with three open entries and entry number 2 selected: Epicor refused the request, 400 Bad Request, could not find a property named GLJrnDtl_BookCode. The record at the failing node shows book MAIN, period 4, fiscal year 2025, expected 142, and the request.
The 400 in Dead letters, with the record the failing node received. The host address in the error text is blurred.
Two alert emails: Pipeline failure full-call::g-fail with the 400 error text, and Pipeline failure probe-call::g-fail with the 401 error, each with node, pipeline, outcome and invocation id.
The alert emails for the 400 and the 401. Host addresses are blurred.

With the faults cleared, the three dead letters were replayed into the failing request node. Replaying into the source would have repeated the 13 partitions that had already finished. All three replays ended done, and Postgres was back to 3,062 rows in 16 partitions.

A short partition is refused

To provoke a short answer, a second mock instance ran with MOCK_MAX_PAGE_SIZE=1000. It cuts every answer at 1,000 rows and adds an @odata.nextLink, while @odata.count still reports the full total. Fiscal year 2021, empty in Postgres, has two partitions above 1,000 rows: period 8, MAIN with 2,008 rows and period 9, MAIN with 7,517.

Both were refused by the control row’s CHECK. The other 13 partitions loaded, 1,280 rows. The two refused partitions have no rows in Postgres, not even the first 1,000, because the statement was rolled back. The run left two dead letters and sent an alert email, followed by a summary mail for the second failure.

A terminal showing the pull of fiscal year 2021 against the capped mock: failed, two dead letters with the check constraint partition_complete violated, 1,280 rows in Postgres against 10,805 in the file, and none of the rows of the refused partitions present.
The refused partitions, from the scenario log.
The Dead letters tab with two open entries on the load node. The selected one reads: sql node load, new row for relation epicor_partition_load violates check constraint partition_complete. The record at the failing node is shown as truncated, 700419 bytes.
The refused partition in Dead letters. The record is shown as truncated.
An alert email: Pipeline failure, Pull GL journal detail, Postgres upsert and control row, sql node load violates check constraint partition_complete.
The alert email for the refused partition.

OpexFlow replaces a dead-lettered record over 256 KB with a marker, so this record, 700 KB of rows, cannot be replayed into the node. It can be replayed into the source, which re-runs the whole fiscal year and is safe because the load is idempotent. After switching back to the uncapped mock, one replay into the source loaded all 10,805 rows of fiscal year 2021 and the other dead letter was discarded.

What a failure part-way through paging looks like

The mock can fail a request only when $skip is at or above a threshold, which is what a $top and $skip client meets in the middle of a table. Against fiscal year 2021, period 9, MAIN (7,517 rows) with $top=2500 and the fault at $skip 5,000, a paging client got 2,500 rows at $skip 0 and 2,500 at $skip 2,500, then a 503 at $skip 5,000 and at 7,500. It would hold 5,000 of 7,517 rows and have to decide what to do with them.

This flow is not a paging client. With the same fault switched on for every request, the pull of fiscal year 2026 loaded all 156 rows: 134 requests, all 200. No request carries $skip, so the fault never applied. For this flow, a failure part-way through is a failure on one partition of many, as in the sections above: the finished partitions stay, the failed one is parked, and a replay or a re-run completes the set without duplicates.

A terminal showing a paging client against the mock: $skip 0 and 2500 answered 200 with 2,500 rows and @odata.count 7517, $skip 5000 and 7500 answered 503. Below, the pull of fiscal year 2026 with the same fault: done, 134 requests, all 200.
The skip-triggered fault against a paging client, and against this flow.

The nightly check: Epicor against Postgres

The reconcile flow runs at 02:30. It reads every partition from Epicor again (the debit and credit columns only, count-checked) and compares Epicor’s total and row count per partition with what Postgres holds. Two ledgers are tallied for each partition, one for the book debit total and one for the row count, each with an Epicor side and a Postgres side. A join node waits for every partition, then two reconcile nodes compare the sides.

The reconcile flow in three rows: schedule 02:30, plan partitions, per partition, amounts request, Epicor amounts, sum with count check, tally Epicor total, tally Epicor rows, Postgres totals, tally Postgres total, tally Postgres rows, all partitions in, reconcile totals and reconcile rows.
The reconcile flow in the editor, cropped to the nodes.

After the clean load the reconcile ran for each fiscal year. The last report compared 600 partitions on each side: 14,207 rows against 14,207, and a book debit of 1,007,970,649,707.87 against the same figure. All 600 matched and there were no exceptions. A report covers every key tallied for its day, so each run includes the earlier fiscal years: the five reports hold 120, 240, 360, 480 and 600 keys.

For the gap, three rows of fiscal year 2025, period 3, MAIN were deleted from Postgres (lines of 70, 100 and 50) and the amount of one line in period 5 was raised by 250. The reconcile for 2025 then found the following.

Reconcile after three deleted rows and one changed amount, fiscal year 2025
ReportPartitionEpicorPostgresDifference
Book debit2025-3-MAIN12,121,837.6212,121,617.62-220
Book debit2025-5-MAIN1,5961,846250
Row count2025-3-MAIN1,2511,248-3

The debit report matched 118 of 120 partitions. The fiscal year total was 51,493,447.25 in Epicor and 51,493,477.25 in Postgres. Both reports were sent by email through the alert channel, and three exception cases opened with 473 at stake.

The Reconciliation tab: the report gl-journal-debit-epicor-vs-postgres for 2026-11-03 with 2 exceptions, 120 keys on each side, totals 51,493,447.25 and 51,493,477.25, and two amount mismatches for 2025-3-MAIN and 2025-5-MAIN.
The reports of the gap run. The two earlier reports are clean.
The Exceptions tab with three open cases: 2025-3-MAIN amount mismatch 220, 2025-5-MAIN amount mismatch 250 and 2025-3-MAIN amount mismatch 3. Counters show 3 needing work, 3 unassigned and 473 at stake.
The same gaps as exception cases that a person works.

Pulling fiscal year 2025 again restored the three rows and corrected the changed amount: the upsert rewrote exactly one row. The reconcile for the same day then matched all 120 partitions with no exceptions, and the three cases closed on their own.

Health and alerts

The Health and SLA tab shows the retries as the flow lived them. Over the whole test there were 26 runs and 2 errors, the two pulls that ended with parked records. First attempts to Epicor: 2,973 calls, 5 errors, 3 retries. Second attempts: 3 calls, 2 retries. Third attempts: 2 calls, 1 retry that ended as a failure. The wait step ran 5 times. Postgres: 1,027 statements, 2 errors, the two refused partitions.

The Health and SLA tab: 26 runs, 2 errors, 92.31 percent availability. The external systems table lists GET Epicor attempt 1 with 2,973 calls, 5 errors and 3 retries, postgres with 1,027 calls and 2 errors, Wait 1 s with 5 calls, attempt 2 with 3 calls and 2 errors, attempt 3 with 2 calls and 1 error.
Health and SLA for the whole test: flows on top, external calls below.

Where OpexFlow 2.2.0 fell short

Gaps found while building this flow, and what was done about each
GapWorkaround used
The http node has no retry setting, back-off or Retry-After handling.Three attempts chained over error wires with a one second wait. The wait is a call to a mock service served by OpexFlow itself, because there is no delay node. Retry-After is ignored.
error_on routes either every failure or only permanent ones to the error wire. It cannot send transient failures to the wire and leave permanent ones for Dead letters.All failures go to the wire. A branch on the error stage sends transient ones back for another attempt, and a final node throws for the rest, which parks them.
No loop or paging node.The partitions are planned up front and fanned out with a map node, which suits the one-request-per-partition rule.
The deploy check of worst-case node steps (map size times nodes, counting every copy of a sub-flow) was 136,828 for a first draft against a limit of 10,000.max_node_steps raised to 200,000 and the map capped at 240 partitions.
A scheduled tick is not retried. A failed partition ends the run as failed, while the other partitions finish.Replay the dead letter into the failing node, or trigger the schedule again. Both are safe because the load is an upsert.
A key such as @odata.count cannot be read by a dot path, because its name contains a dot.A small JavaScript transform reads it, on the count answer, which is one row. The large answer is never passed through a script.
A dead-lettered record over 256 KB is replaced by a marker and cannot be replayed into a node.Replay the dead letter into the source, which re-runs the fiscal year.
Alerts and errors inside a sub-flow name nodes by id (full-call::g-fail), not by label.None. The ids are readable once the flow is known.
A reconcile compares one amount per key.A second ledger and reconcile pair for row counts.
The editor warns “no path to any sink” for the node that fails the record on purpose.Ignored. It is a deliberate dead end.

Scope

  • Epicor was a mock replaying recorded EPIC06 responses. The loss from unordered $top and $skip paging was measured earlier on a real Epicor test server, 12 of 17,160 trial balance rows, and was not reproduced here: the mock’s row order is stable.
  • Faults were produced with the mock’s own options through a small proxy. The 401 came from removing the credentials and the 400 from renaming a column in the filter.
  • Only the journal detail BAQ was pulled, 14,207 rows. The same pattern fits the trial balance (95,818 recorded rows), which was not run.
  • Twelve periods per fiscal year were assumed. A fiscal calendar with 13 periods needs a longer period list.
  • The reconcile compares totals and row counts per partition, not rows. Two errors that cancel inside one partition would pass.
  • Run times of 8 to 12 seconds per fiscal year are from one machine against a local mock. A real server will differ.
  • The schedules were triggered by hand with POST /api/ingest/schedule/{name}. A scheduled tick that is missed is not replayed.
  • The master key was not set, so dead letters were stored unencrypted. They hold ledger rows: set OPEX_MASTER_KEY or encrypt the disk.
  • The alert emails went to a local test mailbox.

Related

From download to first record in minutes.

Run one binary, connect a PLC, a CNC or a webhook, and watch the records arrive in the editor.

Builds for Linux (x86_64 and ARM64), Windows and macOS.

  • One binary
  • Raspberry Pi to rack server
  • Signed delivery with retries

Sizing questions or an air-gapped install? Email marketing@opexcg.com or read the quick start.