Text from HighLevel workflows through your own number
Send the texts your HighLevel workflows already send from your own number through BlueBot instead of a rented one, then put every reply on the contact and tag the ones who text STOP.
Alex runs Summit Fitness, a small gym, and runs his marketing in GoHighLevel. His workflows text every new sign-up and every booked intro session, but they text from a number HighLevel rents him, one his members have never seen. Half of them don’t answer a number they don’t know. With this guide, the same workflows text from Alex’s own phone number through BlueBot, the texts appear in his existing threads with his members, and each reply is added to the contact in HighLevel as a note.
What you’ll have
- A HighLevel workflow action that sends a text from your own number and fills in the contact’s fields.
- Each reply added to the contact in HighLevel as a note, so your team sees it with the rest of their history.
- The tag
stopped-textingon any contact who texts STOP, so a workflow can skip them.
What you need
- A BlueBot account with your Mac connected.
- A HighLevel sub-account where you can edit workflows. The first part needs nothing else.
- For the replies: a HighLevel Private Integration token for that sub-account, its Location ID, and somewhere to run a small web server with an
httpsaddress. Node.js 18 or later, or Python 3.10 or later.
Part 1: send from a workflow
HighLevel workflows have a Webhook action that posts the contact’s details to any address. A BlueBot Text Hook is an address that turns each post it gets into a text, so the workflow needs no API key and no code. Text Hooks are set up in the dashboard; they have no API yet.
Make a Text Hook
In the BlueBot dashboard, open Text Hooks and press New hook. Name it after the workflow (“New member welcome”) and save. Copy the hook’s address: https://usebluebot.com/h/ followed by a long token. Treat it like a password, because anyone with it can make your hook text somebody.
Add the Webhook action to your workflow
In HighLevel, open Automation, then Workflows, and open the workflow that should text, such as the one that starts when a contact is created. Add an action, choose Webhook, set the method to POST and paste the hook’s address as the URL.
If the workflow has a Send SMS action for the same text, delete it, or every contact gets two texts from two numbers. Leave HighLevel’s other actions where they are.
Run the workflow once with yourself
Make a contact in HighLevel with your own first name and your own mobile number, and let the workflow run for it (add it to the workflow by hand if it doesn’t start on its own).
Back on the hook’s page in BlueBot, the test panel lists the last 20 posts by their field names. BlueBot never shows or keeps the values there, only the names. A HighLevel post carries the contact at the top level, with names such as first_name, phone, email and contact_id.
Map the recipient and write the text
Set Recipient to the phone field. Press a field in the test panel to put its path into the template; a template reads the post under body:
Hi {{body.first_name}}, it’s Alex from Summit Fitness. Thanks for signing up. Want to book a free intro session this week?Under Metadata, add ghl_contact_id set to {{body.contact_id}}. BlueBot echoes metadata on every webhook about the text, so anything you build on those webhooks later knows which HighLevel contact a text was for.
Turn on Create a contact and add the tag highlevel, then save the hook.
Test it with your own number
Run the workflow for your own contact again. A text arrives on your phone from your own number within a few seconds, and the hook’s page lists the post.
| You got | It means | Do next |
|---|---|---|
| A text on your phone from your number | It works. | Turn the workflow on for everyone. |
| The post is listed but no text came | The recipient field was empty or not a phone number, or the text is waiting with a hold. | Open the text in Messages in the dashboard and read its state and hold. Quiet hours hold texts at night. |
| No post on the hook’s page | HighLevel didn’t reach the hook. | Check the workflow’s execution log in HighLevel for the Webhook action, and that the URL was pasted whole. |
| Two texts, one from another number | HighLevel’s own Send SMS action is still in the workflow. | Delete it. |
Every text a hook sends meets your sending rules like any other: pacing, quiet hours, your daily limit of new people and the stop list. A workflow that fires for 300 contacts at once doesn’t send 300 texts at once; they leave your Mac one every 20 seconds by default.
Part 2: put replies on the contact
BlueBot tells your server about each reply with a message.received webhook. The server finds the contact in HighLevel by the sender’s number and adds the reply as a note. When someone texts STOP, BlueBot sends contact.stopped, and the server tags the contact.
Make a Private Integration in HighLevel
In the sub-account, open Settings, then Private Integrations, and create one named “BlueBot replies” with the scopes View Contacts and Edit Contacts (contacts.readonly and contacts.write). Copy its token. Copy the sub-account’s Location ID from Settings → Business Profile too.
Make a BlueBot webhook
In BlueBot, open Developers, then Webhooks, and make one for your server with message.received and contact.stopped ticked, or with the API:
/v1/webhookscurl -X POST "https://usebluebot.com/api/v1/webhooks" \
-H "Authorization: Bearer $BLUEBOT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://summitfitness.example/bluebot/webhooks",
"events": [
"message.received",
"contact.stopped"
],
"description": "Replies to HighLevel"
}'Copy the signing secret from the answer; it’s shown once.
export BLUEBOT_API_KEY="bb_live_…"
export BLUEBOT_WEBHOOK_SECRET="whsec_…"
export GHL_TOKEN="pit-…"
export GHL_LOCATION_ID="…"Write the handler
HighLevel’s API finds a contact by phone number with its duplicate search, which matches a number however it was typed. The handler adds the reply as a note and tags anyone who stopped. It skips group chats, and it keeps the event ids it has handled so a retried delivery never adds a note twice (in memory here; use a table in production).
// server.ts: npm install express @usebluebot/sdk
import express from 'express';
import BlueBot, { WebhookVerificationError } from '@usebluebot/sdk';
const bluebot = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY });
const GHL = 'https://services.leadconnectorhq.com';
const handled = new Set<string>(); // event ids; use a table in production
async function ghl(path: string, init: RequestInit = {}) {
const response = await fetch(`${GHL}${path}`, {
...init,
headers: {
Authorization: `Bearer ${process.env.GHL_TOKEN}`,
Version: '2021-07-28',
'Content-Type': 'application/json',
Accept: 'application/json',
},
});
if (!response.ok) throw new Error(`HighLevel answered ${response.status}`);
return response.json();
}
async function findContact(number: string): Promise<string | null> {
const query = new URLSearchParams({ locationId: process.env.GHL_LOCATION_ID!, number });
const result = await ghl(`/contacts/search/duplicate?${query}`);
return result.contact?.id ?? null;
}
const app = express();
app.post('/bluebot/webhooks', express.raw({ type: 'application/json' }), async (req, res) => {
let event;
try {
event = bluebot.webhooks.verify(req.body.toString('utf8'), req.headers, process.env.BLUEBOT_WEBHOOK_SECRET!);
} catch (error) {
if (error instanceof WebhookVerificationError) return res.status(400).send('Bad signature');
throw error;
}
if (handled.has(event.id)) return res.status(204).end();
try {
if (event.type === 'message.received' && !event.data.chatId) {
const message = event.data;
const contactId = await findContact(message.from!);
if (contactId) {
const words = message.text ?? `(sent ${message.files.length} file${message.files.length === 1 ? '' : 's'})`;
await ghl(`/contacts/${contactId}/notes`, {
method: 'POST',
body: JSON.stringify({ body: `Text from ${message.from}: ${words}` }),
});
}
}
if (event.type === 'contact.stopped') {
const contactId = await findContact(event.data.handle);
if (contactId) {
await ghl(`/contacts/${contactId}/tags`, { method: 'POST', body: JSON.stringify({ tags: ['stopped-texting'] }) });
}
}
handled.add(event.id);
res.status(204).end();
} catch (error) {
console.error('Could not update HighLevel', event.id, error instanceof Error ? error.message : error);
res.status(500).end(); // BlueBot tries again
}
});
app.listen(3000);The log lines name the event and never the words of a text. Keep it that way.
Skip stopped contacts in your workflows
Add an If/Else to the start of each workflow that texts: when the contact has the tag stopped-texting, end the workflow. BlueBot would cancel the text anyway, since the person is on your stop list, but skipping them keeps canceled texts out of the workflow’s history.
Test it with your own number
Reply to the welcome text you sent yourself in part 1, from your phone. Open your contact in HighLevel.
| You got | It means | Do next |
|---|---|---|
| A note “Text from +1…” with your reply | It works. | Leave the server running; every reply is now added to the contact. |
No note, and the webhook’s deliveries in Developers → Webhooks show 204 | The duplicate search found nobody with that number in this sub-account. | Check the contact’s phone, and that GHL_LOCATION_ID is this sub-account’s. |
Deliveries show 500 and your log says HighLevel answered 401 | The token is wrong or for another sub-account. | Copy the Private Integration’s token again. |
Deliveries show 400 | The signature didn’t match. | Read the raw body before parsing it, and use this webhook’s secret. |
| No deliveries at all | BlueBot didn’t copy your reply. Your phone may share an Apple ID with the Mac. | Reply from a second phone of your own with a different number. |
When it goes wrong
- Members still get texts from the old number. A workflow still has a Send SMS action, or a campaign outside workflows sends them. Search your workflows for Send SMS.
- A text never went. Open it in Messages in the BlueBot dashboard. A
holdsays why it’s waiting (quiet hours, your Mac offline, the day’s limit of new people), and afailedtext says why it didn’t go. - A big workflow takes hours. That’s pacing: one text every 20 seconds from your Mac, the way a person sends them. Spread a large blast over the day, or send it as a broadcast from the dashboard, which tells you when the last text will go.
- Replies from people the workflow never texted aren’t noted. BlueBot copies texts only from people you’ve texted through it, unless you set Which chats to copy to Everything.