textbee 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,13 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .pytest_cache/
8
+ .mypy_cache/
9
+ .ruff_cache/
10
+ .coverage
11
+ htmlcov/
12
+ .DS_Store
13
+ .omc/
textbee-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2022 - 2026 textbee.dev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
textbee-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,307 @@
1
+ Metadata-Version: 2.5
2
+ Name: textbee
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for textbee.dev, the open source SMS gateway
5
+ Project-URL: Homepage, https://textbee.dev
6
+ Project-URL: Documentation, https://textbee.dev/docs
7
+ Project-URL: Repository, https://github.com/textbee/textbee-python
8
+ Project-URL: Issues, https://github.com/textbee/textbee-python/issues
9
+ Project-URL: Changelog, https://github.com/textbee/textbee-python/releases
10
+ Author-email: "textbee.dev" <support@textbee.dev>
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Keywords: android,e164,gsm-7,sms,sms-gateway,sms-segments,textbee
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Programming Language :: Python :: 3.9
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
+ Classifier: Programming Language :: Python :: 3.14
25
+ Classifier: Topic :: Communications :: Telephony
26
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
27
+ Classifier: Typing :: Typed
28
+ Requires-Python: >=3.9
29
+ Description-Content-Type: text/markdown
30
+
31
+ # textbee
32
+
33
+ [![PyPI](https://img.shields.io/pypi/v/textbee)](https://pypi.org/project/textbee/)
34
+ [![CI](https://github.com/textbee/textbee-python/actions/workflows/ci.yaml/badge.svg)](https://github.com/textbee/textbee-python/actions/workflows/ci.yaml)
35
+ [![Python](https://img.shields.io/pypi/pyversions/textbee)](https://pypi.org/project/textbee/)
36
+ [![license](https://img.shields.io/pypi/l/textbee)](https://github.com/textbee/textbee-python/blob/main/LICENSE)
37
+
38
+ Official Python SDK for [textbee.dev](https://textbee.dev), the open source SMS gateway that turns an Android phone into an SMS API.
39
+
40
+ Zero dependencies, fully typed, works on Python 3.9+.
41
+
42
+ ## Install
43
+
44
+ ```bash
45
+ pip install textbee
46
+ ```
47
+
48
+ ```bash
49
+ uv add textbee
50
+ ```
51
+
52
+ ## Quickstart
53
+
54
+ Get an API key from the [textbee dashboard](https://textbee.dev/dashboard), then:
55
+
56
+ ```python
57
+ import os
58
+
59
+ from textbee import Textbee
60
+
61
+ textbee = Textbee(api_key=os.environ["TEXTBEE_API_KEY"])
62
+
63
+ textbee.send_sms(
64
+ recipients=["+12025550123"],
65
+ message="Hello from textbee!",
66
+ )
67
+ ```
68
+
69
+ ## Send options
70
+
71
+ `send_sms` needs only `message` and `recipients`. Everything else is optional.
72
+
73
+ | Option | Type | What it does |
74
+ | --- | --- | --- |
75
+ | `device_id` | `str` | Which phone sends the message. Omit it and textbee uses your default device, or the enabled device with the most recent heartbeat. |
76
+ | `sim_subscription_id` | `int` | Which SIM sends the message on a multi-SIM phone. Omit it and the phone uses its configured preferred SIM, or the system default. |
77
+ | `scheduled_at` | `str \| datetime` | Send later instead of now. An ISO 8601 string or a timezone-aware `datetime`, in the future. |
78
+
79
+ ```python
80
+ from datetime import datetime, timedelta, timezone
81
+
82
+ textbee.send_sms(
83
+ recipients=["+12025550123"],
84
+ message="Your appointment is tomorrow at 9am",
85
+ device_id="65f0000000000000000000aa",
86
+ sim_subscription_id=2,
87
+ scheduled_at=datetime.now(timezone.utc) + timedelta(hours=1),
88
+ )
89
+ ```
90
+
91
+ A naive `datetime` (one with no `tzinfo`) raises `ValueError`, because the SDK cannot know which timezone you meant.
92
+
93
+ The response shape depends on how the message was dispatched. A queued send carries a batch id. Narrow with `in`, not `.get()`, so your type checker knows which shape you have:
94
+
95
+ ```python
96
+ result = textbee.send_sms(recipients=recipients, message=message)
97
+ if "smsBatchId" in result:
98
+ print(result["smsBatchId"], result["recipientCount"])
99
+
100
+ # Present when the phone last reported a problem, such as the SMS permission off
101
+ if "warning" in result:
102
+ print(result["warning"]["message"])
103
+ ```
104
+
105
+ ### Finding your sim_subscription_id
106
+
107
+ Open the textbee Android app, go to **Dashboard**, and find the **SIM Cards** section. Each SIM shows its subscription id with a copy button.
108
+
109
+ Be aware that this value is not validated. If the id does not match a SIM currently in the phone it is ignored, and the message goes out from the preferred or default SIM instead. Nothing errors, so confirm which SIM was used by checking the number the message arrived from.
110
+
111
+ ## Devices
112
+
113
+ ```python
114
+ devices = textbee.get_devices()
115
+ device = textbee.get_device(device_id)
116
+
117
+ # Change which device handles sends that omit device_id
118
+ textbee.set_default_device(device_id)
119
+ ```
120
+
121
+ ## Messages and delivery status
122
+
123
+ History is account-level: one call covers every device, and `device_ids` narrows it.
124
+
125
+ ```python
126
+ # Paginated history across the whole account, filterable and searchable
127
+ page = textbee.get_messages(
128
+ direction="sent", # "all" | "sent" | "received"
129
+ device_ids=[device_id], # omit for every device
130
+ status="delivered", # delivery state; status="failed" lists failed sends
131
+ search="invoice",
132
+ from_="2026-08-01", # dates are UTC; datetimes need an explicit timezone
133
+ to="2026-09-01T00:00:00Z", # exclusive, so windows never double-count
134
+ page=1,
135
+ limit=50,
136
+ )
137
+ messages, meta = page["data"], page["meta"]
138
+
139
+ # direction on each message is lowercase and feeds straight back into filters
140
+ sent = [m for m in messages if m.get("direction") == "sent"]
141
+
142
+ # Which recipients of a bulk send failed: filter by the batch a send returned
143
+ result = textbee.send_sms(recipients=recipients, message=message)
144
+ if "smsBatchId" in result:
145
+ failed = textbee.get_messages(sms_batch_id=result["smsBatchId"], status="failed")
146
+
147
+ # Drain everything matching a filter: iterate_messages follows the
148
+ # pagination cursor for you until there is nothing left. page_size is the
149
+ # number fetched per request, not a cap on the total.
150
+ for message in textbee.iterate_messages(direction="received", order="asc", page_size=100):
151
+ print(message.get("sender"), message["message"])
152
+
153
+ # A single message and its current status
154
+ sms = textbee.get_sms(device_id, sms_id)
155
+
156
+ # A whole batch, using the smsBatchId returned by send_sms. This needs the id
157
+ # of the device that sent it; when textbee picked the device, use
158
+ # get_messages(sms_batch_id=...) instead.
159
+ batch_result = textbee.get_sms_batch(device_id, sms_batch_id)
160
+ batch, batch_messages = batch_result["batch"], batch_result["messages"]
161
+ ```
162
+
163
+ `get_sms` and `get_sms_batch` return the `Sms` type. It differs from the `Message` that `get_messages` returns: `device` is the device id string, not an object with the phone's name and model, and `direction` is absent (read `type`).
164
+
165
+ The filter is named `from_` because `from` is a Python keyword. It is sent to the API as `from`.
166
+
167
+ `batch["status"]` and the batch counts come from the messages in the batch, so they change as each message reports. `successCount` counts messages sent or delivered, and `failureCount` counts failed ones. `pendingCount`, `dispatchedCount`, `sentCount`, `deliveredCount` and `unknownCount` break the rest down by state.
168
+
169
+ Responses are plain dicts typed as `TypedDict`s, so keys keep the API's names (`smsBatchId`, `createdAt`) and your editor completes them.
170
+
171
+ ## Verifying webhooks
172
+
173
+ textbee signs each webhook delivery with HMAC-SHA256 and sends the hex digest in the `X-Signature` header. Pass the raw request body, not a re-serialized dict, whenever your framework gives you access to it.
174
+
175
+ ```python
176
+ import json
177
+ import os
178
+
179
+ from flask import Flask, request
180
+ from textbee import verify_webhook_signature
181
+
182
+ app = Flask(__name__)
183
+
184
+
185
+ @app.post("/webhooks/textbee")
186
+ def textbee_webhook():
187
+ valid = verify_webhook_signature(
188
+ payload=request.get_data(),
189
+ signature=request.headers.get("X-Signature"),
190
+ signing_secret=os.environ["TEXTBEE_WEBHOOK_SECRET"],
191
+ )
192
+ if not valid:
193
+ return "", 401
194
+
195
+ event = json.loads(request.get_data())
196
+ return "", 200
197
+ ```
198
+
199
+ `payload` takes `bytes`, `str`, or a dict. A dict is re-serialized the way the API serializes it, which only matches while key order and number formatting survive the round trip, so prefer the raw body.
200
+
201
+ ## SMS utilities
202
+
203
+ Pure helpers for working with SMS text and phone numbers. No API key, no network calls, and they are useful with any SMS provider, not just textbee.
204
+
205
+ ### Segments and encoding
206
+
207
+ Carriers bill per segment, not per message. A message stays in the 7-bit GSM alphabet at 160 characters per segment, but a single character outside that alphabet, one emoji or one curly quote, switches the whole message to UCS-2 and drops the limit to 70.
208
+
209
+ ```python
210
+ from textbee import count_sms_segments, find_non_gsm7_characters, get_sms_encoding
211
+
212
+ count_sms_segments("Your code is 123456")
213
+ # SmsSegmentInfo(encoding='gsm-7', length=19, segments=1, remaining_in_segment=141)
214
+
215
+ count_sms_segments("Your code is 123456 🎉")
216
+ # SmsSegmentInfo(encoding='ucs-2', length=22, segments=1, remaining_in_segment=48)
217
+
218
+ get_sms_encoding("plain ascii") # 'gsm-7'
219
+ find_non_gsm7_characters("Hi 🎉") # ['🎉']
220
+ ```
221
+
222
+ Longer messages are split, and concatenation headers shrink each segment to 153 characters (GSM-7) or 67 (UCS-2). In UCS-2, lengths count UTF-16 code units the way phones do, so an emoji costs 2. `remaining_in_segment` counts single-unit characters, so a two-unit character such as an emoji or `€` may not fit even when it reads as 1.
223
+
224
+ ### Keeping messages in GSM-7
225
+
226
+ Text pasted from a word processor or a CMS is full of curly quotes, ellipses, and non-breaking spaces. `sanitize_for_gsm7` swaps them for plain equivalents so a message does not silently cost three times as much.
227
+
228
+ ```python
229
+ from textbee import count_sms_segments, sanitize_for_gsm7
230
+
231
+ pasted = "“Your order shipped…”"
232
+ count_sms_segments(pasted).encoding # 'ucs-2'
233
+
234
+ clean = sanitize_for_gsm7(pasted) # '"Your order shipped..."'
235
+ count_sms_segments(clean).encoding # 'gsm-7'
236
+
237
+ # Optionally strip accents that GSM-7 does not carry. Letters it does carry,
238
+ # like é, ü, and ñ, are always left alone.
239
+ sanitize_for_gsm7("naïve", transliterate_accents=True) # 'naive'
240
+ ```
241
+
242
+ It is best effort: characters with no safe equivalent pass through untouched. Check the result with `get_sms_encoding` and see what is left with `find_non_gsm7_characters`.
243
+
244
+ ### Phone number helpers
245
+
246
+ ```python
247
+ from textbee import is_valid_e164, normalize_phone_number
248
+
249
+ is_valid_e164("+12025550123") # True
250
+ is_valid_e164("202-555-0123") # False
251
+
252
+ normalize_phone_number("+1 (202) 555-0123") # '+12025550123'
253
+ normalize_phone_number("0012025550123") # '+12025550123'
254
+ normalize_phone_number("(202) 555-0123", default_country_code="1") # '+12025550123'
255
+ normalize_phone_number("not a number") # None
256
+ ```
257
+
258
+ These are format-only helpers, not [phonenumbers](https://pypi.org/project/phonenumbers/). They know nothing about country dialing plans, so a well-formed but unassigned number still passes. Input that cannot be normalized returns `None`; an unusable `default_country_code` raises `ValueError`.
259
+
260
+ ## Errors
261
+
262
+ Any non-2xx response raises a `TextbeeError` carrying the status and the parsed body. So does a redirect (the client never follows one, so your key never goes to another host) and a response that is not the API's JSON. Both usually mean a wrong `base_url`.
263
+
264
+ Network failures raise the underlying error instead: an `OSError` such as `URLError` or a socket timeout, or an `http.client` error when the connection drops.
265
+
266
+ ```python
267
+ from textbee import TextbeeError
268
+
269
+ try:
270
+ textbee.send_sms(recipients=["+12025550123"], message="hi")
271
+ except TextbeeError as error:
272
+ print(error.status, error.message, error.body)
273
+ ```
274
+
275
+ ## Client options
276
+
277
+ ```python
278
+ Textbee(
279
+ api_key="your-api-key",
280
+ base_url="https://api.textbee.dev/api/v1", # the default
281
+ timeout=30.0, # seconds for each connect and each read
282
+ )
283
+ ```
284
+
285
+ ### Self-hosted textbee
286
+
287
+ Point `base_url` at your own instance. A bare origin gets `/api/v1` added, so all of these reach the same API:
288
+
289
+ ```python
290
+ Textbee(api_key=api_key, base_url="https://sms.example.com")
291
+ Textbee(api_key=api_key, base_url="https://sms.example.com/api/v1")
292
+ Textbee(api_key=api_key, base_url="http://localhost:3001")
293
+ ```
294
+
295
+ If the API sits under a path prefix, give the full path, for example `https://example.com/textbee/api/v1`. Any path other than `/api/v1` is used as given, so a proxy that maps `/sms` onto the API works with `https://example.com/sms`. A value with no scheme gets `https://`. An invalid URL, or one with a username or password, raises `ValueError` instead of falling back to the hosted API, so a typo never sends your key to textbee.dev.
296
+
297
+ ## What is not covered yet
298
+
299
+ The SDK focuses on sending and reading messages. Bulk send and a few device operations are still REST only, documented at [textbee.dev/docs](https://textbee.dev/docs). The client is synchronous; call it from a thread (`asyncio.to_thread`) inside async code.
300
+
301
+ ## Community and support
302
+
303
+ Questions or feedback? Join the community on [Discord](https://textbee.dev/discord) or email support@textbee.dev.
304
+
305
+ ## License
306
+
307
+ MIT. Part of the [textbee](https://github.com/textbee/textbee) project.
@@ -0,0 +1,277 @@
1
+ # textbee
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/textbee)](https://pypi.org/project/textbee/)
4
+ [![CI](https://github.com/textbee/textbee-python/actions/workflows/ci.yaml/badge.svg)](https://github.com/textbee/textbee-python/actions/workflows/ci.yaml)
5
+ [![Python](https://img.shields.io/pypi/pyversions/textbee)](https://pypi.org/project/textbee/)
6
+ [![license](https://img.shields.io/pypi/l/textbee)](https://github.com/textbee/textbee-python/blob/main/LICENSE)
7
+
8
+ Official Python SDK for [textbee.dev](https://textbee.dev), the open source SMS gateway that turns an Android phone into an SMS API.
9
+
10
+ Zero dependencies, fully typed, works on Python 3.9+.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pip install textbee
16
+ ```
17
+
18
+ ```bash
19
+ uv add textbee
20
+ ```
21
+
22
+ ## Quickstart
23
+
24
+ Get an API key from the [textbee dashboard](https://textbee.dev/dashboard), then:
25
+
26
+ ```python
27
+ import os
28
+
29
+ from textbee import Textbee
30
+
31
+ textbee = Textbee(api_key=os.environ["TEXTBEE_API_KEY"])
32
+
33
+ textbee.send_sms(
34
+ recipients=["+12025550123"],
35
+ message="Hello from textbee!",
36
+ )
37
+ ```
38
+
39
+ ## Send options
40
+
41
+ `send_sms` needs only `message` and `recipients`. Everything else is optional.
42
+
43
+ | Option | Type | What it does |
44
+ | --- | --- | --- |
45
+ | `device_id` | `str` | Which phone sends the message. Omit it and textbee uses your default device, or the enabled device with the most recent heartbeat. |
46
+ | `sim_subscription_id` | `int` | Which SIM sends the message on a multi-SIM phone. Omit it and the phone uses its configured preferred SIM, or the system default. |
47
+ | `scheduled_at` | `str \| datetime` | Send later instead of now. An ISO 8601 string or a timezone-aware `datetime`, in the future. |
48
+
49
+ ```python
50
+ from datetime import datetime, timedelta, timezone
51
+
52
+ textbee.send_sms(
53
+ recipients=["+12025550123"],
54
+ message="Your appointment is tomorrow at 9am",
55
+ device_id="65f0000000000000000000aa",
56
+ sim_subscription_id=2,
57
+ scheduled_at=datetime.now(timezone.utc) + timedelta(hours=1),
58
+ )
59
+ ```
60
+
61
+ A naive `datetime` (one with no `tzinfo`) raises `ValueError`, because the SDK cannot know which timezone you meant.
62
+
63
+ The response shape depends on how the message was dispatched. A queued send carries a batch id. Narrow with `in`, not `.get()`, so your type checker knows which shape you have:
64
+
65
+ ```python
66
+ result = textbee.send_sms(recipients=recipients, message=message)
67
+ if "smsBatchId" in result:
68
+ print(result["smsBatchId"], result["recipientCount"])
69
+
70
+ # Present when the phone last reported a problem, such as the SMS permission off
71
+ if "warning" in result:
72
+ print(result["warning"]["message"])
73
+ ```
74
+
75
+ ### Finding your sim_subscription_id
76
+
77
+ Open the textbee Android app, go to **Dashboard**, and find the **SIM Cards** section. Each SIM shows its subscription id with a copy button.
78
+
79
+ Be aware that this value is not validated. If the id does not match a SIM currently in the phone it is ignored, and the message goes out from the preferred or default SIM instead. Nothing errors, so confirm which SIM was used by checking the number the message arrived from.
80
+
81
+ ## Devices
82
+
83
+ ```python
84
+ devices = textbee.get_devices()
85
+ device = textbee.get_device(device_id)
86
+
87
+ # Change which device handles sends that omit device_id
88
+ textbee.set_default_device(device_id)
89
+ ```
90
+
91
+ ## Messages and delivery status
92
+
93
+ History is account-level: one call covers every device, and `device_ids` narrows it.
94
+
95
+ ```python
96
+ # Paginated history across the whole account, filterable and searchable
97
+ page = textbee.get_messages(
98
+ direction="sent", # "all" | "sent" | "received"
99
+ device_ids=[device_id], # omit for every device
100
+ status="delivered", # delivery state; status="failed" lists failed sends
101
+ search="invoice",
102
+ from_="2026-08-01", # dates are UTC; datetimes need an explicit timezone
103
+ to="2026-09-01T00:00:00Z", # exclusive, so windows never double-count
104
+ page=1,
105
+ limit=50,
106
+ )
107
+ messages, meta = page["data"], page["meta"]
108
+
109
+ # direction on each message is lowercase and feeds straight back into filters
110
+ sent = [m for m in messages if m.get("direction") == "sent"]
111
+
112
+ # Which recipients of a bulk send failed: filter by the batch a send returned
113
+ result = textbee.send_sms(recipients=recipients, message=message)
114
+ if "smsBatchId" in result:
115
+ failed = textbee.get_messages(sms_batch_id=result["smsBatchId"], status="failed")
116
+
117
+ # Drain everything matching a filter: iterate_messages follows the
118
+ # pagination cursor for you until there is nothing left. page_size is the
119
+ # number fetched per request, not a cap on the total.
120
+ for message in textbee.iterate_messages(direction="received", order="asc", page_size=100):
121
+ print(message.get("sender"), message["message"])
122
+
123
+ # A single message and its current status
124
+ sms = textbee.get_sms(device_id, sms_id)
125
+
126
+ # A whole batch, using the smsBatchId returned by send_sms. This needs the id
127
+ # of the device that sent it; when textbee picked the device, use
128
+ # get_messages(sms_batch_id=...) instead.
129
+ batch_result = textbee.get_sms_batch(device_id, sms_batch_id)
130
+ batch, batch_messages = batch_result["batch"], batch_result["messages"]
131
+ ```
132
+
133
+ `get_sms` and `get_sms_batch` return the `Sms` type. It differs from the `Message` that `get_messages` returns: `device` is the device id string, not an object with the phone's name and model, and `direction` is absent (read `type`).
134
+
135
+ The filter is named `from_` because `from` is a Python keyword. It is sent to the API as `from`.
136
+
137
+ `batch["status"]` and the batch counts come from the messages in the batch, so they change as each message reports. `successCount` counts messages sent or delivered, and `failureCount` counts failed ones. `pendingCount`, `dispatchedCount`, `sentCount`, `deliveredCount` and `unknownCount` break the rest down by state.
138
+
139
+ Responses are plain dicts typed as `TypedDict`s, so keys keep the API's names (`smsBatchId`, `createdAt`) and your editor completes them.
140
+
141
+ ## Verifying webhooks
142
+
143
+ textbee signs each webhook delivery with HMAC-SHA256 and sends the hex digest in the `X-Signature` header. Pass the raw request body, not a re-serialized dict, whenever your framework gives you access to it.
144
+
145
+ ```python
146
+ import json
147
+ import os
148
+
149
+ from flask import Flask, request
150
+ from textbee import verify_webhook_signature
151
+
152
+ app = Flask(__name__)
153
+
154
+
155
+ @app.post("/webhooks/textbee")
156
+ def textbee_webhook():
157
+ valid = verify_webhook_signature(
158
+ payload=request.get_data(),
159
+ signature=request.headers.get("X-Signature"),
160
+ signing_secret=os.environ["TEXTBEE_WEBHOOK_SECRET"],
161
+ )
162
+ if not valid:
163
+ return "", 401
164
+
165
+ event = json.loads(request.get_data())
166
+ return "", 200
167
+ ```
168
+
169
+ `payload` takes `bytes`, `str`, or a dict. A dict is re-serialized the way the API serializes it, which only matches while key order and number formatting survive the round trip, so prefer the raw body.
170
+
171
+ ## SMS utilities
172
+
173
+ Pure helpers for working with SMS text and phone numbers. No API key, no network calls, and they are useful with any SMS provider, not just textbee.
174
+
175
+ ### Segments and encoding
176
+
177
+ Carriers bill per segment, not per message. A message stays in the 7-bit GSM alphabet at 160 characters per segment, but a single character outside that alphabet, one emoji or one curly quote, switches the whole message to UCS-2 and drops the limit to 70.
178
+
179
+ ```python
180
+ from textbee import count_sms_segments, find_non_gsm7_characters, get_sms_encoding
181
+
182
+ count_sms_segments("Your code is 123456")
183
+ # SmsSegmentInfo(encoding='gsm-7', length=19, segments=1, remaining_in_segment=141)
184
+
185
+ count_sms_segments("Your code is 123456 🎉")
186
+ # SmsSegmentInfo(encoding='ucs-2', length=22, segments=1, remaining_in_segment=48)
187
+
188
+ get_sms_encoding("plain ascii") # 'gsm-7'
189
+ find_non_gsm7_characters("Hi 🎉") # ['🎉']
190
+ ```
191
+
192
+ Longer messages are split, and concatenation headers shrink each segment to 153 characters (GSM-7) or 67 (UCS-2). In UCS-2, lengths count UTF-16 code units the way phones do, so an emoji costs 2. `remaining_in_segment` counts single-unit characters, so a two-unit character such as an emoji or `€` may not fit even when it reads as 1.
193
+
194
+ ### Keeping messages in GSM-7
195
+
196
+ Text pasted from a word processor or a CMS is full of curly quotes, ellipses, and non-breaking spaces. `sanitize_for_gsm7` swaps them for plain equivalents so a message does not silently cost three times as much.
197
+
198
+ ```python
199
+ from textbee import count_sms_segments, sanitize_for_gsm7
200
+
201
+ pasted = "“Your order shipped…”"
202
+ count_sms_segments(pasted).encoding # 'ucs-2'
203
+
204
+ clean = sanitize_for_gsm7(pasted) # '"Your order shipped..."'
205
+ count_sms_segments(clean).encoding # 'gsm-7'
206
+
207
+ # Optionally strip accents that GSM-7 does not carry. Letters it does carry,
208
+ # like é, ü, and ñ, are always left alone.
209
+ sanitize_for_gsm7("naïve", transliterate_accents=True) # 'naive'
210
+ ```
211
+
212
+ It is best effort: characters with no safe equivalent pass through untouched. Check the result with `get_sms_encoding` and see what is left with `find_non_gsm7_characters`.
213
+
214
+ ### Phone number helpers
215
+
216
+ ```python
217
+ from textbee import is_valid_e164, normalize_phone_number
218
+
219
+ is_valid_e164("+12025550123") # True
220
+ is_valid_e164("202-555-0123") # False
221
+
222
+ normalize_phone_number("+1 (202) 555-0123") # '+12025550123'
223
+ normalize_phone_number("0012025550123") # '+12025550123'
224
+ normalize_phone_number("(202) 555-0123", default_country_code="1") # '+12025550123'
225
+ normalize_phone_number("not a number") # None
226
+ ```
227
+
228
+ These are format-only helpers, not [phonenumbers](https://pypi.org/project/phonenumbers/). They know nothing about country dialing plans, so a well-formed but unassigned number still passes. Input that cannot be normalized returns `None`; an unusable `default_country_code` raises `ValueError`.
229
+
230
+ ## Errors
231
+
232
+ Any non-2xx response raises a `TextbeeError` carrying the status and the parsed body. So does a redirect (the client never follows one, so your key never goes to another host) and a response that is not the API's JSON. Both usually mean a wrong `base_url`.
233
+
234
+ Network failures raise the underlying error instead: an `OSError` such as `URLError` or a socket timeout, or an `http.client` error when the connection drops.
235
+
236
+ ```python
237
+ from textbee import TextbeeError
238
+
239
+ try:
240
+ textbee.send_sms(recipients=["+12025550123"], message="hi")
241
+ except TextbeeError as error:
242
+ print(error.status, error.message, error.body)
243
+ ```
244
+
245
+ ## Client options
246
+
247
+ ```python
248
+ Textbee(
249
+ api_key="your-api-key",
250
+ base_url="https://api.textbee.dev/api/v1", # the default
251
+ timeout=30.0, # seconds for each connect and each read
252
+ )
253
+ ```
254
+
255
+ ### Self-hosted textbee
256
+
257
+ Point `base_url` at your own instance. A bare origin gets `/api/v1` added, so all of these reach the same API:
258
+
259
+ ```python
260
+ Textbee(api_key=api_key, base_url="https://sms.example.com")
261
+ Textbee(api_key=api_key, base_url="https://sms.example.com/api/v1")
262
+ Textbee(api_key=api_key, base_url="http://localhost:3001")
263
+ ```
264
+
265
+ If the API sits under a path prefix, give the full path, for example `https://example.com/textbee/api/v1`. Any path other than `/api/v1` is used as given, so a proxy that maps `/sms` onto the API works with `https://example.com/sms`. A value with no scheme gets `https://`. An invalid URL, or one with a username or password, raises `ValueError` instead of falling back to the hosted API, so a typo never sends your key to textbee.dev.
266
+
267
+ ## What is not covered yet
268
+
269
+ The SDK focuses on sending and reading messages. Bulk send and a few device operations are still REST only, documented at [textbee.dev/docs](https://textbee.dev/docs). The client is synchronous; call it from a thread (`asyncio.to_thread`) inside async code.
270
+
271
+ ## Community and support
272
+
273
+ Questions or feedback? Join the community on [Discord](https://textbee.dev/discord) or email support@textbee.dev.
274
+
275
+ ## License
276
+
277
+ MIT. Part of the [textbee](https://github.com/textbee/textbee) project.