texto-sdk 1.0.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,23 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags: ['v*']
6
+ workflow_dispatch:
7
+
8
+ jobs:
9
+ publish:
10
+ runs-on: ubuntu-latest
11
+ environment: pypi
12
+ permissions:
13
+ id-token: write
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: actions/setup-python@v5
17
+ with:
18
+ python-version: '3.11'
19
+ - run: pip install build pytest responses
20
+ - run: pip install -e .
21
+ - run: pytest
22
+ - run: python -m build
23
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,12 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ build/
5
+ dist/
6
+ .venv/
7
+ venv/
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ .coverage
11
+ .env
12
+ .DS_Store
@@ -0,0 +1,14 @@
1
+ # Changelog
2
+
3
+ All notable changes to `texto-sdk` are documented here. This project follows
4
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
+
6
+ ## [1.0.0] - 2026-01-01
7
+
8
+ ### Added
9
+
10
+ - First public release.
11
+ - Client covering messaging, inbox, opt-outs, credits, accounts, users, access,
12
+ API keys, numbers, webhooks and reporting.
13
+ - Typed exceptions, timeouts, and automatic retries for idempotent requests.
14
+ - HMAC-SHA256 webhook signature verification helpers.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Floop Pty Ltd trading as Texto
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.
@@ -0,0 +1,155 @@
1
+ Metadata-Version: 2.5
2
+ Name: texto-sdk
3
+ Version: 1.0.0
4
+ Summary: Official Python SDK for the Texto SMS API
5
+ Project-URL: Homepage, https://texto.com.au
6
+ Project-URL: Documentation, https://texto.com.au/developers
7
+ Project-URL: Source, https://github.com/textosms/texto-python
8
+ Project-URL: Changelog, https://github.com/textosms/texto-python/blob/main/CHANGELOG.md
9
+ Author-email: "Texto (Floop Pty Ltd)" <support@texto.com.au>
10
+ License: MIT
11
+ License-File: LICENSE
12
+ Keywords: australia,messaging,sms,texto
13
+ Requires-Python: >=3.8
14
+ Requires-Dist: requests>=2.25
15
+ Provides-Extra: dev
16
+ Requires-Dist: pytest>=7; extra == 'dev'
17
+ Requires-Dist: responses>=0.23; extra == 'dev'
18
+ Description-Content-Type: text/markdown
19
+
20
+ # Texto SMS — Python SDK
21
+
22
+ Official Python SDK for the [Texto](https://texto.com.au) SMS API. Send SMS to
23
+ Australian and New Zealand numbers, read replies, and manage sub-accounts,
24
+ numbers, credits, API keys, webhooks and reporting.
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ pip install texto-sdk
30
+ ```
31
+
32
+ Requires Python 3.8 or newer.
33
+
34
+ ## Quick start
35
+
36
+ ```python
37
+ import os
38
+ from texto import Texto
39
+
40
+ texto = Texto(api_key=os.environ["TEXTO_API_KEY"])
41
+
42
+ result = texto.send(to="+61400000000", message="Your booking is confirmed for 2pm.")
43
+ print(result["message_id"], result["credits_used"])
44
+ ```
45
+
46
+ ## Configuration
47
+
48
+ ```python
49
+ texto = Texto(
50
+ api_key="txt_...",
51
+ timeout=30.0, # seconds
52
+ max_retries=2, # retries for idempotent requests
53
+ )
54
+ ```
55
+
56
+ `base_url` can be overridden, but only to point tests at a mock server or a
57
+ corporate proxy. The production API is always `https://api.texto.com.au`.
58
+
59
+ ## Sending
60
+
61
+ ```python
62
+ texto.send(
63
+ to="0400000000",
64
+ message="Hi Sam, your order has shipped.",
65
+ sender="TEXTO", # optional dedicated number or approved Sender ID
66
+ link_tracking=True, # optional per-message override
67
+ campaign="Shipping", # optional campaign name
68
+ )
69
+
70
+ texto.send_batch(
71
+ message="Hi {name}, your appointment is {time}.",
72
+ recipients=[
73
+ {"phone": "0400000001", "merge_data": {"name": "Sam", "time": "2pm"}},
74
+ {"phone": "0400000002", "merge_data": {"name": "Alex", "time": "3pm"}},
75
+ ],
76
+ )
77
+
78
+ payload = texto.get_message(result["message_id"])
79
+ print(payload["delivery_receipt"])
80
+ ```
81
+
82
+ ## Replies and opt-outs
83
+
84
+ ```python
85
+ inbox = texto.inbox(limit=50)
86
+ optouts = texto.optouts()
87
+ ```
88
+
89
+ ## Credits, accounts and numbers
90
+
91
+ ```python
92
+ texto.balance()
93
+ texto.list_accounts()
94
+ sub = texto.create_account(business_name="Acme Pty Ltd", email="ops@acme.com.au")
95
+ texto.allocate_credits(sub["account_id"], 500)
96
+ texto.available_numbers(country="AU")
97
+ texto.purchase_number(label="Support line")
98
+ ```
99
+
100
+ Account, team and group endpoints require account hierarchy to be enabled on
101
+ your Texto account.
102
+
103
+ ## Reporting
104
+
105
+ ```python
106
+ texto.report(from_="2026-01-01", to="2026-01-31", direction="outbound")
107
+ texto.group_report(from_="2026-01-01")
108
+ ```
109
+
110
+ ## Webhooks
111
+
112
+ ```python
113
+ from flask import Flask, request
114
+ from texto import parse_webhook_event, is_inbound_event
115
+
116
+ app = Flask(__name__)
117
+
118
+ @app.post("/texto-webhook")
119
+ def texto_webhook():
120
+ event = parse_webhook_event(
121
+ request.get_data(), # raw body, not re-serialised
122
+ request.headers.get("X-Texto-Signature"),
123
+ os.environ["TEXTO_WEBHOOK_SECRET"],
124
+ )
125
+ if is_inbound_event(event):
126
+ print("Reply from", event["from"], event["body"])
127
+ else:
128
+ print("Delivery status", event["delivery_receipt"])
129
+ return "", 200
130
+ ```
131
+
132
+ ## Errors
133
+
134
+ ```python
135
+ from texto import TextoInsufficientCreditsError, TextoRateLimitError
136
+
137
+ try:
138
+ texto.send(to="0400000000", message="Hi")
139
+ except TextoInsufficientCreditsError as err:
140
+ print("Top up:", err.credits_required)
141
+ except TextoRateLimitError as err:
142
+ print("Retry after", err.retry_after)
143
+ ```
144
+
145
+ Every API error subclasses `TextoApiError` with `status`, `code` and `body`.
146
+ Network failures and timeouts raise `TextoConnectionError`.
147
+
148
+ ## Support
149
+
150
+ - API reference: https://texto.com.au/developers
151
+ - Email: support@texto.com.au
152
+
153
+ ## License
154
+
155
+ MIT © Floop Pty Ltd trading as Texto
@@ -0,0 +1,136 @@
1
+ # Texto SMS — Python SDK
2
+
3
+ Official Python SDK for the [Texto](https://texto.com.au) SMS API. Send SMS to
4
+ Australian and New Zealand numbers, read replies, and manage sub-accounts,
5
+ numbers, credits, API keys, webhooks and reporting.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install texto-sdk
11
+ ```
12
+
13
+ Requires Python 3.8 or newer.
14
+
15
+ ## Quick start
16
+
17
+ ```python
18
+ import os
19
+ from texto import Texto
20
+
21
+ texto = Texto(api_key=os.environ["TEXTO_API_KEY"])
22
+
23
+ result = texto.send(to="+61400000000", message="Your booking is confirmed for 2pm.")
24
+ print(result["message_id"], result["credits_used"])
25
+ ```
26
+
27
+ ## Configuration
28
+
29
+ ```python
30
+ texto = Texto(
31
+ api_key="txt_...",
32
+ timeout=30.0, # seconds
33
+ max_retries=2, # retries for idempotent requests
34
+ )
35
+ ```
36
+
37
+ `base_url` can be overridden, but only to point tests at a mock server or a
38
+ corporate proxy. The production API is always `https://api.texto.com.au`.
39
+
40
+ ## Sending
41
+
42
+ ```python
43
+ texto.send(
44
+ to="0400000000",
45
+ message="Hi Sam, your order has shipped.",
46
+ sender="TEXTO", # optional dedicated number or approved Sender ID
47
+ link_tracking=True, # optional per-message override
48
+ campaign="Shipping", # optional campaign name
49
+ )
50
+
51
+ texto.send_batch(
52
+ message="Hi {name}, your appointment is {time}.",
53
+ recipients=[
54
+ {"phone": "0400000001", "merge_data": {"name": "Sam", "time": "2pm"}},
55
+ {"phone": "0400000002", "merge_data": {"name": "Alex", "time": "3pm"}},
56
+ ],
57
+ )
58
+
59
+ payload = texto.get_message(result["message_id"])
60
+ print(payload["delivery_receipt"])
61
+ ```
62
+
63
+ ## Replies and opt-outs
64
+
65
+ ```python
66
+ inbox = texto.inbox(limit=50)
67
+ optouts = texto.optouts()
68
+ ```
69
+
70
+ ## Credits, accounts and numbers
71
+
72
+ ```python
73
+ texto.balance()
74
+ texto.list_accounts()
75
+ sub = texto.create_account(business_name="Acme Pty Ltd", email="ops@acme.com.au")
76
+ texto.allocate_credits(sub["account_id"], 500)
77
+ texto.available_numbers(country="AU")
78
+ texto.purchase_number(label="Support line")
79
+ ```
80
+
81
+ Account, team and group endpoints require account hierarchy to be enabled on
82
+ your Texto account.
83
+
84
+ ## Reporting
85
+
86
+ ```python
87
+ texto.report(from_="2026-01-01", to="2026-01-31", direction="outbound")
88
+ texto.group_report(from_="2026-01-01")
89
+ ```
90
+
91
+ ## Webhooks
92
+
93
+ ```python
94
+ from flask import Flask, request
95
+ from texto import parse_webhook_event, is_inbound_event
96
+
97
+ app = Flask(__name__)
98
+
99
+ @app.post("/texto-webhook")
100
+ def texto_webhook():
101
+ event = parse_webhook_event(
102
+ request.get_data(), # raw body, not re-serialised
103
+ request.headers.get("X-Texto-Signature"),
104
+ os.environ["TEXTO_WEBHOOK_SECRET"],
105
+ )
106
+ if is_inbound_event(event):
107
+ print("Reply from", event["from"], event["body"])
108
+ else:
109
+ print("Delivery status", event["delivery_receipt"])
110
+ return "", 200
111
+ ```
112
+
113
+ ## Errors
114
+
115
+ ```python
116
+ from texto import TextoInsufficientCreditsError, TextoRateLimitError
117
+
118
+ try:
119
+ texto.send(to="0400000000", message="Hi")
120
+ except TextoInsufficientCreditsError as err:
121
+ print("Top up:", err.credits_required)
122
+ except TextoRateLimitError as err:
123
+ print("Retry after", err.retry_after)
124
+ ```
125
+
126
+ Every API error subclasses `TextoApiError` with `status`, `code` and `body`.
127
+ Network failures and timeouts raise `TextoConnectionError`.
128
+
129
+ ## Support
130
+
131
+ - API reference: https://texto.com.au/developers
132
+ - Email: support@texto.com.au
133
+
134
+ ## License
135
+
136
+ MIT © Floop Pty Ltd trading as Texto
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "texto-sdk"
7
+ version = "1.0.0"
8
+ description = "Official Python SDK for the Texto SMS API"
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Texto (Floop Pty Ltd)", email = "support@texto.com.au" }]
13
+ keywords = ["sms", "texto", "messaging", "australia"]
14
+ dependencies = ["requests>=2.25"]
15
+
16
+ [project.urls]
17
+ Homepage = "https://texto.com.au"
18
+ Documentation = "https://texto.com.au/developers"
19
+ Source = "https://github.com/textosms/texto-python"
20
+ Changelog = "https://github.com/textosms/texto-python/blob/main/CHANGELOG.md"
21
+
22
+ [project.optional-dependencies]
23
+ dev = ["pytest>=7", "responses>=0.23"]
24
+
25
+ [tool.hatch.build.targets.wheel]
26
+ packages = ["texto"]
@@ -0,0 +1,86 @@
1
+ import hashlib
2
+ import hmac
3
+ import json
4
+
5
+ import pytest
6
+ import responses
7
+
8
+ from texto import (
9
+ Texto,
10
+ TextoAuthenticationError,
11
+ TextoInsufficientCreditsError,
12
+ verify_webhook_signature,
13
+ )
14
+
15
+ BASE = "https://api.texto.com.au"
16
+
17
+
18
+ @responses.activate
19
+ def test_send_uses_bearer_auth_and_snake_case_body():
20
+ responses.add(responses.POST, f"{BASE}/send", json={"message_id": "m1", "credits_used": 1})
21
+ texto = Texto(api_key="txt_test")
22
+
23
+ result = texto.send(to="+61400000000", message="Hello", link_tracking=True)
24
+
25
+ assert result["message_id"] == "m1"
26
+ request = responses.calls[0].request
27
+ assert request.headers["Authorization"] == "Bearer txt_test"
28
+ assert json.loads(request.body) == {
29
+ "to": "+61400000000",
30
+ "message": "Hello",
31
+ "link_tracking": True,
32
+ }
33
+
34
+
35
+ @responses.activate
36
+ def test_report_filters_become_query_params():
37
+ responses.add(responses.GET, f"{BASE}/report", json={"summary": {}})
38
+ Texto(api_key="txt_test").report(from_="2026-01-01", direction="outbound")
39
+
40
+ url = responses.calls[0].request.url
41
+ assert "from=2026-01-01" in url and "direction=outbound" in url
42
+
43
+
44
+ @responses.activate
45
+ def test_authentication_error():
46
+ responses.add(responses.GET, f"{BASE}/balance", json={"error": "Invalid API key"}, status=401)
47
+ with pytest.raises(TextoAuthenticationError):
48
+ Texto(api_key="txt_test").balance()
49
+
50
+
51
+ @responses.activate
52
+ def test_insufficient_credits_exposes_details():
53
+ responses.add(
54
+ responses.POST,
55
+ f"{BASE}/send",
56
+ json={"error": "Insufficient credits", "credits_required": 5, "credits_available": 1},
57
+ status=402,
58
+ )
59
+ with pytest.raises(TextoInsufficientCreditsError) as exc:
60
+ Texto(api_key="txt_test").send(to="0400000000", message="hi")
61
+ assert exc.value.credits_required == 5
62
+
63
+
64
+ @responses.activate
65
+ def test_retries_retryable_get():
66
+ responses.add(responses.GET, f"{BASE}/balance", json={"error": "boom"}, status=503)
67
+ responses.add(responses.GET, f"{BASE}/balance", json={"credits": 10})
68
+
69
+ assert Texto(api_key="txt_test", max_retries=1).balance() == 10
70
+ assert len(responses.calls) == 2
71
+
72
+
73
+ @responses.activate
74
+ def test_does_not_retry_post():
75
+ responses.add(responses.POST, f"{BASE}/send", json={"error": "boom"}, status=503)
76
+ with pytest.raises(Exception):
77
+ Texto(api_key="txt_test").send(to="0400000000", message="hi")
78
+ assert len(responses.calls) == 1
79
+
80
+
81
+ def test_webhook_signature():
82
+ body = json.dumps({"event": "message.inbound"})
83
+ sig = "sha256=" + hmac.new(b"secret", body.encode(), hashlib.sha256).hexdigest()
84
+ assert verify_webhook_signature(body, sig, "secret") is True
85
+ assert verify_webhook_signature(body, sig, "other") is False
86
+ assert verify_webhook_signature(body, None, "secret") is False
@@ -0,0 +1,36 @@
1
+ """Official Python SDK for the Texto SMS API."""
2
+
3
+ from .client import Texto
4
+ from .errors import (
5
+ TextoApiError,
6
+ TextoAuthenticationError,
7
+ TextoConnectionError,
8
+ TextoError,
9
+ TextoInsufficientCreditsError,
10
+ TextoInvalidRequestError,
11
+ TextoNotFoundError,
12
+ TextoPermissionError,
13
+ TextoRateLimitError,
14
+ TextoServerError,
15
+ )
16
+ from .webhooks import is_inbound_event, parse_webhook_event, verify_webhook_signature
17
+
18
+ __version__ = "1.0.0"
19
+
20
+ __all__ = [
21
+ "Texto",
22
+ "TextoError",
23
+ "TextoApiError",
24
+ "TextoAuthenticationError",
25
+ "TextoPermissionError",
26
+ "TextoInvalidRequestError",
27
+ "TextoInsufficientCreditsError",
28
+ "TextoNotFoundError",
29
+ "TextoRateLimitError",
30
+ "TextoServerError",
31
+ "TextoConnectionError",
32
+ "verify_webhook_signature",
33
+ "parse_webhook_event",
34
+ "is_inbound_event",
35
+ "__version__",
36
+ ]
@@ -0,0 +1,455 @@
1
+ """Client for the Texto SMS API."""
2
+
3
+ import time
4
+ from typing import Any, Dict, List, Optional, Sequence, Union
5
+
6
+ import requests
7
+
8
+ from .errors import TextoConnectionError, error_from_response
9
+
10
+ DEFAULT_BASE_URL = "https://api.texto.com.au"
11
+ RETRYABLE_STATUSES = {408, 409, 429, 500, 502, 503, 504}
12
+ VERSION = "1.0.0"
13
+
14
+ JSON = Dict[str, Any]
15
+
16
+
17
+ def _clean(data: Dict[str, Any]) -> Dict[str, Any]:
18
+ return {k: v for k, v in data.items() if v is not None}
19
+
20
+
21
+ class Texto:
22
+ """Client for the Texto SMS API.
23
+
24
+ >>> texto = Texto(api_key="txt_...")
25
+ >>> texto.send(to="+61400000000", message="Hello")
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ api_key: str,
31
+ base_url: str = DEFAULT_BASE_URL,
32
+ timeout: float = 30.0,
33
+ max_retries: int = 2,
34
+ session: Optional[requests.Session] = None,
35
+ ) -> None:
36
+ """
37
+ :param api_key: Your Texto API key, beginning ``txt_``.
38
+ :param base_url: Only override to point at a mock server or corporate
39
+ proxy during testing. Production is always the Texto API.
40
+ """
41
+ if not api_key:
42
+ raise ValueError("A Texto API key is required.")
43
+ self.api_key = api_key
44
+ self.base_url = base_url.rstrip("/")
45
+ self.timeout = timeout
46
+ self.max_retries = max_retries
47
+ self.session = session or requests.Session()
48
+
49
+ # ── transport ───────────────────────────────────────────────────
50
+
51
+ def request(
52
+ self,
53
+ method: str,
54
+ path: str,
55
+ params: Optional[Dict[str, Any]] = None,
56
+ json_body: Optional[Any] = None,
57
+ idempotency_key: Optional[str] = None,
58
+ ) -> Any:
59
+ """Perform a raw request. Exposed for endpoints not yet wrapped."""
60
+ url = self.base_url + path
61
+ headers = {
62
+ "Authorization": f"Bearer {self.api_key}",
63
+ "Accept": "application/json",
64
+ "User-Agent": f"texto-python/{VERSION}",
65
+ }
66
+ if idempotency_key:
67
+ headers["Idempotency-Key"] = idempotency_key
68
+
69
+ idempotent = method in ("GET", "PUT", "DELETE") or idempotency_key
70
+ retries = self.max_retries if idempotent else 0
71
+
72
+ last_error: Optional[Exception] = None
73
+ for attempt in range(retries + 1):
74
+ if attempt:
75
+ time.sleep(min(2**attempt * 0.25, 4.0))
76
+ try:
77
+ response = self.session.request(
78
+ method,
79
+ url,
80
+ params=_clean(params or {}) or None,
81
+ json=json_body,
82
+ headers=headers,
83
+ timeout=self.timeout,
84
+ )
85
+ except requests.RequestException as exc:
86
+ last_error = TextoConnectionError(f"Request to {path} failed: {exc}")
87
+ if attempt < retries:
88
+ continue
89
+ raise last_error from exc
90
+
91
+ try:
92
+ body = response.json() if response.content else None
93
+ except ValueError:
94
+ body = {"error": response.text}
95
+
96
+ if response.ok:
97
+ return body
98
+
99
+ if response.status_code in RETRYABLE_STATUSES and attempt < retries:
100
+ last_error = error_from_response(response.status_code, body)
101
+ continue
102
+
103
+ retry_after = response.headers.get("Retry-After")
104
+ raise error_from_response(
105
+ response.status_code,
106
+ body,
107
+ response.headers.get("X-Request-Id"),
108
+ float(retry_after) if retry_after and retry_after.isdigit() else None,
109
+ )
110
+
111
+ raise last_error or TextoConnectionError(f"Request to {path} failed")
112
+
113
+ # ── status ──────────────────────────────────────────────────────
114
+
115
+ def status(self) -> JSON:
116
+ """Public health check. Does not consume credits."""
117
+ return self.request("GET", "/status")
118
+
119
+ # ── messaging ───────────────────────────────────────────────────
120
+
121
+ def send(
122
+ self,
123
+ to: str,
124
+ message: str,
125
+ sender: Optional[str] = None,
126
+ link_tracking: Optional[bool] = None,
127
+ campaign: Optional[str] = None,
128
+ ) -> JSON:
129
+ """Send a single SMS."""
130
+ return self.request(
131
+ "POST",
132
+ "/send",
133
+ json_body=_clean(
134
+ {
135
+ "to": to,
136
+ "message": message,
137
+ "sender": sender,
138
+ "link_tracking": link_tracking,
139
+ "campaign": campaign,
140
+ }
141
+ ),
142
+ )
143
+
144
+ def send_batch(
145
+ self,
146
+ recipients: Sequence[Union[str, Dict[str, Any]]],
147
+ message: str,
148
+ sender: Optional[str] = None,
149
+ link_tracking: Optional[bool] = None,
150
+ campaign: Optional[str] = None,
151
+ ) -> JSON:
152
+ """Send one message to up to 1,000 recipients, with optional merge data."""
153
+ return self.request(
154
+ "POST",
155
+ "/send-batch",
156
+ json_body=_clean(
157
+ {
158
+ "recipients": list(recipients),
159
+ "message": message,
160
+ "sender": sender,
161
+ "link_tracking": link_tracking,
162
+ "campaign": campaign,
163
+ }
164
+ ),
165
+ )
166
+
167
+ def get_message(self, message_id: str) -> JSON:
168
+ """Fetch a message and its delivery receipt by id or idempotency key."""
169
+ return self.request("GET", f"/message/{message_id}")
170
+
171
+ def get_campaign(self, campaign_id: str, limit: Optional[int] = None, offset: Optional[int] = None) -> JSON:
172
+ """Fetch a campaign with its per-message results."""
173
+ return self.request("GET", f"/campaign/{campaign_id}", params={"limit": limit, "offset": offset})
174
+
175
+ # ── inbox and opt-outs ──────────────────────────────────────────
176
+
177
+ def inbox(
178
+ self,
179
+ limit: Optional[int] = None,
180
+ offset: Optional[int] = None,
181
+ from_: Optional[str] = None,
182
+ date_from: Optional[str] = None,
183
+ date_to: Optional[str] = None,
184
+ ) -> JSON:
185
+ """List inbound (reply) messages."""
186
+ return self.request(
187
+ "GET",
188
+ "/inbox",
189
+ params={
190
+ "limit": limit,
191
+ "offset": offset,
192
+ "from": from_,
193
+ "date_from": date_from,
194
+ "date_to": date_to,
195
+ },
196
+ )
197
+
198
+ def optouts(self) -> List[JSON]:
199
+ """List numbers that have opted out, including global opt-outs."""
200
+ return self.request("GET", "/optouts")["optouts"]
201
+
202
+ # ── credits ─────────────────────────────────────────────────────
203
+
204
+ def balance(self) -> int:
205
+ """Current credit balance for the calling account."""
206
+ return self.request("GET", "/balance")["credits"]
207
+
208
+ def account_balance(self, account_id: str) -> int:
209
+ """Credit balance for a sub-account."""
210
+ return self.request("GET", f"/account/{account_id}/balance")["credits"]
211
+
212
+ def allocate_credits(self, account_id: str, amount: int) -> JSON:
213
+ """Move credits from the calling account down to a sub-account."""
214
+ return self.request("POST", f"/account/{account_id}/credits/allocate", json_body={"amount": amount})
215
+
216
+ def recall_credits(self, account_id: str, amount: int) -> JSON:
217
+ """Pull credits back from a sub-account."""
218
+ return self.request("POST", f"/account/{account_id}/credits/recall", json_body={"amount": amount})
219
+
220
+ # ── accounts ────────────────────────────────────────────────────
221
+
222
+ def list_accounts(self) -> List[JSON]:
223
+ """List the calling account and its sub-accounts."""
224
+ return self.request("GET", "/accounts")["accounts"]
225
+
226
+ def create_account(
227
+ self,
228
+ business_name: str,
229
+ email: Optional[str] = None,
230
+ daily_limit: Optional[int] = None,
231
+ managed_by_parent: Optional[bool] = None,
232
+ team_access_from_parent: Optional[bool] = None,
233
+ optout_exempt: Optional[bool] = None,
234
+ seed_credits: Optional[int] = None,
235
+ inherit_parent_senders: Optional[bool] = None,
236
+ ) -> JSON:
237
+ """Create a sub-account. ``email`` is required unless managed by parent."""
238
+ return self.request(
239
+ "POST",
240
+ "/accounts",
241
+ json_body=_clean(
242
+ {
243
+ "business_name": business_name,
244
+ "email": email,
245
+ "daily_limit": daily_limit,
246
+ "managed_by_parent": managed_by_parent,
247
+ "team_access_from_parent": team_access_from_parent,
248
+ "optout_exempt": optout_exempt,
249
+ "seed_credits": seed_credits,
250
+ "inherit_parent_senders": inherit_parent_senders,
251
+ }
252
+ ),
253
+ )
254
+
255
+ def get_account(self, account_id: str) -> JSON:
256
+ """Fetch full detail for an account."""
257
+ return self.request("GET", f"/account/{account_id}")["account"]
258
+
259
+ def update_account(
260
+ self,
261
+ account_id: str,
262
+ business_name: Optional[str] = None,
263
+ daily_limit: Optional[int] = None,
264
+ optout_exempt: Optional[bool] = None,
265
+ team_access_from_parent: Optional[bool] = None,
266
+ inherit_parent_senders: Optional[bool] = None,
267
+ ) -> JSON:
268
+ """Update a sub-account."""
269
+ return self.request(
270
+ "PATCH",
271
+ f"/account/{account_id}",
272
+ json_body=_clean(
273
+ {
274
+ "business_name": business_name,
275
+ "daily_limit": daily_limit,
276
+ "optout_exempt": optout_exempt,
277
+ "team_access_from_parent": team_access_from_parent,
278
+ "inherit_parent_senders": inherit_parent_senders,
279
+ }
280
+ ),
281
+ )
282
+
283
+ def delete_account(self, account_id: str) -> JSON:
284
+ """Schedule a sub-account for deletion."""
285
+ return self.request("DELETE", f"/account/{account_id}")
286
+
287
+ # ── users and access ────────────────────────────────────────────
288
+
289
+ def list_team(self) -> List[JSON]:
290
+ """Team members on the calling account."""
291
+ return self.request("GET", "/team")["users"]
292
+
293
+ def list_account_users(self, account_id: str) -> List[JSON]:
294
+ """Team members on a specific account, including inherited ones."""
295
+ return self.request("GET", f"/account/{account_id}/users")["users"]
296
+
297
+ def invite_user(
298
+ self,
299
+ account_id: str,
300
+ email: str,
301
+ name: Optional[str] = None,
302
+ can_send: Optional[bool] = None,
303
+ can_billing: Optional[bool] = None,
304
+ ) -> JSON:
305
+ """Invite a person to an account."""
306
+ return self.request(
307
+ "POST",
308
+ f"/account/{account_id}/users",
309
+ json_body=_clean({"email": email, "name": name, "can_send": can_send, "can_billing": can_billing}),
310
+ )
311
+
312
+ def remove_user(self, account_id: str, member_id: str) -> JSON:
313
+ """Remove a team member using the row id from the users list."""
314
+ return self.request("DELETE", f"/account/{account_id}/users/{member_id}")
315
+
316
+ def get_access(self, account_id: str) -> JSON:
317
+ """Read parent-management and inherited-team settings."""
318
+ return self.request("GET", f"/account/{account_id}/access")
319
+
320
+ def set_access(
321
+ self,
322
+ account_id: str,
323
+ managed_by_parent: Optional[bool] = None,
324
+ team_access_from_parent: Optional[bool] = None,
325
+ ) -> JSON:
326
+ """Update parent-management and inherited-team settings."""
327
+ return self.request(
328
+ "PUT",
329
+ f"/account/{account_id}/access",
330
+ json_body=_clean(
331
+ {
332
+ "managed_by_parent": managed_by_parent,
333
+ "team_access_from_parent": team_access_from_parent,
334
+ }
335
+ ),
336
+ )
337
+
338
+ # ── API keys ────────────────────────────────────────────────────
339
+
340
+ def list_keys(self, account_id: str) -> List[JSON]:
341
+ """List API keys on an account. Key values are never returned."""
342
+ return self.request("GET", f"/account/{account_id}/keys")["keys"]
343
+
344
+ def create_key(self, account_id: str, name: str) -> JSON:
345
+ """Create an API key. The full value is returned once — store it securely."""
346
+ return self.request("POST", f"/account/{account_id}/key", json_body={"name": name})
347
+
348
+ def revoke_key(self, account_id: str, key_id: str) -> JSON:
349
+ """Revoke an API key."""
350
+ return self.request("DELETE", f"/account/{account_id}/key/{key_id}")
351
+
352
+ # ── numbers ─────────────────────────────────────────────────────
353
+
354
+ def list_numbers(self) -> List[JSON]:
355
+ """Numbers owned by the calling account."""
356
+ return self.request("GET", "/numbers")["numbers"]
357
+
358
+ def list_group_numbers(self) -> List[JSON]:
359
+ """Numbers across the calling account and all its sub-accounts."""
360
+ return self.request("GET", "/numbers/group")["numbers"]
361
+
362
+ def list_account_numbers(self, account_id: str) -> List[JSON]:
363
+ """Numbers assigned to a specific account."""
364
+ return self.request("GET", f"/account/{account_id}/numbers")["numbers"]
365
+
366
+ def available_numbers(
367
+ self,
368
+ country: Optional[str] = None,
369
+ limit: Optional[int] = None,
370
+ offset: Optional[int] = None,
371
+ ) -> JSON:
372
+ """Numbers currently available to purchase."""
373
+ return self.request("GET", "/numbers/available", params={"country": country, "limit": limit, "offset": offset})
374
+
375
+ def purchase_number(
376
+ self,
377
+ number: Optional[str] = None,
378
+ label: Optional[str] = None,
379
+ country: Optional[str] = None,
380
+ ) -> JSON:
381
+ """Purchase a dedicated number. Charges the card saved on the account."""
382
+ return self.request(
383
+ "POST",
384
+ "/numbers/purchase",
385
+ json_body=_clean({"number": number, "label": label, "country": country}),
386
+ )
387
+
388
+ def assign_numbers(self, account_id: str, numbers: Union[str, Sequence[str]]) -> JSON:
389
+ """Assign parent-owned numbers to a sub-account."""
390
+ return self.request("POST", f"/account/{account_id}/numbers/assign", json_body={"numbers": numbers})
391
+
392
+ def recall_numbers(self, account_id: str, numbers: Union[str, Sequence[str]]) -> JSON:
393
+ """Recall numbers from a sub-account."""
394
+ return self.request("POST", f"/account/{account_id}/numbers/recall", json_body={"numbers": numbers})
395
+
396
+ # ── webhooks ────────────────────────────────────────────────────
397
+
398
+ def get_webhooks(self) -> JSON:
399
+ """Read the configured delivery receipt and inbound webhooks."""
400
+ return self.request("GET", "/webhooks")
401
+
402
+ def set_delivery_webhook(self, url: str, enabled: Optional[bool] = None) -> JSON:
403
+ """Create or update the delivery receipt webhook."""
404
+ return self.request("PUT", "/webhooks/delivery", json_body=_clean({"url": url, "enabled": enabled}))[
405
+ "delivery_receipt"
406
+ ]
407
+
408
+ def set_inbound_webhook(self, url: str, enabled: Optional[bool] = None) -> JSON:
409
+ """Create or update the inbound message webhook."""
410
+ return self.request("PUT", "/webhooks/inbound", json_body=_clean({"url": url, "enabled": enabled}))["inbound"]
411
+
412
+ def rotate_delivery_secret(self) -> str:
413
+ """Rotate the delivery receipt signing secret. Returned once."""
414
+ return self.request("POST", "/webhooks/delivery")["secret"]
415
+
416
+ def rotate_inbound_secret(self) -> str:
417
+ """Rotate the inbound signing secret. Returned once."""
418
+ return self.request("POST", "/webhooks/inbound")["secret"]
419
+
420
+ # ── reporting ───────────────────────────────────────────────────
421
+
422
+ def report(
423
+ self,
424
+ from_: Optional[str] = None,
425
+ to: Optional[str] = None,
426
+ direction: Optional[str] = None,
427
+ status: Optional[str] = None,
428
+ country: Optional[str] = None,
429
+ campaign_id: Optional[str] = None,
430
+ keyword: Optional[str] = None,
431
+ number: Optional[str] = None,
432
+ ) -> JSON:
433
+ """Report for the calling account."""
434
+ return self.request("GET", "/report", params=self._report_params(locals()))
435
+
436
+ def account_report(self, account_id: str, **filters: Any) -> JSON:
437
+ """Report for a specific account in the hierarchy."""
438
+ return self.request("GET", f"/account/{account_id}/report", params=self._report_params(filters))
439
+
440
+ def group_report(self, from_: Optional[str] = None, to: Optional[str] = None) -> JSON:
441
+ """Report across the calling account and all its sub-accounts."""
442
+ return self.request("GET", "/report/group", params={"from": from_, "to": to})
443
+
444
+ @staticmethod
445
+ def _report_params(values: Dict[str, Any]) -> Dict[str, Any]:
446
+ return {
447
+ "from": values.get("from_"),
448
+ "to": values.get("to"),
449
+ "direction": values.get("direction"),
450
+ "status": values.get("status"),
451
+ "country": values.get("country"),
452
+ "campaign_id": values.get("campaign_id"),
453
+ "keyword": values.get("keyword"),
454
+ "number": values.get("number"),
455
+ }
@@ -0,0 +1,97 @@
1
+ """Exception types raised by the Texto SDK."""
2
+
3
+ from typing import Any, Optional
4
+
5
+
6
+ class TextoError(Exception):
7
+ """Base class for every error raised by the Texto SDK."""
8
+
9
+
10
+ class TextoApiError(TextoError):
11
+ """Raised when the API returns a non-2xx response."""
12
+
13
+ def __init__(
14
+ self,
15
+ message: str,
16
+ status: int,
17
+ body: Any = None,
18
+ code: Optional[str] = None,
19
+ request_id: Optional[str] = None,
20
+ ) -> None:
21
+ super().__init__(message)
22
+ self.message = message
23
+ self.status = status
24
+ self.body = body
25
+ self.code = code
26
+ self.request_id = request_id
27
+
28
+
29
+ class TextoAuthenticationError(TextoApiError):
30
+ """The API key is missing, invalid or revoked (401)."""
31
+
32
+
33
+ class TextoPermissionError(TextoApiError):
34
+ """The account lacks access or the request was blocked (403)."""
35
+
36
+
37
+ class TextoInvalidRequestError(TextoApiError):
38
+ """The request was rejected as invalid (400, 405, 409)."""
39
+
40
+
41
+ class TextoInsufficientCreditsError(TextoApiError):
42
+ """The account has too few credits, or a card problem occurred (402)."""
43
+
44
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
45
+ super().__init__(*args, **kwargs)
46
+ body = self.body if isinstance(self.body, dict) else {}
47
+ self.credits_required = body.get("credits_required")
48
+ self.credits_available = body.get("credits_available")
49
+
50
+
51
+ class TextoNotFoundError(TextoApiError):
52
+ """The resource does not exist (404)."""
53
+
54
+
55
+ class TextoRateLimitError(TextoApiError):
56
+ """The API is rate limiting the caller (429)."""
57
+
58
+ def __init__(self, *args: Any, retry_after: Optional[float] = None, **kwargs: Any) -> None:
59
+ super().__init__(*args, **kwargs)
60
+ self.retry_after = retry_after
61
+
62
+
63
+ class TextoServerError(TextoApiError):
64
+ """The API returned a 5xx response."""
65
+
66
+
67
+ class TextoConnectionError(TextoError):
68
+ """The request could not be completed (network failure or timeout)."""
69
+
70
+
71
+ def error_from_response(
72
+ status: int,
73
+ body: Any,
74
+ request_id: Optional[str] = None,
75
+ retry_after: Optional[float] = None,
76
+ ) -> TextoApiError:
77
+ record = body if isinstance(body, dict) else {}
78
+ message = record.get("error") or f"Texto API request failed with status {status}"
79
+ if not isinstance(message, str):
80
+ message = f"Texto API request failed with status {status}"
81
+ code = record.get("code") if isinstance(record.get("code"), str) else None
82
+
83
+ kwargs = {"status": status, "body": body, "code": code, "request_id": request_id}
84
+
85
+ if status == 401:
86
+ return TextoAuthenticationError(message, **kwargs)
87
+ if status == 402:
88
+ return TextoInsufficientCreditsError(message, **kwargs)
89
+ if status == 403:
90
+ return TextoPermissionError(message, **kwargs)
91
+ if status == 404:
92
+ return TextoNotFoundError(message, **kwargs)
93
+ if status == 429:
94
+ return TextoRateLimitError(message, retry_after=retry_after, **kwargs)
95
+ if status >= 500:
96
+ return TextoServerError(message, **kwargs)
97
+ return TextoInvalidRequestError(message, **kwargs)
@@ -0,0 +1,44 @@
1
+ """Helpers for verifying and parsing Texto webhooks."""
2
+
3
+ import hashlib
4
+ import hmac
5
+ import json
6
+ from typing import Any, Dict, Optional, Union
7
+
8
+
9
+ def verify_webhook_signature(
10
+ payload: Union[str, bytes],
11
+ signature: Optional[str],
12
+ secret: str,
13
+ ) -> bool:
14
+ """Verify the ``X-Texto-Signature`` header against the raw request body.
15
+
16
+ :param payload: Raw request body exactly as received (do not re-serialise).
17
+ :param signature: Value of the ``X-Texto-Signature`` header, e.g. ``sha256=abc...``.
18
+ :param secret: Signing secret shown when you rotated the webhook secret.
19
+ """
20
+ if not signature or not secret:
21
+ return False
22
+ provided = signature[7:] if signature.startswith("sha256=") else signature
23
+ raw = payload.encode("utf-8") if isinstance(payload, str) else payload
24
+ expected = hmac.new(secret.encode("utf-8"), raw, hashlib.sha256).hexdigest()
25
+ return hmac.compare_digest(provided, expected)
26
+
27
+
28
+ def parse_webhook_event(
29
+ payload: Union[str, bytes],
30
+ signature: Optional[str],
31
+ secret: Optional[str],
32
+ ) -> Dict[str, Any]:
33
+ """Verify (when a secret is supplied) and decode a webhook body.
34
+
35
+ Raises ``ValueError`` when the signature does not match.
36
+ """
37
+ if secret and not verify_webhook_signature(payload, signature, secret):
38
+ raise ValueError("Texto webhook signature verification failed")
39
+ return json.loads(payload)
40
+
41
+
42
+ def is_inbound_event(event: Dict[str, Any]) -> bool:
43
+ """Return True when the event is an inbound (reply) message webhook."""
44
+ return event.get("event") == "message.inbound"