vicifast 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.
- vicifast-0.1.0/LICENSE +21 -0
- vicifast-0.1.0/PKG-INFO +109 -0
- vicifast-0.1.0/README.md +93 -0
- vicifast-0.1.0/pyproject.toml +28 -0
- vicifast-0.1.0/setup.cfg +4 -0
- vicifast-0.1.0/tests/test_client.py +162 -0
- vicifast-0.1.0/vicifast/__init__.py +8 -0
- vicifast-0.1.0/vicifast/_operations.py +245 -0
- vicifast-0.1.0/vicifast/client.py +179 -0
- vicifast-0.1.0/vicifast/py.typed +0 -0
- vicifast-0.1.0/vicifast/webhooks.py +45 -0
- vicifast-0.1.0/vicifast.egg-info/PKG-INFO +109 -0
- vicifast-0.1.0/vicifast.egg-info/SOURCES.txt +13 -0
- vicifast-0.1.0/vicifast.egg-info/dependency_links.txt +1 -0
- vicifast-0.1.0/vicifast.egg-info/top_level.txt +1 -0
vicifast-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 VICIfast LLC
|
|
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.
|
vicifast-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vicifast
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The VICIfast API for Python: phone numbers, calls, billing and webhooks.
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Documentation, https://vicifast.com/api-docs
|
|
7
|
+
Project-URL: Homepage, https://vicifast.com
|
|
8
|
+
Keywords: vicifast,vicidial,phone numbers,did,telephony,api,webhooks
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Topic :: Communications :: Telephony
|
|
12
|
+
Requires-Python: >=3.8
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# vicifast
|
|
18
|
+
|
|
19
|
+
The [VICIfast API](https://vicifast.com/api-docs) for Python 3.8 and later:
|
|
20
|
+
buy and manage phone numbers, place back-orders, read calls, recordings and
|
|
21
|
+
transcripts, follow your wallet, and check the webhooks we send you. Built on
|
|
22
|
+
the standard library only, with no dependencies.
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
pip install vicifast
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Use
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
import os
|
|
32
|
+
from vicifast import Vicifast
|
|
33
|
+
|
|
34
|
+
vf = Vicifast(api_key=os.environ["VICIFAST_API_KEY"])
|
|
35
|
+
|
|
36
|
+
print(vf.get_balance()["data"]["balance_cents"])
|
|
37
|
+
|
|
38
|
+
found = vf.search_numbers(area_code="305", limit=5)
|
|
39
|
+
order = vf.purchase_numbers({"numbers": [found["data"][0]["number"]]})
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Make keys under **API → Keys** in the dashboard. A `vf_test_…` key works
|
|
43
|
+
against the sandbox: the same calls, with nothing charged and no real numbers
|
|
44
|
+
touched. Use one while you build.
|
|
45
|
+
|
|
46
|
+
Each operation in the [reference](https://vicifast.com/api-docs) is a method
|
|
47
|
+
named after its `operationId` in snake*case. Path parameters come first,
|
|
48
|
+
positionally, then the request body. Query parameters are keyword arguments. A
|
|
49
|
+
parameter whose name is a Python keyword takes a trailing underscore:
|
|
50
|
+
`vf.list_calls(from*="2026-10-01")`.
|
|
51
|
+
|
|
52
|
+
## Pages
|
|
53
|
+
|
|
54
|
+
A list answers one page at a time. `paginate` walks every page for you:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
for number in vf.paginate("listNumbers", status="active"):
|
|
58
|
+
print(number["number"], number["routing"])
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The calls export is CSV, one file per page:
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
with open("calls.csv", "w") as out:
|
|
65
|
+
for i, csv in enumerate(vf.export_call_files(window="30d")):
|
|
66
|
+
out.write(csv if i == 0 else csv.split("\n", 1)[1])
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Errors and retries
|
|
70
|
+
|
|
71
|
+
Anything other than a success raises `VicifastError`. It carries the HTTP
|
|
72
|
+
`status`, a `code` to branch on (`insufficient_funds`, `rate_limited`, …), a
|
|
73
|
+
`message`, the offending `param` when there is one, and a `request_id` to quote
|
|
74
|
+
to support.
|
|
75
|
+
|
|
76
|
+
A request that is safe to repeat is retried up to `max_retries` times (default 2) on 408, 429 and 5xx, and when the connection fails. A 429 waits for
|
|
77
|
+
`Retry-After`. Reads are always safe to repeat. Calls that spend or move money
|
|
78
|
+
(a purchase, a release, a back-order, a transcript) are safe too, because each
|
|
79
|
+
one is sent with an `Idempotency-Key`: the same key on every retry, so a
|
|
80
|
+
retried purchase still buys once. The client makes the key for you, or you can
|
|
81
|
+
pass your own as `idempotency_key=`.
|
|
82
|
+
|
|
83
|
+
## Webhooks
|
|
84
|
+
|
|
85
|
+
Check every request against the endpoint's signing secret before trusting it.
|
|
86
|
+
Pass the body exactly as it arrived:
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from flask import Flask, abort, request
|
|
90
|
+
from vicifast import parse_event
|
|
91
|
+
|
|
92
|
+
app = Flask(__name__)
|
|
93
|
+
|
|
94
|
+
@app.post("/vicifast")
|
|
95
|
+
def vicifast():
|
|
96
|
+
try:
|
|
97
|
+
event = parse_event(request.get_data(), request.headers.get("X-VICIfast-Signature"), os.environ["VICIFAST_WEBHOOK_SECRET"])
|
|
98
|
+
except ValueError:
|
|
99
|
+
abort(400)
|
|
100
|
+
if event["type"] == "call.inbound.completed":
|
|
101
|
+
...
|
|
102
|
+
return "", 200
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`verify_signature(body, header, secret)` gives you the same check as a
|
|
106
|
+
boolean. A request signed more than five minutes ago is refused.
|
|
107
|
+
|
|
108
|
+
Licensed MIT. This package is generated from
|
|
109
|
+
[vicifast.com/api-docs/openapi.json](https://vicifast.com/api-docs/openapi.json).
|
vicifast-0.1.0/README.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# vicifast
|
|
2
|
+
|
|
3
|
+
The [VICIfast API](https://vicifast.com/api-docs) for Python 3.8 and later:
|
|
4
|
+
buy and manage phone numbers, place back-orders, read calls, recordings and
|
|
5
|
+
transcripts, follow your wallet, and check the webhooks we send you. Built on
|
|
6
|
+
the standard library only, with no dependencies.
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
pip install vicifast
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Use
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
import os
|
|
16
|
+
from vicifast import Vicifast
|
|
17
|
+
|
|
18
|
+
vf = Vicifast(api_key=os.environ["VICIFAST_API_KEY"])
|
|
19
|
+
|
|
20
|
+
print(vf.get_balance()["data"]["balance_cents"])
|
|
21
|
+
|
|
22
|
+
found = vf.search_numbers(area_code="305", limit=5)
|
|
23
|
+
order = vf.purchase_numbers({"numbers": [found["data"][0]["number"]]})
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Make keys under **API → Keys** in the dashboard. A `vf_test_…` key works
|
|
27
|
+
against the sandbox: the same calls, with nothing charged and no real numbers
|
|
28
|
+
touched. Use one while you build.
|
|
29
|
+
|
|
30
|
+
Each operation in the [reference](https://vicifast.com/api-docs) is a method
|
|
31
|
+
named after its `operationId` in snake*case. Path parameters come first,
|
|
32
|
+
positionally, then the request body. Query parameters are keyword arguments. A
|
|
33
|
+
parameter whose name is a Python keyword takes a trailing underscore:
|
|
34
|
+
`vf.list_calls(from*="2026-10-01")`.
|
|
35
|
+
|
|
36
|
+
## Pages
|
|
37
|
+
|
|
38
|
+
A list answers one page at a time. `paginate` walks every page for you:
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
for number in vf.paginate("listNumbers", status="active"):
|
|
42
|
+
print(number["number"], number["routing"])
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The calls export is CSV, one file per page:
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
with open("calls.csv", "w") as out:
|
|
49
|
+
for i, csv in enumerate(vf.export_call_files(window="30d")):
|
|
50
|
+
out.write(csv if i == 0 else csv.split("\n", 1)[1])
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Errors and retries
|
|
54
|
+
|
|
55
|
+
Anything other than a success raises `VicifastError`. It carries the HTTP
|
|
56
|
+
`status`, a `code` to branch on (`insufficient_funds`, `rate_limited`, …), a
|
|
57
|
+
`message`, the offending `param` when there is one, and a `request_id` to quote
|
|
58
|
+
to support.
|
|
59
|
+
|
|
60
|
+
A request that is safe to repeat is retried up to `max_retries` times (default 2) on 408, 429 and 5xx, and when the connection fails. A 429 waits for
|
|
61
|
+
`Retry-After`. Reads are always safe to repeat. Calls that spend or move money
|
|
62
|
+
(a purchase, a release, a back-order, a transcript) are safe too, because each
|
|
63
|
+
one is sent with an `Idempotency-Key`: the same key on every retry, so a
|
|
64
|
+
retried purchase still buys once. The client makes the key for you, or you can
|
|
65
|
+
pass your own as `idempotency_key=`.
|
|
66
|
+
|
|
67
|
+
## Webhooks
|
|
68
|
+
|
|
69
|
+
Check every request against the endpoint's signing secret before trusting it.
|
|
70
|
+
Pass the body exactly as it arrived:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from flask import Flask, abort, request
|
|
74
|
+
from vicifast import parse_event
|
|
75
|
+
|
|
76
|
+
app = Flask(__name__)
|
|
77
|
+
|
|
78
|
+
@app.post("/vicifast")
|
|
79
|
+
def vicifast():
|
|
80
|
+
try:
|
|
81
|
+
event = parse_event(request.get_data(), request.headers.get("X-VICIfast-Signature"), os.environ["VICIFAST_WEBHOOK_SECRET"])
|
|
82
|
+
except ValueError:
|
|
83
|
+
abort(400)
|
|
84
|
+
if event["type"] == "call.inbound.completed":
|
|
85
|
+
...
|
|
86
|
+
return "", 200
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`verify_signature(body, header, secret)` gives you the same check as a
|
|
90
|
+
boolean. A request signed more than five minutes ago is refused.
|
|
91
|
+
|
|
92
|
+
Licensed MIT. This package is generated from
|
|
93
|
+
[vicifast.com/api-docs/openapi.json](https://vicifast.com/api-docs/openapi.json).
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "vicifast"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "The VICIfast API for Python: phone numbers, calls, billing and webhooks."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
dependencies = []
|
|
13
|
+
keywords = ["vicifast", "vicidial", "phone numbers", "did", "telephony", "api", "webhooks"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Topic :: Communications :: Telephony",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Documentation = "https://vicifast.com/api-docs"
|
|
22
|
+
Homepage = "https://vicifast.com"
|
|
23
|
+
|
|
24
|
+
[tool.setuptools]
|
|
25
|
+
packages = ["vicifast"]
|
|
26
|
+
|
|
27
|
+
[tool.setuptools.package-data]
|
|
28
|
+
vicifast = ["py.typed"]
|
vicifast-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""The client against a stub server on localhost: what it sends, when it
|
|
2
|
+
retries, how errors and pages come back. Run: python -m unittest"""
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import hmac
|
|
6
|
+
import json
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
import unittest
|
|
10
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
11
|
+
from urllib.parse import parse_qs, urlparse
|
|
12
|
+
|
|
13
|
+
from vicifast import Vicifast, VicifastError, parse_event, verify_signature
|
|
14
|
+
|
|
15
|
+
SEEN = []
|
|
16
|
+
HANDLER = [None]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Stub(BaseHTTPRequestHandler):
|
|
20
|
+
def _any(self):
|
|
21
|
+
n = int(self.headers.get("content-length") or 0)
|
|
22
|
+
body = self.rfile.read(n).decode() if n else ""
|
|
23
|
+
SEEN.append({"method": self.command, "url": self.path, "headers": dict(self.headers), "body": body})
|
|
24
|
+
status, headers, out = HANDLER[0](SEEN[-1], len(SEEN))
|
|
25
|
+
self.send_response(status)
|
|
26
|
+
for k, v in headers.items():
|
|
27
|
+
self.send_header(k, v)
|
|
28
|
+
self.end_headers()
|
|
29
|
+
self.wfile.write(out.encode())
|
|
30
|
+
|
|
31
|
+
do_GET = do_POST = do_PATCH = do_DELETE = _any
|
|
32
|
+
|
|
33
|
+
def log_message(self, *args):
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def js(status, body, **headers):
|
|
38
|
+
return status, dict({"Content-Type": "application/json"}, **{k.replace("_", "-"): v for k, v in headers.items()}), json.dumps(body)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ClientTest(unittest.TestCase):
|
|
42
|
+
@classmethod
|
|
43
|
+
def setUpClass(cls):
|
|
44
|
+
cls.server = HTTPServer(("127.0.0.1", 0), Stub)
|
|
45
|
+
threading.Thread(target=cls.server.serve_forever, daemon=True).start()
|
|
46
|
+
cls.base = "http://127.0.0.1:%d/" % cls.server.server_port
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def tearDownClass(cls):
|
|
50
|
+
cls.server.shutdown()
|
|
51
|
+
|
|
52
|
+
def setUp(self):
|
|
53
|
+
del SEEN[:]
|
|
54
|
+
self.slept = []
|
|
55
|
+
|
|
56
|
+
def client(self, **kw):
|
|
57
|
+
return Vicifast(api_key="vf_test_abc", base_url=self.base, sleep=self.slept.append, **kw)
|
|
58
|
+
|
|
59
|
+
def test_auth_query_and_path(self):
|
|
60
|
+
HANDLER[0] = lambda s, n: js(200, {"data": {}})
|
|
61
|
+
c = self.client()
|
|
62
|
+
self.assertFalse(c.livemode)
|
|
63
|
+
c.get_number("+13055550100")
|
|
64
|
+
c.list_calls(limit=5, from_="2026-10-01", answered=True, status=None)
|
|
65
|
+
self.assertEqual(SEEN[0]["url"], "/api/v1/numbers/%2B13055550100")
|
|
66
|
+
self.assertEqual(SEEN[0]["headers"]["Authorization"], "Bearer vf_test_abc")
|
|
67
|
+
self.assertTrue(SEEN[0]["headers"]["User-Agent"].startswith("vicifast-python/"))
|
|
68
|
+
self.assertNotIn("Idempotency-Key", SEEN[0]["headers"])
|
|
69
|
+
self.assertEqual(SEEN[1]["url"], "/api/v1/calls?limit=5&from=2026-10-01&answered=true")
|
|
70
|
+
|
|
71
|
+
def test_purchase_reuses_one_idempotency_key_across_retries(self):
|
|
72
|
+
HANDLER[0] = lambda s, n: js(503, {"error": {"code": "service_unavailable", "message": "busy"}}) if n < 3 else js(201, {"data": {"id": "ord_1"}})
|
|
73
|
+
out = self.client().purchase_numbers({"numbers": ["+13055550100"]})
|
|
74
|
+
self.assertEqual(out, {"data": {"id": "ord_1"}})
|
|
75
|
+
self.assertEqual(len(SEEN), 3)
|
|
76
|
+
keys = {s["headers"]["Idempotency-Key"] for s in SEEN}
|
|
77
|
+
self.assertEqual(len(keys), 1)
|
|
78
|
+
self.assertEqual(len(next(iter(keys))), 36)
|
|
79
|
+
self.assertEqual(json.loads(SEEN[0]["body"]), {"numbers": ["+13055550100"]})
|
|
80
|
+
|
|
81
|
+
def test_own_idempotency_key(self):
|
|
82
|
+
HANDLER[0] = lambda s, n: js(201, {"data": {}})
|
|
83
|
+
self.client().place_backorder({"lines": []}, idempotency_key="mine-1")
|
|
84
|
+
self.assertEqual(SEEN[0]["headers"]["Idempotency-Key"], "mine-1")
|
|
85
|
+
|
|
86
|
+
def test_429_waits_for_retry_after_then_gives_up(self):
|
|
87
|
+
HANDLER[0] = lambda s, n: js(429, {"error": {"code": "rate_limited", "message": "slow down"}}, retry_after="7", x_request_id="req_9")
|
|
88
|
+
with self.assertRaises(VicifastError) as ctx:
|
|
89
|
+
self.client(max_retries=1).get_balance()
|
|
90
|
+
e = ctx.exception
|
|
91
|
+
self.assertEqual((e.status, e.code, e.request_id), (429, "rate_limited", "req_9"))
|
|
92
|
+
self.assertEqual(len(SEEN), 2)
|
|
93
|
+
self.assertEqual(self.slept, [7.0])
|
|
94
|
+
|
|
95
|
+
def test_unsafe_call_not_retried(self):
|
|
96
|
+
HANDLER[0] = lambda s, n: js(503, {"error": {"code": "service_unavailable", "message": "x"}})
|
|
97
|
+
with self.assertRaises(VicifastError):
|
|
98
|
+
self.client().create_webhook_endpoint({"url": "https://x.test", "events": []})
|
|
99
|
+
self.assertEqual(len(SEEN), 1)
|
|
100
|
+
|
|
101
|
+
def test_error_fields(self):
|
|
102
|
+
HANDLER[0] = lambda s, n: js(402, {"error": {"code": "insufficient_funds", "message": "Top up", "param": "numbers", "details": {"short_cents": 120}}})
|
|
103
|
+
with self.assertRaises(VicifastError) as ctx:
|
|
104
|
+
self.client().purchase_numbers({"numbers": ["+1"]})
|
|
105
|
+
e = ctx.exception
|
|
106
|
+
self.assertEqual((str(e), e.param, e.details), ("Top up", "numbers", {"short_cents": 120}))
|
|
107
|
+
self.assertEqual(len(SEEN), 1)
|
|
108
|
+
|
|
109
|
+
def test_missing_path_param(self):
|
|
110
|
+
with self.assertRaisesRegex(ValueError, "number is required"):
|
|
111
|
+
self.client().get_number("")
|
|
112
|
+
self.assertEqual(len(SEEN), 0)
|
|
113
|
+
|
|
114
|
+
def test_paginate(self):
|
|
115
|
+
pages = {
|
|
116
|
+
None: {"data": [{"id": "a"}, {"id": "b"}], "has_more": True, "next_cursor": "c2"},
|
|
117
|
+
"c2": {"data": [{"id": "c"}], "has_more": True, "next_cursor": "c3"},
|
|
118
|
+
"c3": {"data": [{"id": "d"}], "has_more": False, "next_cursor": None},
|
|
119
|
+
}
|
|
120
|
+
HANDLER[0] = lambda s, n: js(200, pages[(parse_qs(urlparse(s["url"]).query).get("cursor") or [None])[0]])
|
|
121
|
+
ids = [c["id"] for c in self.client().paginate("listCalls", window="30d")]
|
|
122
|
+
self.assertEqual(ids, ["a", "b", "c", "d"])
|
|
123
|
+
self.assertEqual([s["url"] for s in SEEN], ["/api/v1/calls?window=30d", "/api/v1/calls?window=30d&cursor=c2", "/api/v1/calls?window=30d&cursor=c3"])
|
|
124
|
+
with self.assertRaises(ValueError):
|
|
125
|
+
list(self.client().paginate("getBalance"))
|
|
126
|
+
|
|
127
|
+
def test_export_files(self):
|
|
128
|
+
def h(s, n):
|
|
129
|
+
more = "cursor" not in s["url"]
|
|
130
|
+
return 200, dict({"Content-Type": "text/csv; charset=utf-8"}, **({"X-Next-Cursor": "k2"} if more else {})), ("id\n1\n2\n" if more else "id\n3\n")
|
|
131
|
+
HANDLER[0] = h
|
|
132
|
+
self.assertEqual(list(self.client().export_call_files(limit=2)), ["id\n1\n2\n", "id\n3\n"])
|
|
133
|
+
self.assertEqual(SEEN[1]["url"], "/api/v1/calls/export?limit=2&cursor=k2")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class SignatureTest(unittest.TestCase):
|
|
137
|
+
secret = "whsec_sdk_test"
|
|
138
|
+
body = json.dumps({"id": "evt_1", "type": "number.purchased", "data": {"note": "café ✓"}}, ensure_ascii=False)
|
|
139
|
+
|
|
140
|
+
def sign(self, body, t, secret=None):
|
|
141
|
+
mac = hmac.new((secret or self.secret).encode(), ("%d." % t).encode() + body.encode(), hashlib.sha256).hexdigest()
|
|
142
|
+
return "t=%d,v1=%s" % (t, mac)
|
|
143
|
+
|
|
144
|
+
def test_accepts_exactly_the_genuine_request(self):
|
|
145
|
+
now = int(time.time())
|
|
146
|
+
self.assertTrue(verify_signature(self.body, self.sign(self.body, now), self.secret))
|
|
147
|
+
self.assertTrue(verify_signature(self.body.encode(), self.sign(self.body, now), self.secret))
|
|
148
|
+
self.assertFalse(verify_signature(self.body + " ", self.sign(self.body, now), self.secret))
|
|
149
|
+
self.assertFalse(verify_signature(self.body, self.sign(self.body, now, "whsec_other"), self.secret))
|
|
150
|
+
self.assertFalse(verify_signature(self.body, self.sign(self.body, now - 600), self.secret))
|
|
151
|
+
self.assertFalse(verify_signature(self.body, "nonsense", self.secret))
|
|
152
|
+
self.assertFalse(verify_signature(self.body, None, self.secret))
|
|
153
|
+
|
|
154
|
+
def test_parse_event(self):
|
|
155
|
+
now = int(time.time())
|
|
156
|
+
self.assertEqual(parse_event(self.body, self.sign(self.body, now), self.secret)["id"], "evt_1")
|
|
157
|
+
with self.assertRaises(ValueError):
|
|
158
|
+
parse_event(self.body, self.sign(self.body, now - 600), self.secret)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
if __name__ == "__main__":
|
|
162
|
+
unittest.main()
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""The VICIfast API: phone numbers, calls, billing and webhooks. https://vicifast.com/api-docs"""
|
|
2
|
+
|
|
3
|
+
from ._operations import OPERATIONS
|
|
4
|
+
from .client import VERSION, Vicifast, VicifastError
|
|
5
|
+
from .webhooks import parse_event, verify_signature
|
|
6
|
+
|
|
7
|
+
__version__ = VERSION
|
|
8
|
+
__all__ = ["OPERATIONS", "VERSION", "Vicifast", "VicifastError", "parse_event", "verify_signature"]
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
# GENERATED by scripts/generate-sdks.mts from sdks/openapi.json. Do not edit.
|
|
2
|
+
# openapi.json sha256 fb0c46ff891048f01557614f3c70c6883ff55f6e45d6e7024252bfaba49a429c
|
|
3
|
+
# fmt: off
|
|
4
|
+
from typing import Any, Dict, Optional
|
|
5
|
+
|
|
6
|
+
OPERATIONS: Dict[str, Dict[str, Any]] = {
|
|
7
|
+
"addCidGroupNumbers": {"method": "POST", "path": "/api/v1/servers/{id}/cid-groups/{group}/numbers", "path_params": ["id", "group"], "idempotent": False, "list": False},
|
|
8
|
+
"bulkUpdateNumbers": {"method": "PATCH", "path": "/api/v1/numbers", "path_params": [], "idempotent": False, "list": False},
|
|
9
|
+
"cancelBackorder": {"method": "POST", "path": "/api/v1/backorders/{id}/cancel", "path_params": ["id"], "idempotent": True, "list": False},
|
|
10
|
+
"createTranscript": {"method": "POST", "path": "/api/v1/calls/{id}/transcript", "path_params": ["id"], "idempotent": True, "list": False},
|
|
11
|
+
"createWebhookEndpoint": {"method": "POST", "path": "/api/v1/webhook-endpoints", "path_params": [], "idempotent": False, "list": False},
|
|
12
|
+
"deleteWebhookEndpoint": {"method": "DELETE", "path": "/api/v1/webhook-endpoints/{id}", "path_params": ["id"], "idempotent": False, "list": False},
|
|
13
|
+
"deliverBackorder": {"method": "POST", "path": "/api/v1/sandbox/backorders/{id}/deliver", "path_params": ["id"], "idempotent": False, "list": False},
|
|
14
|
+
"enrollInJourney": {"method": "POST", "path": "/api/v1/journeys/{id}/enroll", "path_params": ["id"], "idempotent": True, "list": False},
|
|
15
|
+
"exportCalls": {"method": "GET", "path": "/api/v1/calls/export", "path_params": [], "idempotent": False, "list": False},
|
|
16
|
+
"getBackorder": {"method": "GET", "path": "/api/v1/backorders/{id}", "path_params": ["id"], "idempotent": False, "list": False},
|
|
17
|
+
"getBalance": {"method": "GET", "path": "/api/v1/billing/balance", "path_params": [], "idempotent": False, "list": False},
|
|
18
|
+
"getCall": {"method": "GET", "path": "/api/v1/calls/{id}", "path_params": ["id"], "idempotent": False, "list": False},
|
|
19
|
+
"getCidGroup": {"method": "GET", "path": "/api/v1/servers/{id}/cid-groups/{group}", "path_params": ["id", "group"], "idempotent": False, "list": False},
|
|
20
|
+
"getEvent": {"method": "GET", "path": "/api/v1/events/{id}", "path_params": ["id"], "idempotent": False, "list": False},
|
|
21
|
+
"getNumber": {"method": "GET", "path": "/api/v1/numbers/{number}", "path_params": ["number"], "idempotent": False, "list": False},
|
|
22
|
+
"getOrder": {"method": "GET", "path": "/api/v1/orders/{id}", "path_params": ["id"], "idempotent": False, "list": False},
|
|
23
|
+
"getRecordingLink": {"method": "GET", "path": "/api/v1/calls/{id}/recording", "path_params": ["id"], "idempotent": False, "list": False},
|
|
24
|
+
"getTranscript": {"method": "GET", "path": "/api/v1/calls/{id}/transcript", "path_params": ["id"], "idempotent": False, "list": False},
|
|
25
|
+
"getWebhookEndpoint": {"method": "GET", "path": "/api/v1/webhook-endpoints/{id}", "path_params": ["id"], "idempotent": False, "list": False},
|
|
26
|
+
"listBackorders": {"method": "GET", "path": "/api/v1/backorders", "path_params": [], "idempotent": False, "list": True},
|
|
27
|
+
"listCalls": {"method": "GET", "path": "/api/v1/calls", "path_params": [], "idempotent": False, "list": True},
|
|
28
|
+
"listCidGroups": {"method": "GET", "path": "/api/v1/servers/{id}/cid-groups", "path_params": ["id"], "idempotent": False, "list": False},
|
|
29
|
+
"listEvents": {"method": "GET", "path": "/api/v1/events", "path_params": [], "idempotent": False, "list": True},
|
|
30
|
+
"listMessages": {"method": "GET", "path": "/api/v1/messages", "path_params": [], "idempotent": False, "list": True},
|
|
31
|
+
"listNumbers": {"method": "GET", "path": "/api/v1/numbers", "path_params": [], "idempotent": False, "list": True},
|
|
32
|
+
"listOrders": {"method": "GET", "path": "/api/v1/orders", "path_params": [], "idempotent": False, "list": True},
|
|
33
|
+
"listRenewals": {"method": "GET", "path": "/api/v1/billing/renewals", "path_params": [], "idempotent": False, "list": False},
|
|
34
|
+
"listServers": {"method": "GET", "path": "/api/v1/servers", "path_params": [], "idempotent": False, "list": False},
|
|
35
|
+
"listTransactions": {"method": "GET", "path": "/api/v1/billing/transactions", "path_params": [], "idempotent": False, "list": True},
|
|
36
|
+
"listWebhookDeliveries": {"method": "GET", "path": "/api/v1/webhook-endpoints/{id}/deliveries", "path_params": ["id"], "idempotent": False, "list": True},
|
|
37
|
+
"listWebhookEndpoints": {"method": "GET", "path": "/api/v1/webhook-endpoints", "path_params": [], "idempotent": False, "list": False},
|
|
38
|
+
"placeBackorder": {"method": "POST", "path": "/api/v1/backorders", "path_params": [], "idempotent": True, "list": False},
|
|
39
|
+
"purchaseNumbers": {"method": "POST", "path": "/api/v1/numbers/purchase", "path_params": [], "idempotent": True, "list": False},
|
|
40
|
+
"releaseNumbers": {"method": "POST", "path": "/api/v1/numbers/release", "path_params": [], "idempotent": True, "list": False},
|
|
41
|
+
"removeCidGroupNumbers": {"method": "POST", "path": "/api/v1/servers/{id}/cid-groups/{group}/numbers/remove", "path_params": ["id", "group"], "idempotent": False, "list": False},
|
|
42
|
+
"resendEvent": {"method": "POST", "path": "/api/v1/events/{id}/resend", "path_params": ["id"], "idempotent": False, "list": False},
|
|
43
|
+
"resetSandbox": {"method": "POST", "path": "/api/v1/sandbox/reset", "path_params": [], "idempotent": False, "list": False},
|
|
44
|
+
"rotateWebhookSecret": {"method": "POST", "path": "/api/v1/webhook-endpoints/{id}/rotate-secret", "path_params": ["id"], "idempotent": False, "list": False},
|
|
45
|
+
"searchNumbers": {"method": "GET", "path": "/api/v1/numbers/available", "path_params": [], "idempotent": False, "list": True},
|
|
46
|
+
"sendMessage": {"method": "POST", "path": "/api/v1/messages", "path_params": [], "idempotent": True, "list": False},
|
|
47
|
+
"sendTestWebhook": {"method": "POST", "path": "/api/v1/webhook-endpoints/{id}/test", "path_params": ["id"], "idempotent": False, "list": False},
|
|
48
|
+
"setSandboxWallet": {"method": "POST", "path": "/api/v1/sandbox/wallet", "path_params": [], "idempotent": False, "list": False},
|
|
49
|
+
"simulateInboundCall": {"method": "POST", "path": "/api/v1/sandbox/inbound-calls", "path_params": [], "idempotent": False, "list": False},
|
|
50
|
+
"timeTravel": {"method": "POST", "path": "/api/v1/sandbox/time-travel", "path_params": [], "idempotent": False, "list": False},
|
|
51
|
+
"updateNumber": {"method": "PATCH", "path": "/api/v1/numbers/{number}", "path_params": ["number"], "idempotent": False, "list": False},
|
|
52
|
+
"updateWebhookEndpoint": {"method": "PATCH", "path": "/api/v1/webhook-endpoints/{id}", "path_params": ["id"], "idempotent": False, "list": False},
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Operations:
|
|
57
|
+
"""One method per API operation. Query parameters are keyword arguments;
|
|
58
|
+
one that is a Python keyword takes a trailing underscore (from_="2026-10-01")."""
|
|
59
|
+
|
|
60
|
+
def call(self, operation: str, path: Optional[Dict[str, str]] = None, query: Optional[Dict[str, Any]] = None, body: Any = None, idempotency_key: Optional[str] = None) -> Any: # pragma: no cover
|
|
61
|
+
raise NotImplementedError
|
|
62
|
+
|
|
63
|
+
def add_cid_group_numbers(self, id: str, group: str, body: Any, **query: Any) -> Any:
|
|
64
|
+
"""Put numbers into a CID group. POST /api/v1/servers/{id}/cid-groups/{group}/numbers"""
|
|
65
|
+
return self.call("addCidGroupNumbers", path={"id": id, "group": group}, query=query or None, body=body, idempotency_key=None)
|
|
66
|
+
|
|
67
|
+
def bulk_update_numbers(self, body: Any, **query: Any) -> Any:
|
|
68
|
+
"""Change many numbers at once. PATCH /api/v1/numbers"""
|
|
69
|
+
return self.call("bulkUpdateNumbers", path=None, query=query or None, body=body, idempotency_key=None)
|
|
70
|
+
|
|
71
|
+
def cancel_backorder(self, id: str, idempotency_key: Optional[str] = None, **query: Any) -> Any:
|
|
72
|
+
"""Cancel a back-order. POST /api/v1/backorders/{id}/cancel"""
|
|
73
|
+
return self.call("cancelBackorder", path={"id": id}, query=query or None, body=None, idempotency_key=idempotency_key)
|
|
74
|
+
|
|
75
|
+
def create_transcript(self, id: str, body: Any = None, idempotency_key: Optional[str] = None, **query: Any) -> Any:
|
|
76
|
+
"""Transcribe a call. POST /api/v1/calls/{id}/transcript"""
|
|
77
|
+
return self.call("createTranscript", path={"id": id}, query=query or None, body=body, idempotency_key=idempotency_key)
|
|
78
|
+
|
|
79
|
+
def create_webhook_endpoint(self, body: Any, **query: Any) -> Any:
|
|
80
|
+
"""Add a webhook endpoint. POST /api/v1/webhook-endpoints"""
|
|
81
|
+
return self.call("createWebhookEndpoint", path=None, query=query or None, body=body, idempotency_key=None)
|
|
82
|
+
|
|
83
|
+
def delete_webhook_endpoint(self, id: str, **query: Any) -> Any:
|
|
84
|
+
"""Remove a webhook endpoint. DELETE /api/v1/webhook-endpoints/{id}"""
|
|
85
|
+
return self.call("deleteWebhookEndpoint", path={"id": id}, query=query or None, body=None, idempotency_key=None)
|
|
86
|
+
|
|
87
|
+
def deliver_backorder(self, id: str, body: Any = None, **query: Any) -> Any:
|
|
88
|
+
"""Simulate a back-order delivery. POST /api/v1/sandbox/backorders/{id}/deliver"""
|
|
89
|
+
return self.call("deliverBackorder", path={"id": id}, query=query or None, body=body, idempotency_key=None)
|
|
90
|
+
|
|
91
|
+
def enroll_in_journey(self, id: str, body: Any, idempotency_key: Optional[str] = None, **query: Any) -> Any:
|
|
92
|
+
"""Enrol a lead in a journey. POST /api/v1/journeys/{id}/enroll"""
|
|
93
|
+
return self.call("enrollInJourney", path={"id": id}, query=query or None, body=body, idempotency_key=idempotency_key)
|
|
94
|
+
|
|
95
|
+
def export_calls(self, **query: Any) -> Any:
|
|
96
|
+
"""Export calls as CSV. GET /api/v1/calls/export"""
|
|
97
|
+
return self.call("exportCalls", path=None, query=query or None, body=None, idempotency_key=None)
|
|
98
|
+
|
|
99
|
+
def get_backorder(self, id: str, **query: Any) -> Any:
|
|
100
|
+
"""Get a back-order. GET /api/v1/backorders/{id}"""
|
|
101
|
+
return self.call("getBackorder", path={"id": id}, query=query or None, body=None, idempotency_key=None)
|
|
102
|
+
|
|
103
|
+
def get_balance(self, **query: Any) -> Any:
|
|
104
|
+
"""Get the wallet balance. GET /api/v1/billing/balance"""
|
|
105
|
+
return self.call("getBalance", path=None, query=query or None, body=None, idempotency_key=None)
|
|
106
|
+
|
|
107
|
+
def get_call(self, id: str, **query: Any) -> Any:
|
|
108
|
+
"""Get a call. GET /api/v1/calls/{id}"""
|
|
109
|
+
return self.call("getCall", path={"id": id}, query=query or None, body=None, idempotency_key=None)
|
|
110
|
+
|
|
111
|
+
def get_cid_group(self, id: str, group: str, **query: Any) -> Any:
|
|
112
|
+
"""Get a CID group, with its numbers. GET /api/v1/servers/{id}/cid-groups/{group}"""
|
|
113
|
+
return self.call("getCidGroup", path={"id": id, "group": group}, query=query or None, body=None, idempotency_key=None)
|
|
114
|
+
|
|
115
|
+
def get_event(self, id: str, **query: Any) -> Any:
|
|
116
|
+
"""Get an event, with its deliveries. GET /api/v1/events/{id}"""
|
|
117
|
+
return self.call("getEvent", path={"id": id}, query=query or None, body=None, idempotency_key=None)
|
|
118
|
+
|
|
119
|
+
def get_number(self, number: str, **query: Any) -> Any:
|
|
120
|
+
"""Get a number. GET /api/v1/numbers/{number}"""
|
|
121
|
+
return self.call("getNumber", path={"number": number}, query=query or None, body=None, idempotency_key=None)
|
|
122
|
+
|
|
123
|
+
def get_order(self, id: str, **query: Any) -> Any:
|
|
124
|
+
"""Get an order, with its numbers. GET /api/v1/orders/{id}"""
|
|
125
|
+
return self.call("getOrder", path={"id": id}, query=query or None, body=None, idempotency_key=None)
|
|
126
|
+
|
|
127
|
+
def get_recording_link(self, id: str, **query: Any) -> Any:
|
|
128
|
+
"""Get a recording link. GET /api/v1/calls/{id}/recording"""
|
|
129
|
+
return self.call("getRecordingLink", path={"id": id}, query=query or None, body=None, idempotency_key=None)
|
|
130
|
+
|
|
131
|
+
def get_transcript(self, id: str, **query: Any) -> Any:
|
|
132
|
+
"""Get a call's transcript. GET /api/v1/calls/{id}/transcript"""
|
|
133
|
+
return self.call("getTranscript", path={"id": id}, query=query or None, body=None, idempotency_key=None)
|
|
134
|
+
|
|
135
|
+
def get_webhook_endpoint(self, id: str, **query: Any) -> Any:
|
|
136
|
+
"""Get a webhook endpoint. GET /api/v1/webhook-endpoints/{id}"""
|
|
137
|
+
return self.call("getWebhookEndpoint", path={"id": id}, query=query or None, body=None, idempotency_key=None)
|
|
138
|
+
|
|
139
|
+
def list_backorders(self, **query: Any) -> Any:
|
|
140
|
+
"""List back-orders. GET /api/v1/backorders"""
|
|
141
|
+
return self.call("listBackorders", path=None, query=query or None, body=None, idempotency_key=None)
|
|
142
|
+
|
|
143
|
+
def list_calls(self, **query: Any) -> Any:
|
|
144
|
+
"""List calls. GET /api/v1/calls"""
|
|
145
|
+
return self.call("listCalls", path=None, query=query or None, body=None, idempotency_key=None)
|
|
146
|
+
|
|
147
|
+
def list_cid_groups(self, id: str, **query: Any) -> Any:
|
|
148
|
+
"""List a server's CID groups. GET /api/v1/servers/{id}/cid-groups"""
|
|
149
|
+
return self.call("listCidGroups", path={"id": id}, query=query or None, body=None, idempotency_key=None)
|
|
150
|
+
|
|
151
|
+
def list_events(self, **query: Any) -> Any:
|
|
152
|
+
"""List events. GET /api/v1/events"""
|
|
153
|
+
return self.call("listEvents", path=None, query=query or None, body=None, idempotency_key=None)
|
|
154
|
+
|
|
155
|
+
def list_messages(self, **query: Any) -> Any:
|
|
156
|
+
"""List messages. GET /api/v1/messages"""
|
|
157
|
+
return self.call("listMessages", path=None, query=query or None, body=None, idempotency_key=None)
|
|
158
|
+
|
|
159
|
+
def list_numbers(self, **query: Any) -> Any:
|
|
160
|
+
"""List your numbers. GET /api/v1/numbers"""
|
|
161
|
+
return self.call("listNumbers", path=None, query=query or None, body=None, idempotency_key=None)
|
|
162
|
+
|
|
163
|
+
def list_orders(self, **query: Any) -> Any:
|
|
164
|
+
"""List orders. GET /api/v1/orders"""
|
|
165
|
+
return self.call("listOrders", path=None, query=query or None, body=None, idempotency_key=None)
|
|
166
|
+
|
|
167
|
+
def list_renewals(self, **query: Any) -> Any:
|
|
168
|
+
"""List upcoming renewals. GET /api/v1/billing/renewals"""
|
|
169
|
+
return self.call("listRenewals", path=None, query=query or None, body=None, idempotency_key=None)
|
|
170
|
+
|
|
171
|
+
def list_servers(self, **query: Any) -> Any:
|
|
172
|
+
"""List your servers. GET /api/v1/servers"""
|
|
173
|
+
return self.call("listServers", path=None, query=query or None, body=None, idempotency_key=None)
|
|
174
|
+
|
|
175
|
+
def list_transactions(self, **query: Any) -> Any:
|
|
176
|
+
"""List wallet transactions. GET /api/v1/billing/transactions"""
|
|
177
|
+
return self.call("listTransactions", path=None, query=query or None, body=None, idempotency_key=None)
|
|
178
|
+
|
|
179
|
+
def list_webhook_deliveries(self, id: str, **query: Any) -> Any:
|
|
180
|
+
"""List deliveries to an endpoint. GET /api/v1/webhook-endpoints/{id}/deliveries"""
|
|
181
|
+
return self.call("listWebhookDeliveries", path={"id": id}, query=query or None, body=None, idempotency_key=None)
|
|
182
|
+
|
|
183
|
+
def list_webhook_endpoints(self, **query: Any) -> Any:
|
|
184
|
+
"""List webhook endpoints. GET /api/v1/webhook-endpoints"""
|
|
185
|
+
return self.call("listWebhookEndpoints", path=None, query=query or None, body=None, idempotency_key=None)
|
|
186
|
+
|
|
187
|
+
def place_backorder(self, body: Any, idempotency_key: Optional[str] = None, **query: Any) -> Any:
|
|
188
|
+
"""Place a back-order. POST /api/v1/backorders"""
|
|
189
|
+
return self.call("placeBackorder", path=None, query=query or None, body=body, idempotency_key=idempotency_key)
|
|
190
|
+
|
|
191
|
+
def purchase_numbers(self, body: Any, idempotency_key: Optional[str] = None, **query: Any) -> Any:
|
|
192
|
+
"""Buy numbers. POST /api/v1/numbers/purchase"""
|
|
193
|
+
return self.call("purchaseNumbers", path=None, query=query or None, body=body, idempotency_key=idempotency_key)
|
|
194
|
+
|
|
195
|
+
def release_numbers(self, body: Any, idempotency_key: Optional[str] = None, **query: Any) -> Any:
|
|
196
|
+
"""Give numbers back. POST /api/v1/numbers/release"""
|
|
197
|
+
return self.call("releaseNumbers", path=None, query=query or None, body=body, idempotency_key=idempotency_key)
|
|
198
|
+
|
|
199
|
+
def remove_cid_group_numbers(self, id: str, group: str, body: Any, **query: Any) -> Any:
|
|
200
|
+
"""Take numbers out of a CID group. POST /api/v1/servers/{id}/cid-groups/{group}/numbers/remove"""
|
|
201
|
+
return self.call("removeCidGroupNumbers", path={"id": id, "group": group}, query=query or None, body=body, idempotency_key=None)
|
|
202
|
+
|
|
203
|
+
def resend_event(self, id: str, body: Any = None, **query: Any) -> Any:
|
|
204
|
+
"""Send an event again. POST /api/v1/events/{id}/resend"""
|
|
205
|
+
return self.call("resendEvent", path={"id": id}, query=query or None, body=body, idempotency_key=None)
|
|
206
|
+
|
|
207
|
+
def reset_sandbox(self, **query: Any) -> Any:
|
|
208
|
+
"""Start the sandbox over. POST /api/v1/sandbox/reset"""
|
|
209
|
+
return self.call("resetSandbox", path=None, query=query or None, body=None, idempotency_key=None)
|
|
210
|
+
|
|
211
|
+
def rotate_webhook_secret(self, id: str, **query: Any) -> Any:
|
|
212
|
+
"""Rotate the signing secret. POST /api/v1/webhook-endpoints/{id}/rotate-secret"""
|
|
213
|
+
return self.call("rotateWebhookSecret", path={"id": id}, query=query or None, body=None, idempotency_key=None)
|
|
214
|
+
|
|
215
|
+
def search_numbers(self, **query: Any) -> Any:
|
|
216
|
+
"""Search numbers for sale. GET /api/v1/numbers/available"""
|
|
217
|
+
return self.call("searchNumbers", path=None, query=query or None, body=None, idempotency_key=None)
|
|
218
|
+
|
|
219
|
+
def send_message(self, body: Any, idempotency_key: Optional[str] = None, **query: Any) -> Any:
|
|
220
|
+
"""Send a message. POST /api/v1/messages"""
|
|
221
|
+
return self.call("sendMessage", path=None, query=query or None, body=body, idempotency_key=idempotency_key)
|
|
222
|
+
|
|
223
|
+
def send_test_webhook(self, id: str, body: Any, **query: Any) -> Any:
|
|
224
|
+
"""Send a test event. POST /api/v1/webhook-endpoints/{id}/test"""
|
|
225
|
+
return self.call("sendTestWebhook", path={"id": id}, query=query or None, body=body, idempotency_key=None)
|
|
226
|
+
|
|
227
|
+
def set_sandbox_wallet(self, body: Any, **query: Any) -> Any:
|
|
228
|
+
"""Set the sandbox wallet. POST /api/v1/sandbox/wallet"""
|
|
229
|
+
return self.call("setSandboxWallet", path=None, query=query or None, body=body, idempotency_key=None)
|
|
230
|
+
|
|
231
|
+
def simulate_inbound_call(self, body: Any, **query: Any) -> Any:
|
|
232
|
+
"""Simulate an inbound call. POST /api/v1/sandbox/inbound-calls"""
|
|
233
|
+
return self.call("simulateInboundCall", path=None, query=query or None, body=body, idempotency_key=None)
|
|
234
|
+
|
|
235
|
+
def time_travel(self, body: Any, **query: Any) -> Any:
|
|
236
|
+
"""Move the sandbox on in time. POST /api/v1/sandbox/time-travel"""
|
|
237
|
+
return self.call("timeTravel", path=None, query=query or None, body=body, idempotency_key=None)
|
|
238
|
+
|
|
239
|
+
def update_number(self, number: str, body: Any, **query: Any) -> Any:
|
|
240
|
+
"""Update a number. PATCH /api/v1/numbers/{number}"""
|
|
241
|
+
return self.call("updateNumber", path={"number": number}, query=query or None, body=body, idempotency_key=None)
|
|
242
|
+
|
|
243
|
+
def update_webhook_endpoint(self, id: str, body: Any, **query: Any) -> Any:
|
|
244
|
+
"""Change a webhook endpoint. PATCH /api/v1/webhook-endpoints/{id}"""
|
|
245
|
+
return self.call("updateWebhookEndpoint", path={"id": id}, query=query or None, body=body, idempotency_key=None)
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""The HTTP half of the client: auth, retries, errors and pages.
|
|
2
|
+
|
|
3
|
+
The operations themselves are generated (_operations.py) from the OpenAPI
|
|
4
|
+
document; this file is written by hand and is not touched by the generator.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import random
|
|
9
|
+
import time
|
|
10
|
+
import uuid
|
|
11
|
+
from typing import Any, Callable, Dict, Iterator, Optional, Tuple
|
|
12
|
+
from urllib.error import HTTPError, URLError
|
|
13
|
+
from urllib.parse import quote, urlencode
|
|
14
|
+
from urllib.request import Request, urlopen
|
|
15
|
+
|
|
16
|
+
from ._operations import OPERATIONS, Operations
|
|
17
|
+
|
|
18
|
+
VERSION = "0.1.0"
|
|
19
|
+
RETRYABLE = {408, 429, 500, 502, 503, 504}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class VicifastError(Exception):
|
|
23
|
+
"""An answer that was not a success, as the API describes it. Branch on `code`."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, status: int, body: Any, request_id: Optional[str]) -> None:
|
|
26
|
+
err = body.get("error", {}) if isinstance(body, dict) else {}
|
|
27
|
+
self.status = status
|
|
28
|
+
self.code: str = err.get("code") or "http_error"
|
|
29
|
+
self.message: str = err.get("message") or "HTTP %d" % status
|
|
30
|
+
self.param: Optional[str] = err.get("param")
|
|
31
|
+
self.details: Optional[Dict[str, Any]] = err.get("details")
|
|
32
|
+
self.request_id = request_id # quote this to support
|
|
33
|
+
super().__init__(self.message)
|
|
34
|
+
|
|
35
|
+
def __repr__(self) -> str:
|
|
36
|
+
return "VicifastError(status=%d, code=%r, message=%r)" % (self.status, self.code, self.message)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _backoff(attempt: int) -> float:
|
|
40
|
+
return min(8.0, 0.5 * 2 ** attempt)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Vicifast(Operations):
|
|
44
|
+
"""
|
|
45
|
+
client = Vicifast(api_key="vf_test_...")
|
|
46
|
+
for number in client.paginate("listNumbers", status="active"):
|
|
47
|
+
print(number["number"])
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
api_key: str,
|
|
53
|
+
base_url: str = "https://vicifast.com",
|
|
54
|
+
max_retries: int = 2,
|
|
55
|
+
timeout: float = 30.0,
|
|
56
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
57
|
+
) -> None:
|
|
58
|
+
if not api_key:
|
|
59
|
+
raise ValueError("Vicifast: api_key is required")
|
|
60
|
+
self.api_key = api_key
|
|
61
|
+
self.base_url = base_url.rstrip("/")
|
|
62
|
+
self.max_retries = max_retries
|
|
63
|
+
self.timeout = timeout
|
|
64
|
+
self._sleep = sleep
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def livemode(self) -> bool:
|
|
68
|
+
"""True for a live key; a test key works against the sandbox."""
|
|
69
|
+
return self.api_key.startswith("vf_live_")
|
|
70
|
+
|
|
71
|
+
def call(
|
|
72
|
+
self,
|
|
73
|
+
operation: str,
|
|
74
|
+
path: Optional[Dict[str, str]] = None,
|
|
75
|
+
query: Optional[Dict[str, Any]] = None,
|
|
76
|
+
body: Any = None,
|
|
77
|
+
idempotency_key: Optional[str] = None,
|
|
78
|
+
) -> Any:
|
|
79
|
+
"""Any operation by its id. The named methods (client.list_numbers(...)) call this."""
|
|
80
|
+
return self._request(operation, path, query, body, idempotency_key)[0]
|
|
81
|
+
|
|
82
|
+
def _request(
|
|
83
|
+
self,
|
|
84
|
+
operation: str,
|
|
85
|
+
path: Optional[Dict[str, str]],
|
|
86
|
+
query: Optional[Dict[str, Any]],
|
|
87
|
+
body: Any,
|
|
88
|
+
idempotency_key: Optional[str],
|
|
89
|
+
) -> Tuple[Any, Dict[str, str]]:
|
|
90
|
+
op = OPERATIONS.get(operation)
|
|
91
|
+
if op is None:
|
|
92
|
+
raise ValueError("unknown operation %r" % operation)
|
|
93
|
+
url_path = op["path"]
|
|
94
|
+
for name in op["path_params"]:
|
|
95
|
+
value = (path or {}).get(name)
|
|
96
|
+
if value is None or value == "":
|
|
97
|
+
raise ValueError("%s: %s is required" % (operation, name))
|
|
98
|
+
url_path = url_path.replace("{%s}" % name, quote(str(value), safe=""))
|
|
99
|
+
params = []
|
|
100
|
+
for key, value in (query or {}).items():
|
|
101
|
+
if value is None:
|
|
102
|
+
continue
|
|
103
|
+
if isinstance(value, bool):
|
|
104
|
+
value = "true" if value else "false"
|
|
105
|
+
params.append((key[:-1] if key.endswith("_") else key, value))
|
|
106
|
+
url = self.base_url + url_path + ("?" + urlencode(params) if params else "")
|
|
107
|
+
|
|
108
|
+
headers = {
|
|
109
|
+
"Authorization": "Bearer " + self.api_key,
|
|
110
|
+
"Accept": "application/json",
|
|
111
|
+
"User-Agent": "vicifast-python/" + VERSION,
|
|
112
|
+
}
|
|
113
|
+
data = None
|
|
114
|
+
if body is not None:
|
|
115
|
+
headers["Content-Type"] = "application/json"
|
|
116
|
+
data = json.dumps(body).encode("utf-8")
|
|
117
|
+
# Made once and sent on every retry: a retried purchase is still one purchase.
|
|
118
|
+
if op["idempotent"]:
|
|
119
|
+
headers["Idempotency-Key"] = idempotency_key or str(uuid.uuid4())
|
|
120
|
+
safe = op["method"] == "GET" or op["idempotent"]
|
|
121
|
+
|
|
122
|
+
attempt = 0
|
|
123
|
+
while True:
|
|
124
|
+
req = Request(url, data=data, headers=headers, method=op["method"])
|
|
125
|
+
try:
|
|
126
|
+
with urlopen(req, timeout=self.timeout) as res:
|
|
127
|
+
raw = res.read()
|
|
128
|
+
res_headers = {k.lower(): v for k, v in res.headers.items()}
|
|
129
|
+
status = res.status
|
|
130
|
+
except HTTPError as e:
|
|
131
|
+
raw = e.read()
|
|
132
|
+
res_headers = {k.lower(): v for k, v in e.headers.items()}
|
|
133
|
+
status = e.code
|
|
134
|
+
except (URLError, TimeoutError, ConnectionError):
|
|
135
|
+
if safe and attempt < self.max_retries:
|
|
136
|
+
self._sleep(_backoff(attempt) * (0.5 + random.random() / 2))
|
|
137
|
+
attempt += 1
|
|
138
|
+
continue
|
|
139
|
+
raise
|
|
140
|
+
|
|
141
|
+
if status in RETRYABLE and safe and attempt < self.max_retries:
|
|
142
|
+
after = res_headers.get("retry-after", "")
|
|
143
|
+
self._sleep(float(after) if after.isdigit() and int(after) > 0 else _backoff(attempt))
|
|
144
|
+
attempt += 1
|
|
145
|
+
continue
|
|
146
|
+
|
|
147
|
+
is_json = "json" in res_headers.get("content-type", "")
|
|
148
|
+
if status >= 400:
|
|
149
|
+
try:
|
|
150
|
+
parsed = json.loads(raw.decode("utf-8")) if is_json else None
|
|
151
|
+
except ValueError:
|
|
152
|
+
parsed = None
|
|
153
|
+
raise VicifastError(status, parsed, res_headers.get("x-request-id"))
|
|
154
|
+
text = raw.decode("utf-8")
|
|
155
|
+
return (json.loads(text) if is_json and text else text), res_headers
|
|
156
|
+
|
|
157
|
+
def paginate(self, operation: str, path: Optional[Dict[str, str]] = None, **query: Any) -> Iterator[Any]:
|
|
158
|
+
"""Every item of a list operation, fetching the next page as you go."""
|
|
159
|
+
if not OPERATIONS.get(operation, {}).get("list"):
|
|
160
|
+
raise ValueError("%s is not a list operation" % operation)
|
|
161
|
+
while True:
|
|
162
|
+
page = self.call(operation, path=path, query=query)
|
|
163
|
+
for item in page["data"]:
|
|
164
|
+
yield item
|
|
165
|
+
if not page.get("has_more") or not page.get("next_cursor"):
|
|
166
|
+
return
|
|
167
|
+
query = dict(query, cursor=page["next_cursor"])
|
|
168
|
+
|
|
169
|
+
def export_call_files(self, **query: Any) -> Iterator[str]:
|
|
170
|
+
"""The calls export, one CSV file at a time: up to `limit` rows each
|
|
171
|
+
(10,000 unless you say, at most 50,000), until every call that matches
|
|
172
|
+
is out. Each file has its own header row."""
|
|
173
|
+
while True:
|
|
174
|
+
text, headers = self._request("exportCalls", None, query, None, None)
|
|
175
|
+
yield text
|
|
176
|
+
cursor = headers.get("x-next-cursor")
|
|
177
|
+
if not cursor:
|
|
178
|
+
return
|
|
179
|
+
query = dict(query, cursor=cursor)
|
|
File without changes
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Checking that a webhook request came from VICIfast."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any, Optional, Union
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def verify_signature(
|
|
11
|
+
raw_body: Union[bytes, str],
|
|
12
|
+
header: Optional[str],
|
|
13
|
+
secret: str,
|
|
14
|
+
tolerance_sec: int = 300,
|
|
15
|
+
now: Optional[float] = None,
|
|
16
|
+
) -> bool:
|
|
17
|
+
"""X-VICIfast-Signature is `t=<unix seconds>,v1=<hex HMAC-SHA256 of
|
|
18
|
+
"<t>.<raw body>" with the endpoint's secret>`. Pass the body exactly as it
|
|
19
|
+
arrived, before parsing. A timestamp older than tolerance_sec (default
|
|
20
|
+
five minutes) is refused, so a captured request cannot be replayed."""
|
|
21
|
+
if not header:
|
|
22
|
+
return False
|
|
23
|
+
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
|
|
24
|
+
try:
|
|
25
|
+
t = int(parts.get("t", ""))
|
|
26
|
+
except ValueError:
|
|
27
|
+
return False
|
|
28
|
+
if abs((time.time() if now is None else now) - t) > tolerance_sec:
|
|
29
|
+
return False
|
|
30
|
+
body = raw_body.encode("utf-8") if isinstance(raw_body, str) else raw_body
|
|
31
|
+
want = hmac.new(secret.encode("utf-8"), str(t).encode() + b"." + body, hashlib.sha256).hexdigest()
|
|
32
|
+
return hmac.compare_digest(parts.get("v1", ""), want)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def parse_event(
|
|
36
|
+
raw_body: Union[bytes, str],
|
|
37
|
+
header: Optional[str],
|
|
38
|
+
secret: str,
|
|
39
|
+
tolerance_sec: int = 300,
|
|
40
|
+
now: Optional[float] = None,
|
|
41
|
+
) -> Any:
|
|
42
|
+
"""The event, once its signature checks out; raises ValueError otherwise."""
|
|
43
|
+
if not verify_signature(raw_body, header, secret, tolerance_sec, now):
|
|
44
|
+
raise ValueError("VICIfast webhook: the signature does not match, or the request is too old")
|
|
45
|
+
return json.loads(raw_body)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vicifast
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The VICIfast API for Python: phone numbers, calls, billing and webhooks.
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Documentation, https://vicifast.com/api-docs
|
|
7
|
+
Project-URL: Homepage, https://vicifast.com
|
|
8
|
+
Keywords: vicifast,vicidial,phone numbers,did,telephony,api,webhooks
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Topic :: Communications :: Telephony
|
|
12
|
+
Requires-Python: >=3.8
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# vicifast
|
|
18
|
+
|
|
19
|
+
The [VICIfast API](https://vicifast.com/api-docs) for Python 3.8 and later:
|
|
20
|
+
buy and manage phone numbers, place back-orders, read calls, recordings and
|
|
21
|
+
transcripts, follow your wallet, and check the webhooks we send you. Built on
|
|
22
|
+
the standard library only, with no dependencies.
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
pip install vicifast
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Use
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
import os
|
|
32
|
+
from vicifast import Vicifast
|
|
33
|
+
|
|
34
|
+
vf = Vicifast(api_key=os.environ["VICIFAST_API_KEY"])
|
|
35
|
+
|
|
36
|
+
print(vf.get_balance()["data"]["balance_cents"])
|
|
37
|
+
|
|
38
|
+
found = vf.search_numbers(area_code="305", limit=5)
|
|
39
|
+
order = vf.purchase_numbers({"numbers": [found["data"][0]["number"]]})
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Make keys under **API → Keys** in the dashboard. A `vf_test_…` key works
|
|
43
|
+
against the sandbox: the same calls, with nothing charged and no real numbers
|
|
44
|
+
touched. Use one while you build.
|
|
45
|
+
|
|
46
|
+
Each operation in the [reference](https://vicifast.com/api-docs) is a method
|
|
47
|
+
named after its `operationId` in snake*case. Path parameters come first,
|
|
48
|
+
positionally, then the request body. Query parameters are keyword arguments. A
|
|
49
|
+
parameter whose name is a Python keyword takes a trailing underscore:
|
|
50
|
+
`vf.list_calls(from*="2026-10-01")`.
|
|
51
|
+
|
|
52
|
+
## Pages
|
|
53
|
+
|
|
54
|
+
A list answers one page at a time. `paginate` walks every page for you:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
for number in vf.paginate("listNumbers", status="active"):
|
|
58
|
+
print(number["number"], number["routing"])
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The calls export is CSV, one file per page:
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
with open("calls.csv", "w") as out:
|
|
65
|
+
for i, csv in enumerate(vf.export_call_files(window="30d")):
|
|
66
|
+
out.write(csv if i == 0 else csv.split("\n", 1)[1])
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Errors and retries
|
|
70
|
+
|
|
71
|
+
Anything other than a success raises `VicifastError`. It carries the HTTP
|
|
72
|
+
`status`, a `code` to branch on (`insufficient_funds`, `rate_limited`, …), a
|
|
73
|
+
`message`, the offending `param` when there is one, and a `request_id` to quote
|
|
74
|
+
to support.
|
|
75
|
+
|
|
76
|
+
A request that is safe to repeat is retried up to `max_retries` times (default 2) on 408, 429 and 5xx, and when the connection fails. A 429 waits for
|
|
77
|
+
`Retry-After`. Reads are always safe to repeat. Calls that spend or move money
|
|
78
|
+
(a purchase, a release, a back-order, a transcript) are safe too, because each
|
|
79
|
+
one is sent with an `Idempotency-Key`: the same key on every retry, so a
|
|
80
|
+
retried purchase still buys once. The client makes the key for you, or you can
|
|
81
|
+
pass your own as `idempotency_key=`.
|
|
82
|
+
|
|
83
|
+
## Webhooks
|
|
84
|
+
|
|
85
|
+
Check every request against the endpoint's signing secret before trusting it.
|
|
86
|
+
Pass the body exactly as it arrived:
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from flask import Flask, abort, request
|
|
90
|
+
from vicifast import parse_event
|
|
91
|
+
|
|
92
|
+
app = Flask(__name__)
|
|
93
|
+
|
|
94
|
+
@app.post("/vicifast")
|
|
95
|
+
def vicifast():
|
|
96
|
+
try:
|
|
97
|
+
event = parse_event(request.get_data(), request.headers.get("X-VICIfast-Signature"), os.environ["VICIFAST_WEBHOOK_SECRET"])
|
|
98
|
+
except ValueError:
|
|
99
|
+
abort(400)
|
|
100
|
+
if event["type"] == "call.inbound.completed":
|
|
101
|
+
...
|
|
102
|
+
return "", 200
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`verify_signature(body, header, secret)` gives you the same check as a
|
|
106
|
+
boolean. A request signed more than five minutes ago is refused.
|
|
107
|
+
|
|
108
|
+
Licensed MIT. This package is generated from
|
|
109
|
+
[vicifast.com/api-docs/openapi.json](https://vicifast.com/api-docs/openapi.json).
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
tests/test_client.py
|
|
5
|
+
vicifast/__init__.py
|
|
6
|
+
vicifast/_operations.py
|
|
7
|
+
vicifast/client.py
|
|
8
|
+
vicifast/py.typed
|
|
9
|
+
vicifast/webhooks.py
|
|
10
|
+
vicifast.egg-info/PKG-INFO
|
|
11
|
+
vicifast.egg-info/SOURCES.txt
|
|
12
|
+
vicifast.egg-info/dependency_links.txt
|
|
13
|
+
vicifast.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
vicifast
|