byourside 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 By Your Side
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,187 @@
1
+ Metadata-Version: 2.4
2
+ Name: byourside
3
+ Version: 0.1.0
4
+ Summary: By Your Side SDK: give an AI a phone (outbound objective-driven calls).
5
+ Author: By Your Side
6
+ License: MIT
7
+ Project-URL: Homepage, https://byourside.ai/docs/agent-api
8
+ Project-URL: Repository, https://github.com/allexp1/voip-agent
9
+ Keywords: voice,ai,phone,outbound,agent,sdk,voip,calls
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.8
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Communications :: Telephony
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Requires-Python: >=3.8
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Dynamic: license-file
25
+
26
+ # By Your Side Python SDK
27
+
28
+ Zero-dependency Python client for the By Your Side Agent Outbound-Call API.
29
+ Place objective-driven AI calls, poll for results, and verify webhooks using only the Python standard library.
30
+
31
+ Requires Python 3.8+. No third-party packages needed.
32
+
33
+ ---
34
+
35
+ ## Quick start
36
+
37
+ ### Place a call and wait for the result
38
+
39
+ ```python
40
+ from byourside import Client
41
+
42
+ client = Client(api_key="bys_ak_YOUR_KEY")
43
+
44
+ # Place an outbound call with an objective
45
+ call = client.place_call(
46
+ to="+14155550123",
47
+ objective="Confirm the appointment for tomorrow at 2pm and ask if they need to reschedule.",
48
+ fields=[
49
+ {"name": "confirmed", "type": "boolean"},
50
+ {"name": "new_time", "type": "string"},
51
+ ],
52
+ )
53
+ print("Placed:", call["callId"], "status:", call["status"])
54
+
55
+ # Poll until the call reaches a terminal status (default timeout: 180s)
56
+ result = client.wait_for_call(call["callId"], timeout=180, interval=5)
57
+ print("Final status:", result["status"])
58
+ print("Extracted fields:", result.get("extracted"))
59
+ ```
60
+
61
+ ### List recent calls
62
+
63
+ ```python
64
+ calls = client.list_calls(limit=10)
65
+ for c in calls:
66
+ print(c["callId"], c["status"])
67
+ ```
68
+
69
+ ### Get a specific call
70
+
71
+ ```python
72
+ call = client.get_call("call_abc123")
73
+ print(call)
74
+ ```
75
+
76
+ ---
77
+
78
+ ## Call statuses
79
+
80
+ | Status | Meaning |
81
+ |---|---|
82
+ | `queued` | Call accepted, waiting to be placed |
83
+ | `in_progress` | Call is active |
84
+ | `completed` | Call finished normally (terminal) |
85
+ | `no_answer` | Recipient did not pick up (terminal) |
86
+ | `voicemail` | Reached voicemail (terminal) |
87
+ | `declined` | Recipient declined the call (terminal) |
88
+ | `failed` | Call could not complete (terminal) |
89
+
90
+ `wait_for_call` returns once the status is one of the five terminal states.
91
+
92
+ ---
93
+
94
+ ## Webhook verification
95
+
96
+ By Your Side sends a `X-BYS-Signature` header with each webhook delivery.
97
+ Verify it before processing the payload.
98
+
99
+ ### Flask example
100
+
101
+ ```python
102
+ from flask import Flask, request, abort
103
+ from byourside import verify_webhook
104
+
105
+ app = Flask(__name__)
106
+ WEBHOOK_SECRET = "whsec_YOUR_SECRET"
107
+
108
+ @app.route("/webhook/bys", methods=["POST"])
109
+ def bys_webhook():
110
+ sig = request.headers.get("X-BYS-Signature", "")
111
+ raw_body = request.get_data(as_text=True)
112
+ if not verify_webhook(sig, raw_body, WEBHOOK_SECRET):
113
+ abort(400, "Invalid signature")
114
+ payload = request.get_json()
115
+ print("Call update:", payload["callId"], payload["status"])
116
+ return "", 200
117
+ ```
118
+
119
+ The signature format is `t=<unix_seconds>,v1=<hex>` where
120
+ `hex = HMAC-SHA256(secret, f"{t}.{raw_body}")`.
121
+
122
+ `verify_webhook` never raises. It returns `False` for invalid or expired signatures
123
+ (default tolerance: 300 seconds).
124
+
125
+ ---
126
+
127
+ ## Error handling
128
+
129
+ All API errors raise `ByoursideError`:
130
+
131
+ ```python
132
+ from byourside import Client, ByoursideError
133
+
134
+ client = Client(api_key="bys_ak_YOUR_KEY")
135
+ try:
136
+ client.place_call(to="+19001234567", objective="Sell something")
137
+ except ByoursideError as e:
138
+ print(e.code) # e.g. "destination_blocked"
139
+ print(e.status) # HTTP status, or 0 for network/timeout errors
140
+ print(str(e)) # Human-readable message
141
+ ```
142
+
143
+ Common error codes:
144
+
145
+ | Code | Meaning |
146
+ |---|---|
147
+ | `destination_blocked` | Destination not allowed (premium, IRSF, or unsupported country) |
148
+ | `invalid_number` | Number must be E.164, e.g. +14155550123 |
149
+ | `to_required` | Missing destination number |
150
+ | `objective_required` | Missing call objective |
151
+ | `caller_id_not_owned` | Caller ID not on your account |
152
+ | `rate_limited` | Rate limit reached, retry shortly |
153
+ | `over_minute_cap` | Outbound usage limit reached |
154
+ | `unauthorized` | Invalid or missing API key |
155
+ | `not_found` | Call ID not found |
156
+ | `placement_failed` | Carrier or trunk issue, retry shortly |
157
+ | `store_error` | Temporary service error, retry shortly |
158
+ | `network_error` | Could not reach the API |
159
+ | `timeout` | `wait_for_call` deadline exceeded |
160
+
161
+ ---
162
+
163
+ ## Development
164
+
165
+ Clone the repo. No install step needed for local use: run tests from the `sdks/python/` directory so that `byourside/` is on the path automatically.
166
+
167
+ ```bash
168
+ cd sdks/python
169
+ python3 -m unittest discover -s tests
170
+ ```
171
+
172
+ Expected output: all tests pass (11 tests across errors, client, wait, and webhooks).
173
+
174
+ If your environment does not add CWD to the path automatically, use:
175
+
176
+ ```bash
177
+ cd sdks/python
178
+ PYTHONPATH=. python3 -m unittest discover -s tests
179
+ ```
180
+
181
+ Syntax check all source files:
182
+
183
+ ```bash
184
+ python3 -m py_compile byourside/errors.py byourside/client.py byourside/webhooks.py byourside/__init__.py
185
+ ```
186
+
187
+ Publishing to PyPI is deferred. Do not publish without explicit owner approval.
@@ -0,0 +1,162 @@
1
+ # By Your Side Python SDK
2
+
3
+ Zero-dependency Python client for the By Your Side Agent Outbound-Call API.
4
+ Place objective-driven AI calls, poll for results, and verify webhooks using only the Python standard library.
5
+
6
+ Requires Python 3.8+. No third-party packages needed.
7
+
8
+ ---
9
+
10
+ ## Quick start
11
+
12
+ ### Place a call and wait for the result
13
+
14
+ ```python
15
+ from byourside import Client
16
+
17
+ client = Client(api_key="bys_ak_YOUR_KEY")
18
+
19
+ # Place an outbound call with an objective
20
+ call = client.place_call(
21
+ to="+14155550123",
22
+ objective="Confirm the appointment for tomorrow at 2pm and ask if they need to reschedule.",
23
+ fields=[
24
+ {"name": "confirmed", "type": "boolean"},
25
+ {"name": "new_time", "type": "string"},
26
+ ],
27
+ )
28
+ print("Placed:", call["callId"], "status:", call["status"])
29
+
30
+ # Poll until the call reaches a terminal status (default timeout: 180s)
31
+ result = client.wait_for_call(call["callId"], timeout=180, interval=5)
32
+ print("Final status:", result["status"])
33
+ print("Extracted fields:", result.get("extracted"))
34
+ ```
35
+
36
+ ### List recent calls
37
+
38
+ ```python
39
+ calls = client.list_calls(limit=10)
40
+ for c in calls:
41
+ print(c["callId"], c["status"])
42
+ ```
43
+
44
+ ### Get a specific call
45
+
46
+ ```python
47
+ call = client.get_call("call_abc123")
48
+ print(call)
49
+ ```
50
+
51
+ ---
52
+
53
+ ## Call statuses
54
+
55
+ | Status | Meaning |
56
+ |---|---|
57
+ | `queued` | Call accepted, waiting to be placed |
58
+ | `in_progress` | Call is active |
59
+ | `completed` | Call finished normally (terminal) |
60
+ | `no_answer` | Recipient did not pick up (terminal) |
61
+ | `voicemail` | Reached voicemail (terminal) |
62
+ | `declined` | Recipient declined the call (terminal) |
63
+ | `failed` | Call could not complete (terminal) |
64
+
65
+ `wait_for_call` returns once the status is one of the five terminal states.
66
+
67
+ ---
68
+
69
+ ## Webhook verification
70
+
71
+ By Your Side sends a `X-BYS-Signature` header with each webhook delivery.
72
+ Verify it before processing the payload.
73
+
74
+ ### Flask example
75
+
76
+ ```python
77
+ from flask import Flask, request, abort
78
+ from byourside import verify_webhook
79
+
80
+ app = Flask(__name__)
81
+ WEBHOOK_SECRET = "whsec_YOUR_SECRET"
82
+
83
+ @app.route("/webhook/bys", methods=["POST"])
84
+ def bys_webhook():
85
+ sig = request.headers.get("X-BYS-Signature", "")
86
+ raw_body = request.get_data(as_text=True)
87
+ if not verify_webhook(sig, raw_body, WEBHOOK_SECRET):
88
+ abort(400, "Invalid signature")
89
+ payload = request.get_json()
90
+ print("Call update:", payload["callId"], payload["status"])
91
+ return "", 200
92
+ ```
93
+
94
+ The signature format is `t=<unix_seconds>,v1=<hex>` where
95
+ `hex = HMAC-SHA256(secret, f"{t}.{raw_body}")`.
96
+
97
+ `verify_webhook` never raises. It returns `False` for invalid or expired signatures
98
+ (default tolerance: 300 seconds).
99
+
100
+ ---
101
+
102
+ ## Error handling
103
+
104
+ All API errors raise `ByoursideError`:
105
+
106
+ ```python
107
+ from byourside import Client, ByoursideError
108
+
109
+ client = Client(api_key="bys_ak_YOUR_KEY")
110
+ try:
111
+ client.place_call(to="+19001234567", objective="Sell something")
112
+ except ByoursideError as e:
113
+ print(e.code) # e.g. "destination_blocked"
114
+ print(e.status) # HTTP status, or 0 for network/timeout errors
115
+ print(str(e)) # Human-readable message
116
+ ```
117
+
118
+ Common error codes:
119
+
120
+ | Code | Meaning |
121
+ |---|---|
122
+ | `destination_blocked` | Destination not allowed (premium, IRSF, or unsupported country) |
123
+ | `invalid_number` | Number must be E.164, e.g. +14155550123 |
124
+ | `to_required` | Missing destination number |
125
+ | `objective_required` | Missing call objective |
126
+ | `caller_id_not_owned` | Caller ID not on your account |
127
+ | `rate_limited` | Rate limit reached, retry shortly |
128
+ | `over_minute_cap` | Outbound usage limit reached |
129
+ | `unauthorized` | Invalid or missing API key |
130
+ | `not_found` | Call ID not found |
131
+ | `placement_failed` | Carrier or trunk issue, retry shortly |
132
+ | `store_error` | Temporary service error, retry shortly |
133
+ | `network_error` | Could not reach the API |
134
+ | `timeout` | `wait_for_call` deadline exceeded |
135
+
136
+ ---
137
+
138
+ ## Development
139
+
140
+ Clone the repo. No install step needed for local use: run tests from the `sdks/python/` directory so that `byourside/` is on the path automatically.
141
+
142
+ ```bash
143
+ cd sdks/python
144
+ python3 -m unittest discover -s tests
145
+ ```
146
+
147
+ Expected output: all tests pass (11 tests across errors, client, wait, and webhooks).
148
+
149
+ If your environment does not add CWD to the path automatically, use:
150
+
151
+ ```bash
152
+ cd sdks/python
153
+ PYTHONPATH=. python3 -m unittest discover -s tests
154
+ ```
155
+
156
+ Syntax check all source files:
157
+
158
+ ```bash
159
+ python3 -m py_compile byourside/errors.py byourside/client.py byourside/webhooks.py byourside/__init__.py
160
+ ```
161
+
162
+ Publishing to PyPI is deferred. Do not publish without explicit owner approval.
@@ -0,0 +1,5 @@
1
+ from .client import Client
2
+ from .errors import ByoursideError
3
+ from .webhooks import verify_webhook
4
+
5
+ __all__ = ["Client", "ByoursideError", "verify_webhook"]
@@ -0,0 +1,79 @@
1
+ """By Your Side SDK client. Zero third-party deps (urllib). Raises ByoursideError on any
2
+ non-2xx or network failure. The HTTP transport is injectable for tests."""
3
+ import json
4
+ import time
5
+ import urllib.error
6
+ import urllib.parse
7
+ import urllib.request
8
+
9
+ from .errors import map_error, ByoursideError
10
+
11
+ TERMINAL = {"completed", "no_answer", "voicemail", "declined", "failed"}
12
+
13
+
14
+ def _default_transport(method, url, headers, body):
15
+ data = body.encode() if body is not None else None
16
+ req = urllib.request.Request(url, data=data, headers=headers, method=method)
17
+ try:
18
+ with urllib.request.urlopen(req) as resp:
19
+ return (resp.status, json.loads(resp.read().decode() or "null"))
20
+ except urllib.error.HTTPError as e:
21
+ try:
22
+ obj = json.loads(e.read().decode() or "null")
23
+ except Exception:
24
+ obj = None
25
+ return (e.code, obj)
26
+ except urllib.error.URLError as e:
27
+ raise ByoursideError(map_error(0, None), 0, "network_error")
28
+
29
+
30
+ class Client:
31
+ def __init__(self, api_key, base_url="https://api.byourside.ai", transport=None, sleep=None):
32
+ if not api_key:
33
+ raise ByoursideError("api_key is required", 0, "no_api_key")
34
+ self._api_key = api_key
35
+ self._base = base_url.rstrip("/")
36
+ self._transport = transport or _default_transport
37
+ self._sleep = sleep or time.sleep
38
+
39
+ def _request(self, method, path, body=None):
40
+ headers = {"Authorization": "Bearer %s" % self._api_key}
41
+ body_str = None
42
+ if body is not None and method != "GET":
43
+ headers["content-type"] = "application/json"
44
+ body_str = json.dumps(body)
45
+ status, data = self._transport(method, self._base + path, headers, body_str)
46
+ if not (200 <= status < 300):
47
+ code = data.get("error") if isinstance(data, dict) else "error"
48
+ raise ByoursideError(map_error(status, data), status, code or "error")
49
+ return data
50
+
51
+ def place_call(self, to, objective, context=None, fields=None, webhook_url=None, caller_id=None):
52
+ body = {"to": to, "objective": objective}
53
+ if context is not None:
54
+ body["context"] = context
55
+ if fields is not None:
56
+ body["fields"] = fields
57
+ if webhook_url is not None:
58
+ body["webhookUrl"] = webhook_url
59
+ if caller_id is not None:
60
+ body["callerId"] = caller_id
61
+ return self._request("POST", "/v1/agent/calls", body)
62
+
63
+ def get_call(self, call_id):
64
+ return self._request("GET", "/v1/agent/calls/" + urllib.parse.quote(str(call_id), safe=""))
65
+
66
+ def list_calls(self, limit=20):
67
+ n = min(max(1, int(limit) if str(limit).isdigit() else 20), 100)
68
+ data = self._request("GET", "/v1/agent/calls?limit=%d" % n)
69
+ return data.get("calls", []) if isinstance(data, dict) else []
70
+
71
+ def wait_for_call(self, call_id, timeout=180, interval=3):
72
+ deadline = time.monotonic() + timeout
73
+ while True:
74
+ call = self.get_call(call_id)
75
+ if isinstance(call, dict) and call.get("status") in TERMINAL:
76
+ return call
77
+ if time.monotonic() + interval > deadline:
78
+ raise ByoursideError("Call %s did not finish within %ss" % (call_id, timeout), 0, "timeout")
79
+ self._sleep(interval)
@@ -0,0 +1,34 @@
1
+ """Typed error + token->message mapping for the By Your Side SDK. Never includes the API key."""
2
+
3
+ _TOKEN_MESSAGES = {
4
+ "destination_blocked": "That destination is not allowed (premium, IRSF, or unsupported country).",
5
+ "invalid_number": "The destination number is invalid (use full E.164, e.g. +14155550123).",
6
+ "to_required": "A destination number (to) is required.",
7
+ "objective_required": "An objective for the call is required.",
8
+ "caller_id_not_owned": "That caller ID is not a number on your account.",
9
+ "rate_limited": "Rate limit reached. Try again shortly.",
10
+ "over_minute_cap": "Outbound usage limit reached for now.",
11
+ "unauthorized": "Invalid or missing API key.",
12
+ "not_found": "No call found with that id (or it does not belong to your account).",
13
+ "placement_failed": "The call could not be placed (carrier/trunk issue). Try again shortly.",
14
+ "store_error": "Temporary service error. Please retry shortly.",
15
+ }
16
+
17
+
18
+ def map_error(status, body):
19
+ token = body.get("error", "") if isinstance(body, dict) else ""
20
+ if token and token in _TOKEN_MESSAGES:
21
+ return _TOKEN_MESSAGES[token]
22
+ if not status or status >= 500:
23
+ return "Temporary service error. Please retry shortly."
24
+ if token:
25
+ return "Request failed (%s)." % token
26
+ return "Request failed with status %s." % status
27
+
28
+
29
+ class ByoursideError(Exception):
30
+ def __init__(self, message, status=0, code=None):
31
+ super().__init__(message)
32
+ self.message = message
33
+ self.status = status
34
+ self.code = code
@@ -0,0 +1,26 @@
1
+ """Verify a By Your Side webhook signature header. Never raises; returns bool.
2
+ Header: "t=<unix_seconds>,v1=<hex>"; sig = HMAC-SHA256(secret, f"{t}.{raw_body}")."""
3
+ import hmac
4
+ import hashlib
5
+ import time
6
+
7
+
8
+ def verify_webhook(signature_header, raw_body, secret, tolerance=300):
9
+ try:
10
+ if not signature_header or not secret:
11
+ return False
12
+ parts = {}
13
+ for kv in str(signature_header).split(","):
14
+ if "=" in kv:
15
+ k, v = kv.split("=", 1)
16
+ parts[k.strip()] = v.strip()
17
+ t = parts.get("t"); v1 = parts.get("v1")
18
+ if t is None or v1 is None:
19
+ return False
20
+ t = int(t)
21
+ if abs(int(time.time()) - t) > tolerance:
22
+ return False
23
+ expected = hmac.new(secret.encode(), ("%d.%s" % (t, raw_body)).encode(), hashlib.sha256).hexdigest()
24
+ return hmac.compare_digest(expected, str(v1))
25
+ except Exception:
26
+ return False
@@ -0,0 +1,187 @@
1
+ Metadata-Version: 2.4
2
+ Name: byourside
3
+ Version: 0.1.0
4
+ Summary: By Your Side SDK: give an AI a phone (outbound objective-driven calls).
5
+ Author: By Your Side
6
+ License: MIT
7
+ Project-URL: Homepage, https://byourside.ai/docs/agent-api
8
+ Project-URL: Repository, https://github.com/allexp1/voip-agent
9
+ Keywords: voice,ai,phone,outbound,agent,sdk,voip,calls
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.8
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Communications :: Telephony
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Requires-Python: >=3.8
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Dynamic: license-file
25
+
26
+ # By Your Side Python SDK
27
+
28
+ Zero-dependency Python client for the By Your Side Agent Outbound-Call API.
29
+ Place objective-driven AI calls, poll for results, and verify webhooks using only the Python standard library.
30
+
31
+ Requires Python 3.8+. No third-party packages needed.
32
+
33
+ ---
34
+
35
+ ## Quick start
36
+
37
+ ### Place a call and wait for the result
38
+
39
+ ```python
40
+ from byourside import Client
41
+
42
+ client = Client(api_key="bys_ak_YOUR_KEY")
43
+
44
+ # Place an outbound call with an objective
45
+ call = client.place_call(
46
+ to="+14155550123",
47
+ objective="Confirm the appointment for tomorrow at 2pm and ask if they need to reschedule.",
48
+ fields=[
49
+ {"name": "confirmed", "type": "boolean"},
50
+ {"name": "new_time", "type": "string"},
51
+ ],
52
+ )
53
+ print("Placed:", call["callId"], "status:", call["status"])
54
+
55
+ # Poll until the call reaches a terminal status (default timeout: 180s)
56
+ result = client.wait_for_call(call["callId"], timeout=180, interval=5)
57
+ print("Final status:", result["status"])
58
+ print("Extracted fields:", result.get("extracted"))
59
+ ```
60
+
61
+ ### List recent calls
62
+
63
+ ```python
64
+ calls = client.list_calls(limit=10)
65
+ for c in calls:
66
+ print(c["callId"], c["status"])
67
+ ```
68
+
69
+ ### Get a specific call
70
+
71
+ ```python
72
+ call = client.get_call("call_abc123")
73
+ print(call)
74
+ ```
75
+
76
+ ---
77
+
78
+ ## Call statuses
79
+
80
+ | Status | Meaning |
81
+ |---|---|
82
+ | `queued` | Call accepted, waiting to be placed |
83
+ | `in_progress` | Call is active |
84
+ | `completed` | Call finished normally (terminal) |
85
+ | `no_answer` | Recipient did not pick up (terminal) |
86
+ | `voicemail` | Reached voicemail (terminal) |
87
+ | `declined` | Recipient declined the call (terminal) |
88
+ | `failed` | Call could not complete (terminal) |
89
+
90
+ `wait_for_call` returns once the status is one of the five terminal states.
91
+
92
+ ---
93
+
94
+ ## Webhook verification
95
+
96
+ By Your Side sends a `X-BYS-Signature` header with each webhook delivery.
97
+ Verify it before processing the payload.
98
+
99
+ ### Flask example
100
+
101
+ ```python
102
+ from flask import Flask, request, abort
103
+ from byourside import verify_webhook
104
+
105
+ app = Flask(__name__)
106
+ WEBHOOK_SECRET = "whsec_YOUR_SECRET"
107
+
108
+ @app.route("/webhook/bys", methods=["POST"])
109
+ def bys_webhook():
110
+ sig = request.headers.get("X-BYS-Signature", "")
111
+ raw_body = request.get_data(as_text=True)
112
+ if not verify_webhook(sig, raw_body, WEBHOOK_SECRET):
113
+ abort(400, "Invalid signature")
114
+ payload = request.get_json()
115
+ print("Call update:", payload["callId"], payload["status"])
116
+ return "", 200
117
+ ```
118
+
119
+ The signature format is `t=<unix_seconds>,v1=<hex>` where
120
+ `hex = HMAC-SHA256(secret, f"{t}.{raw_body}")`.
121
+
122
+ `verify_webhook` never raises. It returns `False` for invalid or expired signatures
123
+ (default tolerance: 300 seconds).
124
+
125
+ ---
126
+
127
+ ## Error handling
128
+
129
+ All API errors raise `ByoursideError`:
130
+
131
+ ```python
132
+ from byourside import Client, ByoursideError
133
+
134
+ client = Client(api_key="bys_ak_YOUR_KEY")
135
+ try:
136
+ client.place_call(to="+19001234567", objective="Sell something")
137
+ except ByoursideError as e:
138
+ print(e.code) # e.g. "destination_blocked"
139
+ print(e.status) # HTTP status, or 0 for network/timeout errors
140
+ print(str(e)) # Human-readable message
141
+ ```
142
+
143
+ Common error codes:
144
+
145
+ | Code | Meaning |
146
+ |---|---|
147
+ | `destination_blocked` | Destination not allowed (premium, IRSF, or unsupported country) |
148
+ | `invalid_number` | Number must be E.164, e.g. +14155550123 |
149
+ | `to_required` | Missing destination number |
150
+ | `objective_required` | Missing call objective |
151
+ | `caller_id_not_owned` | Caller ID not on your account |
152
+ | `rate_limited` | Rate limit reached, retry shortly |
153
+ | `over_minute_cap` | Outbound usage limit reached |
154
+ | `unauthorized` | Invalid or missing API key |
155
+ | `not_found` | Call ID not found |
156
+ | `placement_failed` | Carrier or trunk issue, retry shortly |
157
+ | `store_error` | Temporary service error, retry shortly |
158
+ | `network_error` | Could not reach the API |
159
+ | `timeout` | `wait_for_call` deadline exceeded |
160
+
161
+ ---
162
+
163
+ ## Development
164
+
165
+ Clone the repo. No install step needed for local use: run tests from the `sdks/python/` directory so that `byourside/` is on the path automatically.
166
+
167
+ ```bash
168
+ cd sdks/python
169
+ python3 -m unittest discover -s tests
170
+ ```
171
+
172
+ Expected output: all tests pass (11 tests across errors, client, wait, and webhooks).
173
+
174
+ If your environment does not add CWD to the path automatically, use:
175
+
176
+ ```bash
177
+ cd sdks/python
178
+ PYTHONPATH=. python3 -m unittest discover -s tests
179
+ ```
180
+
181
+ Syntax check all source files:
182
+
183
+ ```bash
184
+ python3 -m py_compile byourside/errors.py byourside/client.py byourside/webhooks.py byourside/__init__.py
185
+ ```
186
+
187
+ Publishing to PyPI is deferred. Do not publish without explicit owner approval.
@@ -0,0 +1,15 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ byourside/__init__.py
5
+ byourside/client.py
6
+ byourside/errors.py
7
+ byourside/webhooks.py
8
+ byourside.egg-info/PKG-INFO
9
+ byourside.egg-info/SOURCES.txt
10
+ byourside.egg-info/dependency_links.txt
11
+ byourside.egg-info/top_level.txt
12
+ tests/test_client.py
13
+ tests/test_errors.py
14
+ tests/test_wait.py
15
+ tests/test_webhooks.py
@@ -0,0 +1 @@
1
+ byourside
@@ -0,0 +1,35 @@
1
+ [project]
2
+ name = "byourside"
3
+ version = "0.1.0"
4
+ description = "By Your Side SDK: give an AI a phone (outbound objective-driven calls)."
5
+ readme = "README.md"
6
+ requires-python = ">=3.8"
7
+ dependencies = []
8
+ license = { text = "MIT" }
9
+ authors = [{ name = "By Your Side" }]
10
+ keywords = ["voice", "ai", "phone", "outbound", "agent", "sdk", "voip", "calls"]
11
+ classifiers = [
12
+ "Development Status :: 3 - Alpha",
13
+ "Intended Audience :: Developers",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.8",
17
+ "Programming Language :: Python :: 3.9",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Topic :: Communications :: Telephony",
22
+ "Topic :: Software Development :: Libraries :: Python Modules"
23
+ ]
24
+
25
+ [project.urls]
26
+ Homepage = "https://byourside.ai/docs/agent-api"
27
+ Repository = "https://github.com/allexp1/voip-agent"
28
+
29
+ [build-system]
30
+ requires = ["setuptools>=61"]
31
+ build-backend = "setuptools.build_meta"
32
+
33
+ [tool.setuptools.packages.find]
34
+ where = ["."]
35
+ include = ["byourside*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,48 @@
1
+ import json, unittest
2
+ from byourside.client import Client
3
+ from byourside.errors import ByoursideError
4
+
5
+ def make_client(captured, resp=None, raise_status=None):
6
+ def transport(method, url, headers, body):
7
+ captured["method"] = method; captured["url"] = url
8
+ captured["headers"] = headers; captured["body"] = body
9
+ if raise_status is not None:
10
+ return raise_status # (status, obj)
11
+ return resp
12
+ return Client(api_key="bys_ak_x", base_url="https://b", transport=transport)
13
+
14
+ class TestClient(unittest.TestCase):
15
+ def test_place_call(self):
16
+ cap = {}
17
+ c = make_client(cap, resp=(200, {"callId": "c1", "status": "queued"}))
18
+ r = c.place_call(to="+14155550123", objective="Book a table", fields=[{"name": "booked"}])
19
+ self.assertEqual(cap["method"], "POST")
20
+ self.assertEqual(cap["url"], "https://b/v1/agent/calls")
21
+ self.assertEqual(cap["headers"]["Authorization"], "Bearer bys_ak_x")
22
+ self.assertEqual(json.loads(cap["body"])["to"], "+14155550123")
23
+ self.assertEqual(r["callId"], "c1")
24
+
25
+ def test_get_call_encoded(self):
26
+ cap = {}
27
+ c = make_client(cap, resp=(200, {"id": "c1", "status": "completed"}))
28
+ c.get_call("c 1")
29
+ self.assertEqual(cap["method"], "GET")
30
+ self.assertEqual(cap["url"], "https://b/v1/agent/calls/c%201")
31
+ self.assertIsNone(cap["body"])
32
+
33
+ def test_list_calls_unwrap_clamp(self):
34
+ cap = {}
35
+ c = make_client(cap, resp=(200, {"calls": [{"id": "c1"}]}))
36
+ r = c.list_calls(limit=9999)
37
+ self.assertTrue(cap["url"].endswith("/v1/agent/calls?limit=100"))
38
+ self.assertEqual(r, [{"id": "c1"}])
39
+
40
+ def test_error_raises(self):
41
+ c = make_client({}, raise_status=(400, {"error": "destination_blocked"}))
42
+ with self.assertRaises(ByoursideError) as ctx:
43
+ c.place_call(to="+1900", objective="x")
44
+ self.assertEqual(ctx.exception.status, 400)
45
+ self.assertEqual(ctx.exception.code, "destination_blocked")
46
+
47
+ if __name__ == "__main__":
48
+ unittest.main()
@@ -0,0 +1,17 @@
1
+ import unittest
2
+ from byourside.errors import map_error, ByoursideError
3
+
4
+ class TestErrors(unittest.TestCase):
5
+ def test_known_tokens(self):
6
+ self.assertIn("not allowed", map_error(400, {"error": "destination_blocked"}).lower())
7
+ self.assertIn("api key", map_error(401, {"error": "unauthorized"}).lower())
8
+ def test_fallbacks(self):
9
+ self.assertIn("temporary", map_error(503, {"error": "store_error"}).lower())
10
+ self.assertIn("temporary", map_error(0, None).lower())
11
+ self.assertIn("weird", map_error(400, {"error": "weird"}))
12
+ def test_error_fields(self):
13
+ e = ByoursideError("msg", 400, "destination_blocked")
14
+ self.assertEqual(e.status, 400); self.assertEqual(e.code, "destination_blocked"); self.assertEqual(e.message, "msg")
15
+
16
+ if __name__ == "__main__":
17
+ unittest.main()
@@ -0,0 +1,26 @@
1
+ import unittest
2
+ from byourside.client import Client
3
+ from byourside.errors import ByoursideError
4
+
5
+ def seq_client(statuses):
6
+ state = {"i": 0, "sleeps": 0}
7
+ def transport(method, url, headers, body):
8
+ s = statuses[min(state["i"], len(statuses) - 1)]; state["i"] += 1
9
+ return (200, {"id": "c1", "status": s})
10
+ c = Client(api_key="k", base_url="https://b", transport=transport, sleep=lambda *_: state.__setitem__("sleeps", state["sleeps"] + 1))
11
+ return c, state
12
+
13
+ class TestWait(unittest.TestCase):
14
+ def test_returns_when_terminal(self):
15
+ c, st = seq_client(["queued", "in_progress", "completed"])
16
+ r = c.wait_for_call("c1", timeout=100, interval=0)
17
+ self.assertEqual(r["status"], "completed")
18
+ self.assertEqual(st["sleeps"], 2)
19
+ def test_timeout(self):
20
+ c, _ = seq_client(["queued"])
21
+ with self.assertRaises(ByoursideError) as ctx:
22
+ c.wait_for_call("c1", timeout=0, interval=0)
23
+ self.assertEqual(ctx.exception.code, "timeout")
24
+
25
+ if __name__ == "__main__":
26
+ unittest.main()
@@ -0,0 +1,22 @@
1
+ import hmac, hashlib, time, unittest
2
+ from byourside.webhooks import verify_webhook
3
+
4
+ SECRET = "whsec"
5
+ def sign(ts, body):
6
+ v1 = hmac.new(SECRET.encode(), ("%d.%s" % (ts, body)).encode(), hashlib.sha256).hexdigest()
7
+ return "t=%d,v1=%s" % (ts, v1)
8
+
9
+ class TestWebhooks(unittest.TestCase):
10
+ def test_accepts_valid(self):
11
+ body = '{"callId":"c1"}'; ts = int(time.time())
12
+ self.assertTrue(verify_webhook(sign(ts, body), body, SECRET))
13
+ def test_rejects(self):
14
+ body = '{"callId":"c1"}'; ts = int(time.time())
15
+ self.assertFalse(verify_webhook(sign(ts, body), '{"callId":"c2"}', SECRET))
16
+ self.assertFalse(verify_webhook(sign(ts, body), body, "other"))
17
+ self.assertFalse(verify_webhook(sign(ts - 9999, body), body, SECRET, tolerance=300))
18
+ self.assertFalse(verify_webhook("garbage", body, SECRET))
19
+ self.assertFalse(verify_webhook("", body, SECRET))
20
+
21
+ if __name__ == "__main__":
22
+ unittest.main()