RiverbornBook Call

Breaking the Loop: How We Stopped Our Twenty CRM Sync From Feeding on Itself

Engineering6 min read

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.

Breaking the Loop: How We Stopped Our Twenty CRM Sync From Feeding on Itself

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

Outbound (our system → Twenty)
Mechanism
Domain write → crm_sync_outbox → poller → Twenty REST with a Bearer workspace API key
Inbound (Twenty → our system)
Mechanism
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.

Drop all webhooks that include any apiKeyId
Why we didn't use it
Breaks future third-party / second-key writes into Twenty
Suppress outbox after inbound upsert only
Why we didn't use it
Still re-processes our own echoes; burns cycles; racey
Time-window debounce after our own push
Why we didn't use it
Fragile under concurrency; can drop real human edits
Compare full record hashes
Why we didn't use it
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:

  1. Prefer jti (current Twenty builds set JWT id this way).
  2. Fall back to apiKeyId / api_key_id for 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:

  1. Parse the event into { operation, record, apiKeyId, webhookId }.
  2. Optional stale-webhook check: if webhookId doesn't match the one we registered at connect, drop it.
  3. 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;
}
  1. 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:

  • selfApiKeyId vs. webhook apiKeyId stops the infinite loop. It's about who wrote this change.
  • crm_sync_links, which maps internal_idcrm_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 selfApiKeyId matches 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 / webhookId stay in sync. A stale webhook with a different id is dropped as stale_webhook.
  • Deletes. .deleted operations are currently dropped (delete_not_synced). Unrelated to loop prevention, but worth knowing when testing.
  • Expected "failure." An outbound push followed by own_echo in 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":

  1. Edit a contact on our end. Outbox pushes to Twenty.
  2. Twenty delivers person.updated with our apiKeyId.
  3. Handler returns dropped: true, reason: "own_echo". Our row is not rewritten; outbox does not re-enqueue from that webhook.
  4. Edit the same Person in Twenty's UI.
  5. Webhook arrives without our apiKeyId (or with a different one).
  6. 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

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.

Published by Moyenul Islam

Ready to ship production-grade AI?

Free. 30 minutes. No prep required.