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 service | Codehooks |
|---|---|
| Priced per task or per execution | Flat rate, so a busy client does not cost you more |
| Visual logic that gets awkward past a point | Real code in a repo, reviewed like anything else |
| State lives in another service, or nowhere | Database in the same deploy as the handler |
| Self-hosting means you own a server | Nothing to host or patch |
| Reconciliation is a separate build | Cron 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.
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();
Questions agencies ask first
Including the ones where a visual tool is the better answer
We already use n8n or Make. Why change?
Can I run one instance per client?
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?
maxRetries setting per step.How do I know the two systems actually agree?
Where is the data stored?
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.
Set up your coding agent (skill, MCP server and the coho prompt reference) →
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