Skip to main content

Integration sync

The integration that keeps two systems honest

Retries, state and nightly reconciliation, without standing up another service for every client you onboard.

Isolated data per client · Nothing to host · Data stored in the EU

Easy for a week, then someone owns it forever

Connecting two SaaS systems looks trivial when you scope it. Read from one side, write to the other, run it on a schedule. A week of work, quoted as part of a larger project.

Then an endpoint returns a 502 at three in the morning. A record syncs twice. A client calls to say their stock numbers are wrong, and nobody can say with confidence which of the two systems is correct.

The no-code tools reach their ceiling around the same time, usually the moment real logic or real state is needed. They also price per task, so a busy client costs you more precisely when they are most valuable to you.

For an agency this is a margin problem rather than a technical one. The integration was quoted once, and it keeps costing hours every month it stays running.

Between the no-code ceiling and building your own service

Zapier, n8n or a custom serviceCodehooks
Priced per task or per executionFlat rate, so a busy client does not cost you more
Visual logic that gets awkward past a pointReal code in a repo, reviewed like anything else
State lives in another service, or nowhereDatabase in the same deploy as the handler
Self-hosting means you own a serverNothing to host or patch
Reconciliation is a separate buildCron and queues are already there

Sync, retry and reconcile in one deploy

The nightly reconciliation is the part agencies always end up building by hand. Here it is a cron job in the same file as the handler, reading the same database.

index.js
import { app, Datastore } from 'codehooks-js';

// Routes require a key by default, so let the partner system post here
app.auth('/webhook/*', (req, res, next) => next());

// A change arrives from system A
app.post('/webhook/orders', async (req, res) => {
// Verify the signature first. Our webhook-verify package covers 21
// providers, and has generic handlers for anything in-house:
// verify('shopify', req.rawBody, req.headers, process.env.SECRET)

const db = await Datastore.open();
await db.insertOne('orders', { ...req.body, synced: false });
await db.enqueue('sync-erp', { id: req.body.id });
res.status(202).end();
});

// Runs in the background, so a slow partner never blocks the response
app.worker('sync-erp', async (req, res) => {
const db = await Datastore.open();
const { id } = req.body.payload;

try {
await pushToErp(id);
await db.updateOne('orders', { id }, { synced: true });
} catch (err) {
// Leave it unsynced. The nightly job below re-queues it.
await db.updateOne('orders', { id }, { lastError: String(err) });
}
res.end();
});

// Every night at 03:00, prove both sides still agree
app.job('0 3 * * *', async (req, res) => {
const db = await Datastore.open();

// One queued job per record that never synced
const { ticket } = await db.enqueueFromQuery('orders', {
synced: false
}, 'sync-erp');

console.log(`re-queued ${ticket.count} orders`);
res.end();
});

export default app.init();

pushToErp() is your own function, wherever the other system lives. Signature verification uses our webhook-verify package.

Questions agencies ask first

Including the ones where a visual tool is the better answer

We already use n8n or Make. Why change?
For a lot of flows you shouldn't. Visual tools are quicker to build and easier to hand over, and plenty of client integrations never outgrow them. The point to reach for code is when the logic stops fitting in boxes, when you need state that survives between runs, or when per-task pricing starts scaling with your client's success instead of yours. Most agencies end up running both.
Can I run one instance per client?
Yes, using spaces. A space is a self-contained bundle of code, data and settings, so each client gets isolated data and its own deploy while sharing the same codebase. You create one with coho add and switch between them with coho use. Spaces can also be restricted to team admins, which is useful for anything client-facing.
What happens when the other system is down for hours?
The work sits in a queue rather than disappearing, because queues are persistent and each topic is stored in the database. What happens on failure is your code's decision: the usual pattern is to catch the error, leave the record marked unsynced, and let the nightly job re-queue it once the partner recovers. That nightly pass is the real safety net, and it covers cases a retry never would, like a partner that accepted the write but stored it wrong. If you want retry counts and backoff managed for you rather than written by you, the Workflow API supports a maxRetries setting per step.
How do I know the two systems actually agree?
That's what the scheduled job is for, and it's the part most teams end up writing by hand. Because the cron job, the queue and the data live in the same deploy, reconciliation is a query against your own database rather than a separate service with credentials to both sides.
Where is the data stored?
In the EU, on servers in Amsterdam and Ireland.

Per-client variation

The tenth client should not cost what the first one did

Every client you onboard wants the same integration with different field names, different edge cases and a different idea of what a valid record looks like. That variation is where the hours go, and it is the reason the tenth integration rarely costs much less than the first.

When the mapping layer is a file in a repo rather than boxes in a visual editor, a coding agent can regenerate it against a new client's field names, and you review the result in a pull request like any other change.

Worth being clear about the limit: an agent can write the mapping, but it cannot decide which system is right when the two disagree. That judgement is still yours, and it is the part of this work that actually takes experience.

Stop standing up a service per client

One codebase, a separate space per client, and the queue and cron you need for the parts that go wrong.

Isolated data per client · Data stored in the EU