Skip to content
All writing

GoHighLevel two-way sync: stopping your own messages echoing back

In a two-way GoHighLevel sync, every message you post comes back to you as a webhook. An idempotency key won't catch it, because it isn't the same event.

We run a messaging platform that syncs both ways with GoHighLevel. When a reply lands in our inbox we post it into the matching GoHighLevel conversation, so the agency sees it in the tool they actually work in. Their marketplace app then webhooks every message in that account back to us, including the one we just wrote.

So our own write returns as an event about a message we already have. Save it and the thread shows it twice.

How a message echoes back from GoHighLevel

A sequence between our platform and GoHighLevel. Step 1: we call crm.postMessage with the conversation id and the body. GoHighLevel stores the message under its own messageId, gh_9f2c. Step 2: the response carries that id, and we save it on our message as sourceMeta.mirror.remoteMessageId before anything else. Minutes later, step 3: GoHighLevel's webhook sends the same message back with payload.messageId gh_9f2c. Step 4: the echo guard looks up our message where remoteMessageId equals payload.messageId. A hit is skipped as own_mirror_echo and the thread shows it once. A miss is saved as a new message, which is the fail-open case: the thread shows it twice.

Our platformGoHighLevelStores the messageSaved before anything elseEcho guardDropped. The thread shows it once.Fail-open: the thread shows it twice.1234crm.postMessage(conversationId, body)messageId "gh_9f2c"remote.messageId = "gh_9f2c"sourceMeta.mirror.remoteMessageId= "gh_9f2c"minutes laterwebhook · payload.messageId: "gh_9f2c"findOne whereremoteMessageId =payload.messageIdhit → skipped · "own_mirror_echo"miss → saved as a new message
How a message echoes back from GoHighLevel

Step 1: our platform calls crm.postMessage with the conversation id and the body. Step 2: GoHighLevel's response carries its id for the message, gh_9f2c, and we save it as sourceMeta.mirror.remoteMessageId before anything else. Minutes later, step 3: the webhook sends the same message back with payload.messageId gh_9f2c. Step 4: the echo guard matches remoteMessageId against payload.messageId. A hit is skipped as own_mirror_echo, one copy in the thread. A miss is saved, fail-open, and the thread shows two copies.

1234Echo guardSaved before anything else, ontoOne copy.Two copies.OUR PLATFORM → GOHIGHLEVELGOHIGHLEVEL → OUR PLATFORMminutes laterWEBHOOK → OUR PLATFORMown_mirror_echofail-opencrm.postMessage(conversationId, body)remote.messageId = "gh_9f2c"payload.messageId: "gh_9f2c"sourceMeta.mirror.remoteMessageIdremoteMessageId = payload.messageIdhit → skippedmiss → saved

One message, two identifiers. The id we keep at step 2 is the only thing that recognises the echo, and it exists only because we stored it on the way out.

The obvious fix doesn't fit

Dedupe on the message id. Except the id we hold is ours, and the webhook carries GoHighLevel's, assigned when they stored the message. Two identifiers for one message, in two namespaces, nothing linking them.

The next idea is content matching: same contact, same body, inside a minute or two. That one fails in a way you don't notice for weeks. People send the same short message twice on purpose. "ok" and then "ok" again. Match on content and you drop the second one, and a dropped customer reply is a far worse bug than a visible duplicate.

Store their id at the moment you write

When we post into GoHighLevel, the response carries their id for our message. That gets written onto our own record before anything else happens. When their webhook shows up, that id is the join.

// Posting our message into the CRM: keep their id for it.
const remote = await crm.postMessage(conversationId, body);
 
await Message.updateOne(
  { _id: message._id },
  { $set: { "sourceMeta.mirror": { remoteMessageId: remote.messageId, postedAt: new Date() } } }
);
 
// Their webhook, minutes later, carrying that same id back at us.
const ours = await Message.findOne({
  accountId,
  "sourceMeta.mirror.remoteMessageId": payload.messageId,
});
if (ours) return { status: "skipped", reason: "own_mirror_echo" };

Now the guard is an exact id lookup rather than a guess.

Our traffic gets into their system by more than one route, so there is more than one field to check. A message we mirrored into a conversation, an SMS we sent through their API as a fallback, and a message we ingested from them earlier are three different things. Each one stores its own remote id, and each one logs its own named skip reason. When something looks wrong on a Monday morning, own_sms_fallback_echo tells you which loop fired. A boolean called duplicate tells you nothing.

There's also a cheaper rule that catches a whole class before any database lookup. An outbound event stamped with our own conversation provider id was sent by us by definition, so it gets dropped on sight. Most of the GoHighLevel integration work I do ends up with a version of both rules in it.

The tradeoff

This is fail-open. If the remote id never got written, because the post failed halfway or their webhook beat our own database write, the echo isn't recognised and gets saved as a new message.

That's deliberate. The alternative is treating anything unmatched as suspect and dropping it, which swaps a visible duplicate for a silently missing reply. Someone reports the duplicate within the hour. The missing reply turns up weeks later in a support ticket, by which point it isn't a bug any more, it's a lost lead.

Same integration, different duplicate. Answer a GoHighLevel webhook with a 4xx and it retries. For a message carrying media, that retry is a second real send to a real person. So terminal failures, an unsupported attachment or a recipient who can't receive, answer 200 with a failed status in the body. Non-2xx is reserved for conditions that might genuinely succeed next time.

The takeaway

An idempotency key protects you from the same event arriving twice, and it does nothing here, because the echo isn't the same event. It's their event about your write, carrying their id. The key is only half of the problem in any integration where you both read and write.

If you write into a system that also notifies you about writes, the identifier you need is the one they assign, recorded at the moment you hand the message over. Store it in the same write that creates the message, not in a follow-up job, and give every echo class a name you can grep for.