# Connect 23 Telecom to HubSpot

> Send SMS from HubSpot workflows with a custom-code action calling 23 Telecom — a compact Node.js snippet, secret storage, callbackId idempotency, and where carrier delivery is tracked.
> Source: https://docs.23telecom.co.uk/integrations/hubspot/
> Activation: Managed activation

Instructions for LLMs: This is one page of the 23 Telecom messaging API docs
(SMS today; more channels planned). Base URL: https://restlink23telecom.com/api/v1,
auth via the X-API-Key header. Match errors on the error_code field, never on
description text. Full docs: https://docs.23telecom.co.uk/llms-full.txt · Schemas: https://docs.23telecom.co.uk/openapi.yaml

| | |
| --- | --- |
| **Setup type** | Custom code · technical admin |
| **Setup time** | About 20 minutes |
| **Sends SMS** | Yes |
| **Delivery status in HubSpot** | No — check 23 Telecom |
| **Who can configure** | HubSpot admin (Data Hub Professional or Enterprise) |

  23 Telecom supports HubSpot custom-code workflows with retry-safe response
  handling built into the example below. Start in sandbox; when you're ready to
  launch, your account manager enables production for the workspace and confirms
  the restricted key and account limits. The action treats a send as successful
  only when `results[0].status` is `"accepted"` with a non-empty
  `results[0].message_id`.

## What this connection does

A HubSpot **custom-code** workflow action calls the 23 Telecom API to send an
SMS. We recommend custom code over **Send a webhook** because it lets you build
the exact JSON body, read the key from a secret, and use a per-execution
idempotency key. HubSpot records the action result; carrier delivery reports stay
in 23 Telecom.

## Before you start

- **Data Hub Professional or Enterprise** (custom-code actions require it).
- An [active workspace](https://docs.23telecom.co.uk/account/workspaces), an
  [approved sender ID](https://docs.23telecom.co.uk/sms/sender-id) and an E.164 test number, e.g.
  `+447911123456`.
- A restricted 23 Telecom [API key](https://docs.23telecom.co.uk/api-keys) with only `sms.send` — start with
  a sandbox `sk_test_` key ([Sandbox & test mode](https://docs.23telecom.co.uk/sandbox)).

`POST /api/v1/sms/send-individual` (permission: `sms.send`)

## Connect 23 Telecom

1. In your workflow choose **+ → Custom code**.

2. Add a **secret** named `TELECOM23_KEY` holding your restricted key, and add
   the contact **phone** as an input property.

3. Paste the action code (below) and map the phone input.

4. Use **Test action** on a test record, then check the status, output and logs.

### Action code

```js title="Custom code (Node.js)"
// Secret: TELECOM23_KEY   Input property: phone
const axios = require('axios'); // bundled in HubSpot custom-code actions

exports.main = async (event, callback) => {
  const phone = event.inputFields.phone;
  try {
    const { data } = await axios.post(
      'https://restlink23telecom.com/api/v1/sms/send-individual',
      { messages: [{ to: phone, message: 'Your message', sender_id: 'YourBrand' }] },
      {
        headers: {
          'Content-Type': 'application/json',
          'X-API-Key': process.env.TELECOM23_KEY,
          'Idempotency-Key': event.callbackId, // unique per execution
        },
      }
    );

    // 2xx only: the body must still say "accepted" with an id. Any other body
    // is not confirmed acceptance and must not be auto-retried.
    const result = data && data.results && data.results[0];
    if (!result || result.status !== 'accepted' || !result.message_id) {
      throw new Error(`not accepted: ${result && result.status}`);
    }
    callback({ outputFields: { message_id: result.message_id, status: 'accepted' } });
  } catch (err) {
    const status = err.response && err.response.status;
    // Transient (429/5xx): re-throw the original AxiosError — HubSpot only retries
    // an action that throws an axios / @hubspot/api-client error for those codes.
    if (status === 429 || (status >= 500 && status <= 599)) {
      throw err;
    }
    // Permanent (other 4xx, or a 200 that wasn't "accepted"): fail with a
    // plain error and do NOT ask HubSpot to retry.
    throw new Error(`send failed permanently (status ${status || 'none'})`);
  }
};
```

## Send a test

Run **Test action** on a test record with your `sk_test_` key. Expect the output
`message_id` to be set and the log to show `results[0].status: "accepted"`.

  The action treats a send as successful **only** when `results[0].status` is
  `"accepted"` **and** `results[0].message_id` is present. `blocked_country` is not
  queued. Missing local pricing is different: valid live API traffic is accepted
  fail-open and the delivery report supplies the final status and cost. Any legacy
  non-accepted body fails without automatic retry; check the 23 Telecom message
  log before retrying it manually.

  HubSpot retries an action only when it throws an error from **`axios`** (or
  `@hubspot/api-client`) for `429/5xx`, so the snippet re-throws the original
  `AxiosError` on those and fails **permanently** on `4xx`. Each execution has a
  unique **`event.callbackId`**, used as the `Idempotency-Key` so a retry never
  sends a second SMS. Custom-code limits: **20 s, 128 MB, up to 50 input
  properties**. The action result is API **acceptance**, not delivery.

## Production activation

Unlike the webhook-only platforms, the HubSpot custom code **validates the response
body** — it fails unless `results[0].status` is `"accepted"` with a `message_id`,
so a bare `2xx` never turns the workflow green. That satisfies the production
acceptance requirement without a dedicated endpoint. After the sandbox check,
your 23 Telecom account manager enables production for the workspace and confirms
the account limits; then swap the `TELECOM23_KEY` secret from the sandbox
`sk_test_` key to a restricted `sk_prod_` key.

## Verify the result

**Accepted** means queued. Read carrier delivery in [Message status](https://docs.23telecom.co.uk/sms/status)
or [Statistics](https://docs.23telecom.co.uk/sms/statistics), or add a live
[delivery webhook](https://docs.23telecom.co.uk/webhooks/delivery). If you need delivery status inside
HubSpot, ask us to scope a managed contact-property or custom-event bridge.

## Troubleshooting

| Response | Cause & fix |
| --- | --- |
| `400 IDEMPOTENCY_KEY_REQUIRED` | Send `event.callbackId` as `Idempotency-Key` |
| `401 INVALID_API_KEY` | Wrong or disabled key in the `TELECOM23_KEY` secret |
| `403 NO_SMS_ACCESS` | SMS not enabled — contact 23 Telecom |
| `429 RATE_LIMIT_EXCEEDED` | Sending faster than your budget — throttle the workflow |

## Need help?

- **Workflow, custom code, secrets or limits** — HubSpot support.
- **API key, sender ID, accepted-but-not-delivered** — your 23 Telecom account
  manager, or the [contact form](https://23telecom.co.uk/contact).

## Official platform documentation

- [Custom code actions](https://developers.hubspot.com/docs/api-reference/latest/automation/workflow-actions/custom-code-actions)
- [Send a webhook](https://knowledge.hubspot.com/workflows/how-do-i-use-webhooks-with-hubspot-workflows)
- [Choose your workflow actions](https://knowledge.hubspot.com/workflows/choose-your-workflow-actions)

*Last verified: 16 July 2026.*