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.
@@ -0,0 +1,6 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .mypy_cache/
@@ -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"]