apisim 0.1.0__py3-none-any.whl

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.
apisim/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ """ApiSIM Python SDK: your business phone, connected to software.
2
+
3
+ from apisim import ApiSim
4
+
5
+ apisim = ApiSim(api_key=os.environ["APISIM_API_KEY"])
6
+ line = apisim.lines.list()[0]
7
+ op = apisim.messages.send(line["id"], "+15555550101", "Hi Alex, can I help?", idempotency_key="welcome-1042")
8
+ print(apisim.operations.wait_for(op["id"])["state"]) # sent | delivered | failed | uncertain
9
+ """
10
+
11
+ from .client import ApiSim, ApiSimError
12
+ from .webhooks import parse_webhook, verify_webhook
13
+
14
+ __all__ = ["ApiSim", "ApiSimError", "parse_webhook", "verify_webhook"]
15
+ __version__ = "0.1.0"
apisim/client.py ADDED
@@ -0,0 +1,236 @@
1
+ """Synchronous client over httpx. Every write takes an idempotency key: one key, one real action."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from collections.abc import Iterator
7
+ from typing import Any, Self
8
+
9
+ import httpx
10
+
11
+ TERMINAL = ("sent", "delivered", "failed", "uncertain", "completed")
12
+
13
+
14
+ class ApiSimError(Exception):
15
+ """An error answer from the API: `status`, the API's `detail`, and `retry_after` seconds on 429."""
16
+
17
+ def __init__(self, status: int, detail: Any, retry_after: float | None = None):
18
+ super().__init__(detail if isinstance(detail, str) else repr(detail))
19
+ self.status = status
20
+ self.detail = detail
21
+ self.retry_after = retry_after
22
+
23
+
24
+ class ApiSim:
25
+ def __init__(
26
+ self,
27
+ api_key: str,
28
+ base_url: str = "https://api.apisim.app",
29
+ timeout: float = 30.0,
30
+ transport: httpx.BaseTransport | None = None,
31
+ ):
32
+ if not api_key:
33
+ raise ValueError("api_key is required (create one in the dashboard)")
34
+ self._http = httpx.Client(
35
+ base_url=base_url.rstrip("/"),
36
+ timeout=timeout,
37
+ transport=transport,
38
+ headers={"Authorization": f"Bearer {api_key}", "User-Agent": "apisim-sdk-python/0.1"},
39
+ )
40
+ self.lines = _Lines(self)
41
+ self.devices = _Devices(self)
42
+ self.messages = _Messages(self)
43
+ self.operations = _Operations(self)
44
+ self.calls = _Calls(self)
45
+ self.recordings = _Recordings(self)
46
+ self.events = _Events(self)
47
+ self.webhooks = _Webhooks(self)
48
+ self.keys = _Keys(self)
49
+ self.billing = _Billing(self)
50
+
51
+ def close(self) -> None:
52
+ self._http.close()
53
+
54
+ def __enter__(self) -> Self:
55
+ return self
56
+
57
+ def __exit__(self, *exc: object) -> None:
58
+ self.close()
59
+
60
+ def request(
61
+ self,
62
+ method: str,
63
+ path: str,
64
+ json: Any = None,
65
+ params: dict[str, Any] | None = None,
66
+ idempotency_key: str | None = None,
67
+ ) -> Any:
68
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else {}
69
+ response = self._http.request(method, path, json=json, params=params, headers=headers)
70
+ try:
71
+ data = response.json() if response.content else {}
72
+ except ValueError:
73
+ data = response.text
74
+ if response.is_error:
75
+ detail = data.get("detail", data) if isinstance(data, dict) else data
76
+ retry = response.headers.get("retry-after")
77
+ raise ApiSimError(
78
+ response.status_code, detail or response.reason_phrase, float(retry) if retry else None
79
+ )
80
+ return data
81
+
82
+ def me(self) -> dict[str, Any]:
83
+ """Workspace identity, e-mail verification and the capabilities this deployment enables."""
84
+ return self.request("GET", "/v1/me")
85
+
86
+
87
+ class _Resource:
88
+ def __init__(self, client: ApiSim):
89
+ self._c = client
90
+
91
+
92
+ class _Lines(_Resource):
93
+ def list(self) -> list[dict[str, Any]]:
94
+ return self._c.request("GET", "/v1/lines")
95
+
96
+ def set_calling(self, line_id: str, enabled: bool, conference_confirmed: bool = False) -> dict[str, Any]:
97
+ """Enable calling after confirming the carrier's conference calling works on this SIM."""
98
+ body = {"enabled": enabled, "conference_confirmed": conference_confirmed}
99
+ return self._c.request("POST", f"/v1/lines/{line_id}/calling", json=body)
100
+
101
+
102
+ class _Devices(_Resource):
103
+ def list(self) -> list[dict[str, Any]]:
104
+ return self._c.request("GET", "/v1/devices")
105
+
106
+ def disconnect(self, device_id: str) -> Any:
107
+ return self._c.request("DELETE", f"/v1/devices/{device_id}")
108
+
109
+
110
+ class _Messages(_Resource):
111
+ def send(self, line_id: str, to: str, body: str, idempotency_key: str) -> dict[str, Any]:
112
+ """Queue an SMS on a line; `idempotency_key` identifies this one message and makes retries safe."""
113
+ payload = {"line_id": line_id, "to": to, "body": body}
114
+ return self._c.request("POST", "/v1/messages", json=payload, idempotency_key=idempotency_key)
115
+
116
+ def list(self) -> list[dict[str, Any]]:
117
+ """Scoped business conversations: replies and manual messages to numbers you have contacted."""
118
+ return self._c.request("GET", "/v1/messages")
119
+
120
+
121
+ class _Operations(_Resource):
122
+ def list(self) -> list[dict[str, Any]]:
123
+ return self._c.request("GET", "/v1/operations")
124
+
125
+ def get(self, operation_id: str) -> dict[str, Any]:
126
+ return self._c.request("GET", f"/v1/operations/{operation_id}")
127
+
128
+ def wait_for(
129
+ self,
130
+ operation_id: str,
131
+ until: tuple[str, ...] = TERMINAL,
132
+ timeout: float = 60.0,
133
+ interval: float = 1.5,
134
+ ) -> dict[str, Any]:
135
+ """Poll until the operation reaches one of `until`; raises ApiSimError(408) on timeout."""
136
+ deadline = time.monotonic() + timeout
137
+ while True:
138
+ op = self.get(operation_id)
139
+ if op["state"] in until:
140
+ return op
141
+ if time.monotonic() >= deadline:
142
+ raise ApiSimError(408, f"operation {operation_id} still {op['state']} after timeout")
143
+ time.sleep(interval)
144
+
145
+
146
+ class _Calls(_Resource):
147
+ def start(
148
+ self,
149
+ line_id: str,
150
+ to: str,
151
+ idempotency_key: str,
152
+ record: bool = False,
153
+ recording_notice_acknowledged: bool = False,
154
+ ) -> dict[str, Any]:
155
+ """Dial from the line through the bridge; the recipient sees the SIM's number."""
156
+ payload = {
157
+ "line_id": line_id,
158
+ "to": to,
159
+ "record": record,
160
+ "recording_notice_acknowledged": recording_notice_acknowledged,
161
+ }
162
+ return self._c.request("POST", "/v1/calls", json=payload, idempotency_key=idempotency_key)
163
+
164
+ def answer(self, call_id: str, record: bool = False, recording_notice_acknowledged: bool = False) -> dict:
165
+ body = {"record": record, "recording_notice_acknowledged": recording_notice_acknowledged}
166
+ return self._c.request("POST", f"/v1/calls/{call_id}/answer", json=body)
167
+
168
+ def end(self, call_id: str) -> dict[str, Any]:
169
+ return self._c.request("POST", f"/v1/calls/{call_id}/end", json={})
170
+
171
+ def session(self, call_id: str) -> dict[str, Any]:
172
+ """Short-lived WebRTC credentials (`url`, `token`, `room`) for an agent to join the call."""
173
+ return self._c.request("POST", f"/v1/calls/{call_id}/session", json={})
174
+
175
+ def start_recording(self, call_id: str) -> dict[str, Any]:
176
+ """Record at the bridge; needs record + notice acknowledgement on the call, and credit."""
177
+ return self._c.request("POST", f"/v1/calls/{call_id}/recording", json={})
178
+
179
+
180
+ class _Recordings(_Resource):
181
+ def list(self) -> list[dict[str, Any]]:
182
+ return self._c.request("GET", "/v1/recordings")
183
+
184
+ def playback(self, recording_id: str) -> dict[str, Any]:
185
+ """Two-minute private URL (`url`, `expires_in`)."""
186
+ return self._c.request("GET", f"/v1/recordings/{recording_id}/playback")
187
+
188
+ def delete(self, recording_id: str) -> Any:
189
+ return self._c.request("DELETE", f"/v1/recordings/{recording_id}")
190
+
191
+
192
+ class _Events(_Resource):
193
+ def list(self, after: int = 0) -> list[dict[str, Any]]:
194
+ """Up to 200 events with seq greater than `after`, in order. Persist the last seq you processed."""
195
+ return self._c.request("GET", "/v1/events", params={"after": after})
196
+
197
+ def stream(self, after: int = 0, interval: float = 2.0) -> Iterator[dict[str, Any]]:
198
+ """Yield events as they appear, resuming from `after`. Runs until the caller stops iterating."""
199
+ while True:
200
+ batch = self.list(after)
201
+ for event in batch:
202
+ after = event["seq"]
203
+ yield event
204
+ if not batch:
205
+ time.sleep(interval)
206
+
207
+
208
+ class _Webhooks(_Resource):
209
+ def create(self, url: str) -> dict[str, Any]:
210
+ """The signing `secret` is returned once; keep it for verify_webhook()."""
211
+ return self._c.request("POST", "/v1/webhooks", json={"url": url})
212
+
213
+ def list(self) -> list[dict[str, Any]]:
214
+ return self._c.request("GET", "/v1/webhooks")
215
+
216
+ def delete(self, webhook_id: str) -> Any:
217
+ return self._c.request("DELETE", f"/v1/webhooks/{webhook_id}")
218
+
219
+
220
+ class _Keys(_Resource):
221
+ def list(self) -> list[dict[str, Any]]:
222
+ return self._c.request("GET", "/v1/keys")
223
+
224
+ def create(self, label: str) -> dict[str, Any]:
225
+ return self._c.request("POST", "/v1/keys", json={"label": label})
226
+
227
+ def revoke(self, key_id: str) -> Any:
228
+ return self._c.request("DELETE", f"/v1/keys/{key_id}")
229
+
230
+
231
+ class _Billing(_Resource):
232
+ def status(self) -> dict[str, Any]:
233
+ return self._c.request("GET", "/v1/billing")
234
+
235
+ def ledger(self) -> list[dict[str, Any]]:
236
+ return self._c.request("GET", "/v1/billing/ledger")
apisim/py.typed ADDED
File without changes
apisim/webhooks.py ADDED
@@ -0,0 +1,43 @@
1
+ """Verify ApiSIM webhook deliveries.
2
+
3
+ Header `X-Apisim-Signature: t=<unix seconds>,v1=<hex>` where
4
+ v1 = HMAC-SHA256(secret, f"{t}.{raw_body}"). Always verify against the raw request body as received,
5
+ never re-serialised JSON. Delivery is at least once: deduplicate on the `X-Apisim-Event` header.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import hmac
12
+ import json
13
+ import time
14
+ from typing import Any
15
+
16
+ from .client import ApiSimError
17
+
18
+
19
+ def verify_webhook(
20
+ secret: str,
21
+ signature: str | None,
22
+ raw_body: bytes | str,
23
+ tolerance_seconds: int = 300,
24
+ now: float | None = None,
25
+ ) -> bool:
26
+ parts = dict(p.split("=", 1) for p in (signature or "").split(",") if "=" in p)
27
+ timestamp, given = parts.get("t"), parts.get("v1")
28
+ if not timestamp or not given or not timestamp.isdigit():
29
+ return False
30
+ if abs((now if now is not None else time.time()) - int(timestamp)) > tolerance_seconds:
31
+ return False
32
+ body = raw_body.encode() if isinstance(raw_body, str) else raw_body
33
+ expected = hmac.new(secret.encode(), timestamp.encode() + b"." + body, hashlib.sha256).hexdigest()
34
+ return hmac.compare_digest(expected, given.lower())
35
+
36
+
37
+ def parse_webhook(
38
+ secret: str, signature: str | None, raw_body: bytes | str, tolerance_seconds: int = 300
39
+ ) -> dict[str, Any]:
40
+ """Verify and parse in one step; raises ApiSimError(401) when the signature is invalid or stale."""
41
+ if not verify_webhook(secret, signature, raw_body, tolerance_seconds):
42
+ raise ApiSimError(401, "invalid webhook signature")
43
+ return json.loads(raw_body)
@@ -0,0 +1,79 @@
1
+ Metadata-Version: 2.5
2
+ Name: apisim
3
+ Version: 0.1.0
4
+ Summary: Python client for the ApiSIM API: send SMS, place calls and read conversations through a phone you own.
5
+ Project-URL: Homepage, https://apisim.app/docs
6
+ Project-URL: Repository, https://github.com/ronnybru/apisim
7
+ Project-URL: Issues, https://github.com/ronnybru/apisim/issues
8
+ Author-email: Digital Trend AS <support@apisim.app>
9
+ License-Expression: MIT
10
+ Keywords: ai-agents,api,apisim,phone,sms,voice,webhooks
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Communications :: Telephony
13
+ Classifier: Typing :: Typed
14
+ Requires-Python: >=3.11
15
+ Requires-Dist: httpx>=0.27
16
+ Description-Content-Type: text/markdown
17
+
18
+ # apisim
19
+
20
+ Python client for the [ApiSIM](https://apisim.app) API: send SMS, place calls with browser or AI
21
+ audio, read conversations and recordings, all through an Android phone and SIM you own.
22
+ Python 3.11+, one dependency (`httpx`).
23
+
24
+ ```sh
25
+ pip install apisim
26
+ ```
27
+
28
+ ```python
29
+ import os
30
+ from apisim import ApiSim
31
+
32
+ apisim = ApiSim(api_key=os.environ["APISIM_API_KEY"])
33
+
34
+ line = apisim.lines.list()[0]
35
+ op = apisim.messages.send(
36
+ line["id"],
37
+ "+15555550101",
38
+ "Hi Alex, can I help?",
39
+ idempotency_key="welcome-1042", # one real-world message; retries with the same key never send twice
40
+ )
41
+ print(apisim.operations.wait_for(op["id"])["state"]) # sent | delivered | failed | uncertain
42
+ ```
43
+
44
+ Outcomes are honest: `sent` is carrier acceptance, `delivered` is handset confirmation, and
45
+ `uncertain` means the phone went quiet mid-action; reconcile before retrying with a new key.
46
+
47
+ ## Events and webhooks
48
+
49
+ ```python
50
+ for event in apisim.events.stream(after=last_seq):
51
+ if event["type"] == "messages.incoming":
52
+ handle(event["data"])
53
+ last_seq = event["seq"] # persist it
54
+ ```
55
+
56
+ Or register a webhook and verify each delivery against the raw request body:
57
+
58
+ ```python
59
+ from apisim import parse_webhook
60
+
61
+ event = parse_webhook(
62
+ os.environ["APISIM_WEBHOOK_SECRET"], request.headers.get("X-Apisim-Signature"), request.body
63
+ )
64
+ ```
65
+
66
+ Delivery is at least once; deduplicate on the `X-Apisim-Event` header.
67
+
68
+ ## Calls
69
+
70
+ ```python
71
+ call = apisim.calls.start(line["id"], "+15555550101", idempotency_key="callback-77")
72
+ session = apisim.calls.session(call["id"]) # {"url", "token", "room"}: WebRTC credentials for your agent
73
+ apisim.calls.end(call["id"])
74
+ ```
75
+
76
+ Calling is enabled per line after a conference-capability check; recording needs `record=True`,
77
+ `recording_notice_acknowledged=True` and credit.
78
+
79
+ Full reference: https://apisim.app/docs and the OpenAPI document at https://api.apisim.app/docs.
@@ -0,0 +1,7 @@
1
+ apisim/__init__.py,sha256=RmBdtmzx0Sfp7ASJxUVWTcGSWfHSvh_RNMEGbpZs72Y,573
2
+ apisim/client.py,sha256=KDIfKBoKpmKyN3eAD3rk76DUi33Yxf2uF16SEtbyQsQ,8872
3
+ apisim/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ apisim/webhooks.py,sha256=j--NxYip5JTd7UAOh-A9D5yuzos4y2cyCDcIxlQn2gc,1584
5
+ apisim-0.1.0.dist-info/METADATA,sha256=ASAaTRXkXJjLg97th9GdIGSGDRVV4G-MHzqKz1Z1HPc,2580
6
+ apisim-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
7
+ apisim-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any