Breaking the Loop: How We Stopped Our Twenty CRM Sync From Feeding on Itself
We wired bidirectional sync into self-hosted Twenty CRM. Our own writes kept echoing back as inbound changes. The fix wasn't a clever filter — it was storing our API key identity once and comparing against it on every webhook.
Table Of Contents
We recently connected our multi-agent CRM system to Twenty CRM, an open-source, self-hosted CRM, with sync running in both directions. Write a contact in our system, it should show up in Twenty. Edit that same contact in Twenty's UI, it should flow back. Straightforward on paper.
Twenty isn't just an open-source clone of Salesforce or HubSpot. Its architecture assumes you'll self-host and extend it, so webhooks and API access are first-class rather than bolted on. That's exactly what made a real bidirectional integration possible in the first place, and exactly what created the failure mode below.
It broke almost immediately, and in the most predictable way possible: our own writes kept syncing back into themselves.
What we built first
crm_sync_outbox → poller → Twenty REST with a Bearer workspace API keyPOST /api/v1/crm/twenty/webhook/:org_id → upsert| Direction | Mechanism |
|---|---|
| Outbound (our system → Twenty) | Domain write → crm_sync_outbox → poller → Twenty REST with a Bearer workspace API key |
| Inbound (Twenty → our system) | Twenty webhook → POST /api/v1/crm/twenty/webhook/:org_id → upsert |
Both directions worked in isolation. The moment we turned them on together, this happened:
We update a contact
→ outbox pushes Person to Twenty (REST, our API key)
→ Twenty fires person.updated webhook
→ our webhook handler upserts the same contact again
→ that write enqueues the outbox again
→ push to Twenty again
→ webhook again
→ ∞
Every outbound write was indistinguishable from a real inbound change, so it fed straight back into the outbox. Classic sync loop.
The constraints on any fix were narrow on purpose. Human edits in Twenty's UI still had to sync inbound. Future API keys and integrations writing into the same Twenty workspace also had to be allowed through. We did not want a blunt "drop everything that has an apiKeyId" rule.
The first fixes we ruled out
Twenty already tags webhook payloads with the identity of whoever caused the change. API-key writes include apiKeyId on the webhook body. UI / workspace-member edits typically do not carry our apiKeyId; they're attributed to a workspace member instead.
The obvious patch is: if a webhook carries any apiKeyId, assume it's a machine write and drop it. We didn't go with that version. It would also silently drop changes made by any other integration writing into the same Twenty workspace through a different API key. A blocklist that grows every time someone adds a new integration isn't a real fix, it's a maintenance burden waiting to happen.
apiKeyId| Approach | Why we didn't use it |
|---|---|
Drop all webhooks that include any apiKeyId | Breaks future third-party / second-key writes into Twenty |
| Suppress outbox after inbound upsert only | Still re-processes our own echoes; burns cycles; racey |
| Time-window debounce after our own push | Fragile under concurrency; can drop real human edits |
| Compare full record hashes | Expensive, still races, doesn't answer "who wrote this?" |
Identity of the writer (apiKeyId) is the signal Twenty already gives us. The question was how to use it without becoming a blunt instrument.
What actually worked: comparing against our own key id
The fix is narrower than "drop anything with an apiKeyId." It's "drop it only if the apiKeyId matches ours specifically."
Capturing the key id at connect time
On connectTwenty, we decode the workspace API key JWT, extract its key id, and persist that as integrations.credentials.selfApiKeyId alongside the encrypted credentials:
const selfApiKeyId = decodeApiKeyId(apiKey);
// stored in integrations.credentials alongside encrypted apiKey, baseUrl, webhookSecret, …
decodeApiKeyId treats the Twenty API key as a JWT and reads the key id from the payload:
- Prefer
jti(current Twenty builds set JWT id this way). - Fall back to
apiKeyId/api_key_idfor older key shapes.
function decodeApiKeyId(apiKey) {
// decode JWT middle segment
return payload.jti || payload.apiKeyId || payload.api_key_id || null;
}
If decoding fails, connect aborts. Without a reliable selfApiKeyId, echo filtering cannot work safely.
Stored credentials shape, relevant fields:
{
"apiKey": "<encrypted>",
"baseUrl": "https://twenty.example",
"selfApiKeyId": "<uuid-from-jwt>",
"webhookId": "<twenty-webhook-uuid>",
"webhookSecret": "<encrypted>",
"webhookTargetUrl": "https://…/api/v1/crm/twenty/webhook/<org_id>"
}
Filtering on the inbound webhook
After signature verification, handleTwentyWebhook does this:
- Parse the event into
{ operation, record, apiKeyId, webhookId }. - Optional stale-webhook check: if
webhookIddoesn't match the one we registered at connect, drop it. - Echo check against our stored key id:
if (isOwnEcho({ apiKeyId }, creds.selfApiKeyId)) {
return { dropped: true, reason: "own_echo", operation };
}
function isOwnEcho(payload, selfApiKeyId) {
if (!selfApiKeyId) return false;
return payload?.apiKeyId === selfApiKeyId;
}
- Only then normalize the Twenty record, upsert the contact / account / deal on our end, and update
crm_sync_links.
When our own push echoes back:
{ "success": true, "dropped": true, "reason": "own_echo" }
[twenty-webhook] dropped { reason: "own_echo", apiKeyId: "…", selfApiKeyId: "…" }
If it doesn't match, meaning it's either a human edit or a write from some other integration's key, it flows through normally.
That distinction, comparing against a stored identity rather than just checking for presence, is what makes the filter exact instead of a blunt instrument. Echoes from our own writes are dropped. Human UI edits still sync. Another integration's API key writes can still sync inbound later without a growing blocklist.
Loop broken vs human edit allowed
Case A — our outbound write (must not loop):
Our system → Twenty: REST upsert (Bearer our API key)
Twenty → webhook: person.updated { apiKeyId = our selfApiKeyId }
webhook: apiKeyId === selfApiKeyId → drop own_echo
→ 200, no DB upsert, no outbox
Case B — human edit in Twenty UI (must sync):
Twenty → webhook: person.updated { no matching apiKeyId }
webhook: not own echo → upsert our row
→ 200 synced, crm_sync_links.sync_direction = inbound
ID mapping is separate
It's worth separating two things that sound similar but solve different problems:
selfApiKeyIdvs. webhookapiKeyIdstops the infinite loop. It's about who wrote this change.crm_sync_links, which mapsinternal_id↔crm_id, stops duplicate records from being created on repeat syncs. It's about which record this is.
Loop prevention is API key identity. Record identity is crm_sync_links. You need both. Neither one substitutes for the other.
Operational notes
A few details that matter once this is running:
- Reconnect / rotate the API key. Run connect again so
selfApiKeyIdmatches the new JWT. An old stored id will miss echoes (the loop comes back) or, if mismatched the other way, drop legitimate traffic. - Stale webhooks. Connect deletes prior webhooks for our target URL and registers a fresh one so
webhookSecret/webhookIdstay in sync. A stale webhook with a different id is dropped asstale_webhook. - Deletes.
.deletedoperations are currently dropped (delete_not_synced). Unrelated to loop prevention, but worth knowing when testing. - Expected "failure." An outbound push followed by
own_echoin the logs is success, not a bug.
Verifying it actually works
The check that matters isn't "did the sync happen." It's "did the sync happen exactly once, in the right direction, per edit":
- Edit a contact on our end. Outbox pushes to Twenty.
- Twenty delivers
person.updatedwith ourapiKeyId. - Handler returns
dropped: true, reason: "own_echo". Our row is not rewritten; outbox does not re-enqueue from that webhook. - Edit the same Person in Twenty's UI.
- Webhook arrives without our
apiKeyId(or with a different one). - Handler syncs; our contact updates;
crm_sync_links.sync_direction = 'inbound'.
Seeing own_echo in the logs after an outbound push isn't a bug. It's the loop being caught exactly where it should be.
The takeaway
Bidirectional sync between two systems will always create a case where a system's own write looks identical to an external change, unless something in the payload tells you who actually caused it. Twenty already gives you that signal through apiKeyId. The fix wasn't building a more clever filter, it was storing our own identity once at connect time and comparing against it on every event. Simple, cheap, and it doesn't get in the way of anyone else who has legitimate reasons to write into the same workspace.

Moyenul Islam
Lead Backend Engineer
Moyenul leads backend engineering at Riverborn, architecting the agent orchestration, session, and infrastructure layers behind our production AI systems. He works across the stack from database schema to runtime orchestration, with a focus on systems that hold up under real production load rather than just demo conditions.