This article discusses critical considerations for building robust and resilient integration layers, specifically in the context of Zoho CRM but with broadly applicable principles. It focuses on addressing common integration pitfalls such as duplicate records, lost updates, and conflicting data by implementing idempotency, separating event receipt from processing, and enhancing observability. The core architectural challenge is ensuring data consistency and reliability in the face of network failures, duplicate events, and partial synchronizations.
Read original on Dev.to #architectureBuilding integrations, especially with third-party CRMs like Zoho, often goes beyond simple API calls. Common issues arise at the 'edges' of an integration: network failures after successful writes, duplicate webhook deliveries, and schema changes. These lead to significant data inconsistencies, such as duplicate records, conflicting updates, and incorrect reporting, which are costly to detect and resolve later. A naive integration often fails because it doesn't account for these distributed system challenges, particularly the non-idempotent nature of many `POST` operations.
Naive Integration Flow Issues
A simple integration often looks like: Zoho CRM event -> Webhook handler -> Call external API -> Update record -> Return 200. The primary failure point is when the external API processes the request but the success response never reaches the integration service, leading to retries that cause duplicate side effects.
A production-grade integration layer must be designed as a stateful synchronization boundary rather than a collection of point-to-point API calls. This involves explicit event identity, retry rules, strict schema contracts, and comprehensive observability. The fundamental assumption should be that duplicate events and partial failures _will_ occur. The solution lies in proactively managing state before external writes, making retries deterministic, and exposing sufficient telemetry.
To prevent duplicate side effects, assign a unique, deterministic idempotency key to each business event. This key allows the integration to check if an event has already been processed. For transient processing locks, a system like Redis can be used with `NX` (set if not exist) operations. It's crucial that the idempotency key represents the _business event_ (e.g., `zoho:module:id:operation:modified_time`) rather than just a record ID, to allow for legitimate subsequent updates to the same record.
import express from "express";
import Redis from "ioredis";
const app = express();
const redis = new Redis(process.env.REDIS_URL);
app.use(express.json());
app.post("/zoho/events", async (req, res) => {
const event = req.body;
const idempotencyKey = `zoho:${event.module}:${event.id}:${event.operation}:${event.modified_time}`;
const acquired = await redis.set(
idempotencyKey,
"processing",
"NX",
"EX",
3600
);
if (!acquired) {
return res.status(200).json({ status: "already_processed" });
}
try {
await syncRecord(event);
await redis.set(
idempotencyKey,
"completed",
"EX",
86400
);
return res.status(200).json({ status: "completed" });
} catch (error) {
await redis.del(idempotencyKey);
return res.status(500).json({ error: "sync_failed" });
}
});Webhook endpoints should respond quickly to valid events and offload slower, external processing to an asynchronous queue. This prevents upstream system latency from impacting the CRM webhook's response time and provides a controlled mechanism for backpressure. By persisting the event in a durable database (e.g., PostgreSQL) immediately upon receipt and then queuing it for a separate worker, the system gains resilience. The worker can then process the event, update its status, and handle retries independently. This architecture prevents data loss even if the processing worker fails or the external API is temporarily unavailable.
app.post("/zoho/events", async (req, res) => {
const event = req.body;
await db.query(
`INSERT INTO integration_events (event_key, payload, status) VALUES ($1, $2, 'pending') ON CONFLICT (event_key) DO NOTHING`,
[
buildEventKey(event),
JSON.stringify(event)
]
);
await queue.add(
"s