07Writing
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.
One message, two identifiers. The id in the middle 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.
Related: response codes are retry instructions
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.