Naviga order middleware: seven sync flows, no webhooks
A Catholic publisher operating in the United States and Canada
- Publishing
- US and Canada
- Integration, live since 2026
- Sync flows in production
- 7
- Source properties gap-checked
- 79
- Properties that mapped directly
- 49
- Order scan window per run
- 24 hours
- Scheduled run cadence
- Hourly
Books, subscriptions and pricing live in Naviga. The sales desk lives in HubSpot. Naviga publishes no webhooks for this scope and its customer records carry no modification date, so nothing here could be event-driven. We built a middleware service that runs seven sync flows on a schedule, moves orders, line items, subscriptions, products, companies and contacts in both directions, and keeps a checkpoint, a run record and a replay queue for every flow. It is live.
01 The Challenge
HubSpot is the orchestration and visibility layer in this design, not the system of record. Reps capture order intent in the portal, the backend confirms it, and the portal shows the result. Naviga stays authoritative for books, subscriptions, pricing and confirmed orders. That split only works if the portal is kept current, and the backend gave us little to work with.
Discovery tested every endpoint the design depended on against live responses rather than against documentation. Three answers shaped everything after it. There are no webhooks for this integration scope, so the middleware has to ask rather than be told. Customer records return a create date and no modification date, so a polling job has no way to request the customers that changed. Subscription reads are per customer with no date filter, and the order list endpoints were not usable in that environment at the time, so order discovery could not be assumed either.
One more constraint came from the data. Duplicate email addresses are normal in this customer base, and HubSpot allows one contact per address, so the sync had to carry the duplicates rather than reject them.
02 The Solution
All seven flows run through one pipeline, in the same six stages: trigger, acquire the flow lock, read the source, normalize and match, write, persist the checkpoint. A flow that is already running exits as skipped instead of racing itself, and every run ends in one of five states an operator can read.
Customer discovery walks forward through backend customer identifiers from a saved checkpoint and stops after a long run of consecutive misses, because there is no changed-since call to make. It stores a snapshot and a payload hash per customer. A second flow runs continuously over those snapshots, re-fetches a customer, compares the hash and writes only when the payload actually changed. That pair replaces the delta query the API does not offer. Duplicate addresses survive as plus-addressed variants rather than being dropped.
Products and orders both run from a watermark. The order flow caps each run at a 24 hour changed window, so a long outage cannot become an unbounded batch. It dedupes the summaries, hydrates each order, upserts orders, line items and subscriptions, then runs associations as a separate pass. When every hydration in a run fails, the checkpoint holds rather than advancing, so no window is skipped quietly. Associations that do not resolve go to a backlog table that a repair flow retries.
Two flows run the other way. One searches HubSpot deals sitting at a validation stage, asks the backend to preflight the order, writes the returned pricing onto the deal and its line items, and moves the stage. A later pass creates the order in the backend, writes the backend order number onto the deal, and puts any backend error on a property instead of in a log. The second outbound flow sends companies and contacts changed since the last watermark back as accounts.
03 The Outcome
Orders, line items and subscriptions land in the portal on the hour with their associations already wired, and the deal carries the backend’s own validation result rather than a rep’s guess. Because every flow writes a run record and a checkpoint, a bad hour is a replay rather than a re-import: an operator picks the run, queues it, and the flow resumes from its saved watermark.
Most of what keeps it steady sits in the watermark handling. A window with no orders still advances the watermark, so an empty night is not rescanned forever. A window where every detail lookup fails holds the watermark instead. Failed associations queue and are repaired a quarter past the hour rather than vanishing into a log line. Discovery settled 79 properties before the flows were designed: 49 mapped directly, 23 needed a transform or a second call, and 7 exist nowhere in the source, which is now a documented omission rather than a surprise during user acceptance testing.
How the lifecycle runs now
-
Order intent
Sales rep in HubSpot
- Rep captures order intent on a deal Next: Deal sits at a validation stage.
-
In HubSpot: Deal sits at a validation stage Next: Naviga preflights the order, in Pricing preflight.
-
Pricing preflight
Outbound flow, read from HubSpot
-
Naviga preflights the order Next: Pricing written to the deal and its line items. -
In HubSpot: Pricing written to the deal and its line items Next: Middleware moves the deal stage.
- Middleware moves the deal stage Next: Order created in Naviga, in Order booked.
-
-
Order booked
Outbound flow, a later pass
-
Order created in Naviga Next: Naviga accepts the order?. - Decision: Naviga accepts the order? Yes: Backend order number written to the deal. No: Backend error written to a deal property.
-
In HubSpot: Backend order number written to the deal
-
In HubSpot: Backend error written to a deal property
-
-
Hourly order sync
Order flow, from a saved watermark
- Source: Orders changed in Naviga Next: Reads a 24 hour changed window.
-
In Naviga: Reads a 24 hour changed window Next: Summaries deduped, each order hydrated. - Summaries deduped, each order hydrated Next: Every hydration in the run failed?.
- Decision: Every hydration in the run failed? No: Orders, line items and subscriptions upserted. Yes: Checkpoint holds, no window skipped.
-
In HubSpot: Orders, line items and subscriptions upserted Next: Associations run as a separate pass, in Wired in HubSpot.
- Checkpoint holds, no window skipped
-
Wired in HubSpot
Associations pass and repair flow
-
In HubSpot: Associations run as a separate pass Next: Association resolves?.
- Decision: Association resolves? No: Queued in the backlog table. Yes: Order lands with its associations wired.
- Queued in the backlog table Next: Repair flow retries a quarter past the hour.
- Repair flow retries a quarter past the hour Next: Order lands with its associations wired.
- Outcome, done: Order lands with its associations wired
-
04 Stack
One deployable service on Railway: Node, Fastify, and Prisma over PostgreSQL. Six flows run on cron, five at the top of the hour and association repair a quarter past, and the seventh runs as a self-scheduling loop. HubSpot calls are pinned to API version 2026-03.
Every outbound call to either system passes through a limiter with three priority classes, and the backend limiter adds a per-flow concurrency cap so one flow cannot starve the others. PostgreSQL holds five tables: runs, checkpoints, replays, customer snapshots and the association backlog. Operations is an HTTP surface rather than a console: health, a manual trigger for one flow or all of them, replay with an optional immediate run, run history, and a state endpoint that returns locks, limiter statistics and recent replays.
05 Architecture and data model
The scheduler starts every flow. Four flows read the backend and write the portal, two read the portal and write the backend, and one drains a backlog table. Every run writes its status and its checkpoint to PostgreSQL, and the ops API can trigger or replay any of them. Nothing enters the service as an event of its own: both directions are discovered by a query rather than announced by one.
| Source object | HubSpot object | Direction | Match key |
|---|---|---|---|
| Customer | Company and contact | To HubSpot | Backend customer id in a custom property |
| Customer change | Company and contact | To HubSpot | Same id, a payload hash decides the write |
| Product | Product | To HubSpot | Backend product id |
| Order | Order, a custom object | To HubSpot | External order id |
| Order line | Line item | To HubSpot | Line item id |
| Subscription | Subscription, a custom object | To HubSpot | Backend subscription id |
| Deal at a validation stage | Backend book order | To source | Backend order number written back |
| Company and contact edits | Backend account | To source | Backend customer id |
Every key in that table is a backend identifier held in a custom HubSpot property, never a name and never an email address. Names change when someone tidies a record, and one address can belong to several people in this customer base, so both make unreliable keys. Orders, line items and subscriptions are custom objects rather than deals, because a deal is a pipeline record that a rep moves and these are backend facts a rep should read. The one key that is not a lookup is the line item id: a line arriving without one fails the run rather than landing as half a record.
Ten rows of the property mapping, field names only:
| Source field | HubSpot property | Note |
|---|---|---|
| CustomerID | universal_id | Upsert key, held as a string |
| CompanyName, or FirstName and LastName | name | The account type decides which |
| PreferredAddress.City | city | Preferred address, not the first one |
| PreferredAddress.RegionID | state | Region code |
| PreferredAddress.PostCode | zip | |
| CanPlaceOrder with OrderCreditStatus | customer_status | Derived, no single status field exists |
| CustomerDemographicAnswers | diocese_code | Filter the answers by question id |
| OrderID | hs_external_order_id | Upsert key for the order |
| LineItemID | line_item_id | Upsert key for the line, a missing id fails the run |
| SubscriptionID | naviga_subscription_id | Upsert key for the subscription |
- Hosting
- Railway
- Runtime
- Node and Fastify, Prisma over PostgreSQL
- Cadence
- Hourly cron for five flows, a quarter past the hour for association repair, and a continuous loop for customer updates
- Pattern
- Polling on a watermark
- HubSpot
- Sales Hub Professional, Marketing Hub Enterprise, Service Hub Professional
- Fields mapped
- 79
Seventy-nine properties checked against live API responses across five objects, each keyed on the backend identifier rather than on a name or an email address.
What we would do again, and differently
What we would do again
- Give every flow its own lock, checkpoint and run record from the first commit. It costs a day, and it is the difference between replaying one hour and reloading a quarter.
- Settle the property gap against live API responses rather than against documentation, before the flows are designed. Two of those answers changed the shape of the sync, and both would have been expensive to meet in build.
What we would do differently
- Test the discovery endpoints with live calls in week one. The shape of a polling integration rests on how it finds changed records, far more than on the field mapping that usually gets the attention.
- Add a count reconciliation to the ops surface from the start, so that whether everything landed is a question an endpoint answers rather than one an engineer answers by reading run records.
Systems in this build
Partnerships and accreditations
HubSpot accreditations
Industry specializations
Partnerships
Have a system in the same shape?
Send the systems, the direction and the record volume. We reply with a scope and an effort band within one business day.
Thanks, we will reply within one business day
Prash or Sean reads every one of these and comes back with a scope, or with the one question we need answered first.