# textbee, full documentation > The open-source SMS gateway for developers, automations, and AI agents. Turn an Android phone you already own into an SMS gateway and send or receive text messages over a simple REST API, with no per-message gateway fees. Generated from https://textbee.dev. Canonical HTML documentation: https://textbee.dev/docs Machine readable API spec: https://textbee.dev/openapi.json ## Contents - Overview - Getting started - Setting up textbee - Registering a device - Reconnecting an old device - API keys and authentication - Sending SMS - Sending SMS - Sending bulk SMS - Delivery status and message states - Send from a specific SIM - Schedule a message - Receiving SMS - Message history and polling - Filter incoming SMS on the phone - Managing devices - Webhooks - Webhook events reference - SDKs - JavaScript SDK - Agents and automation - MCP server - Agent quickstart - n8n - Self-hosting textbee - Troubleshooting - Messages not sending - Keep the phone online - Pricing - FAQ - API reference ## Base URL and authentication Send and receive SMS through an Android phone you own. Authenticate every request with an API key from your textbee dashboard, sent as the x-api-key header. Base URL: https://api.textbee.dev API key from your textbee dashboard, sent on every request. # Overview Source: https://textbee.dev/docs textbee turns an Android phone you own into an SMS gateway. Learn how it works, pick a way to send and receive, and find every docs section. textbee is an open-source SMS gateway that turns an Android phone you own into an SMS API. Your application calls a REST endpoint (or uses the dashboard, the JavaScript SDK, n8n or the MCP server), and the message goes out through the phone's SIM from your own number. These docs cover sending, receiving with webhooks or polling, devices, self-hosting, troubleshooting and the full API reference. Start with the [quickstart](/docs/getting-started/setting-up-textbee) to pair a phone and send a first message in about five minutes. Then use the [API reference](/docs/api-reference) for every endpoint. ## How it works textbee has three parts: | Part | Role | |---|---| | The textbee app on your Android phone | Sends and receives SMS with the phone's SIM | | The textbee API | Stores your messages, holds the queue and talks to the phone | | Your code | Calls the API to send, and reads replies from a webhook or the message history | A send goes this way: 1. Your code calls `POST /gateway/send-sms` with an API key. 2. The API stores the message and sends a push to the phone. 3. The app on the phone hands the message to the carrier. 4. The app reports the status back (sent, delivered or failed). A receive goes the other way: 1. A person sends an SMS to the phone's number. 2. The app uploads the message to the API. 3. The API sends a `MESSAGE_RECEIVED` event to your [webhook](/docs/webhooks) and adds the message to your [message history](/docs/receiving-sms/message-history). ## Pick your path | Path | Use it when | Start here | |---|---|---| | REST API | Your backend can make HTTP requests, in any language | [Sending SMS](/docs/sending-sms/sending-sms) | | JavaScript SDK | You write Node.js, Bun, Deno or Workers code and want types | [JavaScript SDK](/docs/sdk/javascript) | | MCP and agents | An AI assistant (Claude, Cursor) or an agent framework sends the messages | [Agents and automation](/docs/agents) | | n8n | You build workflows without code | [n8n](/docs/agents/n8n) | | Dashboard | You send by hand or upload a CSV for a bulk send | [Dashboard](https://app.textbee.dev/dashboard) | ## Getting started 1. **[Set up textbee](/docs/getting-started/setting-up-textbee)**: create your account and install the app. 2. **[Register a device](/docs/getting-started/registering-a-device)**: connect your Android phone. 3. **[Create an API key](/docs/getting-started/api-keys)**: authenticate your requests. 4. **[Send your first SMS](/docs/sending-sms/sending-sms)**: call the API. ## Documentation structure - **[Getting started](/docs/getting-started)**: account setup, device registration, reconnecting a device and API keys. - **[Sending SMS](/docs/sending-sms)**: single and bulk sends, delivery status, choosing a SIM and scheduled messages. - **[Receiving SMS](/docs/receiving-sms)**: incoming messages, message history and SMS filters on the phone. - **[Devices](/docs/devices)**: list your phones, read their health fields and pick a default. - **[Webhooks](/docs/webhooks)**: event subscriptions, [event payloads](/docs/webhooks/events), signatures and retries. - **[SDKs](/docs/sdk/javascript)**: the JavaScript SDK. - **[Agents and automation](/docs/agents)**: the MCP server, the agent quickstart and n8n. - **[Self-hosting](/docs/self-hosting)**: run the API, the dashboard and the app on your own server. - **[Troubleshooting](/docs/troubleshooting)**: messages that do not send and phones that go offline. - **[Pricing](/docs/pricing)**: plans and limits. - **[FAQ](/docs/faq)**: common questions. - **[API reference](/docs/api-reference)**: every public endpoint. ## Limits - **Android only**: textbee needs an Android phone as the gateway. iOS is not supported. - **The phone must stay online**: the phone needs an internet connection (Wi-Fi or mobile data) and power. Android can pause background apps, which delays messages. See [Keep the phone online](/docs/troubleshooting/keep-the-phone-online). - **Plan limits**: each plan has daily, monthly and per-batch message limits and an active device limit. See [Pricing](/docs/pricing). - **SMS only**: textbee does not send or receive MMS. - **Phone speed**: the phone sends one message at a time, with the Send Delay you set in the app between messages. A large batch takes time to finish. ## Reference - [API reference](/docs/api-reference): every public endpoint with parameters, fields, error codes and curl examples - [OpenAPI spec](https://textbee.dev/openapi.json): the same reference as machine-readable OpenAPI 3 - [llms-full.txt](https://textbee.dev/llms-full.txt): all docs and the API reference in one plain-text file ## Need help? - [Troubleshooting](/docs/troubleshooting) - [Discord community](https://textbee.dev/discord) - [FAQ](/docs/faq) - [Status page](https://status.textbee.dev) - support@textbee.dev # Getting started Source: https://textbee.dev/docs/getting-started Set up textbee in four steps: create an account, install the Android app, register your phone and send your first SMS from code. textbee sends and receives SMS through an Android phone that you own. You install the textbee app on the phone, connect it to your account, and then call a REST API that tells the phone what to send. ## What you need | Item | Details | | --- | --- | | An Android phone | Android 7.0 or newer, with a SIM that can send SMS and an internet connection (Wi-Fi or mobile data). | | A textbee account | Sign up in the [dashboard](https://app.textbee.dev/dashboard) and verify your email. Sending needs a verified email. | | An API key | You create it in the dashboard. The phone uses it to connect, and your code uses it to call the API. | The phone does the sending, so it must stay on, online and allowed to run in the background. A free plan is available. Plan limits are on the [pricing page](/docs/pricing). ## The four steps 1. **Create an account and install the app.** Follow [Setting up textbee](/docs/getting-started/setting-up-textbee). It covers sign-up, email verification, the APK download and the permissions the app asks for. 2. **Register the phone.** Follow [Registering a device](/docs/getting-started/registering-a-device). You scan a QR code from the dashboard or paste an API key into the app. The phone then shows in your Devices list. 3. **Get an API key for your code.** Read [API keys and authentication](/docs/getting-started/api-keys). It shows how to send the `x-api-key` header and how to check that a key works. 4. **Send your first message.** Call `POST /gateway/send-sms`. The [Sending SMS](/docs/sending-sms/sending-sms) page has the full request and response. This request sends one message from your default phone: ```bash curl -X POST https://api.textbee.dev/api/v1/gateway/send-sms \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"recipients": ["+12015550123"], "message": "Hello from textbee"}' ``` A `200` response means textbee accepted the message and queued it for the phone. It does not mean the recipient got it. Read [Delivery status and message states](/docs/sending-sms/delivery-status) to follow it to the handset. Note: If a phone registered earlier shows as disconnected, do not register it again. Follow [Reconnecting an old device](/docs/getting-started/reconnecting-an-old-device) so its message history stays with it. ## What to read next Pick the goal that matches your project. ### Send SMS from your code - [Sending SMS](/docs/sending-sms/sending-sms): the request fields, status codes and phone number format. - [Sending bulk SMS](/docs/sending-sms/sending-bulk-sms): many messages in one call, or a CSV upload in the dashboard. - [Schedule a message](/docs/sending-sms/scheduled-messages) and [Send from a specific SIM](/docs/sending-sms/choosing-a-sim). - [JavaScript SDK](/docs/sdk/javascript): a typed client for Node.js, Bun, Deno and Workers. ### Receive replies - [Receiving SMS](/docs/receiving-sms): turn on forwarding in the app and read incoming messages. - [Webhooks](/docs/webhooks): get an HTTP request for each incoming message and each status change. - [Message history](/docs/receiving-sms/message-history): list and filter sent and received messages. ### Use an AI agent - [Agents overview](/docs/agents): let an assistant send and read SMS for you. - [MCP server](/docs/agents/mcp): connect Claude, Cursor or another MCP client. - [n8n](/docs/agents/n8n): send SMS from an n8n workflow. ### Keep the phone reliable - [Devices](/docs/devices): heartbeats, the default device and more than one phone. - [Keep the phone online](/docs/troubleshooting/keep-the-phone-online): battery settings that stop Android from pausing the app. - [Messages not sending](/docs/troubleshooting/messages-not-sending): a checklist for stuck messages. ## Next steps - [Setting up textbee](/docs/getting-started/setting-up-textbee) - [Registering a device](/docs/getting-started/registering-a-device) - [API keys and authentication](/docs/getting-started/api-keys) - [Sending SMS](/docs/sending-sms/sending-sms) # Setting up textbee Source: https://textbee.dev/docs/getting-started/setting-up-textbee Create a textbee account, install the Android app, connect the phone and grant permissions, then send your first SMS from the API. To set up textbee, create an account, install the Android app on a phone with a SIM, connect the phone to your account and grant the SMS permissions. Then send a test message from the API. There is no sign-in inside the app: the phone connects with a QR code or an API key from the dashboard. ## Before you start - An Android phone with Android 7.0 or newer. - A SIM with an active plan that can send SMS. - An internet connection on the phone (Wi-Fi or mobile data). - An email address for your account. Warning: You must verify your email before you can send SMS. Until you do, send requests fail with a `400` error. ## Step 1: Create your account 1. Open the [dashboard](https://app.textbee.dev/dashboard) and choose to sign up. 2. Sign up with email and password, or with Google. 3. Open the verification email and click the link. ## Step 2: Download the Android app The app is not on the Play Store. You install the APK file directly. 1. On the phone, open [textbee.dev/download](https://textbee.dev/download) and download the APK. 2. If Android asks, allow your browser to install unknown apps (**Settings** > **Security** > **Install unknown apps**). 3. Open the APK and tap **Install**. ## Step 3: Register your device Registration links the app on the phone to your account. The app walks you through it: 1. Open the app and tap **Get Started**. 2. On **Connect your account**, tap **Scan QR Code** and scan the QR code from the dashboard. You can also tap **Enter manually instead** and paste an API key. 3. On **Set up your device**, enter a device name (optional) and tap **Register Device**. [Registering a device](/docs/getting-started/registering-a-device) has the full steps, including where the QR code is in the dashboard. ## Step 4: Grant permissions After registration the app shows **Grant Permissions**. Tap **Grant All Permissions** and accept each prompt. The app asks for: | Permission | Why the app needs it | | --- | --- | | Send SMS | Sends the messages you request through the API. | | Receive SMS | Forwards incoming messages to your account. | | Phone State | Detects the SIM cards so you can choose a SIM per message. | | Notifications (Android 13 and newer) | Shows that textbee is running and sending in the background. | Tap **Continue** when Send SMS shows as granted. Sending needs the Send SMS permission. The other permissions add receiving, SIM selection and the running notification. The last screen, **You're all set!**, shows your Device ID. Warning: On Android 15 and newer the SMS permission is locked for apps installed from an APK. Open App info, tap the menu (⋮), choose Allow restricted settings, then allow SMS. [Step by step with screenshots](/blog/android-15-send-sms-permission-guide). Note: Android can pause background apps to save battery, and a paused app holds your messages. Set battery usage for textbee to Unrestricted and keep the sticky notification on. [Keep the phone online](/docs/troubleshooting/keep-the-phone-online) has the steps for each phone brand. ## Send your first message You need an API key for your code. Use the key you created for the phone, or create another one in the **API Keys** panel. [API keys and authentication](/docs/getting-started/api-keys) explains both. This sample sends one message from your default device. Replace the number with your own phone number: curl: ```bash BASE_URL="${TEXTBEE_BASE_URL:-https://api.textbee.dev/api/v1}" curl -sS -X POST "$BASE_URL/gateway/send-sms" \ -H "x-api-key: $TEXTBEE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"recipients": ["+12015550123"], "message": "Hello from textbee"}' ``` JavaScript: ```javascript const BASE_URL = process.env.TEXTBEE_BASE_URL ?? 'https://api.textbee.dev/api/v1' const API_KEY = process.env.TEXTBEE_API_KEY async function sendSms(recipients, message) { const response = await fetch(`${BASE_URL}/gateway/send-sms`, { method: 'POST', headers: { 'x-api-key': API_KEY, 'Content-Type': 'application/json' }, body: JSON.stringify({ recipients, message }), }) if (!response.ok) throw new Error(`HTTP ${response.status}`) const { data } = await response.json() return data } const result = await sendSms(['+12015550123'], 'Hello from textbee') console.log(result.smsBatchId, result.recipientCount) ``` Python: ```python import json import os import urllib.request BASE_URL = os.environ.get("TEXTBEE_BASE_URL", "https://api.textbee.dev/api/v1") API_KEY = os.environ["TEXTBEE_API_KEY"] def send_sms(recipients, message): body = json.dumps({"recipients": recipients, "message": message}).encode() request = urllib.request.Request( f"{BASE_URL}/gateway/send-sms", data=body, headers={"x-api-key": API_KEY, "Content-Type": "application/json"}, method="POST", ) with urllib.request.urlopen(request, timeout=30) as response: return json.load(response)["data"] result = send_sms(["+12015550123"], "Hello from textbee") print(result["smsBatchId"], result["recipientCount"]) ``` PHP: ```php true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ["x-api-key: $apiKey", 'Content-Type: application/json'], CURLOPT_POSTFIELDS => json_encode(['recipients' => $recipients, 'message' => $message]), ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); if ($status !== 200) { throw new RuntimeException("HTTP $status"); } return json_decode($body, true)['data']; } $result = sendSms($baseUrl, $apiKey, ['+12015550123'], 'Hello from textbee'); echo $result['smsBatchId'], ' ', $result['recipientCount'], PHP_EOL; ``` Go: ```go package main import ( "bytes" "encoding/json" "fmt" "net/http" "os" ) func baseURL() string { if url := os.Getenv("TEXTBEE_BASE_URL"); url != "" { return url } return "https://api.textbee.dev/api/v1" } func main() { payload, _ := json.Marshal(map[string]any{ "recipients": []string{"+12015550123"}, "message": "Hello from textbee", }) request, _ := http.NewRequest(http.MethodPost, baseURL()+"/gateway/send-sms", bytes.NewReader(payload)) request.Header.Set("x-api-key", os.Getenv("TEXTBEE_API_KEY")) request.Header.Set("Content-Type", "application/json") response, err := http.DefaultClient.Do(request) if err != nil { panic(err) } defer response.Body.Close() if response.StatusCode != http.StatusOK { panic(fmt.Sprintf("HTTP %d", response.StatusCode)) } var result struct { Data struct { SmsBatchID string `json:"smsBatchId"` RecipientCount int `json:"recipientCount"` } `json:"data"` } if err := json.NewDecoder(response.Body).Decode(&result); err != nil { panic(err) } fmt.Println(result.Data.SmsBatchID, result.Data.RecipientCount) } ``` A successful call returns `200` with an `smsBatchId` and a `recipientCount`. Then check the message in two places: - **In the dashboard:** open **Messaging** > **History**. The message shows with its status: `pending` first, then `sent` and, if the carrier sends a delivery report, `delivered`. - **On the phone:** open the **Activity** tab in the textbee app. The phone's own messaging app also shows the sent message. If the message stays in `pending`, the phone did not get it. Check that the phone is online and that the app can run in the background. [Messages not sending](/docs/troubleshooting/messages-not-sending) lists the common causes. ## Frequently asked questions ### Do I need to sign in inside the app? No. The app connects with a QR code or an API key from the dashboard. It has no username or password field. ### Can I use a phone without a SIM? No. The phone sends each message through its own SIM and carrier. Without a SIM it cannot send or receive SMS. ### Does the phone need to stay on? Yes. textbee pushes each message to the phone, and the phone sends it. If the phone is off or offline, messages wait until it comes back. ## Next steps - [Registering a device](/docs/getting-started/registering-a-device) - [API keys and authentication](/docs/getting-started/api-keys) - [Sending SMS](/docs/sending-sms/sending-sms) - [Keep the phone online](/docs/troubleshooting/keep-the-phone-online) - [FAQ](/docs/faq) # Registering a device Source: https://textbee.dev/docs/getting-started/registering-a-device Connect an Android phone to your textbee account with a QR code or an API key, name it, set a default device and check its heartbeat. Registering a device links the textbee app on an Android phone to your account. You scan a QR code from the dashboard or paste an API key into the app. After registration the phone shows in your Devices list with a Device ID, and the API can send through it. ## Before you start - A textbee account with a verified email. - The textbee app installed on the phone. See [Setting up textbee](/docs/getting-started/setting-up-textbee). - The phone connected to the internet. ## Option 1: Scan a QR code (recommended) 1. Open the [dashboard](https://app.textbee.dev/dashboard). In the Devices panel, click **Add a device** and then **Continue**. The dashboard creates a new API key and shows it as a QR code. You can also click **Generate API Key** in the **API Keys** panel. 2. Open the textbee app on the phone and tap **Get Started**. 3. On **Connect your account**, tap **Scan QR Code** and point the camera at the QR code. 4. On **Set up your device**, enter a name (optional) and tap **Register Device**. 5. Allow the SMS permission when the app asks, then tap **Continue**. On Android 15 and newer, Android blocks the permission at first. Follow the note below this list. Warning: On Android 15 and newer the SMS permission is locked for apps installed from an APK. Open App info, tap the menu (⋮), choose Allow restricted settings, then allow SMS. [Step by step with screenshots](/blog/android-15-send-sms-permission-guide). The app shows **You're all set!** and the Device ID. The phone now shows in the Devices panel of the dashboard. ## Option 2: Paste an API key Use this option when you cannot scan the screen, for example when the dashboard is open on the same phone. 1. In the dashboard, click **Generate API Key** in the **API Keys** panel and copy the key. The dashboard shows the key only once. 2. Open the app, tap **Get Started**, then tap **Enter manually instead**. 3. Paste the key in the **API Key** field and tap **Next**. 4. Enter a device name (optional) and tap **Register Device**. 5. Allow the SMS permission when the app asks, then tap **Continue**. On Android 15 and newer, allow restricted settings first, as in the note above. Warning: The phone keeps the API key it registered with. If you revoke that key, the phone can no longer reach textbee, and you must reconnect it with a new key. ## Name the device The **Device Name (optional)** field sets a label, for example "Office phone". If you leave it empty, textbee uses the brand and model of the phone. The name shows in the dashboard, in the `name` field of `GET /gateway/devices`, and on each message in the history. A clear name helps when you have more than one phone. To change it later, open **Settings** > **Device Name** in the app. ## The Device ID Each registered phone gets a Device ID. The API returns it as `_id`. Pass it as `deviceId` when you want a specific phone to send a message. You find it in three places: - On the **You're all set!** screen in the app. - In the Devices panel of the dashboard (the copy button next to each device). - In the response of `GET /gateway/devices`. Keep the Device ID. You need it to [reconnect the phone](/docs/getting-started/reconnecting-an-old-device) after a reinstall. ## The default device A send request without `deviceId` goes out from your default device. If you have not set a default, textbee uses the enabled device with the most recent heartbeat. To choose the default, open the device actions menu in the Devices panel and click **Set as default**. The API shows it as `isDefault: true`. ## More than one device One account can have several phones. Each phone registers with the same steps and gets its own Device ID. Use more than one phone to split a large volume, to send from different numbers, or to keep a backup. Your plan sets how many devices can be active at the same time. When you reach the limit, registration fails with this error: ```text Active device limit reached: your plan allows up to N active device(s)... Disable or delete another device, or upgrade your plan ``` To free a slot, turn off **Gateway Enabled** in the app on a phone you do not use, or delete that device in the dashboard. You can also upgrade. The [pricing page](/docs/pricing) lists the device limit for each plan. ## The heartbeat The app sends a heartbeat to textbee at a fixed interval. A heartbeat is a short report that says "this phone is online". It also carries the battery level, network type, app version and SIM list. The last one is stored as `lastHeartbeat` on the device. It matters for two reasons: - **It shows if the phone is online.** A phone that has been silent for a long time is likely offline, asleep or stopped by Android. Messages sent to it wait until it comes back. - **It picks the sender.** Without a default device, textbee sends from the enabled device with the most recent heartbeat. By default the app sends one every 30 minutes. The `heartbeatIntervalMinutes` field on the device shows the interval. To send one now, open **Settings** > **Device health** in the app and tap **Send heartbeat** on the **Heartbeat** row. [Devices](/docs/devices) explains every device field, and [Keep the phone online](/docs/troubleshooting/keep-the-phone-online) covers phones that go silent. ## Frequently asked questions ### Do I register again after I reinstall the app? No. Reconnect the phone with its existing Device ID instead. A new registration creates a new device, and the message history stays with the old one. See [Reconnecting an old device](/docs/getting-started/reconnecting-an-old-device). ### Can one phone belong to two accounts? No. The app on a phone holds one set of credentials at a time. ### The registration failed. What do I check? Check that the phone is online, that the API key is not revoked, and that your plan has room for another active device. ## Next steps - [Send your first SMS](/docs/sending-sms/sending-sms) - [API keys and authentication](/docs/getting-started/api-keys) - [Devices](/docs/devices) - [Receiving SMS](/docs/receiving-sms) - [Webhooks](/docs/webhooks) # Reconnecting an old device Source: https://textbee.dev/docs/getting-started/reconnecting-an-old-device Reconnect a phone to its existing textbee device with the Device ID, so its message history and settings stay in one place. Reconnecting links the textbee app to a device that already exists in your account. You use the same QR code or API key flow as a new registration, and you also enter the existing Device ID. The phone then continues as the same device, with the same history. ## When to reconnect Reconnect when the phone was registered before and one of these happened: - You reinstalled the app or cleared its data. - You reset the phone. - You tapped **Disconnect Device** in the app. - You revoked the API key the phone used. ## Reconnect or register a new device? | | Reconnect | Register a new device | | --- | --- | --- | | Device ID | Stays the same | A new ID is created | | Message history | Stays with the device | The old history stays with the old device | | Code that passes `deviceId` | Keeps working | Must change to the new ID | | Plan device limit | Uses no extra slot | Uses another slot while the old device is active | Reconnect when it is the same phone, or a replacement phone that takes over the same role. Register a new device when you add a phone to your setup. Warning: Deleting a device in the dashboard removes its messages from your history. Reconnect instead of delete and register when you want to keep the history. ## Find the Device ID Open the [dashboard](https://app.textbee.dev/dashboard) and find the device in the Devices panel. Click the copy button next to it to copy the Device ID. The API also returns it as `_id` in `GET /gateway/devices`. ## Option 1: Reconnect with a QR code 1. In the dashboard, click **Add a device** and then **Continue**, or click **Generate API Key** in the **API Keys** panel. A QR code appears. 2. Open the textbee app and tap **Reconnect a device**. 3. On **Connect your account**, tap **Scan QR Code** and scan the QR code. 4. On **Reconnect your device**, paste the Device ID. Change the device name if you want to. 5. Tap **Reconnect Device**, then grant the permissions and tap **Continue**. ## Option 2: Reconnect with an API key 1. In the dashboard, copy an active API key, or create one with **Generate API Key**. 2. Open the app and tap **Reconnect a device**. 3. Tap **Enter manually instead**, paste the key in the **API Key** field and tap **Next**. 4. Paste the Device ID and tap **Reconnect Device**. 5. Grant the permissions and tap **Continue**. If you tapped **Get Started** by mistake, you can still reconnect. On **Set up your device**, turn on **This device was previously registered** and enter the Device ID. Check the result in the Devices panel. The device shows a recent heartbeat after the phone reports. ## What Disconnect Device does **Settings** > **Disconnect Device** in the app removes the credentials from the phone. The app shows: "This will remove all credentials from this device. You'll need to reconnect to use the gateway." After you disconnect: - The phone forgets its Device ID and API key, and it stops sending heartbeats. - The app returns to the first setup screen. - The device stays on your account with its message history. It is not deleted, and it is not disabled. A disconnected device that is still enabled counts toward your plan's active device limit. It can still be your default device, and textbee can still pick it for a send. Before you disconnect a phone for good, turn off **Gateway Enabled** in the app **Settings**. That disables the device on your account. After a disconnect, the app can no longer change the device, so your options are in the dashboard: click **Set as default** on another device, or **Delete** the disconnected one (this also removes its messages from your history). ## Troubleshooting - **The device still looks offline.** Check the phone's internet connection, open the app, and confirm the SMS permissions. Then open **Settings** > **Device health** and tap **Send heartbeat**. [Keep the phone online](/docs/troubleshooting/keep-the-phone-online) covers phones that Android stops in the background. - **"Active device limit reached".** Your plan has no free slot for another active device. Turn off **Gateway Enabled** in the app on a phone you do not use, delete a device in the dashboard, or upgrade. The [pricing page](/docs/pricing) lists the limits. - **The Device ID is not accepted.** Check that the API key and the device belong to the same account. If the device was deleted, [register the phone as a new device](/docs/getting-started/registering-a-device). ## Next steps - [Devices](/docs/devices) - [Sending SMS](/docs/sending-sms/sending-sms) - [Webhooks](/docs/webhooks) - [FAQ](/docs/faq) # API keys and authentication Source: https://textbee.dev/docs/getting-started/api-keys Authenticate textbee API calls with the x-api-key header: create, test, store, rotate and revoke API keys from the dashboard. Every textbee API request carries your API key in the `x-api-key` header. You create and revoke keys in the **API Keys** panel of the dashboard. A key gives full access to your account, so keep it on your server and treat it like a password. ## Send the key with each request Add the header to every call. The base URL is `https://api.textbee.dev/api/v1`. ```text x-api-key: YOUR_API_KEY ``` There is no login call and no token to refresh. A request without the header, or with a wrong key, gets `401`. ## Create a key 1. Open the [dashboard](https://app.textbee.dev/dashboard). 2. In the **API Keys** panel, click **Generate API Key**. 3. Copy the key and store it in a safe place. The dashboard shows the key only once. After you close the dialog, you cannot view it again. If you lose a key, create a new one and revoke the old one. The same dialog shows the key as a QR code. The textbee app scans that code to [register a device](/docs/getting-started/registering-a-device). You can create one key for each phone and one for each application, so you can revoke one without affecting the others. ## Check that your key works `GET /gateway/stats` is a cheap call that returns your account totals. Use it to test a new key or a new environment before you send a message. GET /api/v1/gateway/stats curl: ```bash curl https://api.textbee.dev/api/v1/gateway/stats \ -H "x-api-key: YOUR_API_KEY" ``` JavaScript: ```javascript const response = await fetch('https://api.textbee.dev/api/v1/gateway/stats', { headers: { 'x-api-key': process.env.TEXTBEE_API_KEY }, }) if (!response.ok) throw new Error(`HTTP ${response.status}`) const { data } = await response.json() console.log(data.totalDeviceCount, data.totalSentSMSCount) ``` Python: ```python import json import os import urllib.request request = urllib.request.Request( "https://api.textbee.dev/api/v1/gateway/stats", headers={"x-api-key": os.environ["TEXTBEE_API_KEY"]}, ) with urllib.request.urlopen(request, timeout=30) as response: data = json.load(response)["data"] print(data["totalDeviceCount"], data["totalSentSMSCount"]) ``` A working key returns `200` with this body: ```json { "data": { "totalSentSMSCount": 128, "totalReceivedSMSCount": 42, "totalDeviceCount": 1, "totalApiKeyCount": 2 } } ``` ## What 401 means `401` means textbee did not accept the key. There are three causes: - The `x-api-key` header is missing. Check the header name and that your HTTP client sends it. - The key is wrong. Check for a missing character or extra spaces from a copy and paste. - The key is revoked. The body looks like this: ```json { "error": "Unauthorized", "code": "AUTH_INVALID" } ``` Do not retry a `401`. The same request fails again until you fix the key. ## Keep the key secret A key has the same access as your account. It can send SMS from your phones, read all your messages and change your webhooks. Follow these rules: - Keep the key on a server, in a serverless function or in a background job. - Never put the key in a mobile app, a browser bundle or a public repository. Anyone who opens the app or the page can read it. - Let your front end call your own server, and let your server call textbee. - Store the key in an environment variable or a secret manager, not in source code. ## Environment variable convention Name the variable `TEXTBEE_API_KEY`. The code samples in these docs read it, and so does the textbee MCP server. Self-hosted instances also set `TEXTBEE_BASE_URL`. ```bash export TEXTBEE_API_KEY="YOUR_API_KEY" ``` ## Where the key goes | Tool | Where you put the key | | --- | --- | | REST API | The `x-api-key` header on each request. | | [JavaScript SDK](/docs/sdk/javascript) | The constructor: `new Textbee({ apiKey: process.env.TEXTBEE_API_KEY })`. | | [MCP server](/docs/agents/mcp) | The `TEXTBEE_API_KEY` entry in the `env` block of your MCP client config. | | [n8n](/docs/agents/n8n) | A Header Auth credential with the name `x-api-key`. | | The Android app | The QR code or the **API Key** field during registration. | ## Rotate a key Rotate a key when it may have leaked, when a team member leaves, or on a fixed schedule. Rotation has no downtime if you follow this order: 1. Create a new key in the **API Keys** panel. 2. Deploy the new key to every place that uses the old one. 3. Check that each service works, for example with `GET /gateway/stats`. 4. Revoke the old key from its actions menu in the **API Keys** panel. Revoking stops the key everywhere at once. Revoked keys stay listed under **View revoked keys**, and you can remove them from there. Warning: If a phone registered with the key you revoke, the phone loses its connection. [Reconnect it](/docs/getting-started/reconnecting-an-old-device) with a new key and its existing Device ID. ## Frequently asked questions ### Can I limit a key to sending only? No. Every key has full access to the account. To limit the impact of a leak, use a separate key for each service and revoke only the one that leaked. ### How many keys can I have? The dashboard lets you create more than one. `totalApiKeyCount` in `GET /gateway/stats` shows how many your account has. ### Can I call the API from the browser? No. The browser would expose the key to every visitor. Call textbee from your server and let the browser call your server. ## Next steps - [Sending SMS](/docs/sending-sms/sending-sms) - [Registering a device](/docs/getting-started/registering-a-device) - [JavaScript SDK](/docs/sdk/javascript) - [API reference](/docs/api-reference#get-api-v1-gateway-stats) # Sending SMS Source: https://textbee.dev/docs/sending-sms Send SMS through your Android phone with the textbee API: single sends, bulk sends, scheduled sends, SIM choice and delivery tracking. textbee sends SMS from an Android phone that you registered to your account. Your code calls the API, textbee pushes the message to the phone, and the phone sends it through its SIM. This section covers every way to send and how to follow a message to the recipient. ## Send one message This request sends one message from your default device: ```bash curl -X POST https://api.textbee.dev/api/v1/gateway/send-sms \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"recipients": ["+12015550123"], "message": "Your order has shipped"}' ``` The response has an `smsBatchId`. Keep it to follow delivery. [Sending SMS](/docs/sending-sms/sending-sms) has every request field, the status codes and samples in five languages. ## Ways to send | Goal | How | Page | | --- | --- | --- | | Send the same text to one or more numbers | `POST /gateway/send-sms` with a `recipients` array | [Sending SMS](/docs/sending-sms/sending-sms) | | Send different texts to different numbers in one call | `POST /gateway/send-bulk-sms` with a `messages` array | [Sending bulk SMS](/docs/sending-sms/sending-bulk-sms) | | Send a personalized message to each row of a spreadsheet | CSV upload in the dashboard | [Sending bulk SMS](/docs/sending-sms/sending-bulk-sms) | | Send at a later time | `scheduledAt` on the request | [Schedule a message](/docs/sending-sms/scheduled-messages) | | Send from a specific SIM on a dual-SIM phone | `simSubscriptionId` on the request | [Send from a specific SIM](/docs/sending-sms/choosing-a-sim) | | Send from a specific phone | `deviceId` on the request | [Devices](/docs/devices) | ## Choose the phone A request without `deviceId` goes out from your default device. If you have no default, textbee uses the enabled device with the most recent heartbeat. Pass `deviceId` to pick a phone yourself. [Registering a device](/docs/getting-started/registering-a-device) explains how to set the default. ## Follow the message A `200` response means textbee accepted the message. It does not mean the recipient got it. After the response, the message moves through these states: 1. `pending` and `dispatched`: textbee has the message and pushes it to the phone. 2. `sent`: the phone handed it to the carrier, and the carrier accepted it. 3. `delivered`: the carrier reported that the recipient's handset got it. A message can also end as `failed`. Some carriers never send a delivery report, so `delivered` does not always arrive. [Delivery status and message states](/docs/sending-sms/delivery-status) explains every state and its timestamps. You can follow messages in three ways: - **Webhooks.** textbee calls your URL when a message is sent, delivered or failed. See [Webhook events](/docs/webhooks/events). - **The batch endpoint.** `GET /gateway/devices/{id}/sms-batch/{smsBatchId}` returns the batch and each message in it. - **Message history.** `GET /gateway/messages?smsBatchId=...&status=failed` lists the recipients that failed. See [Message history](/docs/receiving-sms/message-history). ## Limits Each recipient counts as one message against your plan. Your plan's daily, monthly and per-batch limits apply to every send. When a send goes over a limit, the API returns `429` and sends nothing. The [pricing page](/docs/pricing) lists the limits for each plan. The phone also sets a pace. The **Send Delay** setting in the app puts a gap between messages in a batch, so a large batch takes time to finish. [Sending bulk SMS](/docs/sending-sms/sending-bulk-sms) explains the setting. ## Before you send - Verify your email. Sends fail with `400` until you do. - Register at least one phone and keep it online. See [Keep the phone online](/docs/troubleshooting/keep-the-phone-online). - Create an API key. See [API keys and authentication](/docs/getting-started/api-keys). - Write phone numbers in E.164 format, for example `+12015550123`. ## Next steps - [Sending SMS](/docs/sending-sms/sending-sms) - [Sending bulk SMS](/docs/sending-sms/sending-bulk-sms) - [Delivery status and message states](/docs/sending-sms/delivery-status) - [Schedule a message](/docs/sending-sms/scheduled-messages) - [Send from a specific SIM](/docs/sending-sms/choosing-a-sim) - [JavaScript SDK](/docs/sdk/javascript) # Sending SMS Source: https://textbee.dev/docs/sending-sms/sending-sms Send an SMS with POST /gateway/send-sms: request fields, response, status codes, phone number format, segments and error handling. To send an SMS, call `POST /gateway/send-sms` with a `message` and a `recipients` array. textbee pushes the message to your Android phone, and the phone sends it through its SIM. The response returns an `smsBatchId` that you use to follow delivery. POST /api/v1/gateway/send-sms ## Before you start - A textbee account with a verified email. - A [registered device](/docs/getting-started/registering-a-device) that is online. - An [API key](/docs/getting-started/api-keys), sent in the `x-api-key` header. ## Request fields Send a JSON body. Only `message` and `recipients` are required. | Field | Type | Required | Description | | --- | --- | --- | --- | | `message` | string | Yes | The text of the message. Long text is split into segments. | | `recipients` | string[] | Yes | Phone numbers in E.164 format. Each recipient counts as one message against your plan. | | `deviceId` | string | No | The phone that sends. Without it, textbee uses your default device, or else the enabled device with the most recent heartbeat. | | `simSubscriptionId` | number | No | The SIM that sends, on a dual-SIM phone. Without it, the phone uses its default SIM. The ID can change after a SIM swap, so copy the current one from the textbee app. See [Send from a specific SIM](/docs/sending-sms/choosing-a-sim). | | `scheduledAt` | string | No | An ISO 8601 time with a timezone, up to 72 hours ahead. Without it, the message goes now. See [Schedule a message](/docs/sending-sms/scheduled-messages). | ## Send a message This sample sends one message from your default device. It reads the key from the `TEXTBEE_API_KEY` environment variable: curl: ```bash BASE_URL="${TEXTBEE_BASE_URL:-https://api.textbee.dev/api/v1}" curl -sS -X POST "$BASE_URL/gateway/send-sms" \ -H "x-api-key: $TEXTBEE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"recipients": ["+12015550123"], "message": "Hello from textbee"}' ``` JavaScript: ```javascript const BASE_URL = process.env.TEXTBEE_BASE_URL ?? 'https://api.textbee.dev/api/v1' const API_KEY = process.env.TEXTBEE_API_KEY async function sendSms(recipients, message) { const response = await fetch(`${BASE_URL}/gateway/send-sms`, { method: 'POST', headers: { 'x-api-key': API_KEY, 'Content-Type': 'application/json' }, body: JSON.stringify({ recipients, message }), }) if (!response.ok) throw new Error(`HTTP ${response.status}`) const { data } = await response.json() return data } const result = await sendSms(['+12015550123'], 'Hello from textbee') console.log(result.smsBatchId, result.recipientCount) ``` Python: ```python import json import os import urllib.request BASE_URL = os.environ.get("TEXTBEE_BASE_URL", "https://api.textbee.dev/api/v1") API_KEY = os.environ["TEXTBEE_API_KEY"] def send_sms(recipients, message): body = json.dumps({"recipients": recipients, "message": message}).encode() request = urllib.request.Request( f"{BASE_URL}/gateway/send-sms", data=body, headers={"x-api-key": API_KEY, "Content-Type": "application/json"}, method="POST", ) with urllib.request.urlopen(request, timeout=30) as response: return json.load(response)["data"] result = send_sms(["+12015550123"], "Hello from textbee") print(result["smsBatchId"], result["recipientCount"]) ``` PHP: ```php true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ["x-api-key: $apiKey", 'Content-Type: application/json'], CURLOPT_POSTFIELDS => json_encode(['recipients' => $recipients, 'message' => $message]), ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); if ($status !== 200) { throw new RuntimeException("HTTP $status"); } return json_decode($body, true)['data']; } $result = sendSms($baseUrl, $apiKey, ['+12015550123'], 'Hello from textbee'); echo $result['smsBatchId'], ' ', $result['recipientCount'], PHP_EOL; ``` Go: ```go package main import ( "bytes" "encoding/json" "fmt" "net/http" "os" ) func baseURL() string { if url := os.Getenv("TEXTBEE_BASE_URL"); url != "" { return url } return "https://api.textbee.dev/api/v1" } func main() { payload, _ := json.Marshal(map[string]any{ "recipients": []string{"+12015550123"}, "message": "Hello from textbee", }) request, _ := http.NewRequest(http.MethodPost, baseURL()+"/gateway/send-sms", bytes.NewReader(payload)) request.Header.Set("x-api-key", os.Getenv("TEXTBEE_API_KEY")) request.Header.Set("Content-Type", "application/json") response, err := http.DefaultClient.Do(request) if err != nil { panic(err) } defer response.Body.Close() if response.StatusCode != http.StatusOK { panic(fmt.Sprintf("HTTP %d", response.StatusCode)) } var result struct { Data struct { SmsBatchID string `json:"smsBatchId"` RecipientCount int `json:"recipientCount"` } `json:"data"` } if err := json.NewDecoder(response.Body).Decode(&result); err != nil { panic(err) } fmt.Println(result.Data.SmsBatchID, result.Data.RecipientCount) } ``` Working in JavaScript or TypeScript? The [`@textbee/sdk`](/docs/sdk/javascript) package wraps this call with types and error classes. ## Send from a specific device This variant passes `deviceId` to choose the phone. Find the ID in the dashboard Devices panel or in `GET /gateway/devices`: curl: ```bash curl -X POST https://api.textbee.dev/api/v1/gateway/send-sms \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "recipients": ["+12015550123", "+12015550124"], "message": "Your appointment is tomorrow at 10am", "deviceId": "664a9b8cd0e1f2a3b4c5d6e7" }' ``` JavaScript: ```javascript const response = await fetch('https://api.textbee.dev/api/v1/gateway/send-sms', { method: 'POST', headers: { 'x-api-key': process.env.TEXTBEE_API_KEY, 'Content-Type': 'application/json' }, body: JSON.stringify({ recipients: ['+12015550123', '+12015550124'], message: 'Your appointment is tomorrow at 10am', deviceId: '664a9b8cd0e1f2a3b4c5d6e7', }), }) if (!response.ok) throw new Error(`HTTP ${response.status}`) const { data } = await response.json() console.log(data.smsBatchId, data.recipientCount) ``` Python: ```python import json import os import urllib.request body = json.dumps({ "recipients": ["+12015550123", "+12015550124"], "message": "Your appointment is tomorrow at 10am", "deviceId": "664a9b8cd0e1f2a3b4c5d6e7", }).encode() request = urllib.request.Request( "https://api.textbee.dev/api/v1/gateway/send-sms", data=body, headers={"x-api-key": os.environ["TEXTBEE_API_KEY"], "Content-Type": "application/json"}, method="POST", ) with urllib.request.urlopen(request, timeout=30) as response: data = json.load(response)["data"] print(data["smsBatchId"], data["recipientCount"]) ``` A malformed `deviceId` returns `400` with `Invalid device id`. An ID that is not on your account returns `404` with `Device not found`. textbee never falls back to another phone when you pass `deviceId`. ## Response A successful send returns `200`: ```json { "data": { "success": true, "message": "SMS added to queue for processing", "smsBatchId": "66b1f2c3a4d5e6f7a8b9c0d2", "recipientCount": 2 } } ``` | Field | Description | | --- | --- | | `success` | `true` when textbee accepted the batch. | | `message` | A short outcome in words. | | `smsBatchId` | The batch ID. Use it to follow delivery. | | `recipientCount` | The number of recipients in the batch. | | `estimatedCompletionAt` | Only on large batches. The time the phone should finish, based on its Send Delay setting. The phone must stay online for the estimate to hold. | ## Status codes | Code | Meaning | | --- | --- | | `200` | textbee accepted the message. This is not delivery. | | `400` | No enabled device to send from, the device is disabled, your email is not verified, or the message could not be pushed to the phone. The body says which. | | `401` | The API key is missing, invalid or revoked. | | `429` | Your daily, monthly or per-batch plan limit is used up. The body says which one. Nothing was sent. | ## What happens after 200 The `200` response means textbee stored the message and queued it for your phone. The message has the status `pending`. textbee then pushes it to the phone. The phone hands it to the radio and reports `sent` when the carrier accepts it. If the carrier sends a delivery report, the status changes to `delivered`. If the phone is offline, the message waits until the phone comes back. [Delivery status and message states](/docs/sending-sms/delivery-status) explains each state and three ways to track it. ## Phone number format Write every number in E.164 format: a plus sign, the country code, then the number, with no spaces or punctuation. | Correct | Incorrect | | --- | --- | | `+12015550123` | `2015550123` (no country code) | | `+12015550124` | `(201) 555-0124` (punctuation) | | `+12015550125` | `001 201 555 0125` (international prefix instead of `+`) | textbee removes spaces from each number. It does not add a missing country code. The phone passes the number to its carrier as written. The JavaScript SDK has `isValidE164` and `normalizePhoneNumber` helpers to check numbers before you send. ## Segments and encoding The phone splits a long message into segments and sends each one. The recipient's phone joins them again. The segment size depends on the characters in the message: | Encoding | When it applies | One message | Each segment of a longer message | | --- | --- | --- | --- | | GSM-7 | Only characters from the GSM-7 alphabet | 160 characters | 153 characters | | UCS-2 | At least one other character | 70 characters | 67 characters | One character outside GSM-7 switches the whole message to UCS-2. An emoji or a curly quote is enough. A 150-character message with one emoji needs three segments instead of one. The [JavaScript SDK](/docs/sdk/javascript) has utilities for this: `countSmsSegments`, `getSmsEncoding`, `findNonGsm7Characters` and `sanitizeForGsm7`. They run locally and need no API key. ## Handling errors Check the status code of every response. Retry a `429` once after a pause, and do not retry `400` or `401` until you fix the cause. This sample shows the pattern: curl: ```bash BASE_URL="${TEXTBEE_BASE_URL:-https://api.textbee.dev/api/v1}" # --retry repeats only on transient statuses such as 429; a 400 or 401 fails at once. STATUS=$(curl -sS -o response.json -w '%{http_code}' --retry 1 --retry-delay 2 -X POST "$BASE_URL/gateway/send-sms" \ -H "x-api-key: $TEXTBEE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"recipients": ["+12015550123"], "message": "Hello from textbee"}') case "$STATUS" in 200) jq -r '.data.smsBatchId' response.json ;; 401) echo "API key rejected: $(jq -r .message response.json)" >&2; exit 1 ;; 400) echo "Request rejected: $(jq -r .message response.json)" >&2; exit 1 ;; *) echo "HTTP $STATUS: $(jq -r .message response.json)" >&2; exit 1 ;; esac ``` JavaScript: ```javascript const BASE_URL = process.env.TEXTBEE_BASE_URL ?? 'https://api.textbee.dev/api/v1' const API_KEY = process.env.TEXTBEE_API_KEY async function sendSms(recipients, message, retried = false) { const response = await fetch(`${BASE_URL}/gateway/send-sms`, { method: 'POST', headers: { 'x-api-key': API_KEY, 'Content-Type': 'application/json' }, body: JSON.stringify({ recipients, message }), }) if (response.ok) return (await response.json()).data const detail = (await response.json().catch(() => ({}))).message ?? response.statusText if (response.status === 429 && !retried) { await new Promise((resolve) => setTimeout(resolve, 2000)) // a plan limit or a burst; one retry return sendSms(recipients, message, true) } if (response.status === 401) throw new Error(`API key rejected: ${detail}`) if (response.status === 400) throw new Error(`Request rejected: ${detail}`) throw new Error(`HTTP ${response.status}: ${detail}`) } console.log(await sendSms(['+12015550123'], 'Hello from textbee')) ``` Python: ```python import json import os import time import urllib.error import urllib.request BASE_URL = os.environ.get("TEXTBEE_BASE_URL", "https://api.textbee.dev/api/v1") API_KEY = os.environ["TEXTBEE_API_KEY"] def send_sms(recipients, message, retried=False): body = json.dumps({"recipients": recipients, "message": message}).encode() request = urllib.request.Request( f"{BASE_URL}/gateway/send-sms", data=body, headers={"x-api-key": API_KEY, "Content-Type": "application/json"}, method="POST", ) try: with urllib.request.urlopen(request, timeout=30) as response: return json.load(response)["data"] except urllib.error.HTTPError as error: detail = json.loads(error.read() or b"{}").get("message", error.reason) if error.code == 429 and not retried: time.sleep(2) # a plan limit or a burst; one retry after a pause return send_sms(recipients, message, retried=True) if error.code == 401: raise SystemExit(f"API key rejected: {detail}") if error.code == 400: raise SystemExit(f"Request rejected: {detail}") raise SystemExit(f"HTTP {error.code}: {detail}") print(send_sms(["+12015550123"], "Hello from textbee")) ``` PHP: ```php true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ["x-api-key: $apiKey", 'Content-Type: application/json'], CURLOPT_POSTFIELDS => json_encode(['recipients' => $recipients, 'message' => $message]), ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); if ($status === 200) { return json_decode($body, true)['data']; } $detail = json_decode($body, true)['message'] ?? 'no detail'; if ($status === 429 && !$retried) { sleep(2); // a plan limit or a burst; one retry after a pause return sendSms($baseUrl, $apiKey, $recipients, $message, true); } if ($status === 401) { throw new RuntimeException("API key rejected: $detail"); } if ($status === 400) { throw new RuntimeException("Request rejected: $detail"); } throw new RuntimeException("HTTP $status: $detail"); } print_r(sendSms($baseUrl, $apiKey, ['+12015550123'], 'Hello from textbee')); ``` Go: ```go package main import ( "bytes" "encoding/json" "fmt" "io" "net/http" "os" "time" ) func baseURL() string { if url := os.Getenv("TEXTBEE_BASE_URL"); url != "" { return url } return "https://api.textbee.dev/api/v1" } func sendSms(payload []byte, retried bool) (string, error) { request, _ := http.NewRequest(http.MethodPost, baseURL()+"/gateway/send-sms", bytes.NewReader(payload)) request.Header.Set("x-api-key", os.Getenv("TEXTBEE_API_KEY")) request.Header.Set("Content-Type", "application/json") response, err := http.DefaultClient.Do(request) if err != nil { return "", err } defer response.Body.Close() body, _ := io.ReadAll(response.Body) if response.StatusCode == http.StatusOK { return string(body), nil } var detail struct { Message string `json:"message"` } json.Unmarshal(body, &detail) switch { case response.StatusCode == http.StatusTooManyRequests && !retried: time.Sleep(2 * time.Second) // a plan limit or a burst; one retry after a pause return sendSms(payload, true) case response.StatusCode == http.StatusUnauthorized: return "", fmt.Errorf("API key rejected: %s", detail.Message) case response.StatusCode == http.StatusBadRequest: return "", fmt.Errorf("request rejected: %s", detail.Message) } return "", fmt.Errorf("HTTP %d: %s", response.StatusCode, detail.Message) } func main() { payload, _ := json.Marshal(map[string]any{"recipients": []string{"+12015550123"}, "message": "Hello from textbee"}) result, err := sendSms(payload, false) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } fmt.Println(result) } ``` Error bodies carry a human-readable reason. A `429` body has a `message` field that names the limit. Most `400` bodies have an `error` field, for example: ```json { "success": false, "error": "No enabled device found. Enable a device or pass a deviceId." } ``` ## Frequently asked questions ### Does a 200 response mean the message was delivered? No. It means textbee accepted the message. Follow the status with a webhook or the batch endpoint. See [Delivery status and message states](/docs/sending-sms/delivery-status). ### How many recipients can one request have? Your plan's per-batch limit sets the maximum. A request over the limit returns `429` and sends nothing. For more numbers, split them into several requests. The [pricing page](/docs/pricing) lists the limits. ### Can I send different text to each recipient? Not with this endpoint. Use [`POST /gateway/send-bulk-sms`](/docs/sending-sms/sending-bulk-sms), where each item has its own text and recipients. ### Can I send MMS or images? No. textbee sends text SMS only. ### Why does the message go out from the wrong phone? Without `deviceId`, textbee uses your default device. Set the default in the dashboard, or pass `deviceId` on each request. ## Next steps - [Sending bulk SMS](/docs/sending-sms/sending-bulk-sms) - [Delivery status and message states](/docs/sending-sms/delivery-status) - [Schedule a message](/docs/sending-sms/scheduled-messages) - [Webhooks](/docs/webhooks) - [API reference](/docs/api-reference#post-api-v1-gateway-send-sms) # Sending bulk SMS Source: https://textbee.dev/docs/sending-sms/sending-bulk-sms Send many SMS in one call with POST /gateway/send-bulk-sms or a CSV upload, pace the phone with Send Delay and track failed recipients. You can send many messages at once in two ways: call `POST /gateway/send-bulk-sms` from your code, or upload a CSV file in the dashboard. Both create one batch with one message per recipient. The phone then sends the batch at the pace of its **Send Delay** setting. ## Option 1: The API POST /api/v1/gateway/send-bulk-sms Use this endpoint when each group of recipients needs a different text. For the same text to every recipient, [`POST /gateway/send-sms`](/docs/sending-sms/sending-sms) with a `recipients` array is enough. ### Request fields | Field | Type | Required | Description | | --- | --- | --- | --- | | `messages` | object[] | Yes | The messages to send. Each item is described below. | | `deviceId` | string | No | The phone that sends the whole batch. Without it, textbee uses your default device, or else the enabled device with the most recent heartbeat. | | `messageTemplate` | string | No | A label for the batch. It is not sent to anyone. | Each item in `messages` has these fields: | Field | Type | Required | Description | | --- | --- | --- | --- | | `message` | string | Yes | The text for this item. | | `recipients` | string[] | Yes | Phone numbers in E.164 format that get this text. | | `simSubscriptionId` | number | No | The SIM for this item. The ID can change after a SIM swap, so copy the current one from the textbee app. See [Send from a specific SIM](/docs/sending-sms/choosing-a-sim). | | `scheduledAt` | string | No | An ISO 8601 time with a timezone for this item. See [Schedule a message](/docs/sending-sms/scheduled-messages). | ### Example This request sends a personal reminder to two people and a general notice to a third, all from one phone: curl: ```bash curl -X POST https://api.textbee.dev/api/v1/gateway/send-bulk-sms \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "deviceId": "664a9b8cd0e1f2a3b4c5d6e7", "messageTemplate": "September reminders", "messages": [ { "recipients": ["+12015550123"], "message": "Hi Ana, your appointment is on Tuesday at 10am." }, { "recipients": ["+12015550124"], "message": "Hi Ben, your appointment is on Wednesday at 2pm." }, { "recipients": ["+12015550125"], "message": "The office is closed on Friday." } ] }' ``` JavaScript: ```javascript const response = await fetch('https://api.textbee.dev/api/v1/gateway/send-bulk-sms', { method: 'POST', headers: { 'x-api-key': process.env.TEXTBEE_API_KEY, 'Content-Type': 'application/json' }, body: JSON.stringify({ deviceId: '664a9b8cd0e1f2a3b4c5d6e7', messageTemplate: 'September reminders', messages: [ { recipients: ['+12015550123'], message: 'Hi Ana, your appointment is on Tuesday at 10am.' }, { recipients: ['+12015550124'], message: 'Hi Ben, your appointment is on Wednesday at 2pm.' }, { recipients: ['+12015550125'], message: 'The office is closed on Friday.' }, ], }), }) if (!response.ok) throw new Error(`HTTP ${response.status}`) const { data } = await response.json() console.log(data.smsBatchId, data.recipientCount) ``` Python: ```python import json import os import urllib.request body = json.dumps({ "deviceId": "664a9b8cd0e1f2a3b4c5d6e7", "messageTemplate": "September reminders", "messages": [ {"recipients": ["+12015550123"], "message": "Hi Ana, your appointment is on Tuesday at 10am."}, {"recipients": ["+12015550124"], "message": "Hi Ben, your appointment is on Wednesday at 2pm."}, {"recipients": ["+12015550125"], "message": "The office is closed on Friday."}, ], }).encode() request = urllib.request.Request( "https://api.textbee.dev/api/v1/gateway/send-bulk-sms", data=body, headers={"x-api-key": os.environ["TEXTBEE_API_KEY"], "Content-Type": "application/json"}, method="POST", ) with urllib.request.urlopen(request, timeout=30) as response: data = json.load(response)["data"] print(data["smsBatchId"], data["recipientCount"]) ``` ### Response A successful call returns `200` with one `smsBatchId` for the whole batch: ```json { "data": { "success": true, "message": "Bulk SMS added to queue for processing", "smsBatchId": "66b1f2c3a4d5e6f7a8b9c0d2", "recipientCount": 3 } } ``` A large batch also returns `estimatedCompletionAt`: the time the phone should finish, based on its Send Delay. The status codes are the same as for [`POST /gateway/send-sms`](/docs/sending-sms/sending-sms). ## Option 2: CSV upload in the dashboard Use the CSV upload to send a personalized message to each row of a spreadsheet without code. 1. Open the [dashboard](https://app.textbee.dev/dashboard) and go to **Messaging** > **Bulk Send**. 2. **Upload your CSV.** The file needs a header row and one row per recipient. It can have any other columns you want to use in the message. The maximum file size is 1 MB. 3. **Choose the phone column and device.** The dashboard guesses the phone column from your headers. Check it, then pick the device under **Send from** and, if you want, a SIM. 4. **Write your message.** Insert a column with `{{ columnName }}`. Each recipient gets the values from their own row. 5. **Review and send.** Step through the preview for each recipient. Nothing is sent until you confirm. Example CSV: ```csv phone,name,orderId +12015550123,Ana,1001 +12015550124,Ben,1002 ``` Example template: `Hello {{ name }}, your order #{{ orderId }} is ready!` The dashboard skips rows with an empty, invalid or duplicate phone number and tells you which ones. ## Pace the phone with Send Delay The phone does not send a whole batch at once. It waits a fixed gap between messages. You set the gap in the app: **Settings** > **Send Delay**, from 0 to 3600 seconds. The default is 5 seconds. The API shows the value as `smsSendDelaySeconds` on the device. Carriers watch for phones that send many messages in a short time. A fast burst can get the SIM blocked or its messages filtered, and Android itself can refuse to send more. A longer delay protects the SIM but makes a large batch take longer. When the delay is 0, the app's **Device health** screen warns: "No gap between messages. Carriers may block a phone that sends too fast." textbee releases large batches in waves that match the delay, so the phone is never asked to hold more than it can send. For very large sends, split the work across several phones with `deviceId`. ## Follow the batch The batch endpoint returns the batch totals and every message in it: ```bash curl https://api.textbee.dev/api/v1/gateway/devices/664a9b8cd0e1f2a3b4c5d6e7/sms-batch/66b1f2c3a4d5e6f7a8b9c0d2 \ -H "x-api-key: YOUR_API_KEY" ``` The `batch` object has a `status` of `pending`, `processing`, `completed`, `partial_success`, `failed` or `unknown`, calculated from its messages. It also counts the messages in each state: `successCount` (sent or delivered), `failureCount`, `pendingCount`, `dispatchedCount`, `sentCount`, `deliveredCount` and `unknownCount`. [Batch status](/docs/sending-sms/delivery-status#batch-status) explains each value. The `messages` array has one message per recipient with its own status. To list only the recipients that failed, filter the message history by batch and status: ```bash curl "https://api.textbee.dev/api/v1/gateway/messages?smsBatchId=66b1f2c3a4d5e6f7a8b9c0d2&status=failed" \ -H "x-api-key: YOUR_API_KEY" ``` Each failed message has an `errorCode` and an `errorMessage`. [Delivery status and message states](/docs/sending-sms/delivery-status) explains the codes. ### Webhooks for a batch If you subscribe to `MESSAGE_SENT`, `MESSAGE_DELIVERED` or `MESSAGE_FAILED`, textbee makes one webhook delivery per recipient, not one per batch. A batch of 500 recipients produces up to 500 `MESSAGE_SENT` deliveries. Each one carries the `smsBatchId`. Make sure your endpoint can handle that volume. See [Webhook events](/docs/webhooks/events). ## Plan limits Each recipient counts as one message. Your plan's daily, monthly and per-batch limits apply. A batch with more recipients than your per-batch limit returns `429` and sends nothing. The body names the limit. Split the recipients into smaller batches, or upgrade. The [pricing page](/docs/pricing) lists the limits. ## Frequently asked questions ### Can one bulk request use more than one phone? No. `deviceId` applies to the whole batch. To send from several phones, make one request per phone. ### Can each item have its own schedule? Yes. Set `scheduledAt` on each item. Items with the same time go out together. ### Why is my batch still processing after an hour? The phone sends one message per Send Delay gap, so large batches take time. Check `estimatedCompletionAt` from the response, and check that the phone is online. See [Messages not sending](/docs/troubleshooting/messages-not-sending). ### Does the CSV upload use the API? Yes. The dashboard creates a batch in the same way. You follow it with the same endpoints and webhooks. ## Next steps - [Delivery status and message states](/docs/sending-sms/delivery-status) - [Schedule a message](/docs/sending-sms/scheduled-messages) - [Webhook events](/docs/webhooks/events) - [Pricing and limits](/docs/pricing) - [API reference](/docs/api-reference#post-api-v1-gateway-send-bulk-sms) # Delivery status and message states Source: https://textbee.dev/docs/sending-sms/delivery-status What each textbee message state means, which timestamp it sets, how to track sends with webhooks or the API, and what failure codes mean. Every message has a `status` that shows how far it got. An outgoing message starts as `pending`, moves to `sent` when the carrier accepts it, and becomes `delivered` if the carrier confirms delivery. It ends as `failed` if the phone cannot send it. Each step also records a timestamp, so you can see where a message waited. ## Message states Statuses are lowercase strings. | Status | Meaning | Timestamps set | | --- | --- | --- | | `pending` | textbee stored the message. It is waiting to be pushed to the phone. Scheduled and paced messages wait here until their time. | `requestedAt`, and `dispatchDueAt` for queued messages | | `dispatched` | The push service accepted the message for the phone. The phone may not have it yet. | `dispatchedAt`, `dispatchAttempts` | | `sent` | The phone reported the message as sent: the carrier accepted it. | `sentAt`, plus `pushReceivedAt` and `sendAttemptedAt` from the phone | | `delivered` | The carrier sent a delivery report: the recipient's handset got the message. | `deliveredAt` | | `failed` | The message could not be sent. This state is final. | `failedAt`, `errorCode`, `errorMessage` | | `unknown` | textbee has no confirmed state. The phone reported a state textbee cannot map, or sent no report within 20 minutes. | `errorMessage` when it timed out | | `received` | An incoming message. Incoming messages always have this status. | `receivedAt` | | `delivery_failed` | Rare. The carrier returned a negative delivery report. This value is not in the API reference enum, and it fires the `UNKNOWN_STATE` webhook event. The message may still have arrived. | None | A message in `unknown` can still change. If the phone reports later, the status moves to `sent`, `delivered` or `failed`. ## The life of a message This is what happens between your API call and the recipient's screen: 1. **Queued on the server.** textbee stores the message as `pending` and returns `200`. A scheduled message, or part of a large batch, waits until its `dispatchDueAt` time. 2. **Pushed to the phone.** textbee hands the message to the push service, which sets `dispatched`. The push service delivers it to the phone when the phone is online. The phone records `pushReceivedAt`. 3. **Handed to the radio.** The phone waits for its Send Delay gap, then passes the message to Android's SMS system. The phone records `sendAttemptedAt`. 4. **Carrier accepted.** The carrier accepts the message and the phone reports `sent` with `sentAt`. 5. **Recipient confirmed.** If the carrier returns a delivery report, the phone reports `delivered` with `deliveredAt`. ## Why delivered may never arrive `sent` is the last state that every message reaches. `delivered` depends on the carrier. Some carriers do not send delivery reports. Some send them only for some destinations. A report can also arrive hours later if the recipient's phone was off. So treat `sent` as success for most purposes. Treat `delivered` as extra confirmation when it comes. Do not wait for `delivered` before you mark a message as done. ## Batch status Each send creates a batch. textbee calculates the batch `status` and its counts from the messages in the batch. Each time a message changes state, the batch updates too. | Status | Meaning | | --- | --- | | `pending` | textbee created the batch and has not queued any message yet. | | `processing` | At least one message is still `pending` or `dispatched`. | | `completed` | Every message is `sent` or `delivered`. | | `partial_success` | Some messages are `sent` or `delivered`. The others are `failed` or `unknown`. | | `failed` | Every message is `failed`. | | `unknown` | No message is `sent` or `delivered`, and at least one is `unknown`. | A batch in `unknown` or `partial_success` can still change. If the phone reports a late result, the batch status and counts change with it. ### Batch counts Each count is the number of messages in the batch that are in that state now. A message is in exactly one state at a time, so the counts change as messages move. | Field | Messages counted | | --- | --- | | `recipientCount` | All messages in the batch, one per recipient. | | `successCount` | `sent` or `delivered`. This is `sentCount` plus `deliveredCount`. | | `failureCount` | `failed`. | | `pendingCount` | `pending`: not yet handed to the phone. Scheduled and paced messages wait here. | | `dispatchedCount` | `dispatched`: handed to the phone, no result yet. | | `sentCount` | `sent`: the carrier accepted the message, no delivery report yet. | | `deliveredCount` | `delivered`: the carrier confirmed delivery. | | `unknownCount` | `unknown`: no report from the phone within 20 minutes. | `pendingCount`, `dispatchedCount`, `sentCount`, `deliveredCount`, `failureCount` and `unknownCount` add up to `recipientCount`. When a delivery report arrives, the message moves from `sentCount` to `deliveredCount`, and `successCount` stays the same. The batch also has `completedAt`, the time the batch reached its current final status, and `statusCheckedAt`, the time textbee last calculated the status and counts. `completedAt` is absent while the batch is `processing`. To see the result for each recipient, read the `messages` array or filter the message history. ## Three ways to track delivery ### 1. Webhooks Subscribe to `MESSAGE_SENT`, `MESSAGE_DELIVERED` and `MESSAGE_FAILED`. textbee then sends an HTTP request to your URL each time a message changes state. Each payload has `smsId`, `smsBatchId`, `recipient` and `status`. This is the best option for most apps, because you do not need to poll. This request creates a subscription for the three status events: ```bash curl -X POST https://api.textbee.dev/api/v1/webhooks \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Delivery tracking", "deliveryUrl": "https://example.com/textbee/webhook", "signingSecret": "a-long-random-secret-of-20-chars-or-more", "events": ["MESSAGE_SENT", "MESSAGE_DELIVERED", "MESSAGE_FAILED"] }' ``` [Webhook events](/docs/webhooks/events) lists every field of each event, and [Webhooks](/docs/webhooks) explains signatures and retries. ### 2. The batch endpoint `GET /gateway/devices/{id}/sms-batch/{smsBatchId}` returns the batch and every message in it. Use the `smsBatchId` from the send response and the ID of the device that sent it. GET /api/v1/gateway/devices/{id}/sms-batch/{smsBatchId} ```bash curl https://api.textbee.dev/api/v1/gateway/devices/664a9b8cd0e1f2a3b4c5d6e7/sms-batch/66b1f2c3a4d5e6f7a8b9c0d2 \ -H "x-api-key: YOUR_API_KEY" ``` The `batch` object in the response, for a send to four recipients where one failed: ```json { "_id": "66b1f2c3a4d5e6f7a8b9c0d2", "recipientCount": 4, "status": "partial_success", "successCount": 3, "failureCount": 1, "pendingCount": 0, "dispatchedCount": 0, "sentCount": 1, "deliveredCount": 2, "unknownCount": 0, "completedAt": "2026-10-04T09:12:44.120Z", "statusCheckedAt": "2026-10-04T09:12:44.120Z" } ``` ### 3. Message history filters `GET /gateway/messages` filters by batch and by status. This call lists the recipients of one batch that failed: ```bash curl "https://api.textbee.dev/api/v1/gateway/messages?smsBatchId=66b1f2c3a4d5e6f7a8b9c0d2&status=failed" \ -H "x-api-key: YOUR_API_KEY" ``` This call lists every failed send on the account, newest first: ```bash curl "https://api.textbee.dev/api/v1/gateway/messages?direction=sent&status=failed" \ -H "x-api-key: YOUR_API_KEY" ``` [Message history](/docs/receiving-sms/message-history) covers every filter and cursor pagination. ## Failure codes A `failed` message has an `errorCode` and an `errorMessage`. When the phone could not send, `errorCode` is the Android result code as a numeric string, for example `"1"`. `errorMessage` explains it in plain words. | `errorCode` | Android constant | `errorMessage` (short) | What to do | | --- | --- | --- | --- | | `1` | `RESULT_ERROR_GENERIC_FAILURE` | "SMS failed on device. Common causes: no SMS credit on SIM, weak signal, or carrier blocked." It can end with "(code N)", the radio's own code. | Check the SIM balance and signal, then retry. | | `2` | `RESULT_ERROR_RADIO_OFF` | "Mobile radio is off (e.g. airplane mode)." | Turn off airplane mode and turn on mobile service. | | `3` | `RESULT_ERROR_NULL_PDU` | "Message could not be sent; invalid format or carrier issue." | Check for an empty body or unusual characters. Try a shorter message. | | `4` | `RESULT_ERROR_NO_SERVICE` | "No cellular service." | Move the phone to a place with coverage and retry. | | `5` | `RESULT_ERROR_LIMIT_EXCEEDED` | "Device/carrier send limit reached (too many SMS in a short time)." | Wait a few minutes. Increase Send Delay and spread large batches over time. | | `7` | `RESULT_ERROR_SHORT_CODE_NOT_ALLOWED` | "Short code not allowed on this carrier." | Send to a full phone number. | | `8` | `RESULT_ERROR_SHORT_CODE_NEVER_ALLOWED` | "Short codes are not supported on this carrier." | Send to a full phone number. | | `17` | `RESULT_NETWORK_ERROR` | "Network error while sending." | Check the signal and retry. | The `errorCode` value is the number of the Android `SmsManager` constant. An unlisted number is another Android result, and its `errorMessage` names the constant. Codes that start with `FCM_` mean the push to the phone failed, so the phone never got the message. Open the app on the phone and reconnect it if needed. [Messages not sending](/docs/troubleshooting/messages-not-sending) covers these and other causes. ## Read the timestamps to find a delay When a message arrives late, compare its timestamps. Fetch the message with `GET /gateway/devices/{id}/sms/{smsId}` or find it in the message history. | Gap | What it means | Where to look | | --- | --- | --- | | `requestedAt` to `dispatchedAt` | Time in the server queue. Long gaps are normal for scheduled and paced messages. | Compare with `dispatchDueAt`. | | `dispatchedAt` to `pushReceivedAt` | Time before the phone got the push. The phone was offline or asleep. | [Keep the phone online](/docs/troubleshooting/keep-the-phone-online) | | `pushReceivedAt` to `sendAttemptedAt` | Time the message waited on the phone. Send Delay and earlier messages in the batch cause most of it. | The Send Delay setting and the batch size. | | `sendAttemptedAt` to `sentAt` | Time in the radio and the carrier network. | Signal strength and carrier. | Older app versions do not report `pushReceivedAt` and `sendAttemptedAt`. Update the app to see them. ## Frequently asked questions ### Is `sent` the same as delivered? No. `sent` means the carrier accepted the message. `delivered` means the carrier reported that the recipient's handset got it. Many messages stay at `sent` because the carrier sends no report. ### Why is my message stuck in `pending`? It has not reached the push service yet. Check `dispatchDueAt`: a scheduled message or a paced batch waits until that time. If it is not queued, check that the device is enabled and online. ### Should I retry a failed message? Retry codes `1`, `4` and `17` after you fix the signal or the SIM. Do not retry short code errors. Send a new request: textbee does not resend a failed message by itself. ### Does textbee retry a failed webhook? Yes. A failed webhook delivery is retried. See [Webhooks](/docs/webhooks). ## Next steps - [Webhook events](/docs/webhooks/events) - [Message history](/docs/receiving-sms/message-history) - [Messages not sending](/docs/troubleshooting/messages-not-sending) - [Sending bulk SMS](/docs/sending-sms/sending-bulk-sms) - [API reference](/docs/api-reference#get-api-v1-gateway-devices-id-sms-batch-smsbatchid) # Send from a specific SIM Source: https://textbee.dev/docs/sending-sms/choosing-a-sim Pick the SIM that sends each message on a dual-SIM phone with simSubscriptionId, and find the ID in the textbee app or the device API. On a phone with two SIMs, `simSubscriptionId` picks the SIM that sends a message. You pass it on `POST /gateway/send-sms`, or on each item of `POST /gateway/send-bulk-sms`. Without it, the phone uses the Default SIM set in the textbee app. ## Why choose a SIM - **Two numbers.** Send support messages from one number and alerts from the other. - **Two carriers.** Send through the carrier that has the better rate or coverage for a recipient. - **Split the load.** Share a large volume between two SIMs so neither one sends too fast. ## Find the subscription ID Android gives each SIM a subscription ID. It is a small number, and it is not the slot number. Find it in the app or in the API. Warning: The subscription ID is not fixed. Android can give a SIM a new ID when you remove and reinsert it, swap SIMs, or reset the phone. An old ID then matches no SIM, and the phone sends from its default SIM without an error. Before you put an ID in your code, open the textbee app and copy the current ID from the **SIM Cards** section. Check it again after any change to the SIMs. ### In the app Open the textbee app and go to the **Dashboard** tab. The **SIM Cards** section lists each SIM with its slot and carrier, for example "SIM 1 · Example Mobile", and its `simSubscriptionId`. Tap the copy button next to a SIM to copy the ID. ### In the API `GET /gateway/devices/{id}` returns the SIMs in `simInfo.sims`. `GET /gateway/devices` returns the same field for every device. GET /api/v1/gateway/devices/{id} ```bash curl https://api.textbee.dev/api/v1/gateway/devices/664a9b8cd0e1f2a3b4c5d6e7 \ -H "x-api-key: YOUR_API_KEY" ``` The response, trimmed to the SIM fields: ```json { "data": { "_id": "664a9b8cd0e1f2a3b4c5d6e7", "name": "Office phone", "simInfo": { "lastUpdated": "2026-09-28T09:12:00.000Z", "sims": [ { "subscriptionId": 1, "simSlotIndex": 0, "carrierName": "Example Mobile", "displayName": "Work", "serviceState": "IN_SERVICE", "signalLevel": 3 }, { "subscriptionId": 2, "simSlotIndex": 1, "carrierName": "Example Telecom", "displayName": "Personal", "serviceState": "IN_SERVICE", "signalLevel": 4 } ] } } } ``` | Field | Description | | --- | --- | | `subscriptionId` | The value to pass as `simSubscriptionId`. | | `simSlotIndex` | The physical slot, starting at 0. | | `carrierName` | The mobile network, as Android reports it. | | `displayName` | The label the phone owner gave the SIM. | | `serviceState` | Whether the SIM had service at the last report: `IN_SERVICE`, `OUT_OF_SERVICE`, `EMERGENCY_ONLY`, `POWER_OFF` or `UNKNOWN`. | | `signalLevel` | Signal strength from 0 (no signal) to 4. | The phone updates `simInfo` with its heartbeat. `simInfo.lastUpdated` shows when. Right after a SIM change, `simInfo` can still show the old ID until the next heartbeat. The app always shows the current ID. ## Send from a SIM Add `simSubscriptionId` to the request. This example sends from the SIM with ID `2`: curl: ```bash curl -X POST https://api.textbee.dev/api/v1/gateway/send-sms \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "recipients": ["+12015550123"], "message": "Your support ticket is updated", "deviceId": "664a9b8cd0e1f2a3b4c5d6e7", "simSubscriptionId": 2 }' ``` JavaScript: ```javascript const response = await fetch('https://api.textbee.dev/api/v1/gateway/send-sms', { method: 'POST', headers: { 'x-api-key': process.env.TEXTBEE_API_KEY, 'Content-Type': 'application/json' }, body: JSON.stringify({ recipients: ['+12015550123'], message: 'Your support ticket is updated', deviceId: '664a9b8cd0e1f2a3b4c5d6e7', simSubscriptionId: 2, }), }) if (!response.ok) throw new Error(`HTTP ${response.status}`) console.log((await response.json()).data.smsBatchId) ``` Python: ```python import json import os import urllib.request body = json.dumps({ "recipients": ["+12015550123"], "message": "Your support ticket is updated", "deviceId": "664a9b8cd0e1f2a3b4c5d6e7", "simSubscriptionId": 2, }).encode() request = urllib.request.Request( "https://api.textbee.dev/api/v1/gateway/send-sms", data=body, headers={"x-api-key": os.environ["TEXTBEE_API_KEY"], "Content-Type": "application/json"}, method="POST", ) with urllib.request.urlopen(request, timeout=30) as response: print(json.load(response)["data"]["smsBatchId"]) ``` Pass `deviceId` together with `simSubscriptionId`. Subscription IDs belong to one phone, so an ID from one device means nothing on another. ### Different SIMs in one bulk request In `POST /gateway/send-bulk-sms`, each item in `messages` can have its own `simSubscriptionId`: ```json { "deviceId": "664a9b8cd0e1f2a3b4c5d6e7", "messages": [ { "recipients": ["+12015550123"], "message": "Sent from the work SIM", "simSubscriptionId": 1 }, { "recipients": ["+12015550124"], "message": "Sent from the second SIM", "simSubscriptionId": 2 } ] } ``` [Sending bulk SMS](/docs/sending-sms/sending-bulk-sms) has the full request in curl, JavaScript and Python. ## The Default SIM setting The app has a **Default SIM** setting under **Settings**. The app shows this hint below it: "Use the simSubscriptionId field in your API requests to override this setting". The phone picks the SIM in this order: 1. The `simSubscriptionId` in the request, if that SIM is in the phone. 2. The **Default SIM** set in the app, if that SIM is in the phone. 3. Android's own default SIM for SMS. ## Unknown IDs fall back without an error Warning: textbee does not check `simSubscriptionId` against the phone. If the ID does not match a SIM in the phone, the phone ignores it and sends from the next SIM in the list above. The request still returns `200` and the message can still reach `sent`. The `simSubscriptionId` on the stored message is the value you sent. It does not prove which SIM the phone used. To confirm the SIM: - Send a test message to a phone you own and check the sender number it shows. - Check `simInfo` on the device before you send, and use an ID from that list. - After a SIM swap, read `simInfo` again before you send. ## Frequently asked questions ### Is the subscription ID the same as the slot number? No. `simSlotIndex` is the slot (0 or 1). `subscriptionId` is an ID that Android assigns to the SIM. Always send `subscriptionId`. ### Can the subscription ID change? Yes. Android can assign a new ID when a SIM is removed and reinserted, swapped, or after a phone reset. If messages start to go out from the wrong SIM, open the textbee app, copy the current ID from the **SIM Cards** section, and update your code. ### Can I receive messages on a specific SIM? The phone forwards messages from all its SIMs. [Receiving SMS](/docs/receiving-sms) explains incoming messages. ### Does it work with eSIMs? Yes. An eSIM shows in `simInfo.sims` with `subscriptionType` set to `ESIM`, and you choose it in the same way. ## Next steps - [Sending SMS](/docs/sending-sms/sending-sms) - [Sending bulk SMS](/docs/sending-sms/sending-bulk-sms) - [Devices](/docs/devices) - [Delivery status and message states](/docs/sending-sms/delivery-status) # Schedule a message Source: https://textbee.dev/docs/sending-sms/scheduled-messages Send an SMS later with scheduledAt: time format, the 72 hour window, validation errors, how to list pending sends and what happens offline. To send a message later, add `scheduledAt` to the request. The value is an ISO 8601 time with a timezone, in the future, up to 72 hours ahead. textbee stores the message as `pending` and pushes it to the phone at that time. ## Where scheduledAt goes | Endpoint | Where to put `scheduledAt` | | --- | --- | | `POST /gateway/send-sms` | At the top level of the body. It applies to every recipient. | | `POST /gateway/send-bulk-sms` | On each item in `messages`. Each item can have its own time, or none. | ## Time format Write the time in ISO 8601 with an explicit timezone. Both of these forms work: | Form | Example | Meaning | | --- | --- | --- | | UTC | `2026-10-01T14:00:00Z` | 14:00 UTC | | Offset | `2026-10-01T09:00:00-05:00` | 09:00 at UTC-5, which is also 14:00 UTC | Always include `Z` or an offset. A time without a timezone, such as `2026-10-01T09:00:00`, is read in the server's timezone and can send hours early or late. The time must be in the future. Schedule up to 72 hours ahead. For a later send, keep the job in your own scheduler and call textbee closer to the time. ## Schedule a message This request sends one reminder at 14:00 UTC on 1 October 2026: curl: ```bash curl -X POST https://api.textbee.dev/api/v1/gateway/send-sms \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "recipients": ["+12015550123"], "message": "Reminder: your appointment is at 3pm today.", "scheduledAt": "2026-10-01T14:00:00Z" }' ``` JavaScript: ```javascript const inOneHour = new Date(Date.now() + 60 * 60 * 1000).toISOString() const response = await fetch('https://api.textbee.dev/api/v1/gateway/send-sms', { method: 'POST', headers: { 'x-api-key': process.env.TEXTBEE_API_KEY, 'Content-Type': 'application/json' }, body: JSON.stringify({ recipients: ['+12015550123'], message: 'Reminder: your appointment is in one hour.', scheduledAt: inOneHour, }), }) if (!response.ok) throw new Error(`HTTP ${response.status}`) console.log((await response.json()).data.smsBatchId) ``` Python: ```python import json import os import urllib.request from datetime import datetime, timedelta, timezone in_one_hour = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat() body = json.dumps({ "recipients": ["+12015550123"], "message": "Reminder: your appointment is in one hour.", "scheduledAt": in_one_hour, }).encode() request = urllib.request.Request( "https://api.textbee.dev/api/v1/gateway/send-sms", data=body, headers={"x-api-key": os.environ["TEXTBEE_API_KEY"], "Content-Type": "application/json"}, method="POST", ) with urllib.request.urlopen(request, timeout=30) as response: print(json.load(response)["data"]["smsBatchId"]) ``` `toISOString()` in JavaScript and `isoformat()` on a timezone-aware `datetime` in Python both include the timezone. The [JavaScript SDK](/docs/sdk/javascript) also accepts a `Date` object for `scheduledAt`. The response is the same as for an immediate send: `200` with an `smsBatchId`. The plan limit check happens at request time, so a `429` comes back now, not at the scheduled time. ### Schedule items in a bulk request In a bulk request, set `scheduledAt` per item. This body sends one item now and one item later: ```json { "messages": [ { "recipients": ["+12015550123"], "message": "Your order is confirmed." }, { "recipients": ["+12015550123"], "message": "Your order ships today.", "scheduledAt": "2026-10-01T14:00:00Z" } ] } ``` Items with the same time go out together, paced by the phone's Send Delay. ## Validation errors The API checks `scheduledAt` when you send the request. It returns `400` for these cases: | Case | `error` in the response body | | --- | --- | | The value is not a valid date | `Invalid scheduledAt format. Must be a valid ISO 8601 date string.` | | The time is in the past | `scheduledAt must be a future date` | In a bulk request, one bad item rejects the whole request. ## What happens until the scheduled time The message is stored with `status: "pending"` and a `dispatchDueAt` field that holds the scheduled time. It stays `pending` until then. At that time, textbee pushes it to the phone and the normal states follow: `dispatched`, then `sent`, then `delivered` if the carrier reports it. See [Delivery status and message states](/docs/sending-sms/delivery-status). ## List pending scheduled sends This call lists your sends that are still `pending`: ```bash curl "https://api.textbee.dev/api/v1/gateway/messages?direction=sent&status=pending" \ -H "x-api-key: YOUR_API_KEY" ``` Scheduled messages in the result have a `dispatchDueAt` in the future. Messages without `dispatchDueAt` are immediate sends that have not reached the phone yet. [Message history](/docs/receiving-sms/message-history) lists the other filters. ## If the phone is offline at the scheduled time textbee pushes the message at the scheduled time whether the phone is online or not. The push service holds the message for the phone. When the phone comes back online, it gets the push and sends the message. The message then goes out late, not at the scheduled time. While textbee waits for the phone, the status can change to `unknown`. It changes to `sent` when the phone reports. To avoid late sends, keep the phone online and allowed to run in the background. See [Keep the phone online](/docs/troubleshooting/keep-the-phone-online). ## Cancel a scheduled message There is no endpoint to cancel or edit a scheduled message today. After textbee accepts the request, the message goes out at its time. If your plans can change, schedule close to the send time. Keep the job in your own system until shortly before the time, then call textbee. You can then cancel the job in your own system as long as you need to. ## Frequently asked questions ### Which timezone does textbee use? The one in your `scheduledAt` value. textbee compares the time as an absolute moment. The phone's own timezone does not matter. ### Can I schedule a message for next week? Not with textbee alone. Schedule up to 72 hours ahead. Keep longer-term jobs in your own scheduler. ### Does a scheduled message count against my plan when I send the request? Yes. The limit check runs when you make the request. Each recipient counts as one message. ### Does scheduling work on a self-hosted instance? Yes, when the instance runs with its Redis queue on. Without the queue, a request with `scheduledAt` returns `400` with `SMS scheduling requires queue to be enabled`. See [Self-hosting](/docs/self-hosting). ## Next steps - [Sending SMS](/docs/sending-sms/sending-sms) - [Sending bulk SMS](/docs/sending-sms/sending-bulk-sms) - [Delivery status and message states](/docs/sending-sms/delivery-status) - [Message history](/docs/receiving-sms/message-history) # Receiving SMS Source: https://textbee.dev/docs/receiving-sms Receive SMS sent to your Android phone through textbee: turn on forwarding, then get each message by webhook or by polling the messages endpoint. textbee forwards each SMS that arrives on your registered Android phone to your account. Your server gets the message in one of two ways: a webhook that textbee pushes to your URL, or a poll of the messages endpoint. Turn on **Receive SMS** in the app first, because nothing is forwarded without it. ## How receiving works 1. A person sends an SMS to the phone number of the SIM in your phone. 2. The textbee app on the phone reads the message and uploads it to textbee. 3. textbee stores the message in your history with `direction: "received"` and `status: "received"`. 4. textbee sends a `MESSAGE_RECEIVED` event to each webhook subscription that asks for it. 5. Your code reads the message from the webhook body or from `GET /gateway/messages`. If the phone is offline when the SMS arrives, the app uploads it when the phone reconnects. The message then appears in history and webhooks at that later time. See [Late uploads](#late-uploads) for very late messages. ## Turn on receiving 1. Open the textbee app on the phone. 2. Go to **Settings**. In the **SMS** section, turn on **Receive SMS** ("Forward incoming SMS to backend"). 3. Grant the SMS permission when Android asks for it. The app cannot read incoming SMS without it. 4. Send a test SMS to the phone from another phone. 5. Check the [dashboard](https://app.textbee.dev/dashboard). The message appears in the message history. The device record shows the setting too. `GET /gateway/devices` returns `receiveSMSEnabled` for each phone. It must be `true` for received history and webhooks. See [Managing devices](/docs/devices) for the other device fields. Note: If a test SMS does not appear, open **Settings** > **Device health** in the app. It lists the SMS permission, the Receive SMS toggle and battery settings that stop the app in the background. ## Webhook or polling Both methods return the same messages. Pick the one that fits your server. | | Webhook | Polling | |---|---|---| | Latency | Seconds after the phone uploads the message | Your poll interval | | Needs a public URL | Yes, HTTPS in production | No | | Retries | textbee retries failed deliveries, up to 10 attempts | Not needed. The next poll reads what you missed. | | Setup | A subscription and an endpoint that verifies the signature | A loop or a scheduled job | | Pick it when | You reply fast, for example to a code or a keyword | Your server is behind a firewall, or you run a batch job | Many integrations use both. The webhook handles new messages, and a daily poll fills any gap. ## Receive with a webhook A webhook is a URL on your server. textbee sends an HTTP POST to it for each event you select. 1. In the [dashboard](https://app.textbee.dev/dashboard), open **Webhooks** and add a webhook. 2. Enter your URL and a signing secret of at least 20 characters. 3. Select the **MESSAGE_RECEIVED** event. 4. Return a 2xx status quickly from your endpoint. The full setup, the signature check and the retry rules are on the [Webhooks](/docs/webhooks) page. Every event payload is on the [Webhook events reference](/docs/webhooks/events). ## Receive by polling The messages endpoint returns sent and received messages for every device on your account. GET /api/v1/gateway/messages This request returns the 20 newest received messages: ```bash curl "https://api.textbee.dev/api/v1/gateway/messages?direction=received&limit=20" \ -H "x-api-key: YOUR_API_KEY" ``` To read only new messages on each run, ask for `order=asc` and follow the cursor. The next poll resumes from the last `nextCursor` you stored. This sample reads every received message since a start time: curl: ```bash BASE_URL="${TEXTBEE_BASE_URL:-https://api.textbee.dev/api/v1}" QUERY="direction=received&order=asc&limit=50&from=2026-09-01T00:00:00Z" CURSOR="" while :; do PAGE=$(curl -sS "$BASE_URL/gateway/messages?$QUERY${CURSOR:+&cursor=$CURSOR}" -H "x-api-key: $TEXTBEE_API_KEY") echo "$PAGE" | jq -r '.data[] | "\(._id) \(.sender) \(.message)"' [ "$(echo "$PAGE" | jq -r '.meta.hasMore')" = "true" ] || break CURSOR=$(echo "$PAGE" | jq -r '.meta.nextCursor') # store this to resume the next poll done ``` JavaScript: ```javascript const BASE_URL = process.env.TEXTBEE_BASE_URL ?? 'https://api.textbee.dev/api/v1' const API_KEY = process.env.TEXTBEE_API_KEY async function fetchPage(params) { const response = await fetch(`${BASE_URL}/gateway/messages?${new URLSearchParams(params)}`, { headers: { 'x-api-key': API_KEY }, }) if (!response.ok) throw new Error(`HTTP ${response.status}`) return response.json() } const params = { direction: 'received', order: 'asc', limit: '50', from: '2026-09-01T00:00:00Z' } let cursor do { const page = await fetchPage(cursor ? { ...params, cursor } : params) for (const message of page.data) console.log(message._id, message.sender, message.message) cursor = page.meta.hasMore ? page.meta.nextCursor : undefined // store it to resume the next poll } while (cursor) ``` Python: ```python import json import os import urllib.parse import urllib.request BASE_URL = os.environ.get("TEXTBEE_BASE_URL", "https://api.textbee.dev/api/v1") API_KEY = os.environ["TEXTBEE_API_KEY"] def fetch_page(params): url = f"{BASE_URL}/gateway/messages?{urllib.parse.urlencode(params)}" request = urllib.request.Request(url, headers={"x-api-key": API_KEY}) with urllib.request.urlopen(request, timeout=30) as response: return json.load(response) params = {"direction": "received", "order": "asc", "limit": 50, "from": "2026-09-01T00:00:00Z"} while True: page = fetch_page(params) for message in page["data"]: print(message["_id"], message["sender"], message["message"]) if not page["meta"]["hasMore"]: break params["cursor"] = page["meta"]["nextCursor"] # store this to resume the next poll ``` PHP: ```php true, CURLOPT_HTTPHEADER => ["x-api-key: $apiKey"], ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); if ($status !== 200) { throw new RuntimeException("HTTP $status"); } return json_decode($body, true); } $params = ['direction' => 'received', 'order' => 'asc', 'limit' => 50, 'from' => '2026-09-01T00:00:00Z']; do { $page = fetchPage($baseUrl, $apiKey, $params); foreach ($page['data'] as $message) { echo $message['_id'], ' ', $message['sender'], ' ', $message['message'], PHP_EOL; } $params['cursor'] = $page['meta']['nextCursor']; // store this to resume the next poll } while ($page['meta']['hasMore']); ``` Go: ```go package main import ( "encoding/json" "fmt" "net/http" "net/url" "os" ) type page struct { Data []struct { ID string `json:"_id"` Sender string `json:"sender"` Message string `json:"message"` } `json:"data"` Meta struct { NextCursor string `json:"nextCursor"` HasMore bool `json:"hasMore"` } `json:"meta"` } func baseURL() string { if url := os.Getenv("TEXTBEE_BASE_URL"); url != "" { return url } return "https://api.textbee.dev/api/v1" } func fetchPage(params url.Values) (page, error) { var result page request, _ := http.NewRequest(http.MethodGet, baseURL()+"/gateway/messages?"+params.Encode(), nil) request.Header.Set("x-api-key", os.Getenv("TEXTBEE_API_KEY")) response, err := http.DefaultClient.Do(request) if err != nil { return result, err } defer response.Body.Close() if response.StatusCode != http.StatusOK { return result, fmt.Errorf("HTTP %d", response.StatusCode) } return result, json.NewDecoder(response.Body).Decode(&result) } func main() { params := url.Values{"direction": {"received"}, "order": {"asc"}, "limit": {"50"}, "from": {"2026-09-01T00:00:00Z"}} for { current, err := fetchPage(params) if err != nil { panic(err) } for _, message := range current.Data { fmt.Println(message.ID, message.Sender, message.Message) } if !current.Meta.HasMore { break } params.Set("cursor", current.Meta.NextCursor) // store it to resume the next poll } } ``` The cursor never skips a message and never returns one twice. [Message history and polling](/docs/receiving-sms/message-history) has the full parameter table and more filters. ## Response shape Each received message in `data` looks like this excerpt: ```json { "data": [ { "_id": "66f7c1e2a4d5e6f7a8b9c0d1", "message": "YES, please confirm my booking", "sender": "+12015550123", "receivedAt": "2026-09-28T09:14:03.000Z", "direction": "received", "status": "received", "device": { "_id": "664a9b8cd0e1f2a3b4c5d6e7", "enabled": true, "name": "Office phone", "brand": "google", "model": "Pixel 7", "buildId": "BP2A.250605.031" }, "createdAt": "2026-09-28T09:14:05.112Z", "updatedAt": "2026-09-28T09:14:05.112Z" } ], "meta": { "limit": 20, "nextCursor": "eyJ0IjoiMjAyNi0wOS0yOFQwOToxNDowNS4xMTJaIn0", "hasMore": true } } ``` | Field | Meaning | |---|---| | `sender` | Number that sent the SMS, as the phone reports it | | `message` | Message text | | `receivedAt` | When the phone received the SMS | | `direction` | `received` for incoming messages | | `device._id` | Device that received the message. Use it as `deviceId`. | ## Reply to a received message To reply, send an SMS to the `sender` number with the [send endpoint](/docs/sending-sms/sending-sms). Pass the `device._id` of the received message as `deviceId` so the reply goes out from the same phone. ```bash curl -X POST https://api.textbee.dev/api/v1/gateway/send-sms \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"recipients": ["+12015550123"], "message": "Thanks, your booking is confirmed.", "deviceId": "664a9b8cd0e1f2a3b4c5d6e7"}' ``` On a phone with two SIMs, pass `simSubscriptionId` as well. See [Choosing a SIM](/docs/sending-sms/choosing-a-sim). ## Notes ### Messages received over the plan limit A received message that arrives while your account is over its plan limit has `overLimit: true`. The field is absent on other messages. See [Pricing](/docs/pricing) for the limits of each plan. ### Late uploads A phone that was offline uploads its backlog when it reconnects. If the upload is more than 24 hours after `receivedAt`, textbee sets `createdAt` to `receivedAt` and stores the upload time in `originalCreatedAt`. The `from` and `to` filters work on `createdAt`, so the message lands in the time window when it was received. If the upload is more than 48 hours after `receivedAt`, textbee stores the message but does not send a `MESSAGE_RECEIVED` webhook for it. A poll still returns it. This is one reason to run a periodic poll next to a webhook. ### Filter messages on the phone The app can forward only some messages, for example only messages from one short code. Filtered messages never leave the phone. See [Filter incoming SMS on the phone](/docs/receiving-sms/sms-filters). ## Frequently asked questions ### Why do I not see received messages? Check four things. **Receive SMS** is on in the app. The SMS permission is granted. `receiveSMSEnabled` is `true` on the device. No SMS filter blocks the sender. The [phone connection guide](/docs/troubleshooting/keep-the-phone-online) covers phones that stop in the background. ### Can one account receive on several phones? Yes. Each registered phone forwards its own messages. The messages endpoint returns all of them. Use `deviceIds` to narrow the list to one phone. ### Does textbee receive MMS? No. textbee receives SMS text messages only. ### Do I need a webhook to receive messages? No. The messages endpoint returns received messages without a webhook. A webhook only removes the delay of a poll. ## Next steps - [Message history and polling](/docs/receiving-sms/message-history): every filter and both pagination modes - [Webhooks](/docs/webhooks): push delivery, signatures and retries - [Webhook events reference](/docs/webhooks/events): the `MESSAGE_RECEIVED` payload and the other events - [Filter incoming SMS on the phone](/docs/receiving-sms/sms-filters): forward only the messages you need - [Sending SMS](/docs/sending-sms/sending-sms): reply to a received message # Message history and polling Source: https://textbee.dev/docs/receiving-sms/message-history List sent and received SMS across every device with GET /gateway/messages: filters, page and cursor pagination, and polling without gaps. The messages endpoint returns sent and received messages for every device on your account, newest first. Filter it by direction, status, device, batch, text and time. Use page numbers to browse, and use the cursor to poll for new messages without gaps or duplicates. ## The endpoint GET /api/v1/gateway/messages This request returns the 50 newest messages in both directions: ```bash curl "https://api.textbee.dev/api/v1/gateway/messages" \ -H "x-api-key: YOUR_API_KEY" ``` ## Parameters All parameters are optional query parameters. An unknown filter value fails with a 400 error. textbee does not ignore it. | Parameter | Values | Description | |---|---|---| | `direction` | `all`, `sent`, `received` | Which messages to return. Default `all`. | | `status` | `pending`, `dispatched`, `sent`, `delivered`, `failed`, `unknown`, `received` | Delivery state. `direction=sent&status=failed` lists sends that failed. | | `deviceIds` | Comma-separated device IDs | Only these devices. Default: every device on the account. | | `smsBatchId` | A batch ID | Only messages from this batch, using the `smsBatchId` a send returns. | | `search` | Text | Matches the message text and the other party's number. | | `from` | ISO 8601 datetime with a timezone, or a date | Inclusive lower bound on `createdAt`. A date such as `2026-09-01` is read as UTC midnight. | | `to` | Same formats as `from` | Exclusive upper bound on `createdAt`. | | `order` | `desc`, `asc` | `desc` (default) is newest first. Use `asc` to walk forward in time when you poll. | | `page` | Integer, 1 or more | Page to return. Default 1. Do not combine with `cursor`. | | `limit` | 1 to 100 | Messages per page. Default 50. | | `cursor` | Opaque string | Position from a previous `meta.nextCursor`. Returns the page after that position. | A datetime without a timezone, such as `2026-09-01T00:00:00`, is rejected. Use `2026-09-01T00:00:00Z` or `2026-09-01T00:00:00+03:00`. `from` is inclusive and `to` is exclusive. Back-to-back windows, such as one day and the next, never count a message twice. `direction` and `status` are different things. `direction=sent` means outgoing messages. `status=sent` means the phone reported the message as sent. A `deviceIds` entry or an `smsBatchId` that is not on your account returns 404. ## Page and cursor pagination The endpoint has two pagination modes. The mode depends on whether you pass `cursor`. ### Page mode Without `cursor`, the response has page numbers and a total count: ```json { "data": [], "meta": { "page": 1, "limit": 50, "total": 1284, "totalPages": 26, "nextCursor": "eyJ0IjoiMjAyNi0wOS0yOFQwOToxNDowNS4xMTJaIn0", "hasMore": true } } ``` Use page mode to show a table with page numbers in your own UI. ### Cursor mode With `cursor`, the response has no total count. It is faster on large histories: ```json { "data": [], "meta": { "limit": 50, "nextCursor": "eyJ0IjoiMjAyNi0wOS0yOFQxMDowMjoxNy4wMDBaIn0", "hasMore": false } } ``` `nextCursor` is `null` on the last page. Treat the cursor as an opaque string. Do not build or change it yourself. Use cursor mode to read a full history or to poll for new messages. Page numbers drift when new messages arrive during the read. A cursor does not drift. ## Poll without gaps or duplicates To poll for new received messages: 1. On the first run, request `direction=received&order=asc` with a `from` time. 2. Process each message in `data`. 3. While `meta.hasMore` is `true`, request the next page with `cursor=`. 4. Store the last `nextCursor` you received, for example in your database. 5. On the next run, start with that stored cursor instead of `from`. This sample runs one poll from a start time and follows the cursor: curl: ```bash BASE_URL="${TEXTBEE_BASE_URL:-https://api.textbee.dev/api/v1}" QUERY="direction=received&order=asc&limit=50&from=2026-09-01T00:00:00Z" CURSOR="" while :; do PAGE=$(curl -sS "$BASE_URL/gateway/messages?$QUERY${CURSOR:+&cursor=$CURSOR}" -H "x-api-key: $TEXTBEE_API_KEY") echo "$PAGE" | jq -r '.data[] | "\(._id) \(.sender) \(.message)"' [ "$(echo "$PAGE" | jq -r '.meta.hasMore')" = "true" ] || break CURSOR=$(echo "$PAGE" | jq -r '.meta.nextCursor') # store this to resume the next poll done ``` JavaScript: ```javascript const BASE_URL = process.env.TEXTBEE_BASE_URL ?? 'https://api.textbee.dev/api/v1' const API_KEY = process.env.TEXTBEE_API_KEY async function fetchPage(params) { const response = await fetch(`${BASE_URL}/gateway/messages?${new URLSearchParams(params)}`, { headers: { 'x-api-key': API_KEY }, }) if (!response.ok) throw new Error(`HTTP ${response.status}`) return response.json() } const params = { direction: 'received', order: 'asc', limit: '50', from: '2026-09-01T00:00:00Z' } let cursor do { const page = await fetchPage(cursor ? { ...params, cursor } : params) for (const message of page.data) console.log(message._id, message.sender, message.message) cursor = page.meta.hasMore ? page.meta.nextCursor : undefined // store it to resume the next poll } while (cursor) ``` Python: ```python import json import os import urllib.parse import urllib.request BASE_URL = os.environ.get("TEXTBEE_BASE_URL", "https://api.textbee.dev/api/v1") API_KEY = os.environ["TEXTBEE_API_KEY"] def fetch_page(params): url = f"{BASE_URL}/gateway/messages?{urllib.parse.urlencode(params)}" request = urllib.request.Request(url, headers={"x-api-key": API_KEY}) with urllib.request.urlopen(request, timeout=30) as response: return json.load(response) params = {"direction": "received", "order": "asc", "limit": 50, "from": "2026-09-01T00:00:00Z"} while True: page = fetch_page(params) for message in page["data"]: print(message["_id"], message["sender"], message["message"]) if not page["meta"]["hasMore"]: break params["cursor"] = page["meta"]["nextCursor"] # store this to resume the next poll ``` PHP: ```php true, CURLOPT_HTTPHEADER => ["x-api-key: $apiKey"], ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); if ($status !== 200) { throw new RuntimeException("HTTP $status"); } return json_decode($body, true); } $params = ['direction' => 'received', 'order' => 'asc', 'limit' => 50, 'from' => '2026-09-01T00:00:00Z']; do { $page = fetchPage($baseUrl, $apiKey, $params); foreach ($page['data'] as $message) { echo $message['_id'], ' ', $message['sender'], ' ', $message['message'], PHP_EOL; } $params['cursor'] = $page['meta']['nextCursor']; // store this to resume the next poll } while ($page['meta']['hasMore']); ``` Go: ```go package main import ( "encoding/json" "fmt" "net/http" "net/url" "os" ) type page struct { Data []struct { ID string `json:"_id"` Sender string `json:"sender"` Message string `json:"message"` } `json:"data"` Meta struct { NextCursor string `json:"nextCursor"` HasMore bool `json:"hasMore"` } `json:"meta"` } func baseURL() string { if url := os.Getenv("TEXTBEE_BASE_URL"); url != "" { return url } return "https://api.textbee.dev/api/v1" } func fetchPage(params url.Values) (page, error) { var result page request, _ := http.NewRequest(http.MethodGet, baseURL()+"/gateway/messages?"+params.Encode(), nil) request.Header.Set("x-api-key", os.Getenv("TEXTBEE_API_KEY")) response, err := http.DefaultClient.Do(request) if err != nil { return result, err } defer response.Body.Close() if response.StatusCode != http.StatusOK { return result, fmt.Errorf("HTTP %d", response.StatusCode) } return result, json.NewDecoder(response.Body).Decode(&result) } func main() { params := url.Values{"direction": {"received"}, "order": {"asc"}, "limit": {"50"}, "from": {"2026-09-01T00:00:00Z"}} for { current, err := fetchPage(params) if err != nil { panic(err) } for _, message := range current.Data { fmt.Println(message.ID, message.Sender, message.Message) } if !current.Meta.HasMore { break } params.Set("cursor", current.Meta.NextCursor) // store it to resume the next poll } } ``` The stored cursor is your resume point. The next run starts after the last message you processed, so it never re-reads a page and never returns a message twice. Note: Send the same filters with every request of one poll. Change only the cursor. Most late uploads are safe. A phone that was offline uploads its backlog when it reconnects, and the next poll returns those messages. A message uploaded more than 24 hours late gets `createdAt` equal to `receivedAt`, so a poll that already moved past that time does not return it. For an exact record, run a daily poll with `from` and `to` over the previous two days and deduplicate on `_id`. ## Useful filters Failed sends of one batch. Use the `smsBatchId` from the send response: ```bash curl "https://api.textbee.dev/api/v1/gateway/messages?smsBatchId=66f7c3a8b1c2d3e4f5a6b7c0&status=failed" \ -H "x-api-key: YOUR_API_KEY" ``` Received messages on one device in one week: ```bash curl "https://api.textbee.dev/api/v1/gateway/messages?direction=received&deviceIds=664a9b8cd0e1f2a3b4c5d6e7&from=2026-09-21&to=2026-09-28" \ -H "x-api-key: YOUR_API_KEY" ``` All messages to or from one number. Encode the `+` as `%2B`: ```bash curl "https://api.textbee.dev/api/v1/gateway/messages?search=%2B12015550123" \ -H "x-api-key: YOUR_API_KEY" ``` Several devices at once: ```bash curl "https://api.textbee.dev/api/v1/gateway/messages?deviceIds=664a9b8cd0e1f2a3b4c5d6e7,664a9b8cd0e1f2a3b4c5d6f1" \ -H "x-api-key: YOUR_API_KEY" ``` The [JavaScript SDK](/docs/sdk/javascript) wraps these filters in `getMessages` and follows the cursor for you with `iterateMessages`. ## Response fields worth knowing | Field | Description | |---|---| | `_id` | Message ID. It matches `smsId` in webhook payloads. | | `direction` | `sent` or `received`, lowercase. Pass it back as the `direction` filter. | | `status` | Delivery state. Received messages are always `received`. | | `sender` | Sender number. Received messages only. | | `recipient` | Destination number. Sent messages only. | | `message` | Message text. It can be an empty string. | | `device` | Object with the device `_id`, `enabled`, `name`, `brand` and `model`. | | `smsBatch` | Batch ID. Sent messages only. | | `simSubscriptionId` | SIM the message went out from, when set. | | `receivedAt` | When the phone received the message. | | `requestedAt`, `dispatchedAt`, `sentAt`, `deliveredAt`, `failedAt` | Timestamps of an outgoing message. See [Delivery status and message states](/docs/sending-sms/delivery-status). | | `errorCode`, `errorMessage` | Failure details of a failed message. | | `overLimit` | `true` on a received message that arrived while the account was over its plan limit. Absent otherwise. | | `createdAt` | When textbee stored the message. The `from` and `to` filters use this field. | | `originalCreatedAt` | Upload time. Set only on a received message uploaded more than 24 hours after `receivedAt`. | A missing `status` on an old message means the message predates status tracking. Treat it as `unknown`. Messages from deleted devices are never included. If you delete a device in the dashboard, its messages leave the history. See [Managing devices](/docs/devices). ## Frequently asked questions ### Do I need a device ID to read messages? No. The endpoint covers every device on your account. Use `deviceIds` only to narrow the list. ### Why does a poll miss a message I see in the dashboard? Check the filters. A `from` time after the message's `createdAt`, a `deviceIds` list without its device, or a different `direction` hides it. Also check the stored cursor: it resumes after the last message of the previous run. ### Can I get messages of one conversation? Yes. Use `search` with the other party's number. It matches both sent and received messages. ### Is polling or a webhook better? A webhook is faster. A poll needs no public URL and catches anything a webhook missed. See the comparison on [Receiving SMS](/docs/receiving-sms). ## Next steps - [Receiving SMS](/docs/receiving-sms): turn on forwarding on the phone - [Webhooks](/docs/webhooks): get new messages pushed to your server - [Delivery status and message states](/docs/sending-sms/delivery-status): what each status and timestamp means - [Sending bulk SMS](/docs/sending-sms/sending-bulk-sms): find failed recipients of a batch - [API reference](/docs/api-reference): the full response schema # Filter incoming SMS on the phone Source: https://textbee.dev/docs/receiving-sms/sms-filters Use SMS filters in the textbee Android app to forward only the incoming messages you need, with allow list or block list rules on sender and text. SMS filters decide on the phone which incoming SMS the textbee app forwards to your account. A filtered message never leaves the phone, so it never appears in your message history or in webhooks. Use filters to forward only the messages your integration needs, for example codes from one short code. ## Where to find filters 1. Open the textbee app on the phone. 2. Go to **Settings**. In the **SMS** section, tap **Configure Filters** ("Allow/block list for incoming SMS"). 3. The **SMS Filters** screen opens. Filters apply only to incoming SMS. They do not change what the phone sends. ## Turn filtering on Turn on **Enable SMS Filtering** ("Filter incoming SMS based on rules below"). When it is off, the app forwards every incoming SMS and ignores the rules. When it is on, pick a mode: | Mode | App text | Effect | |---|---|---| | **Allow List** | "Only SMS matching a rule will be forwarded" | The app forwards a message only if at least one rule matches it. | | **Block List** | "SMS matching a rule will be blocked" | The app forwards every message except those that match a rule. | Block List is the default mode. Warning: With no rules, the app forwards every message, in both modes. An empty Allow List does not block anything. Add at least one rule before you rely on it. ## Rule fields Tap **+** to add a rule. The **Add Rule** dialog has four fields. | Field | Values | Default | Description | |---|---|---|---| | **Pattern** | Text | none | The text to look for. Leading and trailing spaces are removed. Plain text only, no wildcards or regular expressions. | | **Filter Target** | Sender, Message, Both | Sender | What the pattern is checked against. **Both** matches when the sender or the message text matches. | | **Match Type** | Exact, Starts with, Ends with, Contains | Contains | How the pattern is compared with the target. | | **Case sensitive** | On, Off | Off | When off, upper and lower case letters are treated the same. | Match types in detail: | Match Type | A rule matches when the target | |---|---| | Exact | is equal to the pattern | | Starts with | begins with the pattern | | Ends with | ends with the pattern | | Contains | includes the pattern anywhere | The sender is the address Android reports for the message. For a normal number it is usually in E.164 form, such as `+12015550123`. For a short code it is the digits, and for a business sender it can be a text name. Check a received message in your [message history](/docs/receiving-sms/message-history) to see the exact `sender` value before you write a sender rule. Tap a rule to open **Edit Rule**. Tap the delete icon on a rule to delete it. Changes apply to the next incoming SMS. ## Examples ### Only codes from one short code Forward only verification messages from the short code `55501`. | Setting | Value | |---|---| | Mode | Allow List | | Pattern | `55501` | | Filter Target | Sender | | Match Type | Exact | Every other message stays on the phone. ### Block personal contacts The phone has a SIM that friends also text. Keep their messages off your account. | Setting | Value | |---|---| | Mode | Block List | | Rule 1 | Pattern `+12015550123`, Filter Target Sender, Match Type Exact | | Rule 2 | Pattern `+12015550124`, Filter Target Sender, Match Type Exact | Add one rule per number. Messages from all other senders are forwarded. ### Only messages that contain a keyword Forward only messages that contain the word `STOP` or `JOIN`, from any sender. | Setting | Value | |---|---| | Mode | Allow List | | Rule 1 | Pattern `STOP`, Filter Target Message, Match Type Contains, Case sensitive off | | Rule 2 | Pattern `JOIN`, Filter Target Message, Match Type Contains, Case sensitive off | In an Allow List, a message is forwarded when any one rule matches. Rules do not combine with "and". ## How filters work with Receive SMS The app checks two settings for each incoming SMS, in this order: 1. **Receive SMS** in **Settings**. When it is off, the app forwards nothing and the filters do not run. 2. **SMS Filters**. When filtering is on and rules exist, the rules decide. So filters only narrow what **Receive SMS** already forwards. To stop all forwarding, turn off **Receive SMS**. See [Receiving SMS](/docs/receiving-sms). Filters live on the phone only. They are not stored on your account, and each phone has its own rules. If you use [several phones](/docs/devices), set the filters on each one. ## Frequently asked questions ### Can I get a filtered message back later? No. The app does not upload a filtered message, so textbee never has it. The message stays in the phone's own SMS inbox. ### Does a filtered message count against my plan? No. textbee only counts messages it receives from the phone. A filtered message never reaches textbee. ### Can I use a regular expression? No. Patterns are plain text. Use **Starts with**, **Ends with** or **Contains** for partial matches, and add several rules for several values. ### Can I manage filters from the dashboard or the API? No. Filters are set in the app on each phone. ## Next steps - [Receiving SMS](/docs/receiving-sms): turn on forwarding and choose webhook or polling - [Message history and polling](/docs/receiving-sms/message-history): check the `sender` values you get - [Webhook events reference](/docs/webhooks/events): the `MESSAGE_RECEIVED` payload - [Managing devices](/docs/devices): run several phones on one account # Managing devices Source: https://textbee.dev/docs/devices List and inspect the Android phones on your textbee account, choose the sending device, read phone health from the API, and manage the device limit. A device is an Android phone with the textbee app registered to your account. Each device sends and receives SMS through its own SIM cards. Use the devices endpoints to find device IDs, pick the sending phone, and check whether a phone is online and healthy. ## List and inspect devices The devices endpoint returns every phone on your account. GET /api/v1/gateway/devices To read one device, use its `_id`: GET /api/v1/gateway/devices/{id} This request lists your devices: curl: ```bash curl https://api.textbee.dev/api/v1/gateway/devices \ -H "x-api-key: YOUR_API_KEY" ``` JavaScript: ```javascript const response = await fetch('https://api.textbee.dev/api/v1/gateway/devices', { headers: { 'x-api-key': process.env.TEXTBEE_API_KEY }, }) const { data: devices } = await response.json() for (const device of devices) { console.log(device._id, device.name, device.enabled, device.lastHeartbeat) } ``` Python: ```python import os import requests response = requests.get( "https://api.textbee.dev/api/v1/gateway/devices", headers={"x-api-key": os.environ["TEXTBEE_API_KEY"]}, timeout=30, ) for device in response.json()["data"]: print(device["_id"], device.get("name"), device["enabled"], device.get("lastHeartbeat")) ``` The response wraps an array in `data`. This example is trimmed to the most useful fields: ```json { "data": [ { "_id": "664a9b8cd0e1f2a3b4c5d6e7", "name": "Office phone", "enabled": true, "isDefault": true, "brand": "google", "model": "Pixel 7", "osVersion": "16", "appVersionName": "2.8.0", "receiveSMSEnabled": true, "smsSendDelaySeconds": 3, "heartbeatEnabled": true, "heartbeatIntervalMinutes": 30, "lastHeartbeat": "2026-09-28T09:45:12.000Z", "sentSMSCount": 1832, "receivedSMSCount": 412, "batteryInfo": { "percentage": 86, "isCharging": true, "lastUpdated": "2026-09-28T09:45:12.000Z" }, "networkInfo": { "networkType": "wifi", "lastUpdated": "2026-09-28T09:45:12.000Z" }, "powerInfo": { "isIgnoringBatteryOptimizations": true, "isDeviceIdleMode": false, "isPowerSaveMode": false, "lastUpdated": "2026-09-28T09:45:12.000Z" }, "simInfo": { "lastUpdated": "2026-09-28T09:45:12.000Z", "sims": [ { "subscriptionId": 1, "simSlotIndex": 0, "serviceState": "IN_SERVICE", "signalLevel": 3 } ] }, "createdAt": "2026-06-02T14:20:31.000Z", "updatedAt": "2026-09-28T09:45:12.000Z" } ] } ``` ## Device fields | Field | Description | |---|---| | `_id` | Device ID. Pass it as `deviceId` when you send. | | `enabled` | Whether the device may send and receive SMS. Sending to a disabled device fails. | | `isDefault` | Whether sends without a `deviceId` go out from this device. | | `name` | Your own label for the device. | | `lastHeartbeat` | Time of the last heartbeat from the phone. A device silent for a long time is likely offline, and sends to it wait in the queue. | | `heartbeatIntervalMinutes` | Minutes between heartbeats. | | `smsSendDelaySeconds` | Seconds the phone waits between messages in a batch. Set it in the app under **Send Delay**. | | `receiveSMSEnabled` | Whether the phone forwards incoming SMS. Required for received history and webhooks. | | `appVersionName` | textbee app version on the phone. | | `batteryInfo` | Battery `percentage` and `isCharging` at the last heartbeat. | | `networkInfo` | `networkType` at the last heartbeat: `wifi`, `cellular` or `none`. | | `powerInfo` | Android power state at the last heartbeat. Explains a phone that is online but slow. | | `simInfo` | SIMs in the phone. Each `subscriptionId` is a valid `simSubscriptionId` for sends. | | `sentSMSCount` | Messages this device has sent. | | `receivedSMSCount` | Messages this device has received. | The device record also has `brand`, `manufacturer`, `model`, `os`, `osVersion`, `appVersionCode`, `heartbeatEnabled`, `appVersionInfo`, `deviceUptimeInfo`, `memoryInfo`, `storageInfo` and `systemInfo` (timezone and locale). The [API reference](/docs/api-reference) describes each one. ## How textbee chooses the sending device When you send with `POST /gateway/send-sms` or `POST /gateway/send-bulk-sms`, textbee picks the device in this order: 1. The `deviceId` in the request, if you pass one. 2. Your default device (`isDefault: true`), if it is enabled. 3. The enabled device with the most recent heartbeat. A malformed `deviceId` returns 400, and an ID that is not on your account returns 404. textbee does not fall back to another phone, so a typo never sends from the wrong device. With no `deviceId` and no enabled device, the send returns 400. To pick a SIM on the chosen phone, see [Choosing a SIM](/docs/sending-sms/choosing-a-sim). ## Manage devices Some actions are in the dashboard, and some are in the app on the phone. | Action | Where | What it does | |---|---|---| | Set as default | [Dashboard](https://app.textbee.dev/dashboard), device menu | Sends without a `deviceId` go out from this device. | | Delete | Dashboard, device menu | Removes the device from your account. Its messages leave your message history. | | Enable or disable | App, **Settings** > **Gateway Enabled** | A disabled device does not send or receive, and sends to it fail. It does not count toward the active device limit. | | Rename | App, **Settings** > **Device Name** | Changes `name`, the label in the dashboard and the API. | Delete a device only when you no longer need its history. To stop a phone for a while, disable it instead. ### Disconnect on the phone or delete in the dashboard These two actions are different: - **Disconnect Device** in the app removes the credentials from the phone only. The app stops sending heartbeats. The device record, its settings and its history stay on your account. An enabled device still counts toward the active device limit. - **Delete** in the dashboard removes the device record from your account. The phone cannot send or receive through textbee until you register the app again. To connect a phone again after a disconnect, see [Reconnecting an old device](/docs/getting-started/reconnecting-an-old-device). ## Active device limit Each plan allows a number of active (enabled) devices. When you register or enable a device over that number, the API returns HTTP 429 with this message: ``` Active device limit reached: your plan allows up to N active device(s) and you have N. Disable or delete another device, or upgrade your plan ``` To fix it, disable a device you do not use, delete it, or change your plan. [Pricing](/docs/pricing) lists the device limit of each plan. ## Use several phones More phones give you more capacity and a backup. - **Throughput.** Each phone sends at its own pace, set by its send delay and its carrier. Two phones can send about twice as many messages per minute. Split the load by passing a different `deviceId` per request. - **Redundancy.** textbee does not move a send to another phone when the default phone is offline. Check `lastHeartbeat` before a send and pass the `deviceId` of a healthy phone. - **One phone per number.** A phone with two SIMs is one device. Choose the SIM with `simSubscriptionId`. Use a separate phone when you need more capacity on the same number set. Received messages from all phones appear in one history. Filter them with `deviceIds`. See [Message history and polling](/docs/receiving-sms/message-history). ## Check phone health from the API Read these fields from `GET /gateway/devices/{id}` to judge whether a phone can send now: | Check | Healthy | Problem sign | |---|---|---| | `lastHeartbeat` age | Less than about two `heartbeatIntervalMinutes` | Much older: the phone is likely offline or Android stopped the app | | `powerInfo.isIgnoringBatteryOptimizations` | `true` | `false`: Android can delay the app in the background | | `powerInfo.isPowerSaveMode` | `false` | `true`: battery saver can slow sends | | `powerInfo.isDeviceIdleMode` | `false` | `true`: the phone was in Doze at the last heartbeat | | `batteryInfo` | Charging, or a high `percentage` | Low battery and not charging | | `networkInfo.networkType` | `wifi` or `cellular` | `none` | | `simInfo.sims[].serviceState` | `IN_SERVICE` | `OUT_OF_SERVICE`, `EMERGENCY_ONLY` or `POWER_OFF` | A heartbeat time in the future means the phone clock changed. Fix the date and time on the phone. The app has the same checks on the phone. Open **Settings** > **Device health**: "What can slow down or stop messages on this phone, in either direction, and what to change." It lists each problem with the fix for your phone brand. The guide [Keep the phone online](/docs/troubleshooting/keep-the-phone-online) walks through each fix. ## Frequently asked questions ### Where do I find a device ID? Call `GET /gateway/devices` and read `_id`. The app also shows it under **Settings** > **Device ID**. ### Can I change a device with the API? The API reads devices. Enable, disable and rename a device in the app. Set the default device in the dashboard, or with `setDefaultDevice` in the [JavaScript SDK](/docs/sdk/javascript). ### Why is `lastHeartbeat` old? The phone has not sent a heartbeat for a while. The phone is off, has no network, or Android stopped the app in the background. See [Keep the phone online](/docs/troubleshooting/keep-the-phone-online). ### Does deleting a device delete the phone's SMS? No. Delete removes the device and its messages from textbee only. The SMS in the phone's own inbox stay on the phone. ## Next steps - [Registering a device](/docs/getting-started/registering-a-device): add a phone to your account - [Choosing a SIM](/docs/sending-sms/choosing-a-sim): send from a specific SIM - [Keep the phone online](/docs/troubleshooting/keep-the-phone-online): stop Android from pausing the app - [Messages not sending](/docs/troubleshooting/messages-not-sending): fix stuck and failed sends - [Pricing](/docs/pricing): device limits per plan # Webhooks Source: https://textbee.dev/docs/webhooks Get textbee SMS events on your server in real time: create a webhook subscription, verify the X-Signature header, and handle retries and pauses. A webhook sends an HTTP POST to your server when an SMS event happens, for example when your phone receives a message or a sent message is delivered. You create a subscription with a URL, a signing secret and a list of events. textbee signs each request so your server can prove it came from textbee. ## How webhooks work 1. You create a webhook subscription in the dashboard or with the API. 2. An event happens on your account, for example the phone receives an SMS. 3. textbee builds a JSON payload for the event and signs it with the subscription's secret. 4. textbee sends a POST request with the payload to your URL. 5. Your endpoint verifies the signature and returns a 2xx status. 6. If the request fails, textbee retries it later. An account can have several subscriptions: for example one for production, one for staging and one for a CRM. Each subscription has its own URL, secret and events. When two subscriptions select the same event, each one gets its own delivery. ## Create a subscription in the dashboard You need a public URL that accepts HTTP POST requests and returns a 2xx status. 1. Open the [dashboard](https://app.textbee.dev/dashboard) and go to **Webhooks**. 2. Click **Add Webhook** or **Create Webhook**. 3. (Optional) Give the webhook a short **name** so you can tell your endpoints apart. 4. Enter your webhook URL. Use HTTPS in production. Private and loopback hosts (for example `localhost`, `127.0.0.1` and private network ranges) are rejected. 5. Select the events you want. The [events summary](#events) below lists them. 6. Set a **signing secret** of at least 20 characters. 7. Save. textbee sends a POST request to your URL each time a selected event happens. ## Create a subscription with the API The webhooks endpoint creates a subscription from code. POST /api/v1/webhooks This request creates a subscription for received messages and delivery reports: ```bash curl -X POST https://api.textbee.dev/api/v1/webhooks \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Production inbox", "deliveryUrl": "https://example.com/textbee/webhook", "signingSecret": "3f9a1c7e5b2d8f4a6c0e9b1d7a3f5c8e", "events": ["MESSAGE_RECEIVED", "MESSAGE_DELIVERED", "MESSAGE_FAILED"] }' ``` | Field | Required | Description | |---|---|---| | `deliveryUrl` | Yes | URL that receives the POST requests. http or https, publicly reachable. | | `signingSecret` | Yes | Shared secret, at least 20 characters. textbee signs every delivery with it. | | `events` | Yes | One or more event names. | | `name` | No | Label shown in the dashboard, up to 64 characters. | Generate a random secret, for example with `openssl rand -hex 32`, and keep it on your server only. The other routes manage subscriptions and read the delivery log: | Method and path | What it does | |---|---| | `GET /webhooks` | Lists your subscriptions. Deleted ones are excluded. | | `GET /webhooks/{webhookId}` | Returns one subscription with its delivery counters. | | `PATCH /webhooks/{webhookId}` | Changes only the fields you send: `name`, `deliveryUrl`, `signingSecret`, `events`, `isActive`. | | `DELETE /webhooks/{webhookId}` | Stops all deliveries for the subscription. Its delivery log stays readable. | | `GET /webhooks/notifications` | Lists delivery attempts, newest first. | The [API reference](/docs/api-reference) has the full request and response schemas. ## Events | Event | Fires when | |---|---| | `MESSAGE_RECEIVED` | The phone uploads an incoming SMS. | | `MESSAGE_SENT` | The phone reports that the carrier accepted an outgoing SMS. | | `MESSAGE_DELIVERED` | The carrier reports delivery to the recipient's handset. | | `MESSAGE_FAILED` | The phone reports that an outgoing SMS failed. | | `UNKNOWN_STATE` | The phone reports a status that textbee cannot map. | | `SMS_STATUS_UPDATED` | Reserved. You can select it, but textbee does not emit it today. | The [Webhook events reference](/docs/webhooks/events) has a full payload and a field table for each event. ## The delivery request Each delivery is one HTTP request: - Method: `POST` - Body: a flat JSON object. Every event has the same six envelope fields, and each event adds its own fields. - Headers: `Content-Type: application/json` and `X-Signature`, the signature of the body. This is a `MESSAGE_RECEIVED` body: ```json { "smsId": "66f7c1e2a4d5e6f7a8b9c0d1", "message": "YES, please confirm my booking", "deviceId": "664a9b8cd0e1f2a3b4c5d6e7", "webhookSubscriptionId": "664a9b8cd0e1f2a3b4c5d6e8", "webhookEvent": "MESSAGE_RECEIVED", "idempotencyKey": "8f7e6d5c-4b3a-4c1d-8e9f-8a7b6c5d4e3f", "sender": "+12015550123", "receivedAt": "2026-09-28T09:14:03.000Z" } ``` Return a 2xx status as fast as you can. Put slow work, such as calls to other systems or outgoing replies, in a background job. A slow response times out and counts as a failed attempt. ## Verify the signature The `X-Signature` header holds a signature of the request body. Check it before you trust the body. The algorithm: 1. Read the raw request body as bytes. Do not parse it and serialize it again first. 2. Compute HMAC-SHA256 of the raw body. Use the signing secret of the subscription that the request is for. 3. Encode the result as lowercase hex. 4. Compare it with the `X-Signature` header value in constant time, for example with `crypto.timingSafeEqual` in Node or `hmac.compare_digest` in Python. 5. If the values differ, return 401 and ignore the request. This sample verifies the signature, skips duplicates by `idempotencyKey` and handles `MESSAGE_RECEIVED`: curl: ```bash # Post a signed test event to your own endpoint, the way textbee does. BODY='{"smsId":"66b1f2c3a4d5e6f7a8b9c0d1","message":"Hello","deviceId":"664a9b8cd0e1f2a3b4c5d6e7","webhookSubscriptionId":"664a9b8cd0e1f2a3b4c5d6e8","webhookEvent":"MESSAGE_RECEIVED","idempotencyKey":"8f7e6d5c-4b3a-2c1d-0e9f-8a7b6c5d4e3f","sender":"+12015550123","receivedAt":"2026-09-05T10:00:00.000Z"}' SIGNATURE=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$TEXTBEE_WEBHOOK_SECRET" | sed 's/^.* //') curl -sS -o /dev/null -w '%{http_code}\n' -X POST "${WEBHOOK_URL:-http://localhost:3000/}" \ -H "Content-Type: application/json" \ -H "X-Signature: $SIGNATURE" \ -d "$BODY" ``` JavaScript: ```javascript import { createHmac, timingSafeEqual } from 'node:crypto' import { createServer } from 'node:http' const SECRET = process.env.TEXTBEE_WEBHOOK_SECRET const seen = new Set() // use your database in production function verify(rawBody, signature) { const expected = createHmac('sha256', SECRET).update(rawBody).digest('hex') return signature.length === expected.length && timingSafeEqual(Buffer.from(signature), Buffer.from(expected)) } createServer((request, response) => { const chunks = [] request.on('data', (chunk) => chunks.push(chunk)) request.on('end', () => { const rawBody = Buffer.concat(chunks) if (!verify(rawBody, request.headers['x-signature'] ?? '')) { response.writeHead(401).end() return } const event = JSON.parse(rawBody.toString()) if (!seen.has(event.idempotencyKey)) { seen.add(event.idempotencyKey) if (event.webhookEvent === 'MESSAGE_RECEIVED') console.log(`${event.sender}: ${event.message}`) } response.writeHead(200).end() }) }).listen(process.env.PORT ?? 3000) ``` Python: ```python import hashlib import hmac import json import os from http.server import BaseHTTPRequestHandler, HTTPServer SECRET = os.environ["TEXTBEE_WEBHOOK_SECRET"].encode() seen = set() # use your database in production class WebhookHandler(BaseHTTPRequestHandler): def do_POST(self): raw_body = self.rfile.read(int(self.headers.get("Content-Length", 0))) expected = hmac.new(SECRET, raw_body, hashlib.sha256).hexdigest() if not hmac.compare_digest(expected, self.headers.get("X-Signature", "")): self.send_response(401) self.end_headers() return event = json.loads(raw_body) if event["idempotencyKey"] not in seen: seen.add(event["idempotencyKey"]) if event["webhookEvent"] == "MESSAGE_RECEIVED": print(f"{event['sender']}: {event['message']}", flush=True) self.send_response(200) self.end_headers() HTTPServer(("", int(os.environ.get("PORT", 3000))), WebhookHandler).serve_forever() ``` PHP: ```php **Deliveries**. The same data is available from `GET /webhooks/notifications`. Filter it with `status`, `eventType`, `deviceId`, `webhookSubscriptionId`, and `start` with `end`. Fields worth knowing on each delivery record: | Field | Meaning | |---|---| | `computedStatus` | `pending`, `retrying`, `delivered` or `failed` | | `httpStatusCode` | Status code your endpoint returned on the last attempt | | `responseBody` | First 1000 characters of your endpoint's response | | `errorType` | `retryable` or `non-retryable` | | `deliveryAttemptCount` | Attempts made so far | | `nextDeliveryAttemptAt` | When the next retry is due | | `payload` | The exact JSON body textbee sent | ## Idempotency Your endpoint can get the same delivery more than once. For example, your server saves the message but the response times out, so textbee retries. Every attempt of one delivery carries the same `idempotencyKey`. 1. Store each `idempotencyKey` you process, for example in a table with a unique index. 2. When a request arrives, check the key first. 3. If you already processed it, return 200 and do nothing else. ## Bulk sends A bulk send produces one delivery per recipient message. A send to 500 recipients with `MESSAGE_DELIVERED` selected can produce up to 500 deliveries. Make sure your endpoint can accept that volume. Use `smsBatchId` in the payload to group events by batch. ## Local development textbee rejects private and loopback URLs, so it cannot reach `localhost`. To test on your computer, run a tunnel service that gives your local port a public HTTPS URL, and use that URL as the `deliveryUrl`. Delete the test subscription when you finish. The curl tab of the webhook sample above posts a signed test event to your own endpoint. Use it to test the signature check without sending an SMS. ## Troubleshooting ### No deliveries arrive - The subscription has the event selected. For incoming SMS, that is `MESSAGE_RECEIVED`. - The subscription is active. Check for a pause note in the dashboard. - The endpoint returns a 2xx status. Check `httpStatusCode` and `responseBody` in the delivery log. - For incoming SMS, **Receive SMS** is on in the app. See [Receiving SMS](/docs/receiving-sms). ### The signature does not match - Compute the HMAC over the raw body bytes. Many frameworks parse JSON before your handler runs. Read the raw body instead. - Use the secret of the subscription that sent the request. Each subscription has its own secret. - Compare hex strings. The header holds a lowercase hex digest, not base64. ## Frequently asked questions ### How many webhooks can one account have? Several. Each subscription has its own URL, secret and events. The API returns a 400 error when you reach the maximum. ### Does textbee send events in order? Not always. A retry can arrive after a later event for the same message. Read `status` and the timestamps in the payload. The [events reference](/docs/webhooks/events) explains how to handle order and duplicates. ### Can I change the secret without downtime? Create a second subscription with the new secret and the same events, and accept both secrets on your endpoint. Then delete the old subscription and remove the old secret. ### Do deleted subscriptions keep their history? Yes. `GET /webhooks/notifications` still returns deliveries of a deleted subscription. ## Next steps - [Webhook events reference](/docs/webhooks/events): every payload and field - [Receiving SMS](/docs/receiving-sms): turn on forwarding on the phone - [Delivery status and message states](/docs/sending-sms/delivery-status): what each status means - [Message history and polling](/docs/receiving-sms/message-history): fill gaps with a poll - [JavaScript SDK](/docs/sdk/javascript): verify signatures with one call # Webhook events reference Source: https://textbee.dev/docs/webhooks/events Every textbee webhook event with a full JSON payload and field table: received, sent, delivered, failed and unknown state, plus ordering rules. textbee sends six event types to webhook subscriptions. Five of them fire today: `MESSAGE_RECEIVED`, `MESSAGE_SENT`, `MESSAGE_DELIVERED`, `MESSAGE_FAILED` and `UNKNOWN_STATE`. Each payload is a JSON object with the same envelope fields plus fields for that event. To create a subscription and verify signatures, see [Webhooks](/docs/webhooks). ## Envelope fields Every event has these six fields. | Field | Type | Description | |---|---|---| | `smsId` | string | ID of the stored message. The same ID appears in the messages endpoint as `_id`. | | `message` | string | Message text. | | `deviceId` | string | ID of the device that received or sent the message. | | `webhookSubscriptionId` | string | ID of the subscription that produced this delivery. | | `webhookEvent` | string | Event name, for example `MESSAGE_RECEIVED`. | | `idempotencyKey` | string | Unique per delivery. Every retry of one delivery carries the same key. | Switch on `webhookEvent` to handle each event type. ### Optional SIM field Any event can also have `simUsed`, the SIM that sent or received the message. It is present only when the textbee app on the phone reports the SIM. Older app versions do not report it, so treat the field as optional. | Field | Type | Description | |---|---|---| | `simUsed.subscriptionId` | number | Subscription ID of the SIM. Can differ from the `simSubscriptionId` you sent, when that ID matched no SIM in the phone. | | `simUsed.slotIndex` | number | Slot of the SIM, starting at 0: `0` is SIM 1, `1` is SIM 2. | Either part can be missing when the phone reports only one of them. ## MESSAGE_RECEIVED Fires when the phone uploads an incoming SMS to textbee. **Receive SMS** must be on in the app. See [Receiving SMS](/docs/receiving-sms). ```json { "smsId": "66f7c1e2a4d5e6f7a8b9c0d1", "message": "YES, please confirm my booking", "deviceId": "664a9b8cd0e1f2a3b4c5d6e7", "webhookSubscriptionId": "664a9b8cd0e1f2a3b4c5d6e8", "webhookEvent": "MESSAGE_RECEIVED", "idempotencyKey": "8f7e6d5c-4b3a-4c1d-8e9f-8a7b6c5d4e3f", "sender": "+12015550123", "receivedAt": "2026-09-28T09:14:03.000Z" } ``` | Field | Type | Description | |---|---|---| | `sender` | string | Number that sent the SMS, as the phone reports it. It can be a short code or a text sender name. | | `receivedAt` | string | ISO 8601 time when the phone received the SMS. | A phone that was offline uploads its messages later. If the upload is more than 48 hours after `receivedAt`, textbee stores the message but does not send this event. The [messages endpoint](/docs/receiving-sms/message-history) still returns it. ## MESSAGE_SENT Fires when the phone reports that it sent the message and the carrier accepted it. It does not confirm delivery to the recipient's handset. ```json { "smsId": "66f7c3a9b1c2d3e4f5a6b7c8", "message": "Your order has shipped.", "deviceId": "664a9b8cd0e1f2a3b4c5d6e7", "webhookSubscriptionId": "664a9b8cd0e1f2a3b4c5d6e8", "webhookEvent": "MESSAGE_SENT", "idempotencyKey": "2b4d6f8a-1c3e-4a5b-9d7f-0e2c4a6b8d1f", "smsBatchId": "66f7c3a8b1c2d3e4f5a6b7c0", "status": "sent", "recipient": "+12015550124", "sentAt": "2026-09-28T10:02:17.000Z" } ``` | Field | Type | Description | |---|---|---| | `smsBatchId` | string | Batch ID returned by the send request. | | `status` | string | `sent` | | `recipient` | string | Number the message was sent to. | | `sentAt` | string | ISO 8601 time when the phone reported the message as sent. | ## MESSAGE_DELIVERED Fires when the carrier sends a delivery report to the phone. Carriers do not always send delivery reports. A message can reach the recipient without this event. ```json { "smsId": "66f7c3a9b1c2d3e4f5a6b7c8", "message": "Your order has shipped.", "deviceId": "664a9b8cd0e1f2a3b4c5d6e7", "webhookSubscriptionId": "664a9b8cd0e1f2a3b4c5d6e8", "webhookEvent": "MESSAGE_DELIVERED", "idempotencyKey": "5c7e9a1b-3d5f-4b6c-8e0a-2d4f6a8c0e3b", "smsBatchId": "66f7c3a8b1c2d3e4f5a6b7c0", "status": "delivered", "recipient": "+12015550124", "sentAt": "2026-09-28T10:02:17.000Z", "deliveredAt": "2026-09-28T10:02:21.000Z" } ``` | Field | Type | Description | |---|---|---| | `smsBatchId` | string | Batch ID returned by the send request. | | `status` | string | `delivered` | | `recipient` | string | Number the message was sent to. | | `sentAt` | string | ISO 8601 time when the phone reported the message as sent. | | `deliveredAt` | string | ISO 8601 time when the delivery report arrived. | ## MESSAGE_FAILED Fires when a message fails. The phone reports a failure when Android or the carrier rejects the send. textbee also fires this event when it cannot push the message to the phone. ```json { "smsId": "66f7c4d0e1f2a3b4c5d6e7f8", "message": "Your code is 482913", "deviceId": "664a9b8cd0e1f2a3b4c5d6e7", "webhookSubscriptionId": "664a9b8cd0e1f2a3b4c5d6e8", "webhookEvent": "MESSAGE_FAILED", "idempotencyKey": "9e1a3c5b-7d9f-4e2a-b4c6-8f0a2c4e6b8d", "smsBatchId": "66f7c4cfe1f2a3b4c5d6e7f0", "status": "failed", "recipient": "+12015550125", "errorCode": "4", "errorMessage": "No cellular service. Check signal and try again when you have coverage.", "failedAt": "2026-09-28T10:05:42.000Z" } ``` | Field | Type | Description | |---|---|---| | `smsBatchId` | string | Batch ID returned by the send request. | | `status` | string | `failed` | | `recipient` | string | Number the message was sent to. | | `errorCode` | string | Failure code. For a failure on the phone, this is the Android send result code. | | `errorMessage` | string | Reason for the failure in plain words, with a suggested fix. | | `failedAt` | string | ISO 8601 time of the failure. | `failed` is a final state. textbee does not resend a failed message. To try again, send a new message. [Delivery status and message states](/docs/sending-sms/delivery-status) explains the failure codes, and [Messages not sending](/docs/troubleshooting/messages-not-sending) lists the fixes. ## UNKNOWN_STATE Fires when the phone reports a status that textbee does not map to sent, delivered or failed. The most common case is a delivery report that the carrier cancels or marks as failed. The message can still have reached the recipient. ```json { "smsId": "66f7c3a9b1c2d3e4f5a6b7c9", "message": "Your order has shipped.", "deviceId": "664a9b8cd0e1f2a3b4c5d6e7", "webhookSubscriptionId": "664a9b8cd0e1f2a3b4c5d6e8", "webhookEvent": "UNKNOWN_STATE", "idempotencyKey": "4a6c8e0b-2d4f-4a1c-9e3b-5d7f9a1c3e5b", "smsBatchId": "66f7c3a8b1c2d3e4f5a6b7c0", "status": "delivery_failed", "recipient": "+12015550126" } ``` | Field | Type | Description | |---|---|---| | `smsBatchId` | string | Batch ID returned by the send request. | | `status` | string | The status the phone reported, in lowercase. | | `recipient` | string | Number the message was sent to. | Log this event and review it by hand. Do not treat it as a failure without a check. ## SMS_STATUS_UPDATED `SMS_STATUS_UPDATED` is a valid value in a subscription's `events` list, so the API accepts it. It is reserved: textbee does not emit it today. To track status changes, select `MESSAGE_SENT`, `MESSAGE_DELIVERED`, `MESSAGE_FAILED` and `UNKNOWN_STATE`. ## Ordering and duplicates textbee does not guarantee the order of events. A retry of one event can arrive after a later event for the same message. For example, `MESSAGE_DELIVERED` can arrive before `MESSAGE_SENT` when the first attempt of `MESSAGE_SENT` failed. Follow these rules: 1. Deduplicate on `idempotencyKey`. Every retry of one delivery carries the same key. 2. Store the latest known state per `smsId`. Do not move a message back to an earlier state. `delivered` and `failed` are final. 3. Compare timestamps (`sentAt`, `deliveredAt`, `failedAt`) when two events disagree. 4. In rare cases the phone reports the same status twice, and textbee sends a second event with a new `idempotencyKey`. Rule 2 makes this harmless. To read the current state of one message at any time, call `GET /gateway/devices/{id}/sms/{smsId}`. ## Which events to select | Use case | Events | |---|---| | Inbox, two-way chat, keyword replies | `MESSAGE_RECEIVED` | | Delivery tracking | `MESSAGE_SENT`, `MESSAGE_DELIVERED`, `MESSAGE_FAILED`, `UNKNOWN_STATE` | | Failure alerts | `MESSAGE_FAILED` | | Full audit log | All five events that fire today | Each selected event adds deliveries. A bulk send produces one delivery per recipient for each selected status event. Select only what your code uses. ## Frequently asked questions ### Does every sent message produce MESSAGE_DELIVERED? No. Carriers do not always send delivery reports. Treat `MESSAGE_SENT` as "left the phone" and `MESSAGE_DELIVERED` as a bonus when the carrier confirms. ### Does the payload include the device name? No. The payload has `deviceId` only. Call `GET /gateway/devices/{id}` to read the name and other device fields. See [Managing devices](/docs/devices). ### Why is my webhook missing an event that the dashboard shows? Check that the subscription selects that event, and that the subscription is active. Then check the delivery log under **Webhooks** > **Deliveries** for the `httpStatusCode` your endpoint returned. ### Are MESSAGE_SENT and MESSAGE_DELIVERED sent for scheduled messages? Yes. A scheduled message produces the same events when the phone sends it. See [Scheduled messages](/docs/sending-sms/scheduled-messages). ## Next steps - [Webhooks](/docs/webhooks): create a subscription, verify signatures, retries - [Delivery status and message states](/docs/sending-sms/delivery-status): every status and timestamp - [Receiving SMS](/docs/receiving-sms): turn on forwarding on the phone - [Message history and polling](/docs/receiving-sms/message-history): read the same messages by API # SDKs Source: https://textbee.dev/docs/sdk Official textbee.dev client libraries. Send SMS, read message history and verify webhooks from your code without writing raw HTTP calls. textbee SDKs wrap the REST API in typed calls for your language. Each SDK uses the same API key, the same endpoints and the same plan limits as a plain HTTP request. ## Available SDKs | Language | Package | Runs on | |---|---|---| | [JavaScript and TypeScript](/docs/sdk/javascript) | `@textbee/sdk` | Node 18 or newer, Bun, Deno, Cloudflare Workers, Vercel Edge | ## What an SDK gives you - **Typed calls.** Send SMS, list devices and read messages with checked parameters. - **Message iteration.** Read message history page by page without cursor bookkeeping. - **Webhook checks.** Verify the signature on each webhook request. - **SMS utilities.** Count segments and check the encoding before you send. ## No SDK for your language Every SDK feature is also a REST endpoint. Any language with an HTTP client can send an SMS: ```bash curl -X POST https://api.textbee.dev/api/v1/gateway/send-sms \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"recipients": ["+12015550123"], "message": "Hello from textbee"}' ``` See [Sending SMS](/docs/sending-sms/sending-sms) for the request fields and the [API reference](/docs/api-reference) for every endpoint. ## Next steps - [JavaScript SDK](/docs/sdk/javascript) - [API keys](/docs/getting-started/api-keys) - [Webhooks](/docs/webhooks) # JavaScript SDK Source: https://textbee.dev/docs/sdk/javascript Send SMS from Node.js and TypeScript with the official @textbee/sdk package, a zero dependency wrapper around the textbee.dev REST API. `@textbee/sdk` is the official JavaScript and TypeScript client for textbee.dev. It wraps the same REST API documented elsewhere in these docs, so anything you can do with the SDK you can also do with plain HTTP. It has zero dependencies and ships its own TypeScript types. It runs on Node 18 or newer, Bun, Deno, Cloudflare Workers and Vercel Edge. If you would rather call the API directly, see [Sending SMS](/docs/sending-sms/sending-sms). ## Install ```bash npm install @textbee/sdk ``` ## Quickstart Generate an API key in your [dashboard](https://app.textbee.dev/dashboard), then send a message: ```js import { Textbee } from '@textbee/sdk' const textbee = new Textbee({ apiKey: process.env.TEXTBEE_API_KEY }) await textbee.sendSms({ recipients: ['+12025550123'], message: 'Hello from textbee!', }) ``` Never hardcode your API key. Read it from an environment variable so it stays out of your source control. ## Send options `sendSms` requires only `message` and `recipients`. The rest are optional. | Option | Type | Purpose | | --- | --- | --- | | `deviceId` | `string` | Which phone sends the message | | `simSubscriptionId` | `number` | Which SIM sends it on a multi-SIM phone | | `scheduledAt` | `string` or `Date` | Send later instead of now | This call uses all three options: ```js await textbee.sendSms({ recipients: ['+12025550123'], message: 'Your appointment is tomorrow at 9am', deviceId: '65f0000000000000000000aa', simSubscriptionId: 2, scheduledAt: new Date(Date.now() + 60 * 60 * 1000), }) ``` `sendSms` returns the send response, including `smsBatchId`. A successful call means textbee accepted the message. It does not mean the message was delivered. See [Delivery status and message states](/docs/sending-sms/delivery-status). ### deviceId Leave it out and textbee chooses the sender for you: your default device first, and otherwise the enabled device with the most recent heartbeat. Pass one to force a specific phone. You can list your device ids with `getDevices()`. See [Managing devices](/docs/devices). A malformed id fails the request rather than falling back to another phone, so a typo can never send from the wrong device. ### simSubscriptionId Find this value in the textbee Android app under **Dashboard**, in the **SIM Cards** section. Each SIM shows its subscription id next to it with a copy button. The id can change when a SIM is removed and reinserted or swapped, so check it in the app again after any change to the SIMs. Leave it out and the phone decides: it uses the preferred SIM set in the app's settings, or the system default if none is set. This value is not validated. If the id does not match a SIM in the phone, the phone ignores it and sends from the preferred or default SIM. Nothing fails and no error is returned. If the SIM matters, confirm which number the message arrived from. See [Choosing a SIM](/docs/sending-sms/choosing-a-sim). ### scheduledAt Accepts an ISO 8601 string or a `Date`. It must be in the future, and you can schedule up to 72 hours ahead. Omit it to send immediately. See [Scheduled messages](/docs/sending-sms/scheduled-messages). ## Devices These calls read your devices and change the default one: ```js const devices = await textbee.getDevices() const device = await textbee.getDevice(deviceId) // Change which device handles sends that omit deviceId await textbee.setDefaultDevice(deviceId) ``` ## Message history and delivery status History is account-level. `getMessages` covers every device on the account, and it is paginated, filterable and searchable: ```js const { data, meta } = await textbee.getMessages({ direction: 'received', // 'all' | 'sent' | 'received' page: 1, limit: 50, search: 'invoice', }) console.log(meta.total, meta.totalPages) ``` Narrow it with any combination of filters: ```js // Specific devices, instead of the whole account await textbee.getMessages({ deviceIds: [deviceId] }) // Which recipients of a bulk send failed const { smsBatchId } = await textbee.sendSms({ recipients, message }) await textbee.getMessages({ smsBatchId, status: 'failed' }) // A date window. from is inclusive, to is exclusive, so back to back // windows never count a message twice. Datetimes need an explicit // timezone; a bare date is read as UTC midnight. await textbee.getMessages({ from: '2026-08-01', to: '2026-09-01' }) ``` The `direction` field on each message is lowercase, so you can pass it straight back as a filter. [Message history and polling](/docs/receiving-sms/message-history) lists every filter. ### Polling without duplicates Page numbers drift when new messages arrive mid-read. `iterateMessages` follows the pagination cursor instead, so it yields every message exactly once: ```js for await (const message of textbee.iterateMessages({ direction: 'received', order: 'asc', from: lastRunTimestamp, })) { await handleMessage(message) } ``` Break out of the loop and it stops fetching. To resume on your next run, keep `meta.nextCursor` from `getMessages` and pass it back as `cursor`. ### One message or one batch `getSms` returns one message with its current status. `getSmsBatch` returns a batch and its messages, using the `smsBatchId` that `sendSms` returned: ```js // One message and its current status const sms = await textbee.getSms(deviceId, smsId) // A batch, using the smsBatchId returned by sendSms const { batch, messages } = await textbee.getSmsBatch(deviceId, smsBatchId) ``` [Delivery status and message states](/docs/sending-sms/delivery-status) explains each status and timestamp on the result. ## Verifying webhooks textbee signs every webhook delivery with HMAC-SHA256 and sends the hex digest in the `X-Signature` header. `verifyWebhookSignature` checks it with a constant-time compare. Pass the **raw request body** rather than a re-serialized object whenever your framework exposes it. Re-serializing a parsed object usually produces the same bytes, but not always, and a mismatch there looks like an invalid signature. ```js import { verifyWebhookSignature } from '@textbee/sdk' app.post( '/webhooks/textbee', express.raw({ type: 'application/json' }), async (req, res) => { const valid = await verifyWebhookSignature({ payload: req.body.toString('utf8'), signature: req.get('x-signature'), signingSecret: process.env.TEXTBEE_WEBHOOK_SECRET, }) if (!valid) return res.sendStatus(401) const event = JSON.parse(req.body.toString('utf8')) // handle the event res.sendStatus(200) }, ) ``` The function returns a promise, so `await` it. Use the signing secret of the subscription that sent the request. See [Webhooks](/docs/webhooks) for retries and idempotency, and the [Webhook events reference](/docs/webhooks/events) for every payload. ## SMS utilities The SDK exports pure helpers for SMS text and phone numbers. They need no API key and make no network calls. Import only what you need, and your bundler removes the rest. ### Segments and encoding A message in the 7-bit GSM alphabet fits 160 characters in one segment. One character outside that alphabet, such as one emoji or one curly quote, switches the whole message to UCS-2. The limit then drops to 70 characters. ```js import { countSmsSegments, getSmsEncoding, findNonGsm7Characters } from '@textbee/sdk' countSmsSegments("It's ready: code 123456") // { encoding: 'gsm-7', length: 23, segments: 1, remainingInSegment: 137 } // The same text with a curly apostrophe countSmsSegments('It’s ready: code 123456') // { encoding: 'ucs-2', length: 23, segments: 1, remainingInSegment: 47 } getSmsEncoding('plain ascii') // 'gsm-7' findNonGsm7Characters('It’s ready') // ['’'] ``` The phone splits a longer message into segments. Each segment then holds 153 characters (GSM-7) or 67 (UCS-2). `remainingInSegment` counts single-unit characters, so a two-unit character such as an emoji or `€` may not fit even when it reads as 1. ### Keeping messages in GSM-7 Text pasted from a word processor or a CMS often has curly quotes, ellipses and non-breaking spaces. `sanitizeForGsm7` replaces them with plain equivalents so the message stays in GSM-7 and needs fewer segments. ```js import { sanitizeForGsm7, countSmsSegments } from '@textbee/sdk' const pasted = '“Your order shipped…”' countSmsSegments(pasted).encoding // 'ucs-2' const clean = sanitizeForGsm7(pasted) // '"Your order shipped..."' countSmsSegments(clean).encoding // 'gsm-7' // Optionally strip accents that GSM-7 does not carry. Letters it does carry, // like é, ü, and ñ, are always left alone. sanitizeForGsm7('naïve', { transliterateAccents: true }) // 'naive' ``` It is best effort: characters with no safe equivalent pass through unchanged. Check the result with `getSmsEncoding`, and list what is left with `findNonGsm7Characters`. ### Phone number helpers `isValidE164` checks the E.164 format that `recipients` expects. `normalizePhoneNumber` converts common formats to E.164: ```js import { isValidE164, normalizePhoneNumber } from '@textbee/sdk' isValidE164('+12025550123') // true isValidE164('202-555-0123') // false normalizePhoneNumber('+1 (202) 555-0123') // '+12025550123' normalizePhoneNumber('0012025550123') // '+12025550123' normalizePhoneNumber('(202) 555-0123', { defaultCountryCode: '1' }) // '+12025550123' normalizePhoneNumber('not a number') // null ``` These helpers check format only. They know nothing about dialing plans, so a well-formed but unassigned number still passes. Input that cannot be normalized returns `null`. An unusable `defaultCountryCode` throws a `TypeError`. ## Handling errors Any non-2xx response throws a `TextbeeError`. It carries the HTTP `status`, the `message`, and the parsed response `body`. Network failures reject with the underlying `fetch` error instead, so you can tell the two apart. ```js import { TextbeeError } from '@textbee/sdk' try { await textbee.sendSms({ recipients: ['+12025550123'], message: 'hi' }) } catch (error) { if (error instanceof TextbeeError) { console.error(error.status, error.message, error.body) } else { throw error } } ``` Common statuses: 400 for a rejected request (for example no enabled device), 401 for a missing or revoked API key, and 429 when a plan limit is used up. See [Messages not sending](/docs/troubleshooting/messages-not-sending). ## Client options The constructor takes the API key and an optional base URL: ```js new Textbee({ apiKey: 'your-api-key', baseUrl: 'https://api.textbee.dev/api/v1', // override for self-hosted instances }) ``` For a self-hosted instance, set `baseUrl` to your API URL including `/api/v1`. See [Self-hosting](/docs/self-hosting). ## What the SDK does not cover yet The SDK focuses on sending and reading messages. Bulk sending and some device management operations are REST only for now. Use the [Sending bulk SMS](/docs/sending-sms/sending-bulk-sms) guide for bulk sends. ## Links - Package on npm: [@textbee/sdk](https://www.npmjs.com/package/@textbee/sdk) - Source and issues: [github.com/textbee/textbee-js](https://github.com/textbee/textbee-js) - [Message history and polling](/docs/receiving-sms/message-history): every filter the SDK passes through - [Delivery status and message states](/docs/sending-sms/delivery-status): what each status means # Agents and automation Source: https://textbee.dev/docs/agents Connect AI agents and workflows to textbee: the MCP server for Claude and Cursor, a curl quickstart for any agent framework, and n8n for no-code flows. textbee gives AI agents and automation tools three ways to send and read SMS through your Android phone. Chat assistants such as Claude and Cursor use the MCP server. Any other agent framework calls two REST endpoints, and no-code builders use n8n. All three paths use the same account, the same API key and the same plan limits. An agent cannot send more than your plan allows. ## Which one to pick | You use | Pick | Why | |---|---|---| | claude.ai, Claude Desktop, the Claude mobile apps or Claude Code | [MCP server, hosted connector](/docs/agents/mcp#connect-to-claude) | Add a custom connector and sign in with your API key. Nothing to install. | | Cursor or another MCP client | [MCP server, local](/docs/agents/mcp#other-mcp-clients) | The assistant gets `send_sms`, `get_messages` and `list_devices` as tools. No code. | | An agent framework, a script or an LLM that writes code | [Agent quickstart](/docs/agents/agent-quickstart) | Two account-level endpoints: one to send, one to read | | A workflow tool, without code | [n8n](/docs/agents/n8n) | The HTTP Request node sends, the Webhook node receives | | Node.js, Bun, Deno or Workers code | [JavaScript SDK](/docs/sdk/javascript) | Typed calls, a message iterator and webhook signature checks | ## MCP server for chat agents The MCP server gives a chat assistant three tools. The assistant can then send an SMS, read replies and list your phones inside a conversation. You connect it in one of two ways: - **Hosted connector.** In claude.ai or Claude Desktop, open **Customize**, then **Connectors**, then **+ Add** and **Add custom connector**. Enter the URL `https://mcp.textbee.dev/mcp`, click **Continue**, then **Add**, and paste your API key on the textbee sign-in page. It works on the web, in Claude Desktop and in the Claude mobile apps. - **Local server.** The `@textbee/mcp` package on npm runs on your machine with your API key in an environment variable. This command adds the hosted connector to Claude Code. Run `/mcp` and choose **Authenticate** after it: ```bash claude mcp add --transport http -s user textbee https://mcp.textbee.dev/mcp ``` This command adds the local server to Claude Code instead: ```bash claude mcp add textbee -s user -e TEXTBEE_API_KEY=your-key -- npx -y @textbee/mcp ``` Setup for Claude Desktop and Cursor, the tool parameters and the hosted endpoint details are on the [MCP server](/docs/agents/mcp) page. The source is at [github.com/textbee/textbee-mcp](https://github.com/textbee/textbee-mcp). ## REST quickstart for any agent An agent needs two endpoints. Both are account-level, so the agent does not manage device ids. | Task | Endpoint | |---|---| | Send an SMS | `POST /gateway/send-sms` | | Read messages, replies and delivery status | `GET /gateway/messages` | POST /api/v1/gateway/send-sms This request sends one message: ```bash curl -X POST https://api.textbee.dev/api/v1/gateway/send-sms \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"recipients": ["+12015550123"], "message": "Hello from textbee"}' ``` The [agent quickstart](/docs/agents/agent-quickstart) shows how to check delivery with the `smsBatchId` and how to poll for replies with a cursor. [Delivery status](/docs/sending-sms/delivery-status) explains each message state. [Message history](/docs/receiving-sms/message-history) lists every filter. ## n8n for no-code workflows n8n needs no community node. An **HTTP Request** node calls `POST /gateway/send-sms` with your API key in a header. A **Webhook** node receives the `MESSAGE_RECEIVED` event from a textbee [webhook](/docs/webhooks). See [n8n](/docs/agents/n8n) for the node settings and common workflow patterns. ## Rules for agents that send SMS - **Confirm the recipient.** Each recipient counts as one message against your plan. Ask the user before the agent texts a number the user did not give. - **Do not retry a send that may have worked.** A timeout does not mean the send failed. Check `GET /gateway/messages` with the `smsBatchId` or a `search` for the number first. - **Do not retry a 429.** A 429 means a plan limit is used up. A retry does not reset the limit. - **Treat acceptance as acceptance.** A 200 response means textbee accepted the message. The phone sends it after that. ## Self-hosted instances The MCP server and the SDK can talk to your own textbee instance. Set `TEXTBEE_BASE_URL` to your instance URL. See [Self-hosting textbee](/docs/self-hosting). ## Frequently asked questions ### Does the agent need a device id? No. When the send has no `deviceId`, textbee uses your default device, or else the enabled device with the most recent heartbeat. Pass a device id only to force a specific phone. See [Devices](/docs/devices). ### Can the agent read replies? Yes. `get_messages` in the MCP server and `GET /gateway/messages` in the API return received messages across all your phones. Turn on **Receive SMS** in the app first. See [Receiving SMS](/docs/receiving-sms). ### Does reading messages use my plan quota? No. Reads do not count against your message limits. Only sent and received messages count. ## Next steps - [MCP server](/docs/agents/mcp) - [Agent quickstart](/docs/agents/agent-quickstart) - [n8n](/docs/agents/n8n) - [API keys](/docs/getting-started/api-keys) # MCP server Source: https://textbee.dev/docs/agents/mcp Connect Claude, Cursor or any MCP client to textbee and send or read SMS through your own Android phone. Hosted connector or local npx server. The official MCP (Model Context Protocol) server for textbee lets an AI assistant send and read SMS through your own Android phone. The assistant gets three tools: `send_sms`, `get_messages` and `list_devices`. There are two ways to connect: | Way | Best for | Your API key | |---|---|---| | **Hosted connector** at `https://mcp.textbee.dev/mcp` | claude.ai, Claude Desktop, the Claude mobile apps, Claude Code | You paste it once on a textbee sign-in page. textbee checks it and does not store it. | | **Local server** with `npx -y @textbee/mcp` | Claude Code, Claude Desktop, Cursor, any MCP client that runs a command | Stays on your machine, in your client config. | The local server is published on npm as [`@textbee/mcp`](https://www.npmjs.com/package/@textbee/mcp). It is open source, MIT licensed, source at [github.com/textbee/textbee-mcp](https://github.com/textbee/textbee-mcp). The hosted connector runs the same tools. ## Prerequisites - A textbee.dev account with a registered Android device - An API key from the [dashboard](https://app.textbee.dev/dashboard). Create a separate key for your assistant, so you can revoke it on its own later. ## Connect to Claude ### claude.ai, Claude Desktop and the Claude mobile apps Use the hosted connector. You need no Node.js and no config file. 1. In claude.ai or Claude Desktop, open **Customize**, then **Connectors**. 2. Click **+ Add**, then **Add custom connector**. 3. Enter the name `textbee` and the MCP server URL `https://mcp.textbee.dev/mcp`. Click **Continue**. 4. Click **Continue** on the settings Claude detects. Under **Authentication**, choose **Sign in now**. Leave **OAuth client** on the recommended option. Click **Add**. 5. The textbee sign-in page opens. Paste your API key and click **Connect**. If the page does not open, click **Connect** on the textbee connector. The connector now works in claude.ai on the web, in Claude Desktop and in the Claude mobile apps. To use it in a chat, click the **+** button in the message box, open **Connectors** and turn on textbee. - **Mobile apps:** add the connector in claude.ai or Claude Desktop first. The mobile apps then use it with the same account. - **Free plan:** Claude allows one custom connector. - **Team and Enterprise plans:** an owner adds the connector in **Organization settings**, then **Connectors**. Each member then opens **Customize**, then **Connectors**, clicks **Connect** on textbee and pastes their own API key. - **The connection lasts 90 days.** After that, Claude asks you to reconnect. Click **Connect** and paste the key again. - **To disconnect,** revoke the API key in the dashboard. Removing the connector in Claude also stops it. - **Without the sign-in page:** under **Authentication**, choose **No sign in**, and under **Request headers** add `x-textbee-api-key` with your API key as the value. Claude then sends the key on every request and the connection does not expire. ### Claude Code **Hosted, sign in once in the browser:** ```bash claude mcp add --transport http -s user textbee https://mcp.textbee.dev/mcp ``` Then start Claude Code, run `/mcp`, select `textbee` and choose **Authenticate**. The textbee sign-in page opens in your browser. Paste your API key and click **Connect**. **Local, the key stays on your machine:** ```bash claude mcp add textbee -s user -e TEXTBEE_API_KEY=your-key -- npx -y @textbee/mcp ``` `-s user` makes the server available in every project. Run `claude mcp list` to check that it is connected. ### Claude Desktop with a local server Use this when you want the key to stay on your machine, or when you connect to a [self-hosted](/docs/self-hosting) instance. Claude Desktop needs Node.js for `npx`. Add this to `claude_desktop_config.json`, then restart Claude Desktop: - macOS: `~/Library/Application Support/Claude/claude_desktop_config.json` - Windows: `%APPDATA%\Claude\claude_desktop_config.json` ```json { "mcpServers": { "textbee": { "command": "npx", "args": ["-y", "@textbee/mcp"], "env": { "TEXTBEE_API_KEY": "your-key" } } } } ``` ## Other MCP clients ### Cursor Add the same configuration object as Claude Desktop to `~/.cursor/mcp.json`. To use the hosted endpoint instead, send the API key in a header: ```json { "mcpServers": { "textbee": { "url": "https://mcp.textbee.dev/mcp", "headers": { "x-textbee-api-key": "your-key" } } } } ``` ### Any other client - Clients that run a command use `npx -y @textbee/mcp` with `TEXTBEE_API_KEY` in the environment. - Clients that support remote servers with headers use `https://mcp.textbee.dev/mcp` and send the key as `Authorization: Bearer ` or `x-textbee-api-key`. - The browser sign-in works for Claude and for clients that receive the sign-in result on `localhost`. Editors such as Cursor and VS Code cannot use it. They use the header form. ### Slow first start `npx` downloads the package on first run. If your client times out waiting, install globally once and point the config at the binary: ```bash npm install -g @textbee/mcp ``` Then use `"command": "textbee-mcp"` with no args. ## Tools The server exposes three tools. Small surfaces work better with agents, and these cover the full send-and-read loop. ### send_sms Sends a message to one or more recipients in international E.164 format (for example `+12015550123`). The sending phone is chosen automatically: your default device, otherwise the enabled device with the most recent heartbeat. Optional parameters: `device_id` (send from a specific phone), `sim_subscription_id` (pick a SIM on dual-SIM phones), `scheduled_at` (send later). When your account uses the SMS queue, the result includes an `sms_batch_id` for delivery checks. The API accepted the message at this point. It is not yet delivered. See [Delivery status](/docs/sending-sms/delivery-status) for what each state means. ### get_messages Reads messages across every device on the account, no device id needed. Filters: `direction` (`received` by default, or `sent` / `all`), free-text `search`, `sms_batch_id` (the per-recipient delivery status of a send), `device_ids`, and a `from`/`to` time window. Supports cursor pagination, so an agent can poll for new messages without missing or repeating any. The tool reads the same data as [message history](/docs/receiving-sms/message-history). ### list_devices The phones on the account: ids, enabled state, which one sends by default, last check-in, and message counts. ## Environment variables These apply to the local server. - **TEXTBEE_API_KEY** (required): API key from the textbee dashboard. - **TEXTBEE_BASE_URL** (optional): your instance URL when [self-hosting](/docs/self-hosting). Defaults to `https://api.textbee.dev`. The `/api/v1` suffix is optional and added automatically; subpath deployments like `https://example.com/textbee` work too. An invalid URL is an error rather than a silent fallback, so a typo never sends your key to the public API. - **TEXTBEE_TIMEOUT_MS** (optional): per-request timeout in milliseconds. Defaults to `30000`. ## Hosted endpoint `https://mcp.textbee.dev/mcp` is a streamable HTTP endpoint with the same three tools and the same plan limits as the local server. It is stateless, so the client needs no session handling. It accepts two kinds of sign-in: - **Sign-in page (OAuth).** Claude opens the textbee sign-in page. You paste an API key, and textbee checks it against your account. Claude then gets an encrypted token that carries the key. textbee stores nothing. Claude refreshes the token on its own. The connection ends 90 days after the first sign-in. - **Header.** The client sends the key on every request as `Authorization: Bearer ` or `x-textbee-api-key`. No sign-in step. Warning: With the hosted endpoint, your API key passes through textbee's server on every request. textbee does not log or store it. Revoke the key in the dashboard to disconnect every client that uses it. When your client can run npx and you want the key to stay on your machine, use the local server. The hosted endpoint talks to the textbee cloud API only. Self-hosted instances use the local server with TEXTBEE_BASE_URL. ## Notes - Sends count against your textbee plan quota, and plan limits are enforced server-side. An agent cannot outspend your plan. - Reading messages does not consume the send quota. - The local server writes all diagnostics to stderr. Stdout is reserved for the MCP protocol. ## Troubleshooting - **The local server starts but every call fails with a key error.** The `TEXTBEE_API_KEY` env value is missing or revoked. Create a fresh key in the [dashboard](https://app.textbee.dev/dashboard) and restart your client. - **A send fails with "No enabled device found".** Enable a device in the textbee Android app or the dashboard, or pass a specific `device_id` from `list_devices`. - **The Claude connector shows "needs reconnect" or asks you to connect again.** The 90-day connection ended or the key was revoked. Open **Customize**, then **Connectors**, and click **Connect** on the textbee connector. Paste a valid key on the sign-in page. - **Claude Code lists textbee as needing authentication.** Run `/mcp`, select `textbee` and choose **Authenticate**. - **The textbee sign-in page says the key was not accepted.** Copy the full key again from the dashboard. A revoked key does not work. - **The sign-in fails before the textbee page opens.** Remove the connector and add it again. Either OAuth client option works: **Use Claude's published identity** or **Register automatically**. - **The sign-in fails with "redirect_uri not allowed".** Your client cannot use the sign-in page. Send the key in a header instead, or use the local server. - **The textbee tools do not appear in a Claude chat.** Click the **+** button in the message box, open **Connectors** and turn on textbee. - **Self-hosted instance returns 401.** Check the base URL the local server prints in its startup line. It shows exactly which API it is talking to. ## Next steps - **[Agent quickstart](/docs/agents/agent-quickstart)** - The raw API endpoints agents should use - **[n8n](/docs/agents/n8n)** - No-code SMS automation - **[Webhooks](/docs/webhooks)** - Push incoming messages to your own systems - **[Self-hosting](/docs/self-hosting)** - Point the local server at your own instance with TEXTBEE_BASE_URL Need help? Check our [FAQ](/docs/faq) or contact support. # Agent quickstart Source: https://textbee.dev/docs/agents/agent-quickstart The two textbee API endpoints an AI agent needs: account-level send and message history with cursor polling, with copy-paste curl and SDK examples. Everything an AI agent or automation needs from textbee is two endpoints: one to send, one to read. Both are account-level, so there is no device id to manage. Authenticate every request with the `x-api-key` header using a key from the [dashboard](https://app.textbee.dev/dashboard). If your agent speaks MCP, use the [MCP server](/docs/agents/mcp) instead of calling these directly. This page is for HTTP-level integrations and for LLMs writing textbee code. ## Send an SMS ``` POST https://api.textbee.dev/api/v1/gateway/send-sms ``` ```bash curl -X POST https://api.textbee.dev/api/v1/gateway/send-sms \ -H 'Content-Type: application/json' \ -H 'x-api-key: YOUR_API_KEY' \ -d '{ "recipients": ["+12015550123"], "message": "Hello from textbee" }' ``` - **recipients** (array, required): phone numbers in E.164 format with the country code. - **message** (string, required): the text to send. - **deviceId** (string, optional): omit it. textbee picks your default device, otherwise the enabled device with the most recent heartbeat. Pass it only to force a specific phone. - **simSubscriptionId** (number, optional): which SIM on a dual-SIM phone. - **scheduledAt** (ISO 8601, optional): send later instead of now. The response includes an `smsBatchId` when the account uses the SMS queue. Keep it: it is how you check delivery. A 200 response means the API accepted the message, not that the phone sent it. See [Delivery status](/docs/sending-sms/delivery-status). With the [JavaScript SDK](/docs/sdk/javascript): ```bash npm install @textbee/sdk ``` ```javascript import { Textbee } from '@textbee/sdk' const textbee = new Textbee({ apiKey: process.env.TEXTBEE_API_KEY }) const result = await textbee.sendSms({ recipients: ['+12015550123'], message: 'Hello from textbee', }) // result.smsBatchId when the account uses the SMS queue ``` ## Read messages ``` GET https://api.textbee.dev/api/v1/gateway/messages ``` One endpoint for every device on the account. The useful filters for agents: - **direction**: `received` for inbound only (replies, verification codes), `sent` to review outgoing messages and their delivery status, `all` for both. - **smsBatchId**: only messages from one send, using the `smsBatchId` the send returned. This is the delivery-status lookup: each row carries a `status` (`pending`, `dispatched`, `sent`, `delivered`, `failed`, `unknown`, `received`). `delivered` and `failed` are final. Many carriers send no delivery report, so a message can stay `sent`. See [Delivery status](/docs/sending-sms/delivery-status) for each state. - **search**: free-text match across the message body and the other party's number. - **from** / **to**: time window, ISO 8601 with an explicit timezone. `from` is inclusive, `to` exclusive, so consecutive windows never double-count a message. - **order**: `desc` (default, newest first) or `asc` to walk forward when polling. - **cursor**: opaque position from a previous response's `meta.nextCursor`. ```bash curl "https://api.textbee.dev/api/v1/gateway/messages?direction=received&limit=25" \ -H 'x-api-key: YOUR_API_KEY' ``` Check the delivery of a send: ```bash curl "https://api.textbee.dev/api/v1/gateway/messages?smsBatchId=BATCH_ID_FROM_SEND" \ -H 'x-api-key: YOUR_API_KEY' ``` ## Poll for new messages without duplicates Webhooks are the push option (see [Webhooks](/docs/webhooks)); this is the reliable pull option: 1. First call: `GET /gateway/messages?direction=received&order=asc&from=2026-08-01T00:00:00Z` 2. Follow `meta.nextCursor` until `meta.hasMore` is `false`. 3. On the next poll, resume from the last `nextCursor` you saw. The cursor is your resume token: no re-read pages, no missed messages, no duplicates. [Message history](/docs/receiving-sms/message-history) documents every filter and field. The SDK wraps this as an async iterator: ```javascript for await (const message of textbee.iterateMessages({ direction: 'received' })) { handle(message) } ``` Warning: Use only the endpoints on this page. Older device-scoped routes still work for existing integrations but are deprecated and should never appear in new code. ## Rate limits and quotas Sends count against your plan's daily and monthly quotas, enforced server-side. A send over quota fails with HTTP 429 and a body containing `hasReachedLimit: true` plus your remaining counts. Do not retry those automatically; the quota does not reset because you retried. ## Next steps - **[MCP server](/docs/agents/mcp)** - The same capabilities as tools for Claude, Cursor, and other MCP clients - **[Webhooks](/docs/webhooks)** - Get pushed incoming messages instead of polling - **[Messages not sending](/docs/troubleshooting/messages-not-sending)** - Diagnose a send that stays pending or fails - **[API reference](/docs/api-reference)** - Every field of every endpoint Need help? Check our [FAQ](/docs/faq) or contact support. # n8n Source: https://textbee.dev/docs/agents/n8n Send and receive SMS from n8n workflows with textbee: HTTP Request node configuration for sending, webhook trigger for incoming messages, and workflow patterns. n8n sends and receives SMS through textbee with two built-in nodes: the **HTTP Request** node for sending and the **Webhook** node for receiving. You need no community node, and the whole stack can run self-hosted. ## Prerequisites - An n8n instance (cloud or self-hosted) - A textbee.dev account with a registered Android device - An API key from the [dashboard](https://app.textbee.dev/dashboard) ## Send SMS from a workflow Add an **HTTP Request** node and configure it: - **Method**: `POST` - **URL**: `https://api.textbee.dev/api/v1/gateway/send-sms` - **Authentication**: None (the key goes in a header) - **Headers**: `x-api-key` = your API key, `Content-Type` = `application/json` - **Body** (JSON): ```json { "recipients": ["+12015550123"], "message": "Hello from n8n" } ``` Use n8n expressions to fill the number and message from earlier nodes, for example `{{ $json.phone }}` and `{{ $json.message }}`. Note: Store the API key in an n8n credential (Header Auth) rather than pasting it into the node, so it does not end up in exported workflow JSON. ## Receive SMS into a workflow Incoming messages become workflow triggers through textbee webhooks: 1. Add a **Webhook** node in n8n and copy its production URL. 2. In the textbee [dashboard](https://app.textbee.dev/dashboard), add that URL as a webhook and subscribe to the **MESSAGE_RECEIVED** event. 3. Each incoming SMS now POSTs to your workflow with the sender and message text in the payload. Verify the `X-Signature` header if your workflow does anything sensitive; the payload is HMAC-signed with your webhook signing secret. Details on [Webhooks](/docs/webhooks) and [webhook events](/docs/webhooks/events). ## Workflow patterns that work well - **Form to SMS**: form submission trigger, then send a confirmation text to the submitter. - **Cron reminders**: schedule trigger, read rows from a sheet or database, send each person their reminder. - **Alerting**: on error in any workflow, text the on-call phone. SMS cuts through when email and Slack do not. - **Two-way flows**: webhook trigger on incoming SMS, branch on the message text (for example a keyword), reply with the HTTP Request node. - **AI autoresponder**: webhook trigger, pass the message to an LLM node, send the model's reply back by SMS. ## Delivery status The send response includes an `smsBatchId` when your account uses the SMS queue. To check delivery in a later node, call: ``` GET https://api.textbee.dev/api/v1/gateway/messages?smsBatchId=YOUR_BATCH_ID ``` with the same `x-api-key` header. Each message row carries a `status`. `delivered` and `failed` are final, and a message can stay `sent` when the carrier sends no delivery report. See [Delivery status](/docs/sending-sms/delivery-status) for each state and [Message history](/docs/receiving-sms/message-history) for the other filters. ## Going deeper The blog has a full tutorial with screenshots and more patterns: [n8n SMS integration with textbee](/blog/n8n-sms-automation-textbee), and a comparison of doing the same in [Make and Zapier](/blog/send-sms-from-make-zapier-n8n). ## Next steps - **[Agent quickstart](/docs/agents/agent-quickstart)** - The API surface for any automation - **[Webhooks](/docs/webhooks)** - Payload format and signature verification - **[MCP server](/docs/agents/mcp)** - The same send and read tools for AI agents Need help? Check our [FAQ](/docs/faq) or contact support. # Self-hosting textbee Source: https://textbee.dev/docs/self-hosting Run the textbee API, dashboard and Android app on your own server with Docker Compose or a VPS, then point the SDK and MCP server at your instance. textbee is open source, so you can run the whole stack on your own server. You run the API, the web dashboard, MongoDB and Redis, you create a Firebase project for push messages, and you build the Android app for your domain. The source is at [github.com/textbee/textbee](https://github.com/textbee/textbee). Self-hosting keeps all messages and account data in your own database. You also take on updates, backups and uptime. ## What runs where | Component | Role | |---|---| | Web dashboard (Next.js, `web/`) | Sign-up, login, devices, API keys, webhooks, message history. Port 3000 by default. | | API (NestJS, `api/`) | The REST API under `/api/v1`, device registration, webhooks and the send queue. Port 3001 by default. | | MongoDB | Users, devices, messages, webhook subscriptions and plans | | Redis | Background job queues: the SMS send queue, webhook deliveries and other jobs | | Firebase Cloud Messaging | Push service. The API uses it to wake the phone and hand it each message. | | Android app (`android/`) | Sends and receives SMS. You build it with your own domain and Firebase project. | | SMTP server | Email verification, password resets and limit notices | ## Before you start You need: - **A server** with Docker, or with Node.js, `pnpm` and `pm2`. - **MongoDB**: the Docker Compose file starts one. You can also use your own server or MongoDB Atlas. - **Redis**: the Docker Compose file starts one. - **A Firebase project** with Cloud Messaging enabled. You need a service account key for the API and a `google-services.json` for the Android app. - **A domain with HTTPS**, for example `sms.example.com`. The phone and your code reach the API over the internet. - **An SMTP account** for outgoing email. Sending SMS needs a verified email address, and the verification link goes out by email. - **Android Studio or the Android SDK** to build the app. ## Run with Docker Compose The repository root has a `docker-compose.yaml`. It builds the API and the web dashboard from source and starts them with MongoDB and Redis. 1. Clone the repository: ```bash git clone https://github.com/textbee/textbee.git cd textbee ``` 2. Copy both example environment files: ```bash cp web/.env.example web/.env cp api/.env.example api/.env ``` 3. Fill in `api/.env`: | Variable | Value | |---|---| | `MONGO_URI` | The example value points at the `textbee-db` container. Change the user and password, and set the same values in `MONGO_ROOT_USER` and `MONGO_ROOT_PASS`. | | `JWT_SECRET` | A long random string | | `FRONTEND_URL` | The public URL of your dashboard, for example `https://sms.example.com` | | `FIREBASE_*` | The fields from your Firebase service account JSON file | | `MAIL_*`, `ADMIN_EMAIL` | Your SMTP server | | `USE_SMS_QUEUE` | `true` to use Redis for the send queue. Scheduled messages need it. | 4. Fill in `web/.env`: | Variable | Value | |---|---| | `NEXT_PUBLIC_SITE_URL` | The public URL of your dashboard | | `NEXT_PUBLIC_API_BASE_URL` | Your API URL with `/api/v1`, for example `https://sms.example.com/api/v1` | | `NEXTAUTH_SECRET` | A long random string | | `NEXTAUTH_URL` | The public URL of your dashboard | | `MAIL_*` | Your SMTP server | 5. Start the stack from the repository root: ```bash docker compose up -d ``` This command starts five containers: | Container | What it is | Host port | |---|---|---| | `textbee-db` | MongoDB. It creates the `textbee` database. | 27018 | | `textbee-mongo-express` | A web admin UI for MongoDB. Optional. | 8081 | | `textbee-api` | The API | 3001 | | `textbee-web` | The dashboard | 3000 | | `textbee-redis` | Redis | 6379 | The web image reads `NEXT_PUBLIC_` values at build time. After you change one, rebuild with `docker compose up -d --build`. To stop the stack, run: ```bash docker compose down ``` Warning: Do not expose MongoDB, mongo-express or Redis to the internet. Put only the API and the dashboard behind your reverse proxy, and block the other ports in your firewall. ## Build the Android app for your domain The published app talks to textbee.dev. Your instance needs its own build. 1. Open the `android/` directory of the repository. 2. Replace every occurrence of `textbee.dev` with your domain. The API address is `API_BASE_URL` in the `prod` flavor of `android/app/build.gradle`. Set it to your API URL with a trailing slash, for example `https://sms.example.com/api/v1/`. 3. In Firebase, add an Android app with the package name `com.vernu.sms`, or the package name you set. Download its `google-services.json` and put it at `android/app/src/prod/google-services.json`. 4. Build the release APK: ```bash ./gradlew assembleProdRelease ``` The APK is in `android/app/build/outputs/apk/prod/release/`. The README uses `./gradlew assembleRelease`, which builds both flavors. The `dev` flavor then also needs its own `google-services.json`. 5. Install the APK on the phone, then register the device with a QR code or an API key from your own dashboard. See [Registering a device](/docs/getting-started/registering-a-device). The release build signs with the debug keystore by default. Add your own signing config before you give the APK to other people. ## Run on a VPS without Docker You can also run the API and the dashboard as Node.js processes. 1. Install `pnpm`, `pm2` and Caddy on the server. Set up MongoDB and Redis, or use hosted versions. 2. Build the API: ```bash cd api cp .env.example .env pnpm install pnpm build ``` Set `MONGO_URI` and `REDIS_URL` in `api/.env` to your own MongoDB and Redis addresses. 3. Start the API with pm2: ```bash pm2 start dist/main.js --name textbee-api ``` 4. Build and start the dashboard: ```bash cd ../web cp .env.example .env pnpm install pnpm build pm2 start "pnpm start" --name textbee-web ``` 5. Configure Caddy. This Caddyfile sends `/api/*` to the API on port 3001 and everything else to the dashboard on port 3000. Caddy gets the HTTPS certificate for you. ``` sms.example.com { reverse_proxy /api/* localhost:3001 reverse_proxy /* localhost:3000 } ``` 6. Point your domain's DNS at the server and reload Caddy. ## Analytics are off by default A self-hosted instance reports nothing to anyone by default. With no analytics variables set, the dashboard loads no analytics or ad scripts and the API sends no analytics events. Each provider is opt-in. The variables are listed in the [Analytics and telemetry section of the README](https://github.com/textbee/textbee#analytics-and-telemetry). ## Point the SDK and MCP server at your instance The [JavaScript SDK](/docs/sdk/javascript) and the [MCP server](/docs/agents/mcp) read `TEXTBEE_BASE_URL`. Set it to your instance URL, for example `https://sms.example.com`. - The `/api/v1` suffix is optional. The client adds it. - Subpath deployments work, for example `https://example.com/textbee`. - An invalid URL is an error. The client does not fall back to textbee.dev, so a typo never sends your API key to the public service. The hosted MCP endpoint talks only to textbee.dev. For your own instance, run the MCP server locally with `TEXTBEE_BASE_URL`. For REST calls, replace `https://api.textbee.dev/api/v1` with your own API URL, for example `https://sms.example.com/api/v1`. Create API keys and webhooks in the dashboard of your own instance, not in the textbee.dev dashboard. ## Keep your instance up to date 1. Pull the new code: ```bash git pull ``` 2. Compare `api/.env.example` and `web/.env.example` with your `.env` files. Add any new variables. 3. Rebuild and restart. With Docker Compose, run `docker compose up -d --build`. On a VPS, run `pnpm install` and `pnpm build` in `api/` and `web/`, then `pm2 restart textbee-api textbee-web`. 4. When the Android app changes, rebuild the APK with your domain and install it on each phone. Back up MongoDB before each update. ## Hosted or self-hosted | | Hosted (textbee.dev) | Self-hosted | |---|---|---| | Who runs it | The textbee team | You | | Updates | Automatic for the API and dashboard | You pull, rebuild and redeploy | | Android app | The published APK from [textbee.dev/download](https://textbee.dev/download) | Your own build for your domain | | Data | On textbee servers | In your own database | | Support | Email support and Discord | Discord and GitHub issues | | Cost model | A plan with a monthly message allowance | Your server, database and Firebase costs | ## Frequently asked questions ### Do I need Redis? Yes. The API uses Redis at `REDIS_URL` for its background jobs, for example webhook deliveries. `USE_SMS_QUEUE` controls only the send queue. With `false`, each send goes straight to the phone, and a send with `scheduledAt` returns a 400 error. Set it to `true` for scheduled messages and for bulk sends released in waves at the phone's Send Delay. ### Why does sending return "Please verify your email to continue"? Sending needs a verified email address. Configure SMTP so the verification email arrives, then open the link. Google sign-in also marks the email as verified. ### Can I use the published app from textbee.dev/download? No. The published app talks only to textbee.dev. Build the app with your own domain and Firebase project. ### Can one phone connect to two instances? Not with one app. Each build of the app talks to the one API address it was built with. ### Do plan limits apply on my instance? Limits come from the plan documents in your database. A new instance has no plan documents, and then the API applies no message or device limits. ## Next steps - [Registering a device](/docs/getting-started/registering-a-device) - [API keys](/docs/getting-started/api-keys) - [Sending SMS](/docs/sending-sms/sending-sms) - [Keep the phone online](/docs/troubleshooting/keep-the-phone-online) - [textbee on GitHub](https://github.com/textbee/textbee) # Troubleshooting Source: https://textbee.dev/docs/troubleshooting Find the fix for common textbee problems: messages stuck in pending, failed sends, offline phones, missing incoming SMS, API errors and webhooks. Most textbee problems have one of three causes: the API rejected the request, Android paused the app on the phone, or the carrier did not accept the message. Find your symptom in the table below and go to the page it names. Each page lists the checks in order, with a fix for each one. ## Find your symptom | Symptom | Likely cause | Go to | |---|---|---| | A message stays in `pending` | The phone is asleep, a large batch is draining, or the message is scheduled | [Messages not sending: stuck in pending](/docs/troubleshooting/messages-not-sending#4-the-message-is-stuck-in-pending) | | A message has `status: "failed"` and an `errorCode` | The phone or the carrier rejected it | [Messages not sending: failed with an errorCode](/docs/troubleshooting/messages-not-sending#5-the-message-failed-with-an-errorcode) | | The API returns 400 | No enabled device, a disabled device, an unverified email, or the push to the phone failed | [Messages not sending: did the API accept it](/docs/troubleshooting/messages-not-sending#1-did-the-api-accept-the-request) | | The API returns 401 | The API key is missing, invalid or revoked | [Messages not sending: did the API accept it](/docs/troubleshooting/messages-not-sending#1-did-the-api-accept-the-request) | | The API returns 429 | A daily, monthly or per-batch plan limit is used up | [Messages not sending: did the API accept it](/docs/troubleshooting/messages-not-sending#1-did-the-api-accept-the-request) and [Pricing](/docs/pricing) | | The device shows offline, or `lastHeartbeat` is old | Android or the phone maker stops the app in the background | [Keep the phone online](/docs/troubleshooting/keep-the-phone-online) | | A message is `sent` but never `delivered`, or shows `delivery_failed` | The carrier sends no delivery report, the report says not delivered, or the recipient's phone is off | [Messages not sending: sent but not delivered](/docs/troubleshooting/messages-not-sending#6-the-message-is-sent-but-not-delivered) | | Incoming SMS do not appear | **Receive SMS** is off, the SMS permission is missing, or an SMS filter blocks them | [Receiving SMS](/docs/receiving-sms) and [SMS filters](/docs/receiving-sms/sms-filters) | | Incoming SMS arrive late | The phone was offline or asleep and uploaded them later | [Keep the phone online](/docs/troubleshooting/keep-the-phone-online) | | A webhook does not arrive | The URL is private or not reachable, the endpoint returns 4xx (not retried) or keeps failing, or the subscription is paused. `MESSAGE_RECEIVED` is not sent for a message uploaded more than 48 hours after it arrived. | [Webhooks](/docs/webhooks) | | Many messages fail with `errorCode` `"5"` | Android's own cap on outgoing SMS per app | [Messages not sending: Android's outgoing cap](/docs/troubleshooting/messages-not-sending#7-androids-own-outgoing-sms-cap) | ## Check the basics first These three calls answer most questions. Run them before you change anything. This call checks that your API key works: ```bash curl https://api.textbee.dev/api/v1/gateway/stats \ -H "x-api-key: YOUR_API_KEY" ``` This call lists your phones. Look at `enabled` and `lastHeartbeat` on each one: ```bash curl https://api.textbee.dev/api/v1/gateway/devices \ -H "x-api-key: YOUR_API_KEY" ``` This call shows every message from one send, with its `status` and `errorCode`: ```bash curl "https://api.textbee.dev/api/v1/gateway/messages?smsBatchId=YOUR_BATCH_ID" \ -H "x-api-key: YOUR_API_KEY" ``` On the phone, open the textbee app and go to **Settings** > **Device health**. The screen lists what can slow down or stop messages on this phone, and what to change. ## Collect evidence before you ask for help A support request with this information gets a faster answer: | Item | Where to find it | |---|---| | Device id | The `_id` from `GET /gateway/devices`, the **Device ID** row in the app settings, or the Devices panel in the dashboard | | `smsId` or `smsBatchId` | The send response, or the message from `GET /gateway/messages` | | Timestamps | `requestedAt`, `dispatchedAt`, `sentAt`, `failedAt` on the message, and the time you sent the request, with a timezone | | `status`, `errorCode` and `errorMessage` | The message from `GET /gateway/messages` | | API status code and response body | Your application log | | App version | The **App Version** row in the app settings, or `appVersionName` on the device | | Phone model and Android version | `brand`, `model` and `osVersion` on the device | | Device health | A screenshot of **Settings** > **Device health** in the app | Do not send your API key. Nobody at textbee needs it. ## Where to get help - **Discord**: ask the community at [textbee.dev/discord](https://textbee.dev/discord). - **Email**: write to support@textbee.dev with the evidence above. - **Status page**: check [status.textbee.dev](https://status.textbee.dev) for an outage before you debug your own setup. - **Self-hosted instance**: open an issue on [GitHub](https://github.com/textbee/textbee). See [Self-hosting textbee](/docs/self-hosting). ## Next steps - [Messages not sending](/docs/troubleshooting/messages-not-sending) - [Keep the phone online](/docs/troubleshooting/keep-the-phone-online) - [Delivery status](/docs/sending-sms/delivery-status) - [FAQ](/docs/faq) # Messages not sending Source: https://textbee.dev/docs/troubleshooting/messages-not-sending Diagnose an SMS that textbee does not send: API errors, offline devices, permissions, messages stuck in pending, failure codes and carrier delivery. A message that does not arrive stopped at one of four points: the API, the push to the phone, the phone, or the carrier. Work through the checks below in order. Each check says how to find the answer and what to change. The API returns 200 when it accepts a message. Acceptance is not delivery. The message `status` and its timestamps show how far the message got. See [Delivery status](/docs/sending-sms/delivery-status) for every state. ## 1. Did the API accept the request? ### How to check Look at the HTTP status code and the response body of your send request. This call confirms that your API key works: ```bash curl https://api.textbee.dev/api/v1/gateway/stats \ -H "x-api-key: YOUR_API_KEY" ``` ### Fix | Status | Meaning | Fix | |---|---|---| | 400 | `No enabled device found. Enable a device or pass a deviceId.` | Open the app on the phone and turn on **Gateway Enabled** in **Settings**. | | 400 | `Device does not exist or is not enabled` | The `deviceId` you passed points at a disabled device. Turn on **Gateway Enabled** in the app on that phone, or leave `deviceId` out. | | 400 | `Please verify your email to continue` | Open the verification email from textbee and click the link. Sending needs a verified email. | | 400 | `Failed to send SMS` | The push to the phone failed. See [check 8](#8-when-to-reconnect-the-device). | | 400 | `Invalid recipients` or `Message cannot be blank` | Send `recipients` as an array of E.164 numbers, for example `["+12015550123"]`, and a non-empty `message`. | | 400 | `scheduledAt must be a future date` | Send a future time in ISO 8601 with a timezone. See [Scheduled messages](/docs/sending-sms/scheduled-messages). | | 401 | The API key is missing, invalid or revoked | Send the `x-api-key` header. Create a new key in the dashboard if needed. See [API keys](/docs/getting-started/api-keys). | | 404 | `Device not found` | The `deviceId` is not on your account. List your devices with `GET /gateway/devices`. | | 429 | A plan limit is used up | The body says which one: the daily limit, the monthly limit (the current billing period) or the per-batch limit. Nothing was sent. Wait for the limit to reset, split the batch, or upgrade. See [Pricing](/docs/pricing). | A 429 on device registration or when you enable a device has a different text: `Active device limit reached: your plan allows up to N active device(s)...`. Turn off **Gateway Enabled** in the app on another phone, delete an old device in the dashboard Devices panel, or upgrade your plan. **Disconnect Device** in the app only clears the credentials on the phone. The device stays on your account and still counts while it is enabled. Do not retry a 429 in a loop. A retry does not reset the limit. ## 2. Is the device enabled and online? ### How to check List your devices: ```bash curl https://api.textbee.dev/api/v1/gateway/devices \ -H "x-api-key: YOUR_API_KEY" ``` For the device that sends, check two fields: | Field | Good value | |---|---| | `enabled` | `true` | | `lastHeartbeat` | Within the last `heartbeatIntervalMinutes` (30 minutes by default) | A device with an old `lastHeartbeat` is probably offline. The API still accepts sends for it, and the messages wait until the phone comes back. ### Fix 1. On the phone, open the textbee app and check that **Gateway Enabled** is on in **Settings**. 2. Check the phone's internet connection. 3. Go to **Settings** > **Device health** and tap **Send heartbeat**. The row should change to a recent time. 4. If the heartbeat keeps going stale, Android is stopping the app in the background. Follow [Keep the phone online](/docs/troubleshooting/keep-the-phone-online). When you omit `deviceId`, textbee sends from your default device, or else from the enabled device with the most recent heartbeat. Check which device that is on [Devices](/docs/devices). ## 3. Does the app have the SMS permission? ### How to check Open **Settings** > **Device health** in the app. The **SMS permissions** row shows `Granted`, or it lists the missing permissions. The dashboard also shows a red notice, Your phone cannot send SMS, as soon as the app reports the permission as off or a message fails for it. ### Fix Tap **Grant** on the row and allow the permissions. Sending needs the Send SMS permission. The Receive SMS permission is only for forwarding incoming SMS, but the sticky notification cannot start without it. Grant all three permissions that the row lists: Send SMS, Receive SMS and Phone state. If Android does not show the permission prompt again, allow SMS in the phone's app settings. On Android 15 and newer, follow the steps below first. #### Android 15 and newer: allow restricted settings Android blocks the SMS permission for apps installed from an APK file until you allow restricted settings. 1. Open **Settings** > **Apps** > **textbee**. You can also long-press the app icon and tap **App info**. 2. Tap the menu (⋮) in the top right and choose **Allow restricted settings**. Confirm with your PIN or fingerprint. 3. Tap **Permissions** > **SMS** > **Allow**. 4. Send a test message. If the menu has no **Allow restricted settings** entry, the permission is not restricted. Open **Permissions** and allow SMS directly. See the [SMS permission guide for Android 15 and newer](/blog/android-15-send-sms-permission-guide). ## 4. The message is stuck in pending `pending` means textbee has the message and the phone has not reported a send yet. ### How to check Read the message and look at its timestamps: ```bash curl "https://api.textbee.dev/api/v1/gateway/messages?smsBatchId=YOUR_BATCH_ID" \ -H "x-api-key: YOUR_API_KEY" ``` | What you see | Meaning | |---|---| | `dispatchDueAt` is in the future | The message waits on the server. It is scheduled, or it is part of a large batch released in waves. | | `dispatchedAt` is set, `pushReceivedAt` is empty | The push left textbee, but the phone did not get it. The phone is asleep or offline. | | `pushReceivedAt` is set, `sendAttemptedAt` is empty | The phone has the message and has not sent it yet. It works through a batch with the Send Delay between messages. | ### Fix - **Phone asleep or offline**: wake the phone, check its connection and follow [Keep the phone online](/docs/troubleshooting/keep-the-phone-online). The messages send when the phone comes back. - **Large batch**: wait. The phone sends one message at a time, with the **Send Delay** from the app settings between messages. At 5 seconds, 500 recipients take about 41 minutes. The send response has `estimatedCompletionAt` for large batches. - **Scheduled message**: the message sends at its `scheduledAt` time. There is no cancel endpoint. See [Scheduled messages](/docs/sending-sms/scheduled-messages). ## 5. The message failed with an errorCode ### How to check A failed message has `status: "failed"`, a `failedAt` time, an `errorCode` and an `errorMessage`. On a failure from the phone, `errorCode` is Android's numeric result code as a string. One code is not numeric: `PERMISSION_DENIED`, which the app reports when it lacks the SMS permission. The `errorMessage` explains it in words. ### Fix | `errorCode` | Android constant | `errorMessage` | What to do | |---|---|---|---| | `"PERMISSION_DENIED"` | None, reported by the textbee app | "SMS permission not granted" | Allow the SMS permission on the phone. See [check 3](#3-does-the-app-have-the-sms-permission). Then send the message again. | | `"1"` | `RESULT_ERROR_GENERIC_FAILURE` | "SMS failed on device. Common causes: no SMS credit on SIM, weak signal, or carrier blocked. Check SIM balance and signal, then try again." It can end with a radio code in parentheses. | Check the SIM balance and signal. Send one test from the phone's own SMS app. Then retry. | | `"2"` | `RESULT_ERROR_RADIO_OFF` | "Mobile radio is off (e.g. airplane mode). Turn off airplane mode and ensure cellular is on." | Turn off airplane mode and turn on mobile service. | | `"3"` | `RESULT_ERROR_NULL_PDU` | "Message could not be sent; invalid format or carrier issue. Try a shorter message or different recipient." | Check for an empty body or unusual characters. Try a shorter message. | | `"4"` | `RESULT_ERROR_NO_SERVICE` | "No cellular service. Check signal and try again when you have coverage." | Move the phone to a place with coverage, or check the SIM. | | `"5"` | `RESULT_ERROR_LIMIT_EXCEEDED` | "Device/carrier send limit reached (too many SMS in a short time). Wait a few minutes or lower the send rate." | See [check 7](#7-androids-own-outgoing-sms-cap). | | `"7"` | `RESULT_ERROR_SHORT_CODE_NOT_ALLOWED` | "Short code not allowed on this carrier. Use a full phone number." | Send to a full phone number. | | `"8"` | `RESULT_ERROR_SHORT_CODE_NEVER_ALLOWED` | "Short codes are not supported on this carrier. Use a full phone number." | Send to a full phone number. | | `"17"` | `RESULT_NETWORK_ERROR` | "Network error while sending. Check signal and try again." | Check the signal and retry. | An `errorCode` that starts with `FCM_` means the push to the phone failed, so the phone never got the message. See [check 8](#8-when-to-reconnect-the-device). Retry a failed message with a new send request. Check first that the recipient did not get it, because each retry is a new message against your plan. ## 6. The message is sent but not delivered `sent` means the carrier accepted the message. `delivered` means the carrier sent back a delivery report. ### How to check Look for `sentAt` and `deliveredAt` on the message. A message with `sentAt` and no `deliveredAt` left the phone. ### Fix - **No delivery report**: many carriers do not send delivery reports. A message can stay `sent` forever and still arrive. Ask the recipient. - **Status `delivery_failed`**: the carrier's delivery report said the message was not delivered, or the report was canceled because the carrier does not support delivery receipts. The message can still have arrived in the second case. This status fires the `UNKNOWN_STATE` webhook event. - **Wrong number**: check the number in E.164 format with the country code, for example `+12015550123`. - **Recipient phone off or out of coverage**: the carrier holds the message for a time and delivers it when the phone comes back. - **Carrier filtering**: a carrier can drop messages that look like spam. Send fewer identical messages, and increase the Send Delay. ## 7. Android's own outgoing SMS cap Android limits how many SMS one app can send in a short time. When the phone hits the cap, messages fail with `errorCode` `"5"` (`RESULT_ERROR_LIMIT_EXCEEDED`). The cap is set by Android and the phone maker, not by textbee or your plan. ### How to check Filter your sent messages by status and look for many `"5"` codes close together: ```bash curl "https://api.textbee.dev/api/v1/gateway/messages?direction=sent&status=failed&limit=100" \ -H "x-api-key: YOUR_API_KEY" ``` ### Fix 1. Increase the **Send Delay** in the app settings. A longer gap keeps the phone under the cap. 2. Split large batches and send them over a longer time. 3. Spread the load over more than one phone. See [Devices](/docs/devices). 4. Send the failed messages again after a few minutes. ## 8. When to reconnect the device Reconnect the device when a push to the phone fails and the checks above do not help: - The send returns 400 `Failed to send SMS`. - Messages fail with an `errorCode` that starts with `FCM_`, for example `FCM_TOKEN_NOT_REGISTERED`. - `lastHeartbeat` does not update after you tap **Send heartbeat**, and the phone has internet. - You reinstalled the app, cleared its data or restored the phone. Open the app and register it again with the same device id. The app sends a new push token to textbee. See [Reconnecting an old device](/docs/getting-started/reconnecting-an-old-device). ## Frequently asked questions ### Why does the API return 200 when the phone is offline? The API accepts the message and stores it. The phone gets it when it comes back online. Check `lastHeartbeat` before a time-critical send. ### Does textbee retry a failed message? No. A message with `status: "failed"` stays failed. Send a new request to try again. ### Can I send faster than the Send Delay? You can set the Send Delay to 0 in the app. The app warns: "No gap between messages. Carriers may block a phone that sends too fast." A short delay also makes Android's outgoing cap more likely. ### Why do some messages in one batch fail and others send? Each recipient is a separate message. Signal, the carrier and Android's cap act on each one. The batch shows `partial_success` when some are sent and the others fail. Its `failureCount` shows how many failed. ## Next steps - [Keep the phone online](/docs/troubleshooting/keep-the-phone-online) - [Delivery status](/docs/sending-sms/delivery-status) - [Sending bulk SMS](/docs/sending-sms/sending-bulk-sms) - [Troubleshooting](/docs/troubleshooting) # Keep the phone online Source: https://textbee.dev/docs/troubleshooting/keep-the-phone-online Stop Android from pausing the textbee app: the Device health screen, battery settings for each phone maker, physical setup and the device heartbeat. Android pauses apps in the background to save battery, and many phone makers add stricter rules on top. When Android pauses textbee, messages wait in both directions: sends stay `pending`, and incoming SMS upload late. The fix is a few settings on the phone, which the app's **Device health** screen checks for you. ## What a paused app does to your messages | Direction | What happens while the app is paused | |---|---| | Sending | The push with the message waits. The message stays `pending` until the phone wakes, sometimes for minutes or hours. | | Receiving | The SMS arrives on the phone, but the upload waits. Your webhook and message history get it late. | | Heartbeat | The device stops checking in. `lastHeartbeat` gets old, and the dashboard shows the device as offline. | Nothing is lost in a short pause. The app catches up when it runs again. A phone that is paused for many hours makes textbee slow and hard to trust. ## Check the Device health screen Open the textbee app and go to **Settings** > **Device health**. The screen shows "What can slow down or stop messages on this phone, in either direction, and what to change." Each row has a status and, where it helps, a button that fixes it. | Row | Good value | If it is not good | |---|---|---| | SMS permissions | `Granted` | Tap **Grant**. The row lists what is missing: Send SMS, Receive SMS or Phone state. "The gateway cannot work without them." | | Notifications | `Allowed` | Tap **Allow**. "Not allowed. textbee cannot show that it is running, and Android stops it sooner in the background." Only Android 13 and later ask for this. | | Battery usage | `Unrestricted` | Tap **Open settings** and set textbee to Unrestricted. "Android may pause textbee in the background and hold messages for minutes or hours." | | Sticky notification | "On. A permanent notification tells Android to keep textbee running." | Tap **Turn on**. The sticky notification cannot start until the Receive SMS permission is granted. | | Power saving right now | `Off` | This is the current state, not a setting. "Messages may wait until the phone wakes." Turn off battery saver and plug the phone in. | | Send delay | A gap of a few seconds | Information only. The row shows how many messages a minute the phone sends and how long 500 recipients take. | | Heartbeat | A time under 45 minutes ago | Tap **Send heartbeat**. See [The heartbeat](#the-heartbeat) below. | | Tips for your phone brand | Shown on some brands | Follow the steps in the row, or the steps for your brand below. Tap **Open app settings** to start. | The **Device health** entry in **Settings** shows `All checks pass` when nothing needs a change. ## Settings for each phone maker Set battery usage to Unrestricted on every phone. Many brands also have their own app manager that stops apps. The app shows the steps for your brand on the Device health screen. They are the same steps as below. ### Stock Android and Pixel The app shows no brand tips for these phones. The Battery usage row is enough. 1. On the Device health screen, tap **Open settings** on the **Battery usage** row. 2. Set textbee to Unrestricted. On Android 12 and later, this is also at Settings > Apps > textbee > App battery usage > Unrestricted. 3. Turn on the **Sticky Notification** in the textbee settings. ### Samsung Settings > Battery and device care > Battery > Background usage limits: add textbee to Never sleeping apps, and make sure Put unused apps to sleep does not include it. ### Xiaomi (also Redmi and POCO) Settings > Apps > Manage apps > textbee: turn on Autostart, and under Battery saver choose No restrictions. Then in Security > Boost speed > Lock apps, lock textbee. ### Huawei (also Honor) Settings > Battery > App launch > textbee: turn off automatic management and allow Auto-launch, Secondary launch and Run in background. ### OPPO (also Realme) Settings > Battery > App battery management > textbee: allow background activity. Also enable Auto-launch under App management. ### vivo (also iQOO) Settings > Battery > Background power consumption management: allow textbee. Then in iManager > App manager > Autostart, enable textbee. ### OnePlus Settings > Battery > Battery optimization > textbee: Don't optimize. Also turn off Advanced optimization and Sleep standby optimization. ### Tecno and Infinix (also itel) Settings > Battery > App power management: allow textbee. In Phone Master > Auto-start management, enable textbee. Menu names change between Android versions and phone models. If a menu is missing, search the phone's settings for the last item in the path, for example "Autostart". ## Physical setup - **Keep the phone plugged in.** A gateway phone runs all day. A charger also keeps most phones out of power saving. - **Use Wi-Fi for data.** Wi-Fi keeps the connection to textbee stable. The SIM still needs mobile service to send and receive SMS. - **Turn off battery saver.** Battery saver and idle mode hold messages until the phone wakes. - **The screen can be off.** textbee works with the screen off and the phone locked, once the settings above are right. - **Do not force-stop the app.** Do not swipe textbee away with a cleaner app or a "clear all" button that kills apps. - **Keep the SIM active.** Check that the plan has SMS credit and that the SIM does not expire. ## The heartbeat The app sends a heartbeat to textbee at a fixed interval. The device field `heartbeatIntervalMinutes` sets the interval. It is 30 minutes by default. `lastHeartbeat` is the time of the last heartbeat that arrived. This call shows `lastHeartbeat` for each phone: ```bash curl https://api.textbee.dev/api/v1/gateway/devices \ -H "x-api-key: YOUR_API_KEY" ``` The Device health screen rates the last heartbeat: | Age of the last heartbeat | What the app shows | |---|---| | Under 45 minutes | The time, in green | | 45 minutes to 3 hours | "Expected every 30 minutes." | | Over 3 hours | "Android or the phone maker is probably stopping the app in the background." | | Never | "Never. Send one to check the connection." | | Dated in the future | The phone clock changed. The next heartbeat clears this. | Tap **Send heartbeat** to send one now. If the time updates, the phone can reach textbee. If it fails, check the internet connection. If the heartbeat works but goes stale again, go back to the battery settings for your brand. The device record also has `batteryInfo`, `networkInfo` and `powerInfo` from the last heartbeat. `powerInfo` shows the Android power state. It explains a device that is online but slow to send. See [Devices](/docs/devices) for every field. ## Use a dedicated phone For a gateway that runs all day, use a phone that does nothing else: - Nobody turns on battery saver or closes apps on it. - Your own calls and messages do not compete with the gateway. - You can leave it plugged in and on Wi-Fi in one place. - A problem with the gateway does not affect your personal phone. An older Android phone with Android 7.0 or later and a SIM with an SMS plan is enough. For more volume or redundancy, add a second phone. See [Devices](/docs/devices). ## Frequently asked questions ### Does the sticky notification drain the battery? The notification itself uses very little power. It tells Android that textbee is running, so Android stops it less often. ### Will I lose messages when the phone is offline? Sends wait and go out when the phone is back. Incoming SMS stay on the phone and upload later. Webhooks for those messages arrive late. ### The Device health screen shows no tips for my brand. What do I do? Set battery usage to Unrestricted and turn on the sticky notification. Then look in the phone settings for an app manager with options like "Autostart" or "Background activity", and allow textbee there. ### Do I need to open the app after a restart? Open it once after a restart and check that the sticky notification is back. Then tap **Send heartbeat** on the Device health screen. ## Next steps - [Messages not sending](/docs/troubleshooting/messages-not-sending) - [Devices](/docs/devices) - [Receiving SMS](/docs/receiving-sms) - [Troubleshooting](/docs/troubleshooting) # Pricing Source: https://textbee.dev/docs/pricing Learn about textbee.dev pricing plans, features, and message limits for Free, Pro, Scale, and Custom plans. textbee.dev offers four plans, from a free tier for individual developers to custom contracts for large enterprises. Every paid plan is a flat monthly price with messages included; there is no per-message fee. ## Pricing Plans ### Free Plan Perfect for getting started and testing textbee.dev. **Price**: $0/month **Features**: - Send and receive SMS messages - Register 1 active device - Max 50 messages per day - Up to 300 messages per month - Webhook notifications - Basic support **Best for**: Testing, personal projects, low-volume applications ### Pro Plan Ideal for businesses and serious users who need more capacity. **Price**: $14.99/month (monthly) or $99.99/year, which is $8.33/month (save 44%) **Features**: - Everything in Free plan - Register up to 5 active devices - Unlimited daily messages - Up to 5,000 messages per month - Message templates with variables - Scheduled messages - Advanced analytics - Priority support **Best for**: Small businesses, startups, moderate-volume applications ### Scale Plan For teams that have outgrown Pro and want more volume and more devices on one flat price. **Price**: $29.99/month (monthly) or $199.99/year, which is $16.67/month (save 44%) **Features**: - Everything in Pro plan - Register up to 15 active devices - Unlimited daily messages - Up to 25,000 messages per month - Message templates with variables - Priority support **Best for**: Growing businesses, higher-volume transactional sending, multi-device setups ### Custom Plan Tailored solutions for high-volume needs and specific requirements. **Price**: Contact sales for custom pricing **Features**: - Everything in Scale plan - Unlimited devices - Custom message volume limits - White-labeled solution - Custom integrations - SLA with uptime guarantees - Dedicated account manager - Volume discounts **Best for**: Enterprises, high-volume applications, custom requirements ## Message Limits Explained ### Daily Limits - **Free**: 50 messages per day (resets once a day) - **Pro/Scale/Custom**: Unlimited daily messages ### Monthly Limits - **Free**: 300 messages per month - **Pro**: 5,000 messages per month - **Scale**: 25,000 messages per month - **Custom**: Custom limits based on your needs The monthly limit counts the messages of the current billing period. On the Free plan, the period starts on the day you signed up. On a paid plan, it starts on the day the plan started. The count resets on that day every month, at the same time of day (UTC). If the month has no such day (the 29th to the 31st), the reset happens on the last day of that month. When you upgrade from the Free plan, a new period starts and the full new allowance is available at once. A change between paid plans keeps the same reset day, and the new limit applies at once. The billing page and the usage cards in the dashboard show the exact reset date. Sent and received messages both count toward the daily and monthly limits. ### Per-Batch Limits Each plan also limits the number of recipients in one bulk send. Your bulk send limit is shown on the billing page in your dashboard. A batch over the limit is rejected as a whole, and nothing is sent. ### What Happens When You Exceed Limits? - You get an email when you approach your limit, and another when you reach it. - The API rejects new sends with HTTP 429. The response body says which limit you reached: daily, monthly or per batch. - Rejected messages are not queued. Send them again after the limit resets, or upgrade your plan. - Received messages are still stored while you are over the limit. They carry `overLimit: true`. To tell a limit error from other send problems, see [Messages not sending](/docs/troubleshooting/messages-not-sending). ## Device Limits - **Free Plan**: 1 active device - **Pro Plan**: Up to 5 active devices - **Scale Plan**: Up to 15 active devices - **Custom Plan**: Unlimited devices Multiple devices help with: - **Throughput**: Send more messages simultaneously - **Redundancy**: If one device goes offline, you can send from another - **Load distribution**: Distribute message load across devices ## API Rate Limits API requests are rate-limited to protect the service. Sending a very large burst of requests in a short time can return a 429 Too Many Requests response; wait briefly and retry. For normal integrations the limits that matter are your plan's message quotas. ## Upgrading Your Plan ### To Pro or Scale 1. Go to your [dashboard](https://app.textbee.dev/dashboard) 2. Navigate to **"Billing"** or **"Plans"** 3. Click **"Upgrade"** on the plan you want 4. Complete the checkout process 5. Your plan will be upgraded immediately Moving from Pro to Scale works the same way; the new limits apply as soon as checkout completes. ### From Scale to Custom 1. Contact sales at sales@textbee.dev 2. Discuss your requirements 3. Receive a custom quote 4. Complete the upgrade process Custom plans cover higher limits, SLAs, and white-labeling. If what you actually need is engineering help (an integration into your product, a self-hosted deployment, or a bespoke automation), see [Services](/services): the team that builds textbee takes on a limited number of client projects. ## Billing ### Payment Methods - Credit card (Visa, Mastercard, American Express, UnionPay, JCB, Discover) - CashApp ### Billing Cycles - **Monthly**: Billed every month on your signup date - **Yearly**: Billed once per year (save 44% vs monthly) ### Cancellation - Cancel anytime from your dashboard - No cancellation fees - Access to paid features until the end of your billing period - 7-day money-back guarantee for new Pro and Scale subscriptions ## Refunds We offer a 7-day money-back guarantee for new Pro and Scale subscriptions. Contact support@textbee.dev for refund requests. ## Enterprise Features (Custom Plan) ### White-labeling Remove textbee.dev branding and use your own branding throughout the platform. ### Custom Integrations Work with our team to create custom integrations for your specific needs. ### SLA Guarantees - 99.9% uptime guarantee - Response time guarantees - Support SLA ### Dedicated Account Manager Get a dedicated account manager to help with: - Onboarding and setup - Technical support - Best practices - Custom requirements ## Frequently Asked Questions ### Can I change plans anytime? Yes, you can upgrade or downgrade your plan at any time from your dashboard. ### What happens to my messages if I downgrade? If you downgrade and exceed your new plan's limits, the API rejects new sends with HTTP 429 until usage falls under the new limits, or until you upgrade again. ### Do unused messages roll over? No. The daily limit resets once a day. The monthly limit resets once a month, on the day your billing period started. The dashboard shows the exact reset date. ### Can I get a refund? Yes, we offer a 7-day money-back guarantee for new Pro and Scale subscriptions. ### Are there any setup fees? No, there are no setup fees for any plan. ## Next steps - **[Messages not sending](/docs/troubleshooting/messages-not-sending)** - Tell a plan limit error from other send problems - **[Get started](/docs/getting-started/setting-up-textbee)** - Create your free account - **[Overview](/docs)** - See what's included - **[Contact sales](mailto:sales@textbee.dev)** - Discuss custom plan options - **[Services](/services)** - Custom integration and self-hosting help from the textbee team Have questions? Check our [FAQ](/docs/faq) or contact support. # FAQ Source: https://textbee.dev/docs/faq Answers to common questions about textbee.dev: setup, sending and receiving SMS, devices, webhooks, plans and limits, self-hosting and troubleshooting. This page answers the most common questions about textbee. Each answer is short and links to the page with the full detail. If your question is about a message that does not send or a phone that goes offline, start at [Troubleshooting](/docs/troubleshooting). ## General Questions ### What is textbee.dev? textbee.dev is an open-source SMS gateway. It turns your Android phone into an SMS API. Your application calls the REST API, and the phone sends the message with its own SIM and your existing mobile plan. See the [overview](/docs) for how the parts fit together. ### How does textbee.dev work? 1. Install the textbee.dev Android app on your device. 2. Register your device with your textbee.dev account. 3. Use the REST API to send SMS messages through your device. 4. Receive SMS messages sent to your device's phone number. ### Is textbee.dev free? Yes. The Free plan has no cost and needs no credit card: - 1 active device - 50 messages per day - 300 messages per month - Basic features See [Pricing](/docs/pricing) for the paid plans. ### What devices are supported? textbee.dev works with Android devices running Android 7.0 (Nougat) or higher. iOS is not supported. ### Do I need a special mobile plan? No. textbee uses your plan's SMS allowance, so there are no per-message fees from a third-party provider. Your carrier's normal SMS rules and fair-use terms still apply. ### Does textbee support MMS? No. textbee sends and receives SMS only. It does not send or receive MMS (pictures, video or group messages). ## Setup & Installation ### How do I get started? 1. Create an account at [textbee.dev](https://app.textbee.dev/dashboard). 2. Download and install the Android app from [textbee.dev/download](https://textbee.dev/download). 3. Register your device. 4. Start sending SMS via the API. See the [getting started guide](/docs/getting-started/setting-up-textbee) for detailed instructions. ### How do I register my device? You can register your device with: - **QR code** (recommended): scan a QR code from your dashboard. - **API key**: paste an API key in the app. See the [device registration guide](/docs/getting-started/registering-a-device) for details. ### Can I use multiple devices? Yes. The number of active devices depends on your plan: - **Free**: 1 device - **Pro**: up to 5 devices - **Scale**: up to 15 devices - **Custom**: unlimited devices When you send without a `deviceId`, textbee uses your default device, or else the enabled device with the most recent heartbeat. See [Devices](/docs/devices) to list your phones and set a default. ### What permissions does the app need? The app asks for: - **SMS permissions**: Send SMS to send, Receive SMS to forward incoming messages. - **Phone state**: to read the SIM cards in the phone. - **Notifications** (Android 13 and higher): to show that the gateway is running. - **Internet**: to talk to the textbee API. On Android 15 and newer, allow restricted settings first (App info > menu > Allow restricted settings), or the SMS switch stays locked. See [Messages not sending](/docs/troubleshooting/messages-not-sending#3-does-the-app-have-the-sms-permission). Android can also pause the app in the background. Set battery usage to Unrestricted. The app's **Device health** screen checks each item. See [Keep the phone online](/docs/troubleshooting/keep-the-phone-online). ### Where do I find my device ID? Open the app and go to **Settings**. The **Device ID** row shows it. You can also copy it from the **Devices** panel in the [dashboard](https://app.textbee.dev/dashboard), or read the `_id` field from `GET /gateway/devices`. Most calls do not need it. See [Devices](/docs/devices). ### Can I self-host textbee? Yes. The API, the dashboard and the Android app are open source. You run them on your own server with MongoDB, Redis and your own Firebase project, and you build the app for your domain. See [Self-hosting textbee](/docs/self-hosting). ## Usage & Features ### How do I send SMS messages? Call `POST /gateway/send-sms` with your API key. See the [sending SMS guide](/docs/sending-sms/sending-sms) for code examples. ### Can I send bulk SMS? Yes. You can send messages to many recipients at once. See the [bulk SMS guide](/docs/sending-sms/sending-bulk-sms) for details. Your phone sends the messages one at a time, with the **Send Delay** from the app between each one. A large batch arrives steadily, not all at once. Your bulk send limit is shown on the billing page in your dashboard. ### Can I choose which SIM sends on a dual-SIM phone? Yes. Pass `simSubscriptionId` in the send request. The value comes from the device's `simInfo`, or from the **SIM Cards** list in the app's dashboard tab. Without it, the phone uses the **Default SIM** from the app settings. The ID can change when a SIM is removed and reinserted or swapped, so check it in the app after any SIM change. See [Choosing a SIM](/docs/sending-sms/choosing-a-sim). ### How do I receive SMS messages? 1. Turn on **Receive SMS** in the app settings. 2. Get messages by webhook, or read them from `GET /gateway/messages`. See the [receiving SMS guide](/docs/receiving-sms) and [message history](/docs/receiving-sms/message-history). ### Can I filter which incoming SMS textbee forwards? Yes. In the app, open **Settings** > **Configure Filters**. An allow list forwards only messages that match a rule. A block list drops messages that match a rule. Filters run on the phone, so a blocked message never reaches textbee. See [SMS filters](/docs/receiving-sms/sms-filters). ### What are webhooks? A webhook is a URL on your server. textbee sends an HTTP POST to it when an SMS event happens (message received, sent, delivered or failed). See the [webhooks guide](/docs/webhooks) for setup, and [webhook events](/docs/webhooks/events) for each payload. ### Does textbee retry a failed webhook? Yes, for some failures. A 5xx response, a timeout or a network error gets up to 10 attempts. Retries stop 30 days after the event. A 4xx response is not retried: the delivery fails after one attempt. Every retry of one delivery carries the same `idempotencyKey`, so your handler can skip duplicates. A subscription that keeps failing is paused, and you get an email. See [Webhooks](/docs/webhooks). ### Can I schedule messages? Yes. Pass a `scheduledAt` timestamp (ISO 8601 with a timezone, up to 72 hours ahead) when you send via the API or the SDK. The time must be in the future. There is no cancel endpoint, so check the time before you send. See [Scheduled messages](/docs/sending-sms/scheduled-messages). ### Do you support message templates? Bulk sends from the dashboard support personalization with `{{ variable }}` templates. Upload a CSV and reference its columns in your message. Reusable saved templates are coming soon. ### What does the delivery status of a message mean? Each sent message has a `status`. `pending` means textbee has the message and the phone has not sent it yet. `sent` means the carrier accepted it. `delivered` means the carrier sent back a delivery report. `failed` means the phone or the carrier rejected it, with a numeric `errorCode` and an `errorMessage` in words. Many carriers do not send delivery reports, so a message can stay `sent` forever. See [Delivery status](/docs/sending-sms/delivery-status). ### Why is my message stuck in "pending"? `pending` means the phone has not sent the message yet. The usual causes are: - The phone is asleep or Android paused the app. - The phone is working through a large batch with a Send Delay between messages. - The message is scheduled, and its `dispatchDueAt` is in the future. See [Messages not sending](/docs/troubleshooting/messages-not-sending). ## Pricing & Limits ### What are the message limits? **Free plan**: - 50 messages per day - 300 messages per month **Pro plan**: - Unlimited daily messages - 5,000 messages per month **Scale plan**: - Unlimited daily messages - 25,000 messages per month **Custom plan**: - Custom limits based on your needs Sent and received messages both count toward the limits. See [Pricing](/docs/pricing). ### What happens if I exceed my limits? The API rejects the send with HTTP 429. The response body says which limit you reached: daily, monthly or per batch. Nothing is sent and nothing is queued. You also get an email. Upgrade your plan or wait for the limit to reset. See [Messages not sending](/docs/troubleshooting/messages-not-sending). ### Can I upgrade or downgrade my plan? Yes. You can change your plan at any time from your dashboard. ### Do unused messages roll over? No. The daily limit resets once a day. The monthly limit resets once a month, on the day your billing period started (the signup day on the Free plan, the plan start day on a paid plan). The dashboard shows the exact reset date. ### Is there a free trial? The Free plan is always free, with no credit card required. ### What's your refund policy? New Pro and Scale subscriptions have a 7-day money-back guarantee. Contact support@textbee.dev. ## Technical Questions ### What programming languages are supported? textbee.dev works with any programming language that can make HTTP requests. The docs have examples for: - JavaScript and Node.js (also the [JavaScript SDK](/docs/sdk/javascript)) - Python - PHP - Go - cURL ### What's the API rate limit? The limits that matter for most integrations are your plan's daily, monthly and per-batch message limits. A very large burst of API requests in a short time can also return 429 Too Many Requests. Wait briefly and retry with backoff. See [Pricing](/docs/pricing). ### Do you have an API documentation? Yes. The [API reference](/docs/api-reference) lists every endpoint. The same spec is at [textbee.dev/openapi.json](https://textbee.dev/openapi.json). ### How do I handle errors? The API returns standard HTTP status codes: - **200**: the API accepted the request. Acceptance is not delivery. - **400**: bad request. Invalid parameters, no enabled device, a disabled device, an unverified email, or a failed push to the phone. - **401**: the API key is missing, invalid or revoked. - **404**: the device or message does not exist on your account. - **429**: a plan limit is used up, or too many requests in a short time. - **500**: server error. Retry later and check the [status page](https://status.textbee.dev). A 200 does not mean the phone sent the message. Check the message status afterwards. See [Messages not sending](/docs/troubleshooting/messages-not-sending) and [Delivery status](/docs/sending-sms/delivery-status). ### Can I test the API? Yes. Use the Free plan to test the API with up to 300 messages per month. `GET /gateway/stats` is a cheap call to check that your API key works. ## Troubleshooting ### My device shows as disconnected Try these steps: 1. Check the phone's internet connection. 2. Open the app, go to **Settings** > **Device health** and tap **Send heartbeat**. 3. Set battery usage for textbee to Unrestricted and turn on the sticky notification. 4. Check the app permissions. 5. If nothing helps, reconnect the device. See [Keep the phone online](/docs/troubleshooting/keep-the-phone-online) and the [reconnecting device guide](/docs/getting-started/reconnecting-an-old-device). ### Messages aren't sending 1. Check that your device is enabled and online. 2. Verify your API key is correct. 3. Check that you haven't exceeded your message limits. 4. Verify phone numbers are in E.164 format (for example +12015550123). 5. Check the API response and the message `errorCode`. See [Messages not sending](/docs/troubleshooting/messages-not-sending) for the full diagnosis. ### I'm not receiving SMS messages 1. Make sure **Receive SMS** is turned on in the app. 2. Check that your device is connected. 3. Check your SMS filters in the app. 4. Verify the phone number is correct. 5. Check your mobile plan's SMS capabilities. See [Receiving SMS](/docs/receiving-sms) and [SMS filters](/docs/receiving-sms/sms-filters). ### The app won't install 1. Allow installation from unknown sources for your browser or file manager. 2. Check that you have enough storage space. 3. Download the APK again from [textbee.dev/download](https://textbee.dev/download). 4. Make sure your device runs Android 7.0 or higher. ### API requests are failing 1. Verify your API key is correct. 2. Use the account-level endpoints from the [API reference](/docs/api-reference). They do not need a device ID. 3. Check that the base URL is `https://api.textbee.dev/api/v1`. 4. Check your plan limits. A 429 body says which limit you reached. 5. Verify the request format: JSON body, `Content-Type: application/json` and the `x-api-key` header. ## Security & Privacy ### Is my data secure? All API traffic uses HTTPS. Your messages go out through your own Android device and SIM, with no third-party SMS provider in between. Treat your API key like a password: it has full access to your account. ### Where are my messages stored? textbee stores your messages on its servers, so you can read them in the dashboard and through `GET /gateway/messages`. Deleting a device removes its messages from history. To keep all data on your own servers, [self-host textbee](/docs/self-hosting). ### Can I revoke API keys? Yes. You can revoke API keys at any time from your dashboard. See [API keys](/docs/getting-started/api-keys). ### Is textbee.dev open source? Yes. The textbee.dev code is available on [GitHub](https://github.com/textbee/textbee). ## Support ### How do I get help? - Read the [documentation](/docs) and [Troubleshooting](/docs/troubleshooting). - Ask the [community on Discord](https://textbee.dev/discord). - Contact support at support@textbee.dev. - Check the [status page](https://status.textbee.dev) for service updates. ### What's your response time? - **Free plan**: basic support (best effort) - **Pro and Scale plans**: priority support (faster response) - **Custom plan**: dedicated account manager ### Do you offer custom integrations? Yes. Custom integrations are available for Custom plan customers. The team behind textbee also takes on integration, self-hosting and automation projects. See [Services](/services) or contact sales@textbee.dev. ## Still have questions? Note: Can't find your answer? Ask in the [Discord community](https://textbee.dev/discord) or email support@textbee.dev. ## Next steps - [Troubleshooting](/docs/troubleshooting) - [Getting started](/docs/getting-started) - [API reference](/docs/api-reference) # API reference Base URL: https://api.textbee.dev. Every request carries the x-api-key header. ## GET /api/v1/gateway/stats - Get account totals Source: https://textbee.dev/docs/api-reference#get-api-v1-gateway-stats Messages sent, messages received, devices, and API keys across your whole account. Response 200: Account totals. Response 200 fields: - data (GatewayStatsDTO, required): Account totals. - data.totalSentSMSCount (number, required): Messages sent across all your devices. - data.totalReceivedSMSCount (number, required): Messages received across all your devices. - data.totalDeviceCount (number, required): Devices on your account. - data.totalApiKeyCount (number, required): API keys on your account. Errors: - 401: Missing, invalid, or revoked API key. Example: ```bash curl "https://api.textbee.dev/api/v1/gateway/stats" \ -H "x-api-key: YOUR_API_KEY" ``` ## GET /api/v1/gateway/devices - List your devices Source: https://textbee.dev/docs/api-reference#get-api-v1-gateway-devices Every phone paired with your account. The push token and hardware serial are never returned. Response 200: Your devices. Response 200 fields: - data (DeviceDTO[], required): Your devices. - data[]._id (string, required): Device id. Pass it as deviceId when sending. - data[].user (string, required): Owner account id. - data[].enabled (boolean, required): Whether the device may send and receive SMS. Sending to a disabled device fails. - data[].isDefault (boolean, required): Whether sends without a deviceId go out from this device. - data[].brand (string, required): Device brand. - data[].manufacturer (string, required): Device manufacturer. - data[].model (string, required): Device model. - data[].name (string, optional): Your own label for the device. - data[].buildId (string, required): Android build id. - data[].os (string, required): Operating system name. - data[].osVersion (string, required): Android release version. - data[].osApiLevel (number, optional): Android SDK level. - data[].osVersionSource (string, optional): How osVersion was determined. One of: reported, fingerprint, buildId. - data[].osBuildFingerprint (string, optional): Android build fingerprint. - data[].appVersionName (string, optional): textbee app version name. - data[].appVersionCode (number, optional): textbee app version code. - data[].sentSMSCount (number, required): Messages this device has sent. - data[].receivedSMSCount (number, required): Messages this device has received. - data[].heartbeatEnabled (boolean, required): Whether the device reports heartbeats. - data[].heartbeatIntervalMinutes (number, required): Minutes between heartbeats. - data[].receiveSMSEnabled (boolean, required): Whether incoming messages are forwarded to textbee. Required for received message history and webhooks. - data[].smsSendDelaySeconds (number, required): Seconds the device waits between messages in a batch. - data[].lastHeartbeat (string, optional): Last heartbeat. A device silent for long is likely offline and sends will queue. - data[].batteryInfo (BatteryInfoDTO, optional): Battery level at the last heartbeat. - data[].batteryInfo.percentage (number, optional): Battery level, 0 to 100. - data[].batteryInfo.isCharging (boolean, optional): Whether the device was charging at the last heartbeat. - data[].batteryInfo.lastUpdated (string, optional): When this reading was taken. - data[].networkInfo (NetworkInfoDTO, optional): Connection the device was on at the last heartbeat. - data[].networkInfo.networkType (string, optional): Connection the device last reported. One of: wifi, cellular, none. - data[].networkInfo.lastUpdated (string, optional): When this reading was taken. - data[].appVersionInfo (AppVersionInfoDTO, optional): textbee app version running on the device. - data[].appVersionInfo.versionName (string, optional): textbee app version name. - data[].appVersionInfo.versionCode (number, optional): textbee app version code. - data[].appVersionInfo.lastUpdated (string, optional): When this reading was taken. - data[].deviceUptimeInfo (DeviceUptimeInfoDTO, optional): How long the device has been up. - data[].deviceUptimeInfo.uptimeMillis (number, optional): Milliseconds since the device booted. - data[].deviceUptimeInfo.lastUpdated (string, optional): When this reading was taken. - data[].memoryInfo (MemoryInfoDTO, optional): Memory reported at the last heartbeat. - data[].memoryInfo.freeBytes (number, optional): Free memory in bytes. - data[].memoryInfo.totalBytes (number, optional): Total memory in bytes. - data[].memoryInfo.maxBytes (number, optional): Maximum memory the app may use, in bytes. - data[].memoryInfo.lastUpdated (string, optional): When this reading was taken. - data[].storageInfo (StorageInfoDTO, optional): Storage reported at the last heartbeat. - data[].storageInfo.availableBytes (number, optional): Free storage in bytes. - data[].storageInfo.totalBytes (number, optional): Total storage in bytes. - data[].storageInfo.lastUpdated (string, optional): When this reading was taken. - data[].systemInfo (DeviceSystemInfoDTO, optional): Timezone and locale of the device. - data[].systemInfo.timezone (string, optional): Device timezone. - data[].systemInfo.locale (string, optional): Device locale. - data[].systemInfo.lastUpdated (string, optional): When this reading was taken. - data[].powerInfo (PowerInfoDTO, optional): Android power state reported at the last heartbeat. Explains a device that is online but slow to send. - data[].powerInfo.isIgnoringBatteryOptimizations (boolean, optional): Whether the app is exempt from battery optimization. When false, Android may delay the app in the background. - data[].powerInfo.isDeviceIdleMode (boolean, optional): Whether the device was in Doze at the last heartbeat. - data[].powerInfo.isPowerSaveMode (boolean, optional): Whether battery saver was on at the last heartbeat. - data[].powerInfo.lastUpdated (string, optional): When this reading was taken. - data[].simInfo (SimInfoCollectionDTO, optional): SIMs installed in the device. - data[].simInfo.lastUpdated (string, required): When the device last reported its SIM list. - data[].simInfo.sims (SimInfoDTO[], required): SIMs currently installed in the device. - data[].createdAt (string, required): When the device was registered. - data[].updatedAt (string, required): When the device was last updated. Errors: - 401: Missing, invalid, or revoked API key. Example: ```bash curl "https://api.textbee.dev/api/v1/gateway/devices" \ -H "x-api-key: YOUR_API_KEY" ``` ## GET /api/v1/gateway/devices/{id} - Get a device Source: https://textbee.dev/docs/api-reference#get-api-v1-gateway-devices-id Full state of one device, including its last heartbeat, battery, and SIM list. Path parameters: - id (string, required): Device id, from GET /gateway/devices. Response 200: The device. Response 200 fields: - data (DeviceDTO, required): The device. - data._id (string, required): Device id. Pass it as deviceId when sending. - data.user (string, required): Owner account id. - data.enabled (boolean, required): Whether the device may send and receive SMS. Sending to a disabled device fails. - data.isDefault (boolean, required): Whether sends without a deviceId go out from this device. - data.brand (string, required): Device brand. - data.manufacturer (string, required): Device manufacturer. - data.model (string, required): Device model. - data.name (string, optional): Your own label for the device. - data.buildId (string, required): Android build id. - data.os (string, required): Operating system name. - data.osVersion (string, required): Android release version. - data.osApiLevel (number, optional): Android SDK level. - data.osVersionSource (string, optional): How osVersion was determined. One of: reported, fingerprint, buildId. - data.osBuildFingerprint (string, optional): Android build fingerprint. - data.appVersionName (string, optional): textbee app version name. - data.appVersionCode (number, optional): textbee app version code. - data.sentSMSCount (number, required): Messages this device has sent. - data.receivedSMSCount (number, required): Messages this device has received. - data.heartbeatEnabled (boolean, required): Whether the device reports heartbeats. - data.heartbeatIntervalMinutes (number, required): Minutes between heartbeats. - data.receiveSMSEnabled (boolean, required): Whether incoming messages are forwarded to textbee. Required for received message history and webhooks. - data.smsSendDelaySeconds (number, required): Seconds the device waits between messages in a batch. - data.lastHeartbeat (string, optional): Last heartbeat. A device silent for long is likely offline and sends will queue. - data.batteryInfo (BatteryInfoDTO, optional): Battery level at the last heartbeat. - data.batteryInfo.percentage (number, optional): Battery level, 0 to 100. - data.batteryInfo.isCharging (boolean, optional): Whether the device was charging at the last heartbeat. - data.batteryInfo.lastUpdated (string, optional): When this reading was taken. - data.networkInfo (NetworkInfoDTO, optional): Connection the device was on at the last heartbeat. - data.networkInfo.networkType (string, optional): Connection the device last reported. One of: wifi, cellular, none. - data.networkInfo.lastUpdated (string, optional): When this reading was taken. - data.appVersionInfo (AppVersionInfoDTO, optional): textbee app version running on the device. - data.appVersionInfo.versionName (string, optional): textbee app version name. - data.appVersionInfo.versionCode (number, optional): textbee app version code. - data.appVersionInfo.lastUpdated (string, optional): When this reading was taken. - data.deviceUptimeInfo (DeviceUptimeInfoDTO, optional): How long the device has been up. - data.deviceUptimeInfo.uptimeMillis (number, optional): Milliseconds since the device booted. - data.deviceUptimeInfo.lastUpdated (string, optional): When this reading was taken. - data.memoryInfo (MemoryInfoDTO, optional): Memory reported at the last heartbeat. - data.memoryInfo.freeBytes (number, optional): Free memory in bytes. - data.memoryInfo.totalBytes (number, optional): Total memory in bytes. - data.memoryInfo.maxBytes (number, optional): Maximum memory the app may use, in bytes. - data.memoryInfo.lastUpdated (string, optional): When this reading was taken. - data.storageInfo (StorageInfoDTO, optional): Storage reported at the last heartbeat. - data.storageInfo.availableBytes (number, optional): Free storage in bytes. - data.storageInfo.totalBytes (number, optional): Total storage in bytes. - data.storageInfo.lastUpdated (string, optional): When this reading was taken. - data.systemInfo (DeviceSystemInfoDTO, optional): Timezone and locale of the device. - data.systemInfo.timezone (string, optional): Device timezone. - data.systemInfo.locale (string, optional): Device locale. - data.systemInfo.lastUpdated (string, optional): When this reading was taken. - data.powerInfo (PowerInfoDTO, optional): Android power state reported at the last heartbeat. Explains a device that is online but slow to send. - data.powerInfo.isIgnoringBatteryOptimizations (boolean, optional): Whether the app is exempt from battery optimization. When false, Android may delay the app in the background. - data.powerInfo.isDeviceIdleMode (boolean, optional): Whether the device was in Doze at the last heartbeat. - data.powerInfo.isPowerSaveMode (boolean, optional): Whether battery saver was on at the last heartbeat. - data.powerInfo.lastUpdated (string, optional): When this reading was taken. - data.simInfo (SimInfoCollectionDTO, optional): SIMs installed in the device. - data.simInfo.lastUpdated (string, required): When the device last reported its SIM list. - data.simInfo.sims (SimInfoDTO[], required): SIMs currently installed in the device. - data.createdAt (string, required): When the device was registered. - data.updatedAt (string, required): When the device was last updated. Errors: - 400: The device id is not a valid id. - 401: Missing, invalid, or revoked API key. - 404: No device with that id on your account. Example: ```bash curl "https://api.textbee.dev/api/v1/gateway/devices/ID" \ -H "x-api-key: YOUR_API_KEY" ``` ## POST /api/v1/gateway/send-sms - Send an SMS Source: https://textbee.dev/docs/api-reference#post-api-v1-gateway-send-sms Sends one message to one or more recipients from a phone on your account. deviceId is optional: without it textbee uses your default device, otherwise the enabled device with the most recent heartbeat. Every recipient counts as one message against your plan. Request body (application/json): - message (string, required): Text of the message. Long messages are split by the carrier. - recipients (string[], required): Phone numbers to send to, in international format. Each recipient is billed as one message. - simSubscriptionId (number, optional): SIM to send from, as subscriptionId from the device simInfo. Defaults to the device default SIM. Android can assign a new id when a SIM is removed and reinserted or swapped, so read the current id from the SIM Cards section of the textbee app before you hard-code it. An id that matches no active SIM is sent from the preferred SIM set in the textbee app, or the device default SIM. - scheduledAt (string, optional): ISO 8601 time to send the message. Must be in the future. Omit to send now. - deviceId (string, optional): Device to send from. When omitted, uses your default device, or the enabled device with the most recent heartbeat. Response 200: The send was accepted. Use smsBatchId to follow delivery. Acceptance is not delivery: the phone still has to be online. Response 200 fields: - data (SendSMSResultDTO, required): Outcome of the send. - data.success (boolean, optional): Whether the batch was accepted. Queued sends only. - data.message (string, optional): Human readable outcome. Queued sends only. - data.smsBatchId (string, optional): Batch id. Pass it to GET /gateway/devices/{id}/sms-batch/{smsBatchId} to follow delivery. Queued sends only. - data.recipientCount (number, optional): Number of recipients in the batch. Queued sends only. - data.estimatedCompletionAt (string, optional): Projected time the device finishes sending, based on the recipient count and the device send delay setting. Present only when the batch is large enough to be released in waves. The device must stay online for the estimate to hold. - data.successCount (number, optional): Messages pushed to the device. Returned instead of the queue fields when the instance dispatches immediately. - data.failureCount (number, optional): Messages that could not be pushed to the device. - data.warning (SendSMSWarningDTO, optional): Present when the message was accepted but is likely to fail, for example because the phone reported the SMS permission off in the last 7 days. Set for single sends only, not for bulk sends. - data.warning.code (string, required): Machine readable warning code. SMS_PERMISSION_MISSING: the textbee app on the sending phone last reported the SMS permission as off. The message is queued, and it fails if the permission is still off. - data.warning.message (string, required): Human readable explanation of the warning. Errors: - 400: No enabled device to send from, the device is disabled, your email is not verified, or the message could not be pushed to the phone. - 401: Missing, invalid, or revoked API key. - 429: Your daily, monthly, or per-batch plan limit is used up. The body says which one. Example: ```bash curl -X POST "https://api.textbee.dev/api/v1/gateway/send-sms" \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "message": "Your appointment is confirmed for Tuesday at 10am.", "recipients": [ "+12025550123", "+12025550124" ] }' ``` ## POST /api/v1/gateway/send-bulk-sms - Send several SMS in one call Source: https://textbee.dev/docs/api-reference#post-api-v1-gateway-send-bulk-sms Sends a batch where every entry has its own text and recipients. deviceId is optional and resolves the same way as POST /gateway/send-sms. Request body (application/json): - messageTemplate (string, optional): Optional label for the batch. Each entry in messages carries its own text. - messages (SMSData[], required): Messages to send. Every message can target different recipients and use a different SIM. - messages[].message (string, required): Text of the message. Long messages are split by the carrier. - messages[].recipients (string[], required): Phone numbers to send to, in international format. Each recipient is billed as one message. - messages[].simSubscriptionId (number, optional): SIM to send from, as subscriptionId from the device simInfo. Defaults to the device default SIM. Android can assign a new id when a SIM is removed and reinserted or swapped, so read the current id from the SIM Cards section of the textbee app before you hard-code it. An id that matches no active SIM is sent from the preferred SIM set in the textbee app, or the device default SIM. - messages[].scheduledAt (string, optional): ISO 8601 time to send the message. Must be in the future. Omit to send now. - deviceId (string, optional): Device to send from. When omitted, uses your default device, or the enabled device with the most recent heartbeat. Response 200: The batch was accepted. Response 200 fields: - data (SendSMSResultDTO, required): Outcome of the send. - data.success (boolean, optional): Whether the batch was accepted. Queued sends only. - data.message (string, optional): Human readable outcome. Queued sends only. - data.smsBatchId (string, optional): Batch id. Pass it to GET /gateway/devices/{id}/sms-batch/{smsBatchId} to follow delivery. Queued sends only. - data.recipientCount (number, optional): Number of recipients in the batch. Queued sends only. - data.estimatedCompletionAt (string, optional): Projected time the device finishes sending, based on the recipient count and the device send delay setting. Present only when the batch is large enough to be released in waves. The device must stay online for the estimate to hold. - data.successCount (number, optional): Messages pushed to the device. Returned instead of the queue fields when the instance dispatches immediately. - data.failureCount (number, optional): Messages that could not be pushed to the device. - data.warning (SendSMSWarningDTO, optional): Present when the message was accepted but is likely to fail, for example because the phone reported the SMS permission off in the last 7 days. Set for single sends only, not for bulk sends. - data.warning.code (string, required): Machine readable warning code. SMS_PERMISSION_MISSING: the textbee app on the sending phone last reported the SMS permission as off. The message is queued, and it fails if the permission is still off. - data.warning.message (string, required): Human readable explanation of the warning. Errors: - 400: No enabled device to send from, the device is disabled, your email is not verified, or the batch could not be pushed to the phone. - 401: Missing, invalid, or revoked API key. - 429: Your daily, monthly, or per-batch plan limit is used up. The body says which one. Example: ```bash curl -X POST "https://api.textbee.dev/api/v1/gateway/send-bulk-sms" \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "messages": [ { "message": "Your appointment is confirmed for Tuesday at 10am.", "recipients": [ "+12025550123", "+12025550124" ], "simSubscriptionId": 1, "scheduledAt": "2024-01-15T10:30:00Z" } ] }' ``` ## GET /api/v1/gateway/messages - List messages Source: https://textbee.dev/docs/api-reference#get-api-v1-gateway-messages Sent and received messages across your whole account, newest first, with delivery status on each. Filter by device, direction, status, text, and time range. Two pagination modes: page numbers for browsing, or cursor for polling. To poll for new messages: request order=asc with a from timestamp, follow nextCursor until hasMore is false, then resume from the last nextCursor on the next poll. Time filters apply to createdAt (when the platform stored the message). For received messages this is upload time, which can lag receivedAt if the device was offline. A received message uploaded more than 24 hours after receivedAt is stored with createdAt set to receivedAt and the upload time in originalCreatedAt, so a poll that has already moved past receivedAt does not return it. Query parameters: - cursor (string, optional): Opaque position from a previous response nextCursor. Returns the page after that position and switches meta to nextCursor/hasMore without a total count. - limit (integer, optional): Messages per page. Default 50, maximum 100. Defaults to 50. Between 1 and 100. - page (integer, optional): Page to return. Default 1. Mutually exclusive with cursor. Defaults to 1. - order (string, optional): desc (default) for newest first; asc to walk forward in time when polling. One of: desc, asc. - to (string, optional): Exclusive upper bound on createdAt, same formats as from. Exclusive so consecutive windows never double-count a boundary message. - from (string, optional): Inclusive lower bound on createdAt, which for a received message uploaded more than 24 hours late equals receivedAt. ISO-8601 with an explicit timezone (2026-08-01T00:00:00Z or +03:00 form), or a date (2026-08-01, read as UTC midnight). A datetime without a timezone is rejected. - search (string, optional): Match against the message text and the other party number. Encrypted messages cannot be searched. - status (string, optional): Delivery state to return. Combine with direction: direction=sent&status=failed lists sends that failed. One of: pending, dispatched, sent, delivered, failed, unknown, received. - smsBatchId (string, optional): Only messages from this batch, using the smsBatchId returned by a send. Combine with status=failed to list the recipients of a batch that failed. - direction (string, optional): Direction to return. Default all. Matches the lowercase direction field on each message. Not the same as status: direction=sent means outbound, status=sent means the device dispatched it. One of: all, sent, received. - deviceIds (string, optional): Comma-separated device ids to include, from GET /gateway/devices. Default: all devices on the account. Messages from deleted devices are never included. Response 200: A page of messages. Response 200 fields: - data (MessageDTO[], required): Messages, newest first unless order=asc. - data[]._id (string, required): Message id. - data[].message (string, optional): Message text. Empty when the message is end to end encrypted. - data[].device (MessageDeviceDTO, required): Device that sent or received the message. - data[].device._id (string, required): Device id. - data[].device.enabled (boolean, required): Whether the device may send and receive SMS. - data[].device.name (string, optional): Your own label for the device. - data[].device.brand (string, required): Device brand. - data[].device.model (string, required): Device model. - data[].device.buildId (string, required): Android build id. - data[].status (string, optional): Delivery state, lowercase. Incoming messages are always received. Outgoing messages move from pending to sent, then to delivered when the carrier confirms. Absent on messages stored before status tracking, so treat a missing value as unknown. One of: pending, dispatched, sent, delivered, failed, unknown, received. - data[].sender (string, optional): Sender number. Set on received messages. - data[].recipient (string, optional): Destination number. Set on sent messages. - data[].smsBatch (string, optional): Id of the batch this message was sent in. - data[].encrypted (boolean, optional): Whether the body is end to end encrypted. Encrypted bodies can only be read by your own client. - data[].simSubscriptionId (number, optional): SIM requested for this send, as passed in the send request. Not necessarily the SIM used: read simUsed for that. - data[].simUsed (SimUsedDTO, optional): SIM the device reports it used: the SIM that sent an outgoing message, or the SIM that received an incoming one. Absent when the device app does not report it. - data[].simUsed.subscriptionId (number, optional): Subscription id of the SIM. - data[].simUsed.slotIndex (number, optional): Zero-based slot of the SIM: 0 is SIM 1, 1 is SIM 2. - data[].receivedAt (string, optional): When the message was received. Received messages only. - data[].requestedAt (string, optional): When the send was requested. Sent messages only. - data[].dispatchDueAt (string, optional): When the queue is due to hand the message to the push service. Set for queued messages that wait server-side before dispatch: scheduled sends, and large sends, which are released in waves paced to the device send delay so the phone is never asked to hold more than it can send. Absent for immediate sends. - data[].dispatchedAt (string, optional): When the push service accepted the message for delivery to the device. Does not mean the device has received it; sentAt is the first device-confirmed timestamp. - data[].dispatchAttempts (number, optional): How many times the message has been handed to the push service. Normally 1; higher when a message was dispatched again. - data[].pushReceivedAt (string, optional): When the device received the push for this message. Absent for messages handled by app versions that do not report it. - data[].sendAttemptedAt (string, optional): When the device handed the message to the radio. The gap to sentAt is time spent in the radio; the gap from pushReceivedAt is time the message waited on the device. - data[].sentAt (string, optional): When the device reported the message as sent. - data[].deliveredAt (string, optional): When the carrier confirmed delivery. - data[].failedAt (string, optional): When the message failed. - data[].errorCode (string, optional): Failure code reported by the device. - data[].errorMessage (string, optional): Failure reason reported by the device. - data[].overLimit (boolean, optional): True when the message was received while the account was over its plan limit. Received messages only. Absent otherwise. - data[].originalCreatedAt (string, optional): When the platform stored the message. Set only on a received message uploaded more than 24 hours after receivedAt, whose createdAt is set to receivedAt. - data[].createdAt (string, required): When the record was created. For a received message uploaded more than 24 hours after receivedAt, this equals receivedAt and originalCreatedAt holds the upload time. - data[].updatedAt (string, required): When the record was last updated. - data[].direction (string, required): Direction of the message, lowercase. Values match the direction query filter, so a response value can be passed straight back as a filter. Not the same thing as status: direction=sent means outbound, status=sent means the device dispatched it. One of: sent, received. - data[].channel (string, optional): Message channel. Currently always sms; mms will appear here when supported. Absent means sms. One of: sms. - meta (object, required): Pagination metadata. Page mode (no cursor) returns page, limit, total, totalPages plus nextCursor and hasMore; cursor mode returns limit, nextCursor, and hasMore only, skipping the expensive total count. anyOf rather than oneOf: a page-mode object also satisfies the cursor-mode shape, and oneOf would make strict validators reject it. Errors: - 400: Invalid deviceIds, smsBatchId, direction, status, from, to, order, or cursor value. Unknown filter values fail rather than silently applying no filter. - 401: Missing, invalid, or revoked API key. - 404: A deviceIds entry or the smsBatchId is not on your account. Example: ```bash curl "https://api.textbee.dev/api/v1/gateway/messages?limit=50&page=1" \ -H "x-api-key: YOUR_API_KEY" ``` ## GET /api/v1/gateway/devices/{id}/sms/{smsId} - Get one message Source: https://textbee.dev/docs/api-reference#get-api-v1-gateway-devices-id-sms-smsid A single sent or received message with its delivery timestamps. Use it to check the outcome of one recipient. Path parameters: - id (string, required): Device id, from GET /gateway/devices. - smsId (string, required): Message id, from the message history response. Response 200: The message. Response 200 fields: - data (RetrieveSMSDTO, required): The message. - data._id (string, required): Message id. - data.message (string, optional): Message text. Empty when the message is end to end encrypted. - data.device (MessageDeviceDTO, required): Device that sent or received the message. - data.device._id (string, required): Device id. - data.device.enabled (boolean, required): Whether the device may send and receive SMS. - data.device.name (string, optional): Your own label for the device. - data.device.brand (string, required): Device brand. - data.device.model (string, required): Device model. - data.device.buildId (string, required): Android build id. - data.status (string, optional): Delivery state, lowercase. Incoming messages are always received. Outgoing messages move from pending to sent, then to delivered when the carrier confirms. Absent on messages stored before status tracking, so treat a missing value as unknown. One of: pending, dispatched, sent, delivered, failed, unknown, received. - data.sender (string, optional): Sender number. Set on received messages. - data.recipient (string, optional): Destination number. Set on sent messages. - data.smsBatch (string, optional): Id of the batch this message was sent in. - data.encrypted (boolean, optional): Whether the body is end to end encrypted. Encrypted bodies can only be read by your own client. - data.simSubscriptionId (number, optional): SIM requested for this send, as passed in the send request. Not necessarily the SIM used: read simUsed for that. - data.simUsed (SimUsedDTO, optional): SIM the device reports it used: the SIM that sent an outgoing message, or the SIM that received an incoming one. Absent when the device app does not report it. - data.simUsed.subscriptionId (number, optional): Subscription id of the SIM. - data.simUsed.slotIndex (number, optional): Zero-based slot of the SIM: 0 is SIM 1, 1 is SIM 2. - data.receivedAt (string, optional): When the message was received. Received messages only. - data.requestedAt (string, optional): When the send was requested. Sent messages only. - data.dispatchDueAt (string, optional): When the queue is due to hand the message to the push service. Set for queued messages that wait server-side before dispatch: scheduled sends, and large sends, which are released in waves paced to the device send delay so the phone is never asked to hold more than it can send. Absent for immediate sends. - data.dispatchedAt (string, optional): When the push service accepted the message for delivery to the device. Does not mean the device has received it; sentAt is the first device-confirmed timestamp. - data.dispatchAttempts (number, optional): How many times the message has been handed to the push service. Normally 1; higher when a message was dispatched again. - data.pushReceivedAt (string, optional): When the device received the push for this message. Absent for messages handled by app versions that do not report it. - data.sendAttemptedAt (string, optional): When the device handed the message to the radio. The gap to sentAt is time spent in the radio; the gap from pushReceivedAt is time the message waited on the device. - data.sentAt (string, optional): When the device reported the message as sent. - data.deliveredAt (string, optional): When the carrier confirmed delivery. - data.failedAt (string, optional): When the message failed. - data.errorCode (string, optional): Failure code reported by the device. - data.errorMessage (string, optional): Failure reason reported by the device. - data.overLimit (boolean, optional): True when the message was received while the account was over its plan limit. Received messages only. Absent otherwise. - data.originalCreatedAt (string, optional): When the platform stored the message. Set only on a received message uploaded more than 24 hours after receivedAt, whose createdAt is set to receivedAt. - data.createdAt (string, required): When the record was created. For a received message uploaded more than 24 hours after receivedAt, this equals receivedAt and originalCreatedAt holds the upload time. - data.updatedAt (string, required): When the record was last updated. Errors: - 400: The device id is not a valid id. - 401: Missing, invalid, or revoked API key. - 404: No such message on this device. Example: ```bash curl "https://api.textbee.dev/api/v1/gateway/devices/ID/sms/SMS_ID" \ -H "x-api-key: YOUR_API_KEY" ``` ## GET /api/v1/gateway/devices/{id}/sms-batch/{smsBatchId} - Get a send batch Source: https://textbee.dev/docs/api-reference#get-api-v1-gateway-devices-id-sms-batch-smsbatchid The batch returned by a send, plus one message per recipient. The batch status and counts are calculated from the messages, so they follow each status report from the phone. Poll this to see how a send is progressing. Path parameters: - id (string, required): Device id, from GET /gateway/devices. - smsBatchId (string, required): Batch id, returned as smsBatchId by the send endpoints. Response 200: The batch and its messages. Response 200 fields: - data (SMSBatchResultDTO, required): The batch and the messages in it. - data.batch (SMSBatchDTO, required): The batch itself. - data.batch._id (string, required): Batch id. - data.batch.user (string, required): Owner account id. - data.batch.message (string, optional): Message text sent to every recipient in the batch. - data.batch.encrypted (boolean, optional): Whether the body is end to end encrypted. - data.batch.recipientCount (number, required): Recipients in the batch. - data.batch.recipientPreview (string, optional): Short preview of the recipient list. - data.batch.successCount (number, required): Messages the phone reported as sent or delivered. Equals sentCount plus deliveredCount. - data.batch.failureCount (number, required): Messages that failed, either on the phone or before they reached it. - data.batch.pendingCount (number, optional): Messages not yet handed to the phone. Paced and scheduled messages wait here. - data.batch.dispatchedCount (number, optional): Messages handed to the phone that have not reported a result yet. - data.batch.sentCount (number, optional): Messages the phone sent that have no delivery report yet. A message moves to deliveredCount when its delivery report arrives. - data.batch.deliveredCount (number, optional): Messages with a delivery report from the carrier. - data.batch.unknownCount (number, optional): Messages with no result: no report from the phone 20 minutes after they were due, or a state textbee does not recognize. A late report still updates the message and the batch. - data.batch.status (string, optional): Outcome of the batch, calculated from its messages. processing: at least one message is pending or dispatched. completed: every message was sent or delivered. failed: every message failed. partial_success: some were sent or delivered, the rest failed or are unknown. unknown: none was sent and at least one is unknown, or the batch has no messages. pending: the batch was created and no message was queued yet. sent and delivered appear only on old batches that have no messages. One of: pending, processing, completed, partial_success, failed, unknown, sent, delivered. - data.batch.error (string, optional): Error from the last failed attempt to hand the batch to the phone. It can be present on a batch that later sent some messages. - data.batch.completedAt (string, optional): When the batch reached its current final status. Absent while the batch is processing. - data.batch.statusCheckedAt (string, optional): When the status and counts were last calculated from the messages. - data.batch.createdAt (string, required): When the batch was created. - data.batch.updatedAt (string, required): When the batch was last updated. - data.messages (RetrieveSMSDTO[], required): Every message in the batch, one per recipient. - data.messages[]._id (string, required): Message id. - data.messages[].message (string, optional): Message text. Empty when the message is end to end encrypted. - data.messages[].device (MessageDeviceDTO, required): Device that sent or received the message. - data.messages[].status (string, optional): Delivery state, lowercase. Incoming messages are always received. Outgoing messages move from pending to sent, then to delivered when the carrier confirms. Absent on messages stored before status tracking, so treat a missing value as unknown. One of: pending, dispatched, sent, delivered, failed, unknown, received. - data.messages[].sender (string, optional): Sender number. Set on received messages. - data.messages[].recipient (string, optional): Destination number. Set on sent messages. - data.messages[].smsBatch (string, optional): Id of the batch this message was sent in. - data.messages[].encrypted (boolean, optional): Whether the body is end to end encrypted. Encrypted bodies can only be read by your own client. - data.messages[].simSubscriptionId (number, optional): SIM requested for this send, as passed in the send request. Not necessarily the SIM used: read simUsed for that. - data.messages[].simUsed (SimUsedDTO, optional): SIM the device reports it used: the SIM that sent an outgoing message, or the SIM that received an incoming one. Absent when the device app does not report it. - data.messages[].receivedAt (string, optional): When the message was received. Received messages only. - data.messages[].requestedAt (string, optional): When the send was requested. Sent messages only. - data.messages[].dispatchDueAt (string, optional): When the queue is due to hand the message to the push service. Set for queued messages that wait server-side before dispatch: scheduled sends, and large sends, which are released in waves paced to the device send delay so the phone is never asked to hold more than it can send. Absent for immediate sends. - data.messages[].dispatchedAt (string, optional): When the push service accepted the message for delivery to the device. Does not mean the device has received it; sentAt is the first device-confirmed timestamp. - data.messages[].dispatchAttempts (number, optional): How many times the message has been handed to the push service. Normally 1; higher when a message was dispatched again. - data.messages[].pushReceivedAt (string, optional): When the device received the push for this message. Absent for messages handled by app versions that do not report it. - data.messages[].sendAttemptedAt (string, optional): When the device handed the message to the radio. The gap to sentAt is time spent in the radio; the gap from pushReceivedAt is time the message waited on the device. - data.messages[].sentAt (string, optional): When the device reported the message as sent. - data.messages[].deliveredAt (string, optional): When the carrier confirmed delivery. - data.messages[].failedAt (string, optional): When the message failed. - data.messages[].errorCode (string, optional): Failure code reported by the device. - data.messages[].errorMessage (string, optional): Failure reason reported by the device. - data.messages[].overLimit (boolean, optional): True when the message was received while the account was over its plan limit. Received messages only. Absent otherwise. - data.messages[].originalCreatedAt (string, optional): When the platform stored the message. Set only on a received message uploaded more than 24 hours after receivedAt, whose createdAt is set to receivedAt. - data.messages[].createdAt (string, required): When the record was created. For a received message uploaded more than 24 hours after receivedAt, this equals receivedAt and originalCreatedAt holds the upload time. - data.messages[].updatedAt (string, required): When the record was last updated. Errors: - 400: The device id is not a valid id. - 401: Missing, invalid, or revoked API key. - 404: No such batch on this device. Example: ```bash curl "https://api.textbee.dev/api/v1/gateway/devices/ID/sms-batch/SMS_BATCH_ID" \ -H "x-api-key: YOUR_API_KEY" ``` ## GET /api/v1/webhooks - List your webhook subscriptions Source: https://textbee.dev/docs/api-reference#get-api-v1-webhooks Every subscription on your account, deleted ones excluded. Each one says which events it receives and where they are delivered. Response 200: Your subscriptions. Response 200 fields: - data (WebhookSubscriptionDTO[], required): Your webhook subscriptions. Deleted ones are not returned. - data[]._id (string, required): Subscription id. - data[].user (string, required): Owner account id. - data[].name (string, optional): Label shown in the dashboard. - data[].isActive (boolean, required): Whether deliveries are attempted. textbee pauses subscriptions that keep failing. - data[].events (string[], required): Events this subscription receives. - data[].deliveryUrl (string, required): URL events are POSTed to. - data[].signingSecret (string, required): Secret used to sign deliveries. - data[].successfulDeliveryCount (number, required): Deliveries that succeeded. - data[].deliveryFailureCount (number, required): Deliveries that failed. - data[].deliveryAttemptCount (number, required): Delivery attempts made, retries included. - data[].lastDeliveryAttemptAt (string, optional): Last time a delivery was attempted. - data[].lastDeliverySuccessAt (string, optional): Last time a delivery succeeded. - data[].lastDeliveryFailureAt (string, optional): Last time a delivery failed. - data[].notes (WebhookNoteDTO[], optional): Notes textbee added, such as an auto-pause reason. - data[].notes[].at (string, required): When the note was added. - data[].notes[].text (string, required): What textbee recorded, for example why the subscription was paused. - data[].createdAt (string, required): When the subscription was created. - data[].updatedAt (string, required): When it was last updated. Errors: - 401: Missing, invalid, or revoked API key. Example: ```bash curl "https://api.textbee.dev/api/v1/webhooks" \ -H "x-api-key: YOUR_API_KEY" ``` ## POST /api/v1/webhooks - Create a webhook subscription Source: https://textbee.dev/docs/api-reference#post-api-v1-webhooks textbee POSTs the events you pick to your delivery URL and signs each request with your signing secret, sent as the X-Signature header. Failed deliveries are retried up to 10 times, and a subscription that keeps failing is paused. Request body (application/json): - name (string, optional): Label shown in the dashboard. Up to 64 characters. - deliveryUrl (string, required): URL textbee POSTs events to. Must be http or https and publicly reachable. Private and loopback hosts are rejected. - signingSecret (string, required): Shared secret, at least 20 characters. textbee signs every delivery with it and sends the signature in the X-Signature header, so your endpoint can verify the request really came from textbee. - events (string[], required): Events to deliver. At least one is required. Response 201: The subscription was created. Response 201 fields: - data (WebhookSubscriptionDTO, required): The subscription. - data._id (string, required): Subscription id. - data.user (string, required): Owner account id. - data.name (string, optional): Label shown in the dashboard. - data.isActive (boolean, required): Whether deliveries are attempted. textbee pauses subscriptions that keep failing. - data.events (string[], required): Events this subscription receives. - data.deliveryUrl (string, required): URL events are POSTed to. - data.signingSecret (string, required): Secret used to sign deliveries. - data.successfulDeliveryCount (number, required): Deliveries that succeeded. - data.deliveryFailureCount (number, required): Deliveries that failed. - data.deliveryAttemptCount (number, required): Delivery attempts made, retries included. - data.lastDeliveryAttemptAt (string, optional): Last time a delivery was attempted. - data.lastDeliverySuccessAt (string, optional): Last time a delivery succeeded. - data.lastDeliveryFailureAt (string, optional): Last time a delivery failed. - data.notes (WebhookNoteDTO[], optional): Notes textbee added, such as an auto-pause reason. - data.notes[].at (string, required): When the note was added. - data.notes[].text (string, required): What textbee recorded, for example why the subscription was paused. - data.createdAt (string, required): When the subscription was created. - data.updatedAt (string, required): When it was last updated. Errors: - 400: The delivery URL is unusable, the signing secret is shorter than 20 characters, the event list is empty or unknown, or you already have the maximum number of subscriptions. - 401: Missing, invalid, or revoked API key. Example: ```bash curl -X POST "https://api.textbee.dev/api/v1/webhooks" \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "deliveryUrl": "https://example.com/textbee/webhook", "signingSecret": "SIGNING_SECRET", "events": [ "MESSAGE_RECEIVED" ] }' ``` ## GET /api/v1/webhooks/notifications - List webhook delivery attempts Source: https://textbee.dev/docs/api-reference#get-api-v1-webhooks-notifications Delivery history across your subscriptions, newest first. Use it to see what textbee sent, what your endpoint answered, and whether a retry is pending. History is kept for subscriptions you have since deleted. Query parameters: - page (number, optional): Page to return. Default 1. - limit (number, optional): Records per page. Default 10. - status (string, optional): Only return deliveries in this state. One of: pending, retrying, delivered, failed. - eventType (string, optional): Only return deliveries for this event. One of: MESSAGE_RECEIVED, MESSAGE_SENT, MESSAGE_DELIVERED, MESSAGE_FAILED, UNKNOWN_STATE, SMS_STATUS_UPDATED. - deviceId (string, optional): Only return deliveries for messages from this device. - start (string, optional): Start of the time range, ISO 8601. Applied only together with end. - end (string, optional): End of the time range, ISO 8601. - webhookSubscriptionId (string, optional): Only return deliveries for one subscription. Response 200: A page of delivery records. Response 200 fields: - data (WebhookNotificationPageDTO, required): Delivery records with pagination. - data.data (WebhookNotificationDTO[], required): Delivery records, newest first. - data.data[]._id (string, required): Delivery record id. - data.data[].webhookSubscription (string, required): Subscription this belongs to. - data.data[].event (string, required): Event that was delivered. One of: MESSAGE_RECEIVED, MESSAGE_SENT, MESSAGE_DELIVERED, MESSAGE_FAILED, UNKNOWN_STATE, SMS_STATUS_UPDATED. - data.data[].payload (object, required): Exact JSON body textbee POSTed to your endpoint. - data.data[].computedStatus (string, required): Delivery state. Retrying means textbee will try again, failed means it gave up after 10 attempts. One of: pending, retrying, delivered, failed. - data.data[].deliveryUrl (string, optional): URL the delivery was sent to when it was attempted. - data.data[].deliveryAttemptCount (number, required): Attempts made for this event. - data.data[].deliveredAt (string, optional): When your endpoint accepted the delivery. - data.data[].lastDeliveryAttemptAt (string, optional): When the last attempt was made. - data.data[].nextDeliveryAttemptAt (string, optional): When the next retry is due. - data.data[].deliveryAttemptAbortedAt (string, optional): When textbee stopped retrying. - data.data[].errorType (string, optional): Whether the failure is worth retrying. One of: retryable, non-retryable. - data.data[].httpStatusCode (number, optional): Status code your endpoint returned on the last attempt. - data.data[].responseBody (string, optional): First 1000 characters of your endpoint response. - data.data[].idempotencyKey (string, optional): Idempotency key sent with the delivery. - data.data[].createdAt (string, required): When the event was recorded. - data.meta (PaginationMetaDTO, required): Pagination info. - data.meta.page (integer, required): Current page number - data.meta.limit (integer, required): Number of items per page - data.meta.total (integer, required): Total number of items - data.meta.totalPages (integer, required): Total number of pages Errors: - 400: deviceId or webhookSubscriptionId is not a valid id. - 401: Missing, invalid, or revoked API key. - 404: No webhook subscription with that id on your account. Example: ```bash curl "https://api.textbee.dev/api/v1/webhooks/notifications" \ -H "x-api-key: YOUR_API_KEY" ``` ## GET /api/v1/webhooks/{webhookId} - Get a webhook subscription Source: https://textbee.dev/docs/api-reference#get-api-v1-webhooks-webhookid One subscription with its delivery counters. Path parameters: - webhookId (string, required): Subscription id, from GET /webhooks. Response 200: The subscription. Response 200 fields: - data (WebhookSubscriptionDTO, required): The subscription. - data._id (string, required): Subscription id. - data.user (string, required): Owner account id. - data.name (string, optional): Label shown in the dashboard. - data.isActive (boolean, required): Whether deliveries are attempted. textbee pauses subscriptions that keep failing. - data.events (string[], required): Events this subscription receives. - data.deliveryUrl (string, required): URL events are POSTed to. - data.signingSecret (string, required): Secret used to sign deliveries. - data.successfulDeliveryCount (number, required): Deliveries that succeeded. - data.deliveryFailureCount (number, required): Deliveries that failed. - data.deliveryAttemptCount (number, required): Delivery attempts made, retries included. - data.lastDeliveryAttemptAt (string, optional): Last time a delivery was attempted. - data.lastDeliverySuccessAt (string, optional): Last time a delivery succeeded. - data.lastDeliveryFailureAt (string, optional): Last time a delivery failed. - data.notes (WebhookNoteDTO[], optional): Notes textbee added, such as an auto-pause reason. - data.notes[].at (string, required): When the note was added. - data.notes[].text (string, required): What textbee recorded, for example why the subscription was paused. - data.createdAt (string, required): When the subscription was created. - data.updatedAt (string, required): When it was last updated. Errors: - 401: Missing, invalid, or revoked API key. - 404: No webhook subscription with that id on your account. Example: ```bash curl "https://api.textbee.dev/api/v1/webhooks/WEBHOOK_ID" \ -H "x-api-key: YOUR_API_KEY" ``` ## PATCH /api/v1/webhooks/{webhookId} - Update a webhook subscription Source: https://textbee.dev/docs/api-reference#patch-api-v1-webhooks-webhookid Changes only the fields you send. Use isActive to re-enable a subscription textbee paused after repeated failures. Path parameters: - webhookId (string, required): Subscription id, from GET /webhooks. Request body (application/json): - name (string, optional): Label shown in the dashboard. Up to 64 characters. - isActive (boolean, optional): Whether deliveries are attempted. Set it to true to re-enable a subscription textbee paused after repeated failures. - deliveryUrl (string, optional): New delivery URL. Same rules as on create: http or https, no private or loopback hosts. - signingSecret (string, optional): New signing secret, at least 20 characters. - events (string[], optional): Replacement event list. Cannot be empty. Response 200: The updated subscription. Response 200 fields: - data (WebhookSubscriptionDTO, required): The subscription. - data._id (string, required): Subscription id. - data.user (string, required): Owner account id. - data.name (string, optional): Label shown in the dashboard. - data.isActive (boolean, required): Whether deliveries are attempted. textbee pauses subscriptions that keep failing. - data.events (string[], required): Events this subscription receives. - data.deliveryUrl (string, required): URL events are POSTed to. - data.signingSecret (string, required): Secret used to sign deliveries. - data.successfulDeliveryCount (number, required): Deliveries that succeeded. - data.deliveryFailureCount (number, required): Deliveries that failed. - data.deliveryAttemptCount (number, required): Delivery attempts made, retries included. - data.lastDeliveryAttemptAt (string, optional): Last time a delivery was attempted. - data.lastDeliverySuccessAt (string, optional): Last time a delivery succeeded. - data.lastDeliveryFailureAt (string, optional): Last time a delivery failed. - data.notes (WebhookNoteDTO[], optional): Notes textbee added, such as an auto-pause reason. - data.notes[].at (string, required): When the note was added. - data.notes[].text (string, required): What textbee recorded, for example why the subscription was paused. - data.createdAt (string, required): When the subscription was created. - data.updatedAt (string, required): When it was last updated. Errors: - 400: The delivery URL is unusable, the signing secret is shorter than 20 characters, or the event list is empty or unknown. - 401: Missing, invalid, or revoked API key. - 404: No webhook subscription with that id on your account. Example: ```bash curl -X PATCH "https://api.textbee.dev/api/v1/webhooks/WEBHOOK_ID" \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Order notifications", "isActive": true, "deliveryUrl": "https://example.com/textbee/webhook", "signingSecret": "SIGNING_SECRET", "events": [ "MESSAGE_RECEIVED", "MESSAGE_DELIVERED" ] }' ``` ## DELETE /api/v1/webhooks/{webhookId} - Delete a webhook subscription Source: https://textbee.dev/docs/api-reference#delete-api-v1-webhooks-webhookid Stops all deliveries for this subscription. Its delivery history stays readable through GET /webhooks/notifications. Path parameters: - webhookId (string, required): Subscription id, from GET /webhooks. Response 200: The subscription was deleted. Response 200 fields: - data (WebhookDeletedResultDTO, required): Outcome of the delete. - data.success (boolean, required): Whether the delete succeeded. Errors: - 401: Missing, invalid, or revoked API key. - 404: No webhook subscription with that id on your account. Example: ```bash curl -X DELETE "https://api.textbee.dev/api/v1/webhooks/WEBHOOK_ID" \ -H "x-api-key: YOUR_API_KEY" ```