# 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-texting` on 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 `https` address. 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`:

```text title="Alex’s welcome template"
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. [Holds](https://usebluebot.com/docs/concepts/holds) |
| 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. [Sending rules](https://usebluebot.com/docs/concepts/sending-rules)

## 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:

```bash
curl -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"
  }'
```

```ts
import BlueBot from '@usebluebot/sdk';

const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY });

const webhook = await client.webhooks.create({
  url: 'https://summitfitness.example/bluebot/webhooks',
  events: ['message.received', 'contact.stopped'],
  description: 'Replies to HighLevel',
});
console.log(webhook);
```

```python
import os
from bluebot import BlueBot

client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"])

webhook = client.webhooks.create(
    url="https://summitfitness.example/bluebot/webhooks",
    events=["message.received", "contact.stopped"],
    description="Replies to HighLevel",
)
print(webhook)
```

Copy the signing secret from the answer; it’s shown once.

```bash
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).

```ts title="TypeScript"
// 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);
```

```python title="Python"
# server.py: pip install flask requests bluebot
import os

import requests
from flask import Flask, abort, request
from bluebot import BlueBot, WebhookVerificationError

app = Flask(__name__)
bluebot = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"])
GHL = "https://services.leadconnectorhq.com"
handled: set[str] = set()  # event ids; use a table in production

def ghl(method: str, path: str, **kwargs) -> dict:
    response = requests.request(
        method,
        f"{GHL}{path}",
        headers={
            "Authorization": f"Bearer {os.environ['GHL_TOKEN']}",
            "Version": "2021-07-28",
            "Accept": "application/json",
        },
        timeout=10,
        **kwargs,
    )
    response.raise_for_status()
    return response.json()

def find_contact(number: str) -> str | None:
    result = ghl(
        "GET",
        "/contacts/search/duplicate",
        params={"locationId": os.environ["GHL_LOCATION_ID"], "number": number},
    )
    contact = result.get("contact")
    return contact["id"] if contact else None

@app.post("/bluebot/webhooks")
def bluebot_webhook():
    try:
        event = bluebot.webhooks.verify(
            request.get_data(), request.headers, secret=os.environ["BLUEBOT_WEBHOOK_SECRET"]
        )
    except WebhookVerificationError:
        abort(400)
    if event.id in handled:
        return "", 204

    try:
        if event.type == "message.received" and not event.data.chat_id:
            message = event.data
            contact_id = find_contact(message.from_)
            if contact_id:
                words = message.text or f"(sent {len(message.files)} file(s))"
                ghl("POST", f"/contacts/{contact_id}/notes", json={"body": f"Text from {message.from_}: {words}"})
        if event.type == "contact.stopped":
            contact_id = find_contact(event.data.handle)
            if contact_id:
                ghl("POST", f"/contacts/{contact_id}/tags", json={"tags": ["stopped-texting"]})
        handled.add(event.id)
        return "", 204
    except requests.RequestException as error:
        app.logger.error("Could not update HighLevel: %s %s", event.id, error)
        return "", 500  # BlueBot tries again
```

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. [Verify signatures](https://usebluebot.com/docs/webhooks/verify) |
| 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 `hold` says why it’s waiting (quiet hours, your Mac offline, the day’s limit of new people), and a `failed` text says why it didn’t go. [Message states](https://usebluebot.com/docs/concepts/message-states)
- **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. [Sending rules](https://usebluebot.com/docs/concepts/sending-rules)
- **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**. [Receiving texts](https://usebluebot.com/docs/sending/receiving)

## Next

- [GoHighLevel recipes](https://usebluebot.com/docs/recipes/gohighlevel): Text new contacts through Zapier, and the Text Hook and reply recipes on one page.
- [Text Hooks](https://usebluebot.com/docs/text-hooks): Presets, templates, metadata and the test panel in full.
- [HubSpot timeline](https://usebluebot.com/docs/guides/hubspot-timeline): Log texts both ways on a CRM contact, with a match by record ID.
- [Stop words](https://usebluebot.com/docs/concepts/stop-words): What counts as STOP, and how START takes someone off the list.
