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 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.
@@ -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,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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ vicifast