softsolz 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
softsolz/__init__.py ADDED
@@ -0,0 +1,35 @@
1
+ from . import webhooks
2
+ from ._client import SoftSolz
3
+ from ._http import DEFAULT_BASE_URL
4
+ from ._version import __version__
5
+ from .errors import (
6
+ APIConnectionError,
7
+ AuthenticationError,
8
+ ConflictError,
9
+ InvalidRequestError,
10
+ NotFoundError,
11
+ PaymentRequiredError,
12
+ PermissionDeniedError,
13
+ RateLimitError,
14
+ SoftSolzError,
15
+ WebhookSignatureError,
16
+ )
17
+ from .pagination import Page
18
+
19
+ __all__ = [
20
+ "SoftSolz",
21
+ "Page",
22
+ "webhooks",
23
+ "DEFAULT_BASE_URL",
24
+ "SoftSolzError",
25
+ "InvalidRequestError",
26
+ "AuthenticationError",
27
+ "PermissionDeniedError",
28
+ "PaymentRequiredError",
29
+ "NotFoundError",
30
+ "ConflictError",
31
+ "RateLimitError",
32
+ "APIConnectionError",
33
+ "WebhookSignatureError",
34
+ "__version__",
35
+ ]
softsolz/_client.py ADDED
@@ -0,0 +1,60 @@
1
+ from typing import Any, Dict, Optional
2
+
3
+ import httpx
4
+
5
+ from ._http import DEFAULT_BASE_URL, HttpClient
6
+ from .resources import build_resources
7
+
8
+
9
+ class SoftSolz:
10
+ def __init__(
11
+ self,
12
+ api_key: str,
13
+ *,
14
+ base_url: str = DEFAULT_BASE_URL,
15
+ timeout: float = 30.0,
16
+ max_retries: int = 2,
17
+ transport: Optional[httpx.BaseTransport] = None,
18
+ ) -> None:
19
+ self._http = HttpClient(
20
+ api_key,
21
+ base_url=base_url,
22
+ timeout=timeout,
23
+ max_retries=max_retries,
24
+ transport=transport,
25
+ )
26
+ for name, resource in build_resources(self._http).items():
27
+ setattr(self, name, resource)
28
+
29
+ def whoami(self, **options: Any) -> Any:
30
+ return self._http.request("GET", "/api/v1/whoami", options=options or None)
31
+
32
+ def services(self, **options: Any) -> Any:
33
+ return self._http.request("GET", "/api/v1/services", options=options or None)
34
+
35
+ def service_health(self, service_id: str, **options: Any) -> Any:
36
+ from ._http import _q
37
+
38
+ return self._http.request(
39
+ "GET", f"/api/v1/services/{_q(service_id)}/health", options=options or None
40
+ )
41
+
42
+ def request(
43
+ self,
44
+ method: str,
45
+ path: str,
46
+ *,
47
+ query: Optional[Dict[str, Any]] = None,
48
+ body: Optional[Dict[str, Any]] = None,
49
+ **options: Any,
50
+ ) -> Any:
51
+ return self._http.request(method, path, query=query, body=body, options=options or None)
52
+
53
+ def close(self) -> None:
54
+ self._http.close()
55
+
56
+ def __enter__(self) -> "SoftSolz":
57
+ return self
58
+
59
+ def __exit__(self, *exc_info: Any) -> None:
60
+ self.close()
softsolz/_http.py ADDED
@@ -0,0 +1,153 @@
1
+ import random
2
+ import time
3
+ import uuid
4
+ from typing import Any, Dict, Optional
5
+ from urllib.parse import quote
6
+
7
+ import httpx
8
+
9
+ from ._version import __version__
10
+ from .errors import APIConnectionError, error_from_response
11
+ from .pagination import Page
12
+
13
+ DEFAULT_BASE_URL = "https://app.softsolz.uk"
14
+
15
+ _MUTATING_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
16
+
17
+
18
+ def _q(value: Any) -> str:
19
+ return quote(str(value), safe="")
20
+
21
+
22
+ def _backoff_seconds(attempt: int) -> float:
23
+ return min(8.0, 0.5 * (2**attempt)) + random.random() * 0.25
24
+
25
+
26
+ class HttpClient:
27
+ def __init__(
28
+ self,
29
+ api_key: str,
30
+ *,
31
+ base_url: str = DEFAULT_BASE_URL,
32
+ timeout: float = 30.0,
33
+ max_retries: int = 2,
34
+ transport: Optional[httpx.BaseTransport] = None,
35
+ ) -> None:
36
+ if not api_key or not isinstance(api_key, str):
37
+ raise ValueError('SoftSolz: api_key is required, e.g. SoftSolz(api_key="sk_test_...")')
38
+ self._max_retries = max_retries
39
+ self._client = httpx.Client(
40
+ base_url=base_url.rstrip("/"),
41
+ timeout=timeout,
42
+ transport=transport,
43
+ headers={
44
+ "authorization": f"Bearer {api_key}",
45
+ "accept": "application/json",
46
+ "user-agent": f"softsolz-python/{__version__}",
47
+ },
48
+ )
49
+
50
+ def envelope(
51
+ self,
52
+ method: str,
53
+ path: str,
54
+ *,
55
+ query: Optional[Dict[str, Any]] = None,
56
+ body: Optional[Dict[str, Any]] = None,
57
+ response_kind: str = "json",
58
+ options: Optional[Dict[str, Any]] = None,
59
+ ) -> Dict[str, Any]:
60
+ options = options or {}
61
+ method = method.upper()
62
+ headers: Dict[str, str] = {}
63
+ if options.get("request_id"):
64
+ headers["x-request-id"] = str(options["request_id"])
65
+ if method in _MUTATING_METHODS:
66
+ headers["idempotency-key"] = str(options.get("idempotency_key") or uuid.uuid4())
67
+ params = {k: v for k, v in (query or {}).items() if v is not None}
68
+ max_retries = int(options.get("max_retries", self._max_retries))
69
+ timeout = options.get("timeout")
70
+ attempt = 0
71
+ while True:
72
+ try:
73
+ response = self._client.request(
74
+ method,
75
+ path,
76
+ params=params,
77
+ json=body,
78
+ headers=headers,
79
+ timeout=timeout if timeout is not None else httpx.USE_CLIENT_DEFAULT,
80
+ )
81
+ except httpx.HTTPError as exc:
82
+ if attempt < max_retries:
83
+ time.sleep(_backoff_seconds(attempt))
84
+ attempt += 1
85
+ continue
86
+ raise APIConnectionError(str(exc)) from exc
87
+ request_id = response.headers.get("softsolz-request-id")
88
+ if response.is_success:
89
+ if response_kind == "none" or response.status_code == 204:
90
+ return {"data": None, "meta": None, "request_id": request_id}
91
+ if response_kind == "text":
92
+ return {"data": response.text, "meta": None, "request_id": request_id}
93
+ if response_kind == "binary":
94
+ return {"data": response.content, "meta": None, "request_id": request_id}
95
+ payload = self._safe_json(response)
96
+ if isinstance(payload, dict) and "data" in payload:
97
+ data = payload.get("data")
98
+ meta = payload.get("meta") if isinstance(payload.get("meta"), dict) else None
99
+ else:
100
+ data = payload
101
+ meta = None
102
+ return {"data": data, "meta": meta, "request_id": request_id}
103
+ retryable = response.status_code == 429 or response.status_code >= 500
104
+ if retryable and attempt < max_retries:
105
+ retry_after = response.headers.get("retry-after")
106
+ if retry_after and retry_after.isdigit() and int(retry_after) > 0:
107
+ delay = float(retry_after)
108
+ else:
109
+ delay = _backoff_seconds(attempt)
110
+ time.sleep(delay)
111
+ attempt += 1
112
+ continue
113
+ raise error_from_response(response.status_code, self._safe_json(response), request_id)
114
+
115
+ def request(
116
+ self,
117
+ method: str,
118
+ path: str,
119
+ *,
120
+ query: Optional[Dict[str, Any]] = None,
121
+ body: Optional[Dict[str, Any]] = None,
122
+ response_kind: str = "json",
123
+ options: Optional[Dict[str, Any]] = None,
124
+ ) -> Any:
125
+ return self.envelope(
126
+ method, path, query=query, body=body, response_kind=response_kind, options=options
127
+ )["data"]
128
+
129
+ def page(
130
+ self,
131
+ method: str,
132
+ path: str,
133
+ *,
134
+ query: Optional[Dict[str, Any]] = None,
135
+ options: Optional[Dict[str, Any]] = None,
136
+ ) -> Page:
137
+ def fetch_page(offset: Optional[int]) -> Dict[str, Any]:
138
+ merged = dict(query or {})
139
+ if offset is not None:
140
+ merged["offset"] = offset
141
+ return self.envelope(method, path, query=merged, options=options)
142
+
143
+ return Page.create(fetch_page)
144
+
145
+ @staticmethod
146
+ def _safe_json(response: httpx.Response) -> Any:
147
+ try:
148
+ return response.json()
149
+ except ValueError:
150
+ return None
151
+
152
+ def close(self) -> None:
153
+ self._client.close()
softsolz/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
softsolz/errors.py ADDED
@@ -0,0 +1,91 @@
1
+ from typing import Any, Dict, Optional
2
+
3
+
4
+ class SoftSolzError(Exception):
5
+ def __init__(
6
+ self,
7
+ message: str,
8
+ *,
9
+ status: int = 0,
10
+ code: str = "unknown_error",
11
+ details: Optional[Dict[str, Any]] = None,
12
+ request_id: Optional[str] = None,
13
+ ) -> None:
14
+ super().__init__(message)
15
+ self.message = message
16
+ self.status = status
17
+ self.code = code
18
+ self.details = details
19
+ self.request_id = request_id
20
+
21
+
22
+ class InvalidRequestError(SoftSolzError):
23
+ pass
24
+
25
+
26
+ class AuthenticationError(SoftSolzError):
27
+ pass
28
+
29
+
30
+ class PermissionDeniedError(SoftSolzError):
31
+ pass
32
+
33
+
34
+ class PaymentRequiredError(SoftSolzError):
35
+ pass
36
+
37
+
38
+ class NotFoundError(SoftSolzError):
39
+ pass
40
+
41
+
42
+ class ConflictError(SoftSolzError):
43
+ pass
44
+
45
+
46
+ class RateLimitError(SoftSolzError):
47
+ def __init__(self, message: str, *, retry_after_seconds: Optional[int] = None, **kwargs: Any) -> None:
48
+ super().__init__(message, **kwargs)
49
+ self.retry_after_seconds = retry_after_seconds
50
+
51
+
52
+ class APIConnectionError(SoftSolzError):
53
+ def __init__(self, message: str) -> None:
54
+ super().__init__(message, status=0, code="connection_error")
55
+
56
+
57
+ class WebhookSignatureError(Exception):
58
+ def __init__(self, reason: str) -> None:
59
+ super().__init__(f"Webhook signature verification failed: {reason}")
60
+ self.reason = reason
61
+
62
+
63
+ _STATUS_TO_ERROR = {
64
+ 400: InvalidRequestError,
65
+ 401: AuthenticationError,
66
+ 402: PaymentRequiredError,
67
+ 403: PermissionDeniedError,
68
+ 404: NotFoundError,
69
+ 409: ConflictError,
70
+ 422: InvalidRequestError,
71
+ }
72
+
73
+
74
+ def error_from_response(status: int, body: Any, request_id: Optional[str] = None) -> SoftSolzError:
75
+ err = body.get("error") if isinstance(body, dict) else None
76
+ err = err if isinstance(err, dict) else {}
77
+ code = err.get("code") if isinstance(err.get("code"), str) else "unknown_error"
78
+ message = err.get("message") if isinstance(err.get("message"), str) else f"HTTP {status}"
79
+ details = err.get("details") if isinstance(err.get("details"), dict) else None
80
+ if status == 429:
81
+ retry_after = details.get("retry_after_seconds") if details else None
82
+ return RateLimitError(
83
+ message,
84
+ status=status,
85
+ code=code,
86
+ details=details,
87
+ request_id=request_id,
88
+ retry_after_seconds=retry_after if isinstance(retry_after, int) else None,
89
+ )
90
+ error_class = _STATUS_TO_ERROR.get(status, SoftSolzError)
91
+ return error_class(message, status=status, code=code, details=details, request_id=request_id)
softsolz/pagination.py ADDED
@@ -0,0 +1,39 @@
1
+ from typing import Any, Callable, Dict, Iterator, List, Optional
2
+
3
+
4
+ class Page:
5
+ def __init__(
6
+ self,
7
+ data: List[Any],
8
+ meta: Optional[Dict[str, Any]],
9
+ fetch_page: Callable[[Optional[int]], Dict[str, Any]],
10
+ ) -> None:
11
+ self.data = data
12
+ self.meta = meta
13
+ self._fetch_page = fetch_page
14
+
15
+ @classmethod
16
+ def create(cls, fetch_page: Callable[[Optional[int]], Dict[str, Any]]) -> "Page":
17
+ envelope = fetch_page(None)
18
+ return cls(envelope.get("data") or [], envelope.get("meta"), fetch_page)
19
+
20
+ @property
21
+ def has_more(self) -> bool:
22
+ return bool(self.meta) and self.meta.get("has_more") is True
23
+
24
+ def next_page(self) -> Optional["Page"]:
25
+ if not self.has_more or not self.meta:
26
+ return None
27
+ offset = int(self.meta.get("offset", 0)) + int(self.meta.get("limit", len(self.data)))
28
+ envelope = self._fetch_page(offset)
29
+ return Page(envelope.get("data") or [], envelope.get("meta"), self._fetch_page)
30
+
31
+ def __iter__(self) -> Iterator[Any]:
32
+ page: Optional[Page] = self
33
+ while page is not None:
34
+ for item in page.data:
35
+ yield item
36
+ page = page.next_page()
37
+
38
+ def auto_paging_iter(self) -> Iterator[Any]:
39
+ return iter(self)
softsolz/py.typed ADDED
File without changes
@@ -0,0 +1,37 @@
1
+ from typing import Any, Dict
2
+
3
+ from .._http import HttpClient
4
+ from .blogs import BlogsResource
5
+ from .customer_auth import CustomerAuthResource
6
+ from .forms import FormsResource
7
+ from .invoicing import InvoicingResource
8
+ from .payments import PaymentsResource
9
+ from .smart_chat import SmartChatResource
10
+ from .social import SocialResource
11
+ from .workflows import WorkflowsResource
12
+
13
+
14
+ def build_resources(http: HttpClient) -> Dict[str, Any]:
15
+ return {
16
+ "blogs": BlogsResource(http),
17
+ "customer_auth": CustomerAuthResource(http),
18
+ "forms": FormsResource(http),
19
+ "invoicing": InvoicingResource(http),
20
+ "payments": PaymentsResource(http),
21
+ "smart_chat": SmartChatResource(http),
22
+ "social": SocialResource(http),
23
+ "workflows": WorkflowsResource(http),
24
+ }
25
+
26
+
27
+ __all__ = [
28
+ "build_resources",
29
+ "BlogsResource",
30
+ "CustomerAuthResource",
31
+ "FormsResource",
32
+ "InvoicingResource",
33
+ "PaymentsResource",
34
+ "SmartChatResource",
35
+ "SocialResource",
36
+ "WorkflowsResource",
37
+ ]
@@ -0,0 +1,50 @@
1
+ from typing import Any, Dict, Optional
2
+
3
+ from .._http import HttpClient, _q
4
+
5
+
6
+ class BlogsResource:
7
+ def __init__(self, http: HttpClient) -> None:
8
+ self._http = http
9
+
10
+ def list_published_posts(self, **options: Any) -> Any:
11
+ return self._http.request("GET", "/api/v1/services/blogs/posts/published", options=options or None)
12
+
13
+ def get_published_post_by_slug(self, slug: Any, **options: Any) -> Any:
14
+ return self._http.request("GET", f"/api/v1/services/blogs/posts/published/{_q(slug)}", options=options or None)
15
+
16
+ def get_posts(self, **options: Any) -> Any:
17
+ return self._http.request("GET", "/api/v1/services/blogs/posts", options=options or None)
18
+
19
+ def get_post(self, id: Any, **options: Any) -> Any:
20
+ return self._http.request("GET", f"/api/v1/services/blogs/posts/{_q(id)}", options=options or None)
21
+
22
+ def create_post(self, **options: Any) -> Any:
23
+ return self._http.request("POST", "/api/v1/services/blogs/posts", options=options or None)
24
+
25
+ def update_post(self, id: Any, **options: Any) -> Any:
26
+ return self._http.request("PATCH", f"/api/v1/services/blogs/posts/{_q(id)}", options=options or None)
27
+
28
+ def update_post_status(self, id: Any, **options: Any) -> Any:
29
+ return self._http.request("POST", f"/api/v1/services/blogs/posts/{_q(id)}/status", options=options or None)
30
+
31
+ def delete_post(self, id: Any, **options: Any) -> Any:
32
+ return self._http.request("DELETE", f"/api/v1/services/blogs/posts/{_q(id)}", options=options or None)
33
+
34
+ def get_categories(self, **options: Any) -> Any:
35
+ return self._http.request("GET", "/api/v1/services/blogs/categories", options=options or None)
36
+
37
+ def get_analytics(self, **options: Any) -> Any:
38
+ return self._http.request("GET", "/api/v1/services/blogs/analytics", options=options or None)
39
+
40
+ def get_subscribers(self, **options: Any) -> Any:
41
+ return self._http.request("GET", "/api/v1/services/blogs/subscribers", options=options or None)
42
+
43
+ def create_subscriber(self, **options: Any) -> Any:
44
+ return self._http.request("POST", "/api/v1/services/blogs/subscribers", options=options or None)
45
+
46
+ def update_subscriber(self, id: Any, **options: Any) -> Any:
47
+ return self._http.request("PATCH", f"/api/v1/services/blogs/subscribers/{_q(id)}", options=options or None)
48
+
49
+ def delete_subscriber(self, id: Any, **options: Any) -> Any:
50
+ return self._http.request("DELETE", f"/api/v1/services/blogs/subscribers/{_q(id)}", options=options or None)
@@ -0,0 +1,69 @@
1
+ from typing import Any, Dict, Optional
2
+
3
+ from .._http import HttpClient, _q
4
+ from ..pagination import Page
5
+
6
+
7
+ class CustomerAuthResource:
8
+ def __init__(self, http: HttpClient) -> None:
9
+ self._http = http
10
+
11
+ def list_users(self, query: Optional[Dict[str, Any]] = None, **options: Any) -> Page:
12
+ return self._http.page("GET", "/api/v1/services/customer-auth/users", query=query, options=options or None)
13
+
14
+ def get_user(self, id: Any, **options: Any) -> Any:
15
+ return self._http.request("GET", f"/api/v1/services/customer-auth/users/{_q(id)}", options=options or None)
16
+
17
+ def list_user_sessions(self, id: Any, query: Optional[Dict[str, Any]] = None, **options: Any) -> Page:
18
+ return self._http.page("GET", f"/api/v1/services/customer-auth/users/{_q(id)}/sessions", query=query, options=options or None)
19
+
20
+ def create_user(self, body: Dict[str, Any], **options: Any) -> Any:
21
+ return self._http.request("POST", "/api/v1/services/customer-auth/users", body=body, options=options or None)
22
+
23
+ def update_user(self, id: Any, body: Dict[str, Any], **options: Any) -> Any:
24
+ return self._http.request("PATCH", f"/api/v1/services/customer-auth/users/{_q(id)}", body=body, options=options or None)
25
+
26
+ def delete_user(self, id: Any, **options: Any) -> None:
27
+ self._http.request("DELETE", f"/api/v1/services/customer-auth/users/{_q(id)}", response_kind="none", options=options or None)
28
+
29
+ def logout_user(self, id: Any, **options: Any) -> Any:
30
+ return self._http.request("POST", f"/api/v1/services/customer-auth/users/{_q(id)}/logout", options=options or None)
31
+
32
+ def verify_session(self, body: Dict[str, Any], **options: Any) -> Any:
33
+ return self._http.request("POST", "/api/v1/services/customer-auth/sessions/verify", body=body, options=options or None)
34
+
35
+ def update_user_profile(self, id: Any, body: Dict[str, Any], **options: Any) -> Any:
36
+ return self._http.request("PATCH", f"/api/v1/services/customer-auth/users/{_q(id)}/profile", body=body, options=options or None)
37
+
38
+ def create_user_email_verification(self, id: Any, body: Dict[str, Any], **options: Any) -> Any:
39
+ return self._http.request("POST", f"/api/v1/services/customer-auth/users/{_q(id)}/email-verifications", body=body, options=options or None)
40
+
41
+ def complete_email_verification(self, body: Dict[str, Any], **options: Any) -> Any:
42
+ return self._http.request("POST", "/api/v1/services/customer-auth/email-verifications/complete", body=body, options=options or None)
43
+
44
+ def change_user_password(self, id: Any, body: Dict[str, Any], **options: Any) -> Any:
45
+ return self._http.request("POST", f"/api/v1/services/customer-auth/users/{_q(id)}/password", body=body, options=options or None)
46
+
47
+ def create_password_reset(self, body: Dict[str, Any], **options: Any) -> Any:
48
+ return self._http.request("POST", "/api/v1/services/customer-auth/password-resets", body=body, options=options or None)
49
+
50
+ def complete_password_reset(self, body: Dict[str, Any], **options: Any) -> Any:
51
+ return self._http.request("POST", "/api/v1/services/customer-auth/password-resets/complete", body=body, options=options or None)
52
+
53
+ def enrol_user_mfa(self, id: Any, **options: Any) -> Any:
54
+ return self._http.request("POST", f"/api/v1/services/customer-auth/users/{_q(id)}/mfa/enrol", options=options or None)
55
+
56
+ def confirm_user_mfa_enrol(self, id: Any, body: Dict[str, Any], **options: Any) -> Any:
57
+ return self._http.request("POST", f"/api/v1/services/customer-auth/users/{_q(id)}/mfa/enrol/confirm", body=body, options=options or None)
58
+
59
+ def disable_user_mfa(self, id: Any, **options: Any) -> Any:
60
+ return self._http.request("POST", f"/api/v1/services/customer-auth/users/{_q(id)}/mfa/disable", options=options or None)
61
+
62
+ def create_session(self, body: Dict[str, Any], **options: Any) -> Any:
63
+ return self._http.request("POST", "/api/v1/services/customer-auth/sessions", body=body, options=options or None)
64
+
65
+ def complete_session_mfa(self, body: Dict[str, Any], **options: Any) -> Any:
66
+ return self._http.request("POST", "/api/v1/services/customer-auth/sessions/mfa", body=body, options=options or None)
67
+
68
+ def revoke_session(self, body: Dict[str, Any], **options: Any) -> Any:
69
+ return self._http.request("POST", "/api/v1/services/customer-auth/sessions/revoke", body=body, options=options or None)
@@ -0,0 +1,45 @@
1
+ from typing import Any, Dict, Optional
2
+
3
+ from .._http import HttpClient, _q
4
+ from ..pagination import Page
5
+
6
+
7
+ class FormsResource:
8
+ def __init__(self, http: HttpClient) -> None:
9
+ self._http = http
10
+
11
+ def list_forms(self, query: Optional[Dict[str, Any]] = None, **options: Any) -> Page:
12
+ return self._http.page("GET", "/api/v1/services/forms/forms", query=query, options=options or None)
13
+
14
+ def get_form_by_slug(self, slug: Any, **options: Any) -> Any:
15
+ return self._http.request("GET", f"/api/v1/services/forms/forms/by-slug/{_q(slug)}", options=options or None)
16
+
17
+ def get_form(self, id: Any, **options: Any) -> Any:
18
+ return self._http.request("GET", f"/api/v1/services/forms/forms/{_q(id)}", options=options or None)
19
+
20
+ def create_form(self, body: Dict[str, Any], **options: Any) -> Any:
21
+ return self._http.request("POST", "/api/v1/services/forms/forms", body=body, options=options or None)
22
+
23
+ def update_form(self, id: Any, body: Dict[str, Any], **options: Any) -> Any:
24
+ return self._http.request("PATCH", f"/api/v1/services/forms/forms/{_q(id)}", body=body, options=options or None)
25
+
26
+ def delete_form(self, id: Any, **options: Any) -> None:
27
+ self._http.request("DELETE", f"/api/v1/services/forms/forms/{_q(id)}", response_kind="none", options=options or None)
28
+
29
+ def list_form_submissions(self, id: Any, query: Optional[Dict[str, Any]] = None, **options: Any) -> Page:
30
+ return self._http.page("GET", f"/api/v1/services/forms/forms/{_q(id)}/submissions", query=query, options=options or None)
31
+
32
+ def get_form_submission(self, id: Any, sub_id: Any, **options: Any) -> Any:
33
+ return self._http.request("GET", f"/api/v1/services/forms/forms/{_q(id)}/submissions/{_q(sub_id)}", options=options or None)
34
+
35
+ def update_form_submission(self, id: Any, sub_id: Any, body: Dict[str, Any], **options: Any) -> Any:
36
+ return self._http.request("PATCH", f"/api/v1/services/forms/forms/{_q(id)}/submissions/{_q(sub_id)}", body=body, options=options or None)
37
+
38
+ def delete_form_submission(self, id: Any, sub_id: Any, **options: Any) -> None:
39
+ self._http.request("DELETE", f"/api/v1/services/forms/forms/{_q(id)}/submissions/{_q(sub_id)}", response_kind="none", options=options or None)
40
+
41
+ def download_form_submissions_csv(self, id: Any, **options: Any) -> str:
42
+ return self._http.request("GET", f"/api/v1/services/forms/forms/{_q(id)}/submissions.csv", response_kind="text", options=options or None)
43
+
44
+ def submit_form(self, slug: Any, body: Dict[str, Any], **options: Any) -> Any:
45
+ return self._http.request("POST", f"/api/v1/services/forms/forms/{_q(slug)}/submit", body=body, options=options or None)
@@ -0,0 +1,75 @@
1
+ from typing import Any, Dict, Optional
2
+
3
+ from .._http import HttpClient, _q
4
+ from ..pagination import Page
5
+
6
+
7
+ class InvoicingResource:
8
+ def __init__(self, http: HttpClient) -> None:
9
+ self._http = http
10
+
11
+ def list_customers(self, query: Optional[Dict[str, Any]] = None, **options: Any) -> Page:
12
+ return self._http.page("GET", "/api/v1/services/invoicing/customers", query=query, options=options or None)
13
+
14
+ def get_customer(self, id: Any, **options: Any) -> Any:
15
+ return self._http.request("GET", f"/api/v1/services/invoicing/customers/{_q(id)}", options=options or None)
16
+
17
+ def create_customer(self, body: Dict[str, Any], **options: Any) -> Any:
18
+ return self._http.request("POST", "/api/v1/services/invoicing/customers", body=body, options=options or None)
19
+
20
+ def update_customer(self, id: Any, body: Dict[str, Any], **options: Any) -> Any:
21
+ return self._http.request("PUT", f"/api/v1/services/invoicing/customers/{_q(id)}", body=body, options=options or None)
22
+
23
+ def delete_customer(self, id: Any, **options: Any) -> Any:
24
+ return self._http.request("DELETE", f"/api/v1/services/invoicing/customers/{_q(id)}", options=options or None)
25
+
26
+ def create_customer_portal_token(self, id: Any, **options: Any) -> Any:
27
+ return self._http.request("POST", f"/api/v1/services/invoicing/customers/{_q(id)}/portal-token", options=options or None)
28
+
29
+ def list_invoices(self, query: Optional[Dict[str, Any]] = None, **options: Any) -> Page:
30
+ return self._http.page("GET", "/api/v1/services/invoicing/invoices", query=query, options=options or None)
31
+
32
+ def get_invoice(self, id: Any, **options: Any) -> Any:
33
+ return self._http.request("GET", f"/api/v1/services/invoicing/invoices/{_q(id)}", options=options or None)
34
+
35
+ def create_invoice(self, body: Dict[str, Any], **options: Any) -> Any:
36
+ return self._http.request("POST", "/api/v1/services/invoicing/invoices", body=body, options=options or None)
37
+
38
+ def update_invoice(self, id: Any, body: Dict[str, Any], **options: Any) -> Any:
39
+ return self._http.request("PUT", f"/api/v1/services/invoicing/invoices/{_q(id)}", body=body, options=options or None)
40
+
41
+ def post_invoice(self, id: Any, **options: Any) -> Any:
42
+ return self._http.request("POST", f"/api/v1/services/invoicing/invoices/{_q(id)}/post", options=options or None)
43
+
44
+ def send_invoice(self, id: Any, **options: Any) -> Any:
45
+ return self._http.request("POST", f"/api/v1/services/invoicing/invoices/{_q(id)}/send", options=options or None)
46
+
47
+ def void_invoice(self, id: Any, body: Dict[str, Any], **options: Any) -> Any:
48
+ return self._http.request("POST", f"/api/v1/services/invoicing/invoices/{_q(id)}/void", body=body, options=options or None)
49
+
50
+ def mark_invoice_paid(self, id: Any, body: Dict[str, Any], **options: Any) -> Any:
51
+ return self._http.request("POST", f"/api/v1/services/invoicing/invoices/{_q(id)}/mark-paid", body=body, options=options or None)
52
+
53
+ def remind_invoice(self, id: Any, **options: Any) -> Any:
54
+ return self._http.request("POST", f"/api/v1/services/invoicing/invoices/{_q(id)}/remind", options=options or None)
55
+
56
+ def download_invoice_pdf(self, id: Any, **options: Any) -> bytes:
57
+ return self._http.request("GET", f"/api/v1/services/invoicing/invoices/{_q(id)}/pdf", response_kind="binary", options=options or None)
58
+
59
+ def list_recurring(self, query: Optional[Dict[str, Any]] = None, **options: Any) -> Page:
60
+ return self._http.page("GET", "/api/v1/services/invoicing/recurring", query=query, options=options or None)
61
+
62
+ def create_recurring(self, body: Dict[str, Any], **options: Any) -> Any:
63
+ return self._http.request("POST", "/api/v1/services/invoicing/recurring", body=body, options=options or None)
64
+
65
+ def create_credit_note(self, body: Dict[str, Any], **options: Any) -> Any:
66
+ return self._http.request("POST", "/api/v1/services/invoicing/credit-notes", body=body, options=options or None)
67
+
68
+ def create_refund(self, body: Dict[str, Any], **options: Any) -> Any:
69
+ return self._http.request("POST", "/api/v1/services/invoicing/refunds", body=body, options=options or None)
70
+
71
+ def get_approval(self, id: Any, **options: Any) -> Any:
72
+ return self._http.request("GET", f"/api/v1/services/invoicing/approvals/{_q(id)}", options=options or None)
73
+
74
+ def get_ar_aging_report(self, query: Optional[Dict[str, Any]] = None, **options: Any) -> Any:
75
+ return self._http.request("GET", "/api/v1/services/invoicing/reports/ar-aging", query=query, options=options or None)
@@ -0,0 +1,27 @@
1
+ from typing import Any, Dict, Optional
2
+
3
+ from .._http import HttpClient, _q
4
+ from ..pagination import Page
5
+
6
+
7
+ class PaymentsResource:
8
+ def __init__(self, http: HttpClient) -> None:
9
+ self._http = http
10
+
11
+ def create_checkout_session(self, body: Dict[str, Any], **options: Any) -> Any:
12
+ return self._http.request("POST", "/api/v1/services/payments/checkout-sessions", body=body, options=options or None)
13
+
14
+ def list_payment_links(self, query: Optional[Dict[str, Any]] = None, **options: Any) -> Page:
15
+ return self._http.page("GET", "/api/v1/services/payments/payment-links", query=query, options=options or None)
16
+
17
+ def get_payment_link(self, id: Any, **options: Any) -> Any:
18
+ return self._http.request("GET", f"/api/v1/services/payments/payment-links/{_q(id)}", options=options or None)
19
+
20
+ def create_payment_link(self, body: Dict[str, Any], **options: Any) -> Any:
21
+ return self._http.request("POST", "/api/v1/services/payments/payment-links", body=body, options=options or None)
22
+
23
+ def update_payment_link(self, id: Any, body: Dict[str, Any], **options: Any) -> Any:
24
+ return self._http.request("PATCH", f"/api/v1/services/payments/payment-links/{_q(id)}", body=body, options=options or None)
25
+
26
+ def delete_payment_link(self, id: Any, **options: Any) -> None:
27
+ self._http.request("DELETE", f"/api/v1/services/payments/payment-links/{_q(id)}", response_kind="none", options=options or None)
@@ -0,0 +1,87 @@
1
+ from typing import Any, Dict, Optional
2
+
3
+ from .._http import HttpClient, _q
4
+ from ..pagination import Page
5
+
6
+
7
+ class SmartChatResource:
8
+ def __init__(self, http: HttpClient) -> None:
9
+ self._http = http
10
+
11
+ def list_threads(self, query: Optional[Dict[str, Any]] = None, **options: Any) -> Page:
12
+ return self._http.page("GET", "/api/v1/services/smart-chat/threads", query=query, options=options or None)
13
+
14
+ def create_thread(self, body: Dict[str, Any], **options: Any) -> Any:
15
+ return self._http.request("POST", "/api/v1/services/smart-chat/threads", body=body, options=options or None)
16
+
17
+ def get_thread(self, id: Any, **options: Any) -> Any:
18
+ return self._http.request("GET", f"/api/v1/services/smart-chat/threads/{_q(id)}", options=options or None)
19
+
20
+ def update_thread(self, id: Any, body: Dict[str, Any], **options: Any) -> Any:
21
+ return self._http.request("PATCH", f"/api/v1/services/smart-chat/threads/{_q(id)}", body=body, options=options or None)
22
+
23
+ def delete_thread(self, id: Any, **options: Any) -> None:
24
+ self._http.request("DELETE", f"/api/v1/services/smart-chat/threads/{_q(id)}", response_kind="none", options=options or None)
25
+
26
+ def list_thread_messages(self, id: Any, query: Optional[Dict[str, Any]] = None, **options: Any) -> Page:
27
+ return self._http.page("GET", f"/api/v1/services/smart-chat/threads/{_q(id)}/messages", query=query, options=options or None)
28
+
29
+ def create_message(self, body: Dict[str, Any], **options: Any) -> Any:
30
+ return self._http.request("POST", "/api/v1/services/smart-chat/messages", body=body, options=options or None)
31
+
32
+ def list_agents(self, query: Optional[Dict[str, Any]] = None, **options: Any) -> Page:
33
+ return self._http.page("GET", "/api/v1/services/smart-chat/agents", query=query, options=options or None)
34
+
35
+ def get_agent(self, id: Any, **options: Any) -> Any:
36
+ return self._http.request("GET", f"/api/v1/services/smart-chat/agents/{_q(id)}", options=options or None)
37
+
38
+ def create_agent(self, body: Dict[str, Any], **options: Any) -> Any:
39
+ return self._http.request("POST", "/api/v1/services/smart-chat/agents", body=body, options=options or None)
40
+
41
+ def update_agent(self, id: Any, body: Dict[str, Any], **options: Any) -> Any:
42
+ return self._http.request("PUT", f"/api/v1/services/smart-chat/agents/{_q(id)}", body=body, options=options or None)
43
+
44
+ def delete_agent(self, id: Any, **options: Any) -> None:
45
+ self._http.request("DELETE", f"/api/v1/services/smart-chat/agents/{_q(id)}", response_kind="none", options=options or None)
46
+
47
+ def search_kb(self, body: Dict[str, Any], **options: Any) -> Any:
48
+ return self._http.request("POST", "/api/v1/services/smart-chat/kb/search", body=body, options=options or None)
49
+
50
+ def list_kb_documents(self, query: Optional[Dict[str, Any]] = None, **options: Any) -> Page:
51
+ return self._http.page("GET", "/api/v1/services/smart-chat/kb/documents", query=query, options=options or None)
52
+
53
+ def get_kb_document(self, id: Any, **options: Any) -> Any:
54
+ return self._http.request("GET", f"/api/v1/services/smart-chat/kb/documents/{_q(id)}", options=options or None)
55
+
56
+ def delete_kb_document(self, id: Any, **options: Any) -> None:
57
+ self._http.request("DELETE", f"/api/v1/services/smart-chat/kb/documents/{_q(id)}", response_kind="none", options=options or None)
58
+
59
+ def import_kb_document(self, **options: Any) -> Any:
60
+ return self._http.request("POST", "/api/v1/services/smart-chat/kb/documents/import", options=options or None)
61
+
62
+ def create_kb_documents_import_batch(self, body: Dict[str, Any], **options: Any) -> Any:
63
+ return self._http.request("POST", "/api/v1/services/smart-chat/kb/documents/import/batch", body=body, options=options or None)
64
+
65
+ def init_kb_documents_import(self, body: Dict[str, Any], **options: Any) -> Any:
66
+ return self._http.request("POST", "/api/v1/services/smart-chat/kb/documents/import/init", body=body, options=options or None)
67
+
68
+ def complete_kb_documents_import(self, task_id: Any, **options: Any) -> Any:
69
+ return self._http.request("POST", f"/api/v1/services/smart-chat/kb/documents/import/{_q(task_id)}/complete", options=options or None)
70
+
71
+ def list_kb_collections(self, query: Optional[Dict[str, Any]] = None, **options: Any) -> Page:
72
+ return self._http.page("GET", "/api/v1/services/smart-chat/kb/collections", query=query, options=options or None)
73
+
74
+ def create_kb_collection(self, body: Dict[str, Any], **options: Any) -> Any:
75
+ return self._http.request("POST", "/api/v1/services/smart-chat/kb/collections", body=body, options=options or None)
76
+
77
+ def get_task(self, task_id: Any, **options: Any) -> Any:
78
+ return self._http.request("GET", f"/api/v1/services/smart-chat/tasks/{_q(task_id)}", options=options or None)
79
+
80
+ def list_tasks(self, query: Optional[Dict[str, Any]] = None, **options: Any) -> Page:
81
+ return self._http.page("GET", "/api/v1/services/smart-chat/tasks", query=query, options=options or None)
82
+
83
+ def cancel_task(self, task_id: Any, **options: Any) -> Any:
84
+ return self._http.request("POST", f"/api/v1/services/smart-chat/tasks/{_q(task_id)}/cancel", options=options or None)
85
+
86
+ def replay_task(self, task_id: Any, **options: Any) -> Any:
87
+ return self._http.request("POST", f"/api/v1/services/smart-chat/tasks/{_q(task_id)}/replay", options=options or None)
@@ -0,0 +1,32 @@
1
+ from typing import Any, Dict, Optional
2
+
3
+ from .._http import HttpClient, _q
4
+
5
+
6
+ class SocialResource:
7
+ def __init__(self, http: HttpClient) -> None:
8
+ self._http = http
9
+
10
+ def get_accounts(self, **options: Any) -> Any:
11
+ return self._http.request("GET", "/api/v1/services/social/accounts", options=options or None)
12
+
13
+ def get_posts(self, **options: Any) -> Any:
14
+ return self._http.request("GET", "/api/v1/services/social/posts", options=options or None)
15
+
16
+ def get_post(self, id: Any, **options: Any) -> Any:
17
+ return self._http.request("GET", f"/api/v1/services/social/posts/{_q(id)}", options=options or None)
18
+
19
+ def get_post_targets(self, id: Any, **options: Any) -> Any:
20
+ return self._http.request("GET", f"/api/v1/services/social/posts/{_q(id)}/targets", options=options or None)
21
+
22
+ def create_post(self, **options: Any) -> Any:
23
+ return self._http.request("POST", "/api/v1/services/social/posts", options=options or None)
24
+
25
+ def update_post(self, id: Any, **options: Any) -> Any:
26
+ return self._http.request("PATCH", f"/api/v1/services/social/posts/{_q(id)}", options=options or None)
27
+
28
+ def delete_post(self, id: Any, **options: Any) -> Any:
29
+ return self._http.request("DELETE", f"/api/v1/services/social/posts/{_q(id)}", options=options or None)
30
+
31
+ def publish_post(self, id: Any, **options: Any) -> Any:
32
+ return self._http.request("POST", f"/api/v1/services/social/posts/{_q(id)}/publish", options=options or None)
@@ -0,0 +1,45 @@
1
+ from typing import Any, Dict, Optional
2
+
3
+ from .._http import HttpClient, _q
4
+ from ..pagination import Page
5
+
6
+
7
+ class WorkflowsResource:
8
+ def __init__(self, http: HttpClient) -> None:
9
+ self._http = http
10
+
11
+ def list_workflows(self, query: Optional[Dict[str, Any]] = None, **options: Any) -> Page:
12
+ return self._http.page("GET", "/api/v1/services/workflows/workflows", query=query, options=options or None)
13
+
14
+ def get_workflow(self, id: Any, **options: Any) -> Any:
15
+ return self._http.request("GET", f"/api/v1/services/workflows/workflows/{_q(id)}", options=options or None)
16
+
17
+ def create_workflow(self, body: Dict[str, Any], **options: Any) -> Any:
18
+ return self._http.request("POST", "/api/v1/services/workflows/workflows", body=body, options=options or None)
19
+
20
+ def update_workflow(self, id: Any, body: Dict[str, Any], **options: Any) -> Any:
21
+ return self._http.request("PUT", f"/api/v1/services/workflows/workflows/{_q(id)}", body=body, options=options or None)
22
+
23
+ def delete_workflow(self, id: Any, **options: Any) -> None:
24
+ self._http.request("DELETE", f"/api/v1/services/workflows/workflows/{_q(id)}", response_kind="none", options=options or None)
25
+
26
+ def pause_workflow(self, id: Any, **options: Any) -> Any:
27
+ return self._http.request("POST", f"/api/v1/services/workflows/workflows/{_q(id)}/pause", options=options or None)
28
+
29
+ def resume_workflow(self, id: Any, **options: Any) -> Any:
30
+ return self._http.request("POST", f"/api/v1/services/workflows/workflows/{_q(id)}/resume", options=options or None)
31
+
32
+ def run_workflow(self, id: Any, **options: Any) -> Any:
33
+ return self._http.request("POST", f"/api/v1/services/workflows/workflows/{_q(id)}/run", options=options or None)
34
+
35
+ def get_run(self, id: Any, **options: Any) -> Any:
36
+ return self._http.request("GET", f"/api/v1/services/workflows/runs/{_q(id)}", options=options or None)
37
+
38
+ def list_runs(self, query: Optional[Dict[str, Any]] = None, **options: Any) -> Page:
39
+ return self._http.page("GET", "/api/v1/services/workflows/runs", query=query, options=options or None)
40
+
41
+ def list_workflow_runs(self, id: Any, query: Optional[Dict[str, Any]] = None, **options: Any) -> Page:
42
+ return self._http.page("GET", f"/api/v1/services/workflows/workflows/{_q(id)}/runs", query=query, options=options or None)
43
+
44
+ def get_action_catalog(self, **options: Any) -> Any:
45
+ return self._http.request("GET", "/api/v1/services/workflows/action-catalog", options=options or None)
softsolz/webhooks.py ADDED
@@ -0,0 +1,75 @@
1
+ import hashlib
2
+ import hmac
3
+ import time
4
+ from typing import Any, Dict, List, Optional, Union
5
+
6
+ from .errors import WebhookSignatureError
7
+
8
+ DEFAULT_TOLERANCE_SECONDS = 300
9
+ _SIGNATURE_VERSION = "v1"
10
+
11
+
12
+ def _parse_signature_header(header: Optional[str]) -> Optional[Dict[str, Any]]:
13
+ if not isinstance(header, str) or not header:
14
+ return None
15
+ timestamp: Optional[int] = None
16
+ signatures: List[str] = []
17
+ for part in header.split(","):
18
+ if "=" not in part:
19
+ continue
20
+ key, _, value = part.partition("=")
21
+ key = key.strip()
22
+ value = value.strip()
23
+ if key == "t":
24
+ try:
25
+ timestamp = int(value)
26
+ except ValueError:
27
+ return None
28
+ elif key == _SIGNATURE_VERSION:
29
+ signatures.append(value)
30
+ if timestamp is None:
31
+ return None
32
+ return {"t": timestamp, "signatures": signatures}
33
+
34
+
35
+ def compute_signature(timestamp: int, raw_body: Union[str, bytes], secret: str) -> str:
36
+ body_text = raw_body.decode("utf-8") if isinstance(raw_body, bytes) else raw_body
37
+ payload = f"{timestamp}.{body_text}".encode("utf-8")
38
+ return hmac.new(secret.encode("utf-8"), payload, hashlib.sha256).hexdigest()
39
+
40
+
41
+ def verify(
42
+ raw_body: Union[str, bytes],
43
+ header_value: Optional[str],
44
+ secret: str,
45
+ *,
46
+ tolerance_seconds: int = DEFAULT_TOLERANCE_SECONDS,
47
+ now: Optional[int] = None,
48
+ ) -> Dict[str, Any]:
49
+ if not secret:
50
+ return {"valid": False, "reason": "missing_secret"}
51
+ parsed = _parse_signature_header(header_value)
52
+ if parsed is None:
53
+ return {"valid": False, "reason": "malformed_header"}
54
+ if not parsed["signatures"]:
55
+ return {"valid": False, "reason": "no_signatures"}
56
+ current = now if now is not None else int(time.time())
57
+ if abs(current - parsed["t"]) > tolerance_seconds:
58
+ return {"valid": False, "reason": "timestamp_outside_tolerance", "t": parsed["t"]}
59
+ expected = compute_signature(parsed["t"], raw_body, secret)
60
+ if any(hmac.compare_digest(sig, expected) for sig in parsed["signatures"]):
61
+ return {"valid": True, "t": parsed["t"]}
62
+ return {"valid": False, "reason": "signature_mismatch", "t": parsed["t"]}
63
+
64
+
65
+ def assert_valid(
66
+ raw_body: Union[str, bytes],
67
+ header_value: Optional[str],
68
+ secret: str,
69
+ *,
70
+ tolerance_seconds: int = DEFAULT_TOLERANCE_SECONDS,
71
+ now: Optional[int] = None,
72
+ ) -> None:
73
+ result = verify(raw_body, header_value, secret, tolerance_seconds=tolerance_seconds, now=now)
74
+ if not result["valid"]:
75
+ raise WebhookSignatureError(result.get("reason", "invalid"))
@@ -0,0 +1,130 @@
1
+ Metadata-Version: 2.4
2
+ Name: softsolz
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for the SoftSolz platform API.
5
+ Project-URL: Homepage, https://developer.softsolz.uk
6
+ Project-URL: Documentation, https://developer.softsolz.uk
7
+ Project-URL: Repository, https://github.com/soft-solz/sdk
8
+ Author: SoftSolz
9
+ License-Expression: MIT
10
+ Keywords: api,forms,invoicing,payments,sdk,softsolz,webhooks
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Requires-Python: >=3.9
21
+ Requires-Dist: httpx<1,>=0.26
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=8; extra == 'dev'
24
+ Description-Content-Type: text/markdown
25
+
26
+ # softsolz
27
+
28
+ Official Python SDK for the [SoftSolz platform API](https://developer.softsolz.uk).
29
+
30
+ ```bash
31
+ pip install softsolz
32
+ ```
33
+
34
+ Requires Python 3.9 or later. Fully type-annotated (`py.typed`).
35
+
36
+ ## Usage
37
+
38
+ ```python
39
+ import os
40
+ from softsolz import SoftSolz
41
+
42
+ client = SoftSolz(api_key=os.environ["SOFTSOLZ_API_KEY"])
43
+
44
+ me = client.whoami()
45
+
46
+ form = client.forms.create_form({"name": "Contact Us", "status": "published"})
47
+ client.forms.submit_form(form["slug"], {"data": {"full_name": "Jane Doe", "email": "jane@example.com"}})
48
+
49
+ invoice = client.invoicing.create_invoice({
50
+ "customer_id": 42,
51
+ "lines": [{"description": "Design work", "quantity": 1, "unit_price_cents": 50000}],
52
+ })
53
+ client.invoicing.send_invoice(invoice["id"])
54
+ ```
55
+
56
+ Use an `sk_test_*` key to run against your sandbox workspace; swap to `sk_live_*` in production. No other configuration changes.
57
+
58
+ ### Services
59
+
60
+ `client.blogs`, `client.customer_auth`, `client.forms`, `client.invoicing`, `client.payments`, `client.smart_chat`, `client.social`, `client.workflows` - one method per API operation, generated from the platform's service manifests.
61
+
62
+ ### Pagination
63
+
64
+ List methods return a `Page` you can iterate; extra pages are fetched automatically:
65
+
66
+ ```python
67
+ for invoice in client.invoicing.list_invoices({"status": "overdue"}):
68
+ print(invoice["invoice_number"])
69
+ ```
70
+
71
+ Or page manually with `page.data`, `page.has_more`, and `page.next_page()`.
72
+
73
+ ### Errors
74
+
75
+ All API failures raise a typed subclass of `SoftSolzError` carrying `status`, `code`, `message`, `details`, and `request_id`:
76
+
77
+ ```python
78
+ from softsolz import NotFoundError
79
+
80
+ try:
81
+ client.forms.get_form(999)
82
+ except NotFoundError as err:
83
+ print(err.code, err.request_id)
84
+ ```
85
+
86
+ Classes: `InvalidRequestError` (400/422), `AuthenticationError` (401), `PaymentRequiredError` (402), `PermissionDeniedError` (403), `NotFoundError` (404), `ConflictError` (409), `RateLimitError` (429, with `retry_after_seconds`), `APIConnectionError` (network).
87
+
88
+ ### Retries and idempotency
89
+
90
+ 429 and 5xx responses are retried automatically with exponential backoff (default 2 retries), honouring `Retry-After`. Every mutating request carries an auto-generated `Idempotency-Key`, so retries are always safe. Pass your own to control replays:
91
+
92
+ ```python
93
+ client.invoicing.create_invoice(body, idempotency_key="order-1234")
94
+ ```
95
+
96
+ Configure per client: `SoftSolz(api_key=..., max_retries=3, timeout=60.0)`.
97
+
98
+ ### Webhooks
99
+
100
+ Verify the `Softsolz-Signature` header on incoming webhooks using the raw request body:
101
+
102
+ ```python
103
+ from softsolz import webhooks
104
+
105
+ webhooks.assert_valid(
106
+ raw_body=request.get_data(),
107
+ header_value=request.headers.get("Softsolz-Signature"),
108
+ secret=os.environ["SOFTSOLZ_WEBHOOK_SECRET"],
109
+ )
110
+ ```
111
+
112
+ `webhooks.verify(...)` returns `{"valid": False, "reason": ...}` if you prefer not to raise.
113
+
114
+ ### Escape hatch
115
+
116
+ Call any endpoint directly while keeping auth, retries, and errors:
117
+
118
+ ```python
119
+ client.request("GET", "/api/v1/services/forms/forms", query={"limit": 10})
120
+ ```
121
+
122
+ ## Documentation
123
+
124
+ - API reference: https://developer.softsolz.uk/api-reference.html
125
+ - Webhooks: https://developer.softsolz.uk/webhooks.html
126
+ - Playground: https://playground.softsolz.uk
127
+
128
+ ## License
129
+
130
+ MIT
@@ -0,0 +1,20 @@
1
+ softsolz/__init__.py,sha256=YD1NEWZcJ5JtPZn5wfwCiF0ylQlNbt7X_InW89s9t3g,760
2
+ softsolz/_client.py,sha256=6vKYq4gopvVnQm0BvvJcxDbvdhRBSWxHMLGRJHsvxvg,1701
3
+ softsolz/_http.py,sha256=ZjcH823skUgASN06uvniBXGb0_2zK3P4xm0N_BAW-QQ,5496
4
+ softsolz/_version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
5
+ softsolz/errors.py,sha256=D-L-5edehexpqx-6DdRzpijc5NUFWftYuov2JpYghfw,2622
6
+ softsolz/pagination.py,sha256=5NdpIiiR1hF7TpTmnbK33Zpku50G9GdmD94bDFFR9uI,1307
7
+ softsolz/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ softsolz/webhooks.py,sha256=mvIOiE4ZXAHZe4xY6F2FYZoRxVQT0O1baeAMdegqSuQ,2623
9
+ softsolz/resources/__init__.py,sha256=oP11j7mNJPyMYxXy4Nyc0Aa_Z3GzS4BAHloNfwAaW_A,1033
10
+ softsolz/resources/blogs.py,sha256=aCGdPPk5wklzq22Jji_SzFIS5_P75mDbDkHz22M_oU4,2523
11
+ softsolz/resources/customer_auth.py,sha256=ohf9TfNGnR7svoS212NH_piDu07dmJlD9tzYaN3Q4AQ,4417
12
+ softsolz/resources/forms.py,sha256=FLKHwoTp4vd_CCY17isc1JZJnlUeg6_JxSA180FE-qw,2706
13
+ softsolz/resources/invoicing.py,sha256=iofUCXkJYdt2_MRx_YmqyrM6O_RH3BnkSkTGLcTuxcw,4587
14
+ softsolz/resources/payments.py,sha256=xpM50hCrtUjCUS6xrzMmJeZfYW-A9uWZvfnL8EzCsKs,1449
15
+ softsolz/resources/smart_chat.py,sha256=1xd68cZpdlAlAD99JTEK7vQqhYJ1-5qwo07xKbNXzSI,5439
16
+ softsolz/resources/social.py,sha256=7gvlYRsKFJi6rhkJX_G_j2zKIfOq-gd6x-ZfCcop1Jo,1498
17
+ softsolz/resources/workflows.py,sha256=ZH7D2e0SgaSykaWBPdLbgAdwJx4q2PM4wKuQXYXTlbU,2535
18
+ softsolz-0.1.0.dist-info/METADATA,sha256=A64Bv1rZWCb8MXONUosVrBViOWkykv6XfBt5DlHBF8k,4169
19
+ softsolz-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
20
+ softsolz-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any