joinsimplesms 2.8.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.
- joinsimplesms-2.8.0/.gitignore +6 -0
- joinsimplesms-2.8.0/PKG-INFO +314 -0
- joinsimplesms-2.8.0/README.md +298 -0
- joinsimplesms-2.8.0/pyproject.toml +31 -0
- joinsimplesms-2.8.0/src/joinsimplesms/__init__.py +131 -0
- joinsimplesms-2.8.0/src/joinsimplesms/_client.py +201 -0
- joinsimplesms-2.8.0/src/joinsimplesms/_errors.py +66 -0
- joinsimplesms-2.8.0/src/joinsimplesms/_resources.py +860 -0
- joinsimplesms-2.8.0/src/joinsimplesms/_version.py +1 -0
- joinsimplesms-2.8.0/src/joinsimplesms/py.typed +0 -0
- joinsimplesms-2.8.0/src/joinsimplesms/types.py +464 -0
- joinsimplesms-2.8.0/src/joinsimplesms/webhooks.py +119 -0
- joinsimplesms-2.8.0/tests/fixtures/webhook_vectors.json +15 -0
- joinsimplesms-2.8.0/tests/test_client.py +465 -0
- joinsimplesms-2.8.0/tests/test_webhooks.py +98 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: joinsimplesms
|
|
3
|
+
Version: 2.8.0
|
|
4
|
+
Summary: Official Python client for SimpleSMS (joinsimplesms.com): SMS, phone verification, and phone numbers. No dependencies.
|
|
5
|
+
Project-URL: Homepage, https://joinsimplesms.com
|
|
6
|
+
Project-URL: Documentation, https://joinsimplesms.com/docs
|
|
7
|
+
Author: Truelabel Inc
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Keywords: 2fa,ai agents,otp,phone verification,sms,sms api,twilio alternative
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Communications :: Telephony
|
|
13
|
+
Classifier: Typing :: Typed
|
|
14
|
+
Requires-Python: >=3.8
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# joinsimplesms (Python)
|
|
18
|
+
|
|
19
|
+
Official Python client for the [SimpleSMS](https://joinsimplesms.com) API:
|
|
20
|
+
SMS, phone verification, and phone numbers. No dependencies, Python 3.8+.
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install joinsimplesms
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Get a free key (no card) at [the console](https://joinsimplesms.com/console).
|
|
27
|
+
|
|
28
|
+
## Send an SMS
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from joinsimplesms import SimpleSMS
|
|
32
|
+
|
|
33
|
+
client = SimpleSMS() # reads SIMPLESMS_API_KEY
|
|
34
|
+
|
|
35
|
+
message = client.messages.send(to="+15005550006", text="Hello from SimpleSMS")
|
|
36
|
+
print(message["id"]) # msg_...
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`from_` is optional while your account has one number (a sandbox account
|
|
40
|
+
does): the client looks it up once and reuses it. With several numbers, pass
|
|
41
|
+
`from_="+1..."` or set `SIMPLESMS_FROM`. `text` and `body` are the same thing.
|
|
42
|
+
|
|
43
|
+
Retries are safe. An `Idempotency-Key` is generated for every send, so a
|
|
44
|
+
network blip can never double-send. Pass your own to control it:
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
client.messages.send(to=to, body=body, idempotency_key=f"order:{order_id}")
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Phone verification
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
client.verify.send(to="+14155550132")
|
|
54
|
+
result = client.verify.check(to="+14155550132", code=code)
|
|
55
|
+
if result["verified"]:
|
|
56
|
+
...
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
You don't store codes or own a number, and you're billed only when
|
|
60
|
+
`verified` comes back true.
|
|
61
|
+
|
|
62
|
+
By email instead, or when the text doesn't arrive:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
client.verify.send("ada@example.com", channel="email")
|
|
66
|
+
|
|
67
|
+
# Same verification, same single charge; the emailed code replaces the texted one.
|
|
68
|
+
client.verify.fallback(v["id"], email="ada@example.com")
|
|
69
|
+
# The emailed code proves the address, not the phone: check it by email.
|
|
70
|
+
client.verify.check(to="ada@example.com", code=code)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Everything else
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
client.messages.get("msg_...")
|
|
77
|
+
client.messages.list(limit=25) # one page
|
|
78
|
+
for m in client.messages.list_all(): # every page, lazily
|
|
79
|
+
...
|
|
80
|
+
client.messages.schedule(to, "Reminder", send_at=datetime(...))
|
|
81
|
+
|
|
82
|
+
client.numbers.available(area_code="415")
|
|
83
|
+
client.numbers.buy("+14155550132")
|
|
84
|
+
client.numbers.list()
|
|
85
|
+
client.numbers.release("+14155550132")
|
|
86
|
+
|
|
87
|
+
client.lookup.phone("+14155550132") # carrier, line type
|
|
88
|
+
|
|
89
|
+
client.events.list(limit=25)
|
|
90
|
+
for e in client.events.list_all(type="message.delivered"):
|
|
91
|
+
...
|
|
92
|
+
|
|
93
|
+
# Automations: send events from your app; a flow sends, waits, and branches.
|
|
94
|
+
# The first call for a user links user_id to their phone number.
|
|
95
|
+
client.events.track("trial_started", user_id="123", phone="+14155550132")
|
|
96
|
+
client.events.track("subscription_created", user_id="123")
|
|
97
|
+
flow = client.automations.create(
|
|
98
|
+
name="Trial reminder",
|
|
99
|
+
from_="+15005550100",
|
|
100
|
+
trigger={"event": "trial_started"},
|
|
101
|
+
steps=[
|
|
102
|
+
{"type": "wait", "seconds": 3600},
|
|
103
|
+
{"type": "send_sms", "body": "Your trial is live. Reply STOP to opt out."},
|
|
104
|
+
],
|
|
105
|
+
)
|
|
106
|
+
client.automations.activate(flow["id"])
|
|
107
|
+
client.automations.runs(flow["id"]) # each run, with its timeline
|
|
108
|
+
|
|
109
|
+
client.consent.get("+14155550132") # status + history
|
|
110
|
+
client.consent.opt_out("+14155550132", note="CRM sync")
|
|
111
|
+
client.consent.opt_in("+14155550132")
|
|
112
|
+
# For one registration (what marketing from its numbers looks for); proof is
|
|
113
|
+
# required when the registration is marketing or mixed.
|
|
114
|
+
client.consent.opt_in(
|
|
115
|
+
"+14155550132",
|
|
116
|
+
campaign_id="reg_a1B2c3D4e5F6",
|
|
117
|
+
proof={"source": "web_form", "collected_at": "2026-10-01T15:04:05Z", "page_url": "https://acme.example/texts"},
|
|
118
|
+
)
|
|
119
|
+
client.consent.import_(["+14155550132", "+14155550133"])
|
|
120
|
+
csv_text = client.consent.export()
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Recurring schedules
|
|
124
|
+
|
|
125
|
+
One message to one number on a repeating local time. Each occurrence is a
|
|
126
|
+
normal send: opt-outs, quota and your spend limit are checked when it fires.
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
schedule = client.schedules.create(
|
|
130
|
+
to="+14155550132",
|
|
131
|
+
body="Good morning. You have got this.",
|
|
132
|
+
repeat={"frequency": "daily", "time": "08:00", "timezone": "America/New_York"},
|
|
133
|
+
)
|
|
134
|
+
schedule["next_run_at"] # the next 08:00 in New York, in UTC
|
|
135
|
+
client.schedules.pause(schedule["id"]) # resume(id) starts again from now
|
|
136
|
+
client.messages.list(schedule_id=schedule["id"])
|
|
137
|
+
client.schedules.delete(schedule["id"])
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
`frequency` is `daily`, `weekly` (with `days_of_week=["mon", "fri"]` inside
|
|
141
|
+
`repeat`) or `monthly` (with `day_of_month`). The time is a wall-clock time
|
|
142
|
+
and stays put across daylight saving.
|
|
143
|
+
|
|
144
|
+
## Contacts
|
|
145
|
+
|
|
146
|
+
One contact per phone number. Tags double as broadcast audiences.
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
client.contacts.upsert("+14155550132", name="Ada", tags=["vip"])
|
|
150
|
+
|
|
151
|
+
# Sync from your own database, up to 500 per call.
|
|
152
|
+
client.contacts.import_([{"phone_number": u.phone, "name": u.name} for u in users])
|
|
153
|
+
|
|
154
|
+
for c in client.contacts.list_all(tag="vip"):
|
|
155
|
+
print(c["phone_number"])
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## Customers (for platforms)
|
|
159
|
+
|
|
160
|
+
If you send for other businesses, label the traffic so usage, messages, and
|
|
161
|
+
webhooks come back per customer. Entirely optional.
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
acme = client.customers.create(name="Acme Dental", external_id="acct_42")
|
|
165
|
+
client.numbers.buy("+14155550132", customer_id=acme["id"]) # or numbers.update(...)
|
|
166
|
+
|
|
167
|
+
client.messages.send(to=to, text="Your cleaning is tomorrow", from_="+14155550132")
|
|
168
|
+
# -> message["customer_id"] == acme["id"], inherited from the number
|
|
169
|
+
|
|
170
|
+
client.messages.list(customer_id=acme["id"])
|
|
171
|
+
client.customers.usage(acme["id"], start="2026-10-01", end="2026-10-31")
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## Registrations (business texting)
|
|
175
|
+
|
|
176
|
+
A US local number texts the public only once its registration is approved.
|
|
177
|
+
The first submit for a business carries an attestation signed by someone
|
|
178
|
+
authorized to act for it; we verify the business, then file. Every submit
|
|
179
|
+
carries `fees_acknowledged=True`: carrier registration fees are billed at
|
|
180
|
+
cost, a campaign carries a 3-month minimum, and they are non-refundable even
|
|
181
|
+
if the registration is rejected.
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
reg = client.registrations.create(
|
|
185
|
+
business_name="Acme Plumbing LLC",
|
|
186
|
+
website="https://acmeplumbing.com",
|
|
187
|
+
use_case="customer_care",
|
|
188
|
+
opt_in_url="https://acmeplumbing.com/text-updates",
|
|
189
|
+
sample_messages=["Acme Plumbing: Hi Sam, your plumber Dana arrives tomorrow between 9 and 11 AM. Reply STOP to opt out.",
|
|
190
|
+
"Acme Plumbing: Your job #4821 is done. Reply here with any questions."],
|
|
191
|
+
brand={"legal_name": "Acme Plumbing LLC", "ein": "12-3456789", "entity_type": "PRIVATE_PROFIT",
|
|
192
|
+
"street": "12 Main St", "city": "Springfield", "state": "IL", "postal_code": "62701", "vertical": "CONSTRUCTION",
|
|
193
|
+
"contact_name": "Dana Smith", "contact_title": "Owner", "contact_email": "dana@acmeplumbing.com", "contact_phone": "217-555-0100"},
|
|
194
|
+
)
|
|
195
|
+
if reg["status"] == "ready":
|
|
196
|
+
client.registrations.submit(reg["id"], attestation={"signer_name": "Dana Smith", "signer_title": "Owner", "accepted": True}, fees_acknowledged=True)
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
## Webhooks
|
|
200
|
+
|
|
201
|
+
```python
|
|
202
|
+
from joinsimplesms import verify_webhook, SimpleSMSError
|
|
203
|
+
|
|
204
|
+
@app.post("/webhooks/simplesms")
|
|
205
|
+
def hook(request):
|
|
206
|
+
try:
|
|
207
|
+
event = verify_webhook(
|
|
208
|
+
request.body, # raw bytes, not parsed JSON
|
|
209
|
+
request.headers["simplesms-signature"],
|
|
210
|
+
os.environ["SIMPLESMS_WEBHOOK_SECRET"],
|
|
211
|
+
)
|
|
212
|
+
except SimpleSMSError:
|
|
213
|
+
return 400
|
|
214
|
+
...
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Signatures older than 5 minutes are rejected (`tolerance=300`) so a captured
|
|
218
|
+
request can't be replayed. `client.webhooks.verify(...)` is the same function.
|
|
219
|
+
|
|
220
|
+
The signature is also sent as `dsms-signature` and `resms-signature` (the
|
|
221
|
+
header names from before the rename). Pass `request.headers` itself as the
|
|
222
|
+
second argument and the client reads whichever one arrived.
|
|
223
|
+
|
|
224
|
+
Endpoints can be managed from code too:
|
|
225
|
+
|
|
226
|
+
```python
|
|
227
|
+
endpoint = client.webhooks.create("https://example.com/webhooks/simplesms", events=["message.received"])
|
|
228
|
+
endpoint["secret"] # whsec_... returned once, here. Store it.
|
|
229
|
+
|
|
230
|
+
client.webhooks.list()
|
|
231
|
+
client.webhooks.update(endpoint["id"], active=False) # pause
|
|
232
|
+
client.webhooks.delete(endpoint["id"])
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
To receive webhooks on localhost without a tunnel, use the CLI that ships with
|
|
236
|
+
the Node package: `npx joinsimplesms login`, then
|
|
237
|
+
`npx joinsimplesms listen --forward-to http://localhost:8000/webhooks`. It
|
|
238
|
+
prints a signing secret; `verify_webhook` accepts its requests unchanged.
|
|
239
|
+
|
|
240
|
+
## Errors
|
|
241
|
+
|
|
242
|
+
Every failure raises `SimpleSMSError`:
|
|
243
|
+
|
|
244
|
+
```python
|
|
245
|
+
from joinsimplesms import SimpleSMSError
|
|
246
|
+
|
|
247
|
+
try:
|
|
248
|
+
client.verify.send(to=phone)
|
|
249
|
+
except SimpleSMSError as err:
|
|
250
|
+
err.code # "verification_blocked", "rate_limited", ...
|
|
251
|
+
err.status # HTTP status (0 when the API was unreachable)
|
|
252
|
+
err.param # which field was wrong, when known
|
|
253
|
+
err.retry_after # seconds, on rate limits
|
|
254
|
+
err.retryable # whether trying again could work
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Codes you will want to handle:
|
|
258
|
+
|
|
259
|
+
| Code | Status | Meaning |
|
|
260
|
+
| --- | --- | --- |
|
|
261
|
+
| `insufficient_credits` | 402 | Your prepaid balance cannot cover the call. Nothing was sent or charged. `err.balance_micro_usd`, `err.required_micro_usd` and `err.top_up_url` say how short you are and where to add credits. Not retried: top up, then send again. |
|
|
262
|
+
| `spend_limit_reached` | 403 | Your own monthly spend limit is reached. |
|
|
263
|
+
| `recipient_opted_out` | 403 | The recipient unsubscribed. Do not retry. |
|
|
264
|
+
| `sender_not_registered` | 403 | The sending number is not registered yet; it can text your verified numbers only. |
|
|
265
|
+
| `quiet_hours` | 403 | A marketing message outside 8:00am to 9:00pm in the recipient's local time. `err.send_after` is the next allowed time; `err.recipient_local_time` and `err.timezone` say what we read. Send it then, or pass `quiet_hours="defer"` to have it held. Mark marketing sends with `category="marketing"`. |
|
|
266
|
+
| `quota_exceeded` | 429 | A free-tier or daily limit was reached. |
|
|
267
|
+
| `rate_limited` | 429 | Slow down; see `err.retry_after`. |
|
|
268
|
+
|
|
269
|
+
```python
|
|
270
|
+
client.balance.get()["balance_usd"] # prepaid credit balance
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
429s, 5xx, timeouts, and connection errors are retried with exponential
|
|
274
|
+
backoff, honoring `Retry-After`. Two things are deliberately **not** retried:
|
|
275
|
+
a `POST` without an idempotency key (a verification send, a number purchase),
|
|
276
|
+
and a verification cooldown, which is policy, not a blip.
|
|
277
|
+
|
|
278
|
+
## Testing
|
|
279
|
+
|
|
280
|
+
Test keys (`ssms_sk_test_...`) cost nothing and simulate: the message reaches `delivered` with `"simulated": True` and no text is sent, except to your own verified phone.
|
|
281
|
+
`client.is_test_mode` tells you which you have. Magic numbers and codes are
|
|
282
|
+
in the [sandbox docs](https://joinsimplesms.com/docs/sandbox).
|
|
283
|
+
|
|
284
|
+
## Options
|
|
285
|
+
|
|
286
|
+
```python
|
|
287
|
+
SimpleSMS(
|
|
288
|
+
api_key, # default: SIMPLESMS_API_KEY
|
|
289
|
+
default_from="+14155550100", # default: SIMPLESMS_FROM, else your only number
|
|
290
|
+
base_url="https://api.joinsimplesms.com",
|
|
291
|
+
max_retries=2,
|
|
292
|
+
timeout=30.0,
|
|
293
|
+
transport=my_transport, # (method, url, headers, body, timeout) -> Response
|
|
294
|
+
)
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
## Renamed from Delivered
|
|
298
|
+
|
|
299
|
+
SimpleSMS was called Delivered until October 2026. `Delivered` and
|
|
300
|
+
`DeliveredError` are still exported as aliases of `SimpleSMS` and
|
|
301
|
+
`SimpleSMSError`; `DELIVERED_API_KEY`, `DELIVERED_BASE_URL` and
|
|
302
|
+
`DELIVERED_FROM` are still read when the `SIMPLESMS_*` names are unset;
|
|
303
|
+
`dsms_sk_...` keys still authenticate. The old host `api.deliveredsms.com` is retired (410); this SDK defaults to `api.joinsimplesms.com`.
|
|
304
|
+
|
|
305
|
+
## Why no dependencies
|
|
306
|
+
|
|
307
|
+
The client uses only the standard library (`urllib`, `json`, `hmac`). The
|
|
308
|
+
API is plain JSON over HTTPS and every call is synchronous, so a third-party
|
|
309
|
+
HTTP stack would add install weight and version conflicts (the classic
|
|
310
|
+
`requests`/`urllib3` pin fight) without adding anything you'd notice. It
|
|
311
|
+
installs in any environment, including Lambda layers and locked-down CI. If
|
|
312
|
+
you want connection pooling, proxies, or tracing, pass a `transport`.
|
|
313
|
+
|
|
314
|
+
MIT © Truelabel Inc
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
# joinsimplesms (Python)
|
|
2
|
+
|
|
3
|
+
Official Python client for the [SimpleSMS](https://joinsimplesms.com) API:
|
|
4
|
+
SMS, phone verification, and phone numbers. No dependencies, Python 3.8+.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
pip install joinsimplesms
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Get a free key (no card) at [the console](https://joinsimplesms.com/console).
|
|
11
|
+
|
|
12
|
+
## Send an SMS
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
from joinsimplesms import SimpleSMS
|
|
16
|
+
|
|
17
|
+
client = SimpleSMS() # reads SIMPLESMS_API_KEY
|
|
18
|
+
|
|
19
|
+
message = client.messages.send(to="+15005550006", text="Hello from SimpleSMS")
|
|
20
|
+
print(message["id"]) # msg_...
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`from_` is optional while your account has one number (a sandbox account
|
|
24
|
+
does): the client looks it up once and reuses it. With several numbers, pass
|
|
25
|
+
`from_="+1..."` or set `SIMPLESMS_FROM`. `text` and `body` are the same thing.
|
|
26
|
+
|
|
27
|
+
Retries are safe. An `Idempotency-Key` is generated for every send, so a
|
|
28
|
+
network blip can never double-send. Pass your own to control it:
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
client.messages.send(to=to, body=body, idempotency_key=f"order:{order_id}")
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Phone verification
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
client.verify.send(to="+14155550132")
|
|
38
|
+
result = client.verify.check(to="+14155550132", code=code)
|
|
39
|
+
if result["verified"]:
|
|
40
|
+
...
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
You don't store codes or own a number, and you're billed only when
|
|
44
|
+
`verified` comes back true.
|
|
45
|
+
|
|
46
|
+
By email instead, or when the text doesn't arrive:
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
client.verify.send("ada@example.com", channel="email")
|
|
50
|
+
|
|
51
|
+
# Same verification, same single charge; the emailed code replaces the texted one.
|
|
52
|
+
client.verify.fallback(v["id"], email="ada@example.com")
|
|
53
|
+
# The emailed code proves the address, not the phone: check it by email.
|
|
54
|
+
client.verify.check(to="ada@example.com", code=code)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Everything else
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
client.messages.get("msg_...")
|
|
61
|
+
client.messages.list(limit=25) # one page
|
|
62
|
+
for m in client.messages.list_all(): # every page, lazily
|
|
63
|
+
...
|
|
64
|
+
client.messages.schedule(to, "Reminder", send_at=datetime(...))
|
|
65
|
+
|
|
66
|
+
client.numbers.available(area_code="415")
|
|
67
|
+
client.numbers.buy("+14155550132")
|
|
68
|
+
client.numbers.list()
|
|
69
|
+
client.numbers.release("+14155550132")
|
|
70
|
+
|
|
71
|
+
client.lookup.phone("+14155550132") # carrier, line type
|
|
72
|
+
|
|
73
|
+
client.events.list(limit=25)
|
|
74
|
+
for e in client.events.list_all(type="message.delivered"):
|
|
75
|
+
...
|
|
76
|
+
|
|
77
|
+
# Automations: send events from your app; a flow sends, waits, and branches.
|
|
78
|
+
# The first call for a user links user_id to their phone number.
|
|
79
|
+
client.events.track("trial_started", user_id="123", phone="+14155550132")
|
|
80
|
+
client.events.track("subscription_created", user_id="123")
|
|
81
|
+
flow = client.automations.create(
|
|
82
|
+
name="Trial reminder",
|
|
83
|
+
from_="+15005550100",
|
|
84
|
+
trigger={"event": "trial_started"},
|
|
85
|
+
steps=[
|
|
86
|
+
{"type": "wait", "seconds": 3600},
|
|
87
|
+
{"type": "send_sms", "body": "Your trial is live. Reply STOP to opt out."},
|
|
88
|
+
],
|
|
89
|
+
)
|
|
90
|
+
client.automations.activate(flow["id"])
|
|
91
|
+
client.automations.runs(flow["id"]) # each run, with its timeline
|
|
92
|
+
|
|
93
|
+
client.consent.get("+14155550132") # status + history
|
|
94
|
+
client.consent.opt_out("+14155550132", note="CRM sync")
|
|
95
|
+
client.consent.opt_in("+14155550132")
|
|
96
|
+
# For one registration (what marketing from its numbers looks for); proof is
|
|
97
|
+
# required when the registration is marketing or mixed.
|
|
98
|
+
client.consent.opt_in(
|
|
99
|
+
"+14155550132",
|
|
100
|
+
campaign_id="reg_a1B2c3D4e5F6",
|
|
101
|
+
proof={"source": "web_form", "collected_at": "2026-10-01T15:04:05Z", "page_url": "https://acme.example/texts"},
|
|
102
|
+
)
|
|
103
|
+
client.consent.import_(["+14155550132", "+14155550133"])
|
|
104
|
+
csv_text = client.consent.export()
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Recurring schedules
|
|
108
|
+
|
|
109
|
+
One message to one number on a repeating local time. Each occurrence is a
|
|
110
|
+
normal send: opt-outs, quota and your spend limit are checked when it fires.
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
schedule = client.schedules.create(
|
|
114
|
+
to="+14155550132",
|
|
115
|
+
body="Good morning. You have got this.",
|
|
116
|
+
repeat={"frequency": "daily", "time": "08:00", "timezone": "America/New_York"},
|
|
117
|
+
)
|
|
118
|
+
schedule["next_run_at"] # the next 08:00 in New York, in UTC
|
|
119
|
+
client.schedules.pause(schedule["id"]) # resume(id) starts again from now
|
|
120
|
+
client.messages.list(schedule_id=schedule["id"])
|
|
121
|
+
client.schedules.delete(schedule["id"])
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`frequency` is `daily`, `weekly` (with `days_of_week=["mon", "fri"]` inside
|
|
125
|
+
`repeat`) or `monthly` (with `day_of_month`). The time is a wall-clock time
|
|
126
|
+
and stays put across daylight saving.
|
|
127
|
+
|
|
128
|
+
## Contacts
|
|
129
|
+
|
|
130
|
+
One contact per phone number. Tags double as broadcast audiences.
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
client.contacts.upsert("+14155550132", name="Ada", tags=["vip"])
|
|
134
|
+
|
|
135
|
+
# Sync from your own database, up to 500 per call.
|
|
136
|
+
client.contacts.import_([{"phone_number": u.phone, "name": u.name} for u in users])
|
|
137
|
+
|
|
138
|
+
for c in client.contacts.list_all(tag="vip"):
|
|
139
|
+
print(c["phone_number"])
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Customers (for platforms)
|
|
143
|
+
|
|
144
|
+
If you send for other businesses, label the traffic so usage, messages, and
|
|
145
|
+
webhooks come back per customer. Entirely optional.
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
acme = client.customers.create(name="Acme Dental", external_id="acct_42")
|
|
149
|
+
client.numbers.buy("+14155550132", customer_id=acme["id"]) # or numbers.update(...)
|
|
150
|
+
|
|
151
|
+
client.messages.send(to=to, text="Your cleaning is tomorrow", from_="+14155550132")
|
|
152
|
+
# -> message["customer_id"] == acme["id"], inherited from the number
|
|
153
|
+
|
|
154
|
+
client.messages.list(customer_id=acme["id"])
|
|
155
|
+
client.customers.usage(acme["id"], start="2026-10-01", end="2026-10-31")
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## Registrations (business texting)
|
|
159
|
+
|
|
160
|
+
A US local number texts the public only once its registration is approved.
|
|
161
|
+
The first submit for a business carries an attestation signed by someone
|
|
162
|
+
authorized to act for it; we verify the business, then file. Every submit
|
|
163
|
+
carries `fees_acknowledged=True`: carrier registration fees are billed at
|
|
164
|
+
cost, a campaign carries a 3-month minimum, and they are non-refundable even
|
|
165
|
+
if the registration is rejected.
|
|
166
|
+
|
|
167
|
+
```python
|
|
168
|
+
reg = client.registrations.create(
|
|
169
|
+
business_name="Acme Plumbing LLC",
|
|
170
|
+
website="https://acmeplumbing.com",
|
|
171
|
+
use_case="customer_care",
|
|
172
|
+
opt_in_url="https://acmeplumbing.com/text-updates",
|
|
173
|
+
sample_messages=["Acme Plumbing: Hi Sam, your plumber Dana arrives tomorrow between 9 and 11 AM. Reply STOP to opt out.",
|
|
174
|
+
"Acme Plumbing: Your job #4821 is done. Reply here with any questions."],
|
|
175
|
+
brand={"legal_name": "Acme Plumbing LLC", "ein": "12-3456789", "entity_type": "PRIVATE_PROFIT",
|
|
176
|
+
"street": "12 Main St", "city": "Springfield", "state": "IL", "postal_code": "62701", "vertical": "CONSTRUCTION",
|
|
177
|
+
"contact_name": "Dana Smith", "contact_title": "Owner", "contact_email": "dana@acmeplumbing.com", "contact_phone": "217-555-0100"},
|
|
178
|
+
)
|
|
179
|
+
if reg["status"] == "ready":
|
|
180
|
+
client.registrations.submit(reg["id"], attestation={"signer_name": "Dana Smith", "signer_title": "Owner", "accepted": True}, fees_acknowledged=True)
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
## Webhooks
|
|
184
|
+
|
|
185
|
+
```python
|
|
186
|
+
from joinsimplesms import verify_webhook, SimpleSMSError
|
|
187
|
+
|
|
188
|
+
@app.post("/webhooks/simplesms")
|
|
189
|
+
def hook(request):
|
|
190
|
+
try:
|
|
191
|
+
event = verify_webhook(
|
|
192
|
+
request.body, # raw bytes, not parsed JSON
|
|
193
|
+
request.headers["simplesms-signature"],
|
|
194
|
+
os.environ["SIMPLESMS_WEBHOOK_SECRET"],
|
|
195
|
+
)
|
|
196
|
+
except SimpleSMSError:
|
|
197
|
+
return 400
|
|
198
|
+
...
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Signatures older than 5 minutes are rejected (`tolerance=300`) so a captured
|
|
202
|
+
request can't be replayed. `client.webhooks.verify(...)` is the same function.
|
|
203
|
+
|
|
204
|
+
The signature is also sent as `dsms-signature` and `resms-signature` (the
|
|
205
|
+
header names from before the rename). Pass `request.headers` itself as the
|
|
206
|
+
second argument and the client reads whichever one arrived.
|
|
207
|
+
|
|
208
|
+
Endpoints can be managed from code too:
|
|
209
|
+
|
|
210
|
+
```python
|
|
211
|
+
endpoint = client.webhooks.create("https://example.com/webhooks/simplesms", events=["message.received"])
|
|
212
|
+
endpoint["secret"] # whsec_... returned once, here. Store it.
|
|
213
|
+
|
|
214
|
+
client.webhooks.list()
|
|
215
|
+
client.webhooks.update(endpoint["id"], active=False) # pause
|
|
216
|
+
client.webhooks.delete(endpoint["id"])
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
To receive webhooks on localhost without a tunnel, use the CLI that ships with
|
|
220
|
+
the Node package: `npx joinsimplesms login`, then
|
|
221
|
+
`npx joinsimplesms listen --forward-to http://localhost:8000/webhooks`. It
|
|
222
|
+
prints a signing secret; `verify_webhook` accepts its requests unchanged.
|
|
223
|
+
|
|
224
|
+
## Errors
|
|
225
|
+
|
|
226
|
+
Every failure raises `SimpleSMSError`:
|
|
227
|
+
|
|
228
|
+
```python
|
|
229
|
+
from joinsimplesms import SimpleSMSError
|
|
230
|
+
|
|
231
|
+
try:
|
|
232
|
+
client.verify.send(to=phone)
|
|
233
|
+
except SimpleSMSError as err:
|
|
234
|
+
err.code # "verification_blocked", "rate_limited", ...
|
|
235
|
+
err.status # HTTP status (0 when the API was unreachable)
|
|
236
|
+
err.param # which field was wrong, when known
|
|
237
|
+
err.retry_after # seconds, on rate limits
|
|
238
|
+
err.retryable # whether trying again could work
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
Codes you will want to handle:
|
|
242
|
+
|
|
243
|
+
| Code | Status | Meaning |
|
|
244
|
+
| --- | --- | --- |
|
|
245
|
+
| `insufficient_credits` | 402 | Your prepaid balance cannot cover the call. Nothing was sent or charged. `err.balance_micro_usd`, `err.required_micro_usd` and `err.top_up_url` say how short you are and where to add credits. Not retried: top up, then send again. |
|
|
246
|
+
| `spend_limit_reached` | 403 | Your own monthly spend limit is reached. |
|
|
247
|
+
| `recipient_opted_out` | 403 | The recipient unsubscribed. Do not retry. |
|
|
248
|
+
| `sender_not_registered` | 403 | The sending number is not registered yet; it can text your verified numbers only. |
|
|
249
|
+
| `quiet_hours` | 403 | A marketing message outside 8:00am to 9:00pm in the recipient's local time. `err.send_after` is the next allowed time; `err.recipient_local_time` and `err.timezone` say what we read. Send it then, or pass `quiet_hours="defer"` to have it held. Mark marketing sends with `category="marketing"`. |
|
|
250
|
+
| `quota_exceeded` | 429 | A free-tier or daily limit was reached. |
|
|
251
|
+
| `rate_limited` | 429 | Slow down; see `err.retry_after`. |
|
|
252
|
+
|
|
253
|
+
```python
|
|
254
|
+
client.balance.get()["balance_usd"] # prepaid credit balance
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
429s, 5xx, timeouts, and connection errors are retried with exponential
|
|
258
|
+
backoff, honoring `Retry-After`. Two things are deliberately **not** retried:
|
|
259
|
+
a `POST` without an idempotency key (a verification send, a number purchase),
|
|
260
|
+
and a verification cooldown, which is policy, not a blip.
|
|
261
|
+
|
|
262
|
+
## Testing
|
|
263
|
+
|
|
264
|
+
Test keys (`ssms_sk_test_...`) cost nothing and simulate: the message reaches `delivered` with `"simulated": True` and no text is sent, except to your own verified phone.
|
|
265
|
+
`client.is_test_mode` tells you which you have. Magic numbers and codes are
|
|
266
|
+
in the [sandbox docs](https://joinsimplesms.com/docs/sandbox).
|
|
267
|
+
|
|
268
|
+
## Options
|
|
269
|
+
|
|
270
|
+
```python
|
|
271
|
+
SimpleSMS(
|
|
272
|
+
api_key, # default: SIMPLESMS_API_KEY
|
|
273
|
+
default_from="+14155550100", # default: SIMPLESMS_FROM, else your only number
|
|
274
|
+
base_url="https://api.joinsimplesms.com",
|
|
275
|
+
max_retries=2,
|
|
276
|
+
timeout=30.0,
|
|
277
|
+
transport=my_transport, # (method, url, headers, body, timeout) -> Response
|
|
278
|
+
)
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
## Renamed from Delivered
|
|
282
|
+
|
|
283
|
+
SimpleSMS was called Delivered until October 2026. `Delivered` and
|
|
284
|
+
`DeliveredError` are still exported as aliases of `SimpleSMS` and
|
|
285
|
+
`SimpleSMSError`; `DELIVERED_API_KEY`, `DELIVERED_BASE_URL` and
|
|
286
|
+
`DELIVERED_FROM` are still read when the `SIMPLESMS_*` names are unset;
|
|
287
|
+
`dsms_sk_...` keys still authenticate. The old host `api.deliveredsms.com` is retired (410); this SDK defaults to `api.joinsimplesms.com`.
|
|
288
|
+
|
|
289
|
+
## Why no dependencies
|
|
290
|
+
|
|
291
|
+
The client uses only the standard library (`urllib`, `json`, `hmac`). The
|
|
292
|
+
API is plain JSON over HTTPS and every call is synchronous, so a third-party
|
|
293
|
+
HTTP stack would add install weight and version conflicts (the classic
|
|
294
|
+
`requests`/`urllib3` pin fight) without adding anything you'd notice. It
|
|
295
|
+
installs in any environment, including Lambda layers and locked-down CI. If
|
|
296
|
+
you want connection pooling, proxies, or tracing, pass a `transport`.
|
|
297
|
+
|
|
298
|
+
MIT © Truelabel Inc
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.18"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "joinsimplesms"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Official Python client for SimpleSMS (joinsimplesms.com): SMS, phone verification, and phone numbers. No dependencies."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.8"
|
|
12
|
+
authors = [{ name = "Truelabel Inc" }]
|
|
13
|
+
keywords = ["sms", "otp", "phone verification", "2fa", "twilio alternative", "sms api", "ai agents"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Operating System :: OS Independent",
|
|
17
|
+
"Typing :: Typed",
|
|
18
|
+
"Topic :: Communications :: Telephony",
|
|
19
|
+
]
|
|
20
|
+
dependencies = []
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://joinsimplesms.com"
|
|
24
|
+
Documentation = "https://joinsimplesms.com/docs"
|
|
25
|
+
|
|
26
|
+
[tool.hatch.version]
|
|
27
|
+
path = "src/joinsimplesms/_version.py"
|
|
28
|
+
pattern = "VERSION = \"(?P<version>[^\"]+)\""
|
|
29
|
+
|
|
30
|
+
[tool.hatch.build.targets.wheel]
|
|
31
|
+
packages = ["src/joinsimplesms"]
|