The problem
You open Conversations and the thread has been littered with messages that quote your own texts back at you. They are not typos and the contact did not send them on purpose. They look like this:
- Liked “Can you send the quote?”iPhone · en
- Loved “Can you send the quote?”iPhone · en
- Emphasized “Can you send the quote?”iPhone · en
- Laughed at “Can you send the quote?”iPhone · en
- Questioned “Can you send the quote?”iPhone · en
- Disliked “Can you send the quote?”iPhone · en
- 👍 to “Can you send the quote?”Android · RCS
- Đã thích “Can you send the quote?”iPhone · vi
- Le gustó “Can you send the quote?”iPhone · es
- A aimé “Can you send the quote?”iPhone · fr
- Removed a like from “Can you send the quote?”iPhone · en
The verb depends on the reaction and on the language the sender’s phone is set to, so a Vietnamese contact sends Đã thích, a Spanish one sends Le gustó, and a French one sends A aimé. All of them quote the message they tapped. Android phones falling back from RCS send the emoji itself, as 👍 to “…”.
If you got here by pasting one of those strings into Google, searching for a GHL reaction message, a GoHighLevel liked message SMS, or why an iMessage reaction shows as text, this is the thing you are looking at. It has a name: a tapback fallback SMS. It is expected behaviour, it is not your setup, and both halves of it are fixable.
Here is what it looks like in the thread. This is a rebuild of the Conversations pane, not a screenshot. The markup and class names match what GHL renders, so the fix further down can run on it live.
Why it happens
Your GHL number is an A2P 10DLC number. It speaks SMS. That is the whole explanation, but it is worth being precise about the mechanics, because the fix depends on understanding that nothing is broken.
Tapbacks on iPhone and emoji reactions on Android are not part of SMS. They are a feature of iMessage and RCS, which are separate protocols that happen to live inside the same app on the phone. When your contact taps and holds your message and picks the thumbs up, their phone tries to send a reaction. It cannot: the thread is SMS, because the other end is a 10DLC number, and SMS has no field for “this is a reaction to that message”.
So the sending phone degrades gracefully. Rather than fail silently and confuse the user, it converts the reaction into the closest thing SMS can carry: a plain text message that describes what the user did, in the user’s own language, quoting the message they reacted to. That text is generated on their handset and sent as a normal SMS. By the time it reaches Twilio and lands in GHL, it is indistinguishable from any other inbound text.
This is not a GHL bug, and no setting turns it off. You cannot receive a real reaction on an SMS number, and you cannot send one either. Any vendor selling you SMS on a 10DLC number has the same limitation. It is the protocol, not the platform.
Which means there are exactly two honest things to do about it: make it look right in the inbox, and stop it behaving like a reply. Those are two different fixes and you need both.
Why it actually matters
Most people file this under cosmetic and move on. It is not cosmetic. As far as every system downstream is concerned, a tapback is a genuine inbound reply from the contact, and it behaves like one:
- It fires “Customer Replied”. Any workflow triggered on an inbound message runs on a thumbs up exactly as it would on a real question.
- It breaks leads out of no reply sequences. Your ghost or reengagement campaign is built to stop when someone answers. A tapback stops it. The lead liked your message and then never heard from you again.
- It satisfies a “Wait for reply” step. The wait resolves, the workflow advances, and the branch you actually wanted never runs.
- It makes your AI qualifier answer a thumbs up. The bot receives
Liked “Can you send the quote?”as a user turn and dutifully produces a reply to it. Customers do notice. - It corrupts your reporting. Reply rate, time to first response and conversation counts all include reactions unless you filter them out.
The visual fix is the one you will be tempted to ship, because it is the one you can see. The workflow fix is the one that costs you money if you skip it.
Part 1: The visual fix
This makes reaction messages render as a small badge under the message they refer to, the way they look on the phone that sent them. It is presentation only. The message is untouched in the database.
Where this goes: Settings → Company → Custom CSS and Custom JS, at agency level. It applies across every sub account under that agency. You need agency level access. A user scoped to one location cannot do this.
Before and after
Same thread as above. Flip it. The script below is running against this box for real, on the same class names GHL uses.
The CSS
/* ────────────────────────────────────────────────────────────
Tapback fallback badges — GHL Custom CSS
Presentation only. Pairs with the Custom JS block.
──────────────────────────────────────────────────────────── */
/* the badge the script injects */
.tb-reaction{
display:inline-flex;
align-items:center;
gap:7px;
margin-top:5px;
padding:4px 11px 4px 7px;
border-radius:999px;
background:#fff;
border:1px solid rgba(14,16,20,.09);
box-shadow:0 2px 8px -3px rgba(14,16,20,.18);
font-size:11.5px;
line-height:1.25;
color:#7C7F86;
max-width:100%;
}
.tb-reaction__emoji{
font-size:13px;
line-height:1;
flex:none;
}
/* the quoted message, clamped to one line */
.tb-reaction__quote{
max-width:210px;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
font-family:ui-monospace,Menlo,monospace;
font-size:10.5px;
color:#A3A6AC;
}
/* "Removed a like from …" — same badge, drained */
.tb-reaction--removed{ opacity:.62; }
.tb-reaction--removed .tb-reaction__emoji{ filter:grayscale(1); }
/* the junk bubble, once its badge has been re-homed onto the
message it was quoting */
.message-item[data-tb-done="1"]{ display:none; }
/* fallback: quoted message not on screen, so the badge renders in
place — strip the bubble chrome from around it */
.message-item[data-tb-orphan="1"] .chat-bubble-inbound{
background:transparent;
padding:0;
box-shadow:none;
}
The JS
/* ────────────────────────────────────────────────────────────
Tapback fallback → reaction badge — GHL Custom JS
Read-only: never edits a message, only how it renders.
──────────────────────────────────────────────────────────── */
(function () {
'use strict';
/* Reaction verbs → badge glyph.
To add your locale: react to a message from a phone set to that
language, copy the raw inbound string out of Conversations, and add
everything before the opening quote as a new entry. */
var VERBS = [
['Liked', '👍'],
['Loved', '❤️'],
['Disliked', '👎'],
['Laughed at', '😂'],
['Emphasized', '‼️'],
['Questioned', '❓'],
['Đã thích', '👍'], /* vi */
['Le gustó', '👍'], /* es */
['A aimé', '👍'] /* fr */
];
var REMOVERS = [
'Removed a like from',
'Removed a heart from',
'Removed a dislike from',
'Removed a laugh from',
'Removed an exclamation from',
'Removed a question mark from'
];
function esc(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
function verbList() {
return VERBS.map(function (v) { return esc(v[0]); }).join('|');
}
/* Curly quotes are the #1 reason a hand-rolled regex "doesn't work" —
iOS sends U+201C/U+201D, not ". Normalise before matching. */
function normalize(s) {
return String(s || '')
.replace(/[“”„«»]/g, '"')
.replace(/[‘’]/g, "'")
.replace(/[\u00A0\u202F\u2007]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
var VERB_RE = new RegExp('^(' + verbList() + ')\\s+"([\\s\\S]+)"$', 'i');
var RMV_RE = new RegExp('^(' + REMOVERS.map(esc).join('|') + ')\\s+"([\\s\\S]+)"$', 'i');
var OBJ_RE = new RegExp('^(' + verbList() + ')\\s+(an image|a link|an attachment|a video)$', 'i');
/* Android RCS → SMS: 👍 to "…" · iOS 17+ emoji tapback: Reacted 👍 to "…" */
var EMOJI_RE = /^(?:Reacted\s+(?:with\s+)?)?(\p{Extended_Pictographic}(?:️|\p{Emoji_Modifier})?)\s+to\s+"([\s\S]+)"$/u;
function glyphFor(verb) {
var v = verb.toLowerCase();
for (var i = 0; i < VERBS.length; i++) {
if (VERBS[i][0].toLowerCase() === v) return VERBS[i][1];
}
return '👍';
}
function parse(raw) {
var t = normalize(raw), m;
if ((m = t.match(VERB_RE))) return { glyph: glyphFor(m[1]), quote: m[2], removed: false };
if ((m = t.match(RMV_RE))) return { glyph: '👍', quote: m[2], removed: true };
if ((m = t.match(EMOJI_RE))) return { glyph: m[1], quote: m[2], removed: false };
if ((m = t.match(OBJ_RE))) return { glyph: glyphFor(m[1]), quote: m[2], removed: false };
return null;
}
function badge(hit) {
var el = document.createElement('span');
el.className = 'tb-reaction' + (hit.removed ? ' tb-reaction--removed' : '');
el.title = (hit.removed ? 'Reaction removed from' : 'Reacted to') + ': ' + hit.quote;
var g = document.createElement('span');
g.className = 'tb-reaction__emoji';
g.textContent = hit.glyph;
var q = document.createElement('span');
q.className = 'tb-reaction__quote';
q.textContent = hit.quote;
el.appendChild(g);
el.appendChild(q);
return el;
}
/* Walk back up the thread for the message this reaction quotes.
Long messages get truncated in the quote, so compare on a prefix. */
function findTarget(item, quote) {
var key = normalize(quote).toLowerCase();
if (key.length < 3) return null;
var prev = item.previousElementSibling, hops = 0;
while (prev && hops++ < 12) {
if (prev.classList.contains('message-item') && !prev.hasAttribute('data-tb-done')) {
var body = prev.querySelector('.chat-content');
var txt = body ? normalize(body.textContent).toLowerCase() : '';
if (txt && (txt === key ||
(key.length >= 8 && txt.indexOf(key) === 0) ||
(txt.length >= 8 && key.indexOf(txt) === 0))) return prev;
}
prev = prev.previousElementSibling;
}
return null;
}
function decorate(item) {
if (item.hasAttribute('data-tb-done') || item.hasAttribute('data-tb-orphan')) return;
var body = item.querySelector('.chat-content');
if (!body) return;
var hit = parse(body.textContent);
if (!hit) return;
var target = findTarget(item, hit.quote);
if (target) {
var bubble = target.querySelector('.chat-bubble-inbound, .chat-bubble-outbound');
if (bubble && bubble.parentNode) bubble.parentNode.insertBefore(badge(hit), bubble.nextSibling);
else target.appendChild(badge(hit));
item.setAttribute('data-tb-done', '1');
} else {
body.textContent = '';
body.appendChild(badge(hit));
item.setAttribute('data-tb-orphan', '1');
}
}
function scan() {
var items = document.querySelectorAll('.message-item');
for (var i = 0; i < items.length; i++) decorate(items[i]);
}
var queued = false;
function schedule() {
if (queued) return;
queued = true;
requestAnimationFrame(function () { queued = false; scan(); });
}
/* Conversations virtualises the thread — bubbles mount and unmount as you
scroll, so a single pass on load is not enough. */
function boot() {
scan();
new MutationObserver(schedule).observe(document.body, { childList: true, subtree: true });
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
else boot();
})();
What it is doing
In order:
- Reads every
.message-itemon the page and pulls the text out of its.chat-contentnode. - Normalises it: curly quotes to straight, non breaking spaces to spaces, whitespace collapsed. This one step is why the regex works; skip it and it silently matches nothing.
- Matches the normalised text against the fallback patterns: verb + quote, removal + quote, emoji +
to+ quote, or verb + object. - Walks back up to twelve messages looking for the bubble whose text the reaction is quoting, comparing on a prefix so truncated quotes still land.
- Appends the badge directly under that bubble and marks the junk message
data-tb-done, which the CSS hides. If the quoted message is not on screen, the badge renders in place instead and the item is markeddata-tb-orphan. - Runs again on a
MutationObserver, coalesced into one pass per frame, so it survives the virtual scrolling that mounts and unmounts bubbles as you move through a thread.
The data-tb-done and data-tb-orphan attributes are also your debugging handle. See Troubleshooting.
Part 2: The functional fix
Everything above is paint. The message is still a row in the database, it still has a body, and it still triggers every automation listening for an inbound reply. Fix that in the workflow.
Gate every inbound trigger
Put an If/Else as the first step after any inbound trigger: Customer Replied, Conversation, SMS Received. Add one condition per pattern, joined with OR, on Message Body using starts with:
- Liked “starts with
- Loved “starts with
- Disliked “starts with
- Laughed at “starts with
- Emphasized “starts with
- Questioned “starts with
- Removed astarts with
- Đã thích “starts with
- Le gustó “starts with
- A aimé “starts with
The branch that matches goes nowhere. End the workflow. The Else branch carries on with everything you had before. Contacts who react and never type keep flowing through your no reply sequence, which is the behaviour you wanted.
Copy the quote character out of a real message. Type Liked " with a straight quote from your keyboard and it will never match, because the phone sent a curly one. Open the actual inbound message, copy the first seven characters, and paste those into the condition.
The “Wait for reply” trap
This is the one that costs people real leads, and an If/Else cannot save you from it.
A Wait for reply step does not run your workflow steps when a message arrives. It simply resolves and lets the contact move on. A tapback resolves it. By the time your If/Else could look at the body, the wait is already over and the contact has already advanced. You cannot undo that retroactively.
You have to restructure the wait rather than filter after it:
- Replace the wait with a trigger. Drop the Wait for reply step and end the workflow there. Start a second workflow on Customer Replied, gate it with the If/Else above, and have the Else branch do what came after the wait. Now the filter runs before anything advances.
- Or use a plain timed wait plus a check. Wait a fixed period, then use an If/Else on the contact’s last inbound message body to decide whether a real reply happened. Less elegant, but it keeps one workflow.
Whichever you pick, the rule is the same: the reaction has to be filtered before it can advance anything, not after.
The n8n version
If you route inbound messages through your own webhook, do the classification once at the edge and let everything downstream read a boolean. Drop a Code node straight after the webhook, then branch on isReaction with an IF node.
/* Tags iMessage/RCS tapback fallbacks on inbound GHL messages.
Adds: isReaction, reactionKind, reactionVerb, quotedMessage.
Branch downstream with an IF node on {{ $json.isReaction }}. */
const VERBS = [
'Liked', 'Loved', 'Disliked', 'Laughed at', 'Emphasized', 'Questioned',
'Đã thích', /* vi */
'Le gustó', /* es */
'A aimé' /* fr */
];
const REMOVERS = [
'Removed a like from', 'Removed a heart from', 'Removed a dislike from',
'Removed a laugh from', 'Removed an exclamation from', 'Removed a question mark from'
];
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
/* iOS sends curly quotes (U+201C/U+201D) and non-breaking spaces.
Normalise first or nothing below will ever match. */
const normalize = (s) => String(s ?? '')
.replace(/[“”„«»]/g, '"')
.replace(/[‘’]/g, "'")
.replace(/[\u00A0\u202F\u2007]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
const VERB_RE = new RegExp(`^(${VERBS.map(esc).join('|')})\\s+"([\\s\\S]+)"$`, 'i');
const RMV_RE = new RegExp(`^(${REMOVERS.map(esc).join('|')})\\s+"([\\s\\S]+)"$`, 'i');
const OBJ_RE = new RegExp(`^(${VERBS.map(esc).join('|')})\\s+(an image|a link|an attachment|a video)$`, 'i');
const EMOJI_RE = /^(?:Reacted\s+(?:with\s+)?)?(\p{Extended_Pictographic}(?:️|\p{Emoji_Modifier})?)\s+to\s+"([\s\S]+)"$/u;
for (const item of $input.all()) {
/* GHL inbound webhooks vary by trigger — take the first body we find */
const raw = item.json.body ?? item.json.message ?? item.json.Message ?? '';
const text = normalize(raw);
const removed = RMV_RE.exec(text);
const hit = removed || VERB_RE.exec(text) || EMOJI_RE.exec(text) || OBJ_RE.exec(text);
item.json.isReaction = Boolean(hit);
item.json.reactionKind = hit ? (removed ? 'removed' : 'added') : null;
item.json.reactionVerb = hit ? hit[1] : null;
item.json.quotedMessage = hit ? hit[hit.length - 1] : null;
}
return $input.all();
Downstream, the false branch is your real conversation. The true branch is where you decide whether a reaction means anything to you. Most people log it against the contact and stop. If you want the reaction to count as positive intent, quotedMessage tells you exactly which of your messages earned it, which is a genuinely useful signal to score on.
Troubleshooting
Open a conversation, open DevTools, and work down this list. Ninety per cent of failures are one of the first two.
1. Are the selectors still right?
If this returns 0, GHL has renamed the class and the rest of the script is irrelevant. Inspect a bubble and read the current class off the element.
document.querySelectorAll('.message-item').length
document.querySelectorAll('.chat-content').length
document.querySelectorAll('.chat-bubble-inbound').length
2. Did the script fire?
Both zero, on a thread you can see reactions in, means the script loaded but matched nothing. Go to step 3. Any other number means it is working and your problem is CSS.
document.querySelectorAll('[data-tb-done]').length /* re-homed onto their target */
document.querySelectorAll('[data-tb-orphan]').length /* matched, target off-screen */
document.querySelectorAll('.tb-reaction').length /* badges actually in the DOM */
3. Raw vs normalised: the curly quote test
This is the failure. Your regex is written with " and the phone sent “. Print the raw text as JSON and you will see the real bytes: \u201C and \u201D where you expected ", and \u00A0 where you assumed a space.
/* grab the last inbound message on screen */
var nodes = document.querySelectorAll('.chat-content');
var raw = nodes[nodes.length - 1].textContent;
/* the real bytes — look for \u201C quotes and \u00A0 spaces */
JSON.stringify(raw);
var normalize = (s) => String(s || '')
.replace(/[“”„«»]/g, '"')
.replace(/[\u00A0\u202F\u2007]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
var RE = /^Liked\s+"([\s\S]+)"$/i;
RE.test(raw); /* false — the quotes are curly */
RE.test(normalize(raw)); /* true — this is the whole trick */
4. Badges appear, then vanish
The MutationObserver is not attached, or it is attached to a node that gets replaced. Confirm boot() ran once and that you are observing document.body with subtree: true, not the thread container, which Conversations tears down and rebuilds when you switch contacts.
5. Nothing loads at all
Custom JS is set at agency level. Confirm you saved it under Settings → Company and not inside a single sub account, hard reload with cache disabled, and check the console for a syntax error. GHL will happily save a broken block and it takes the whole file down with it.
Caveats
Read these before you ship it to a client account.
- GHL DOM classes can change without notice. This hooks internal class names on a third party app that ships continuously. It will break eventually. When it does, step 1 of Troubleshooting tells you in about ten seconds.
- The cosmetic fix runs in the web app only. The mobile app, the API, LC Phone exports and every report still see the raw text. Custom CSS/JS only runs in the browser. It does not travel.
- It needs custom code access at agency level. If your user is scoped to one location, or you are on a plan without Custom JS, Part 1 is not available to you. Part 2 still is, and Part 2 is the one that matters.
- Test in the console before you save. Paste the script into DevTools on a live Conversations tab and watch it run. A syntax error in agency Custom JS breaks the block for every sub account under the agency, on every page.
- The locale list is not exhaustive. The verbs here cover English plus the three locales we have seen in the wild. Every language has its own string. Add yours the way the comment in the script describes. React from a phone in that language and copy what arrives.
- You still cannot send a reaction. Nothing here changes that. If you tapback from your side, the contact gets a text that says you liked their message.