signwell-sdk-python 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.
Files changed (110) hide show
  1. signwell_sdk/__init__.py +287 -0
  2. signwell_sdk/api/__init__.py +10 -0
  3. signwell_sdk/api/api_application_api.py +481 -0
  4. signwell_sdk/api/bulk_send_api.py +1589 -0
  5. signwell_sdk/api/document_api.py +2425 -0
  6. signwell_sdk/api/me_api.py +246 -0
  7. signwell_sdk/api/regional_api.py +345 -0
  8. signwell_sdk/api/template_api.py +1298 -0
  9. signwell_sdk/api/webhooks_api.py +710 -0
  10. signwell_sdk/api_client.py +747 -0
  11. signwell_sdk/api_response.py +20 -0
  12. signwell_sdk/configuration.py +599 -0
  13. signwell_sdk/embedded.py +385 -0
  14. signwell_sdk/exceptions.py +314 -0
  15. signwell_sdk/models/__init__.py +114 -0
  16. signwell_sdk/models/account_info_response.py +143 -0
  17. signwell_sdk/models/account_info_response_active_users_inner.py +107 -0
  18. signwell_sdk/models/account_info_response_preferences.py +147 -0
  19. signwell_sdk/models/additional_fields_inner_inner.py +274 -0
  20. signwell_sdk/models/additional_fields_inner_inner_value.py +189 -0
  21. signwell_sdk/models/additional_files_inner.py +103 -0
  22. signwell_sdk/models/api_application_response.py +137 -0
  23. signwell_sdk/models/api_application_response_owner.py +107 -0
  24. signwell_sdk/models/api_application_response_preferences.py +135 -0
  25. signwell_sdk/models/attachment_request_info.py +99 -0
  26. signwell_sdk/models/attachment_requests_inner.py +110 -0
  27. signwell_sdk/models/bulk_send_create_response.py +132 -0
  28. signwell_sdk/models/bulk_send_csv_request.py +100 -0
  29. signwell_sdk/models/bulk_send_csv_template_response.py +97 -0
  30. signwell_sdk/models/bulk_send_documents_response.py +167 -0
  31. signwell_sdk/models/bulk_send_list_item.py +141 -0
  32. signwell_sdk/models/bulk_send_list_response.py +138 -0
  33. signwell_sdk/models/bulk_send_response.py +151 -0
  34. signwell_sdk/models/bulk_send_response_templates_inner.py +98 -0
  35. signwell_sdk/models/bulk_send_validate_csv_response.py +129 -0
  36. signwell_sdk/models/checkbox_group_info.py +138 -0
  37. signwell_sdk/models/checkbox_groups_inner.py +136 -0
  38. signwell_sdk/models/checkbox_validation.py +35 -0
  39. signwell_sdk/models/completed_pdf_response.py +177 -0
  40. signwell_sdk/models/completed_pdf_url_response.py +97 -0
  41. signwell_sdk/models/copied_contact_info.py +99 -0
  42. signwell_sdk/models/copied_contacts_inner.py +98 -0
  43. signwell_sdk/models/copied_placeholders_inner.py +111 -0
  44. signwell_sdk/models/create_bulk_send_request.py +155 -0
  45. signwell_sdk/models/create_webhook_request.py +100 -0
  46. signwell_sdk/models/date_format.py +36 -0
  47. signwell_sdk/models/detailed_option.py +105 -0
  48. signwell_sdk/models/document_from_template_request.py +377 -0
  49. signwell_sdk/models/document_from_template_response.py +353 -0
  50. signwell_sdk/models/document_from_template_response_fields_inner_inner.py +270 -0
  51. signwell_sdk/models/document_from_template_response_fields_inner_inner_recipient.py +99 -0
  52. signwell_sdk/models/document_from_template_response_recipients_inner.py +206 -0
  53. signwell_sdk/models/document_list_response.py +138 -0
  54. signwell_sdk/models/document_request.py +350 -0
  55. signwell_sdk/models/document_response.py +357 -0
  56. signwell_sdk/models/document_response_attachment_requests_inner.py +101 -0
  57. signwell_sdk/models/document_response_fields_inner_inner.py +268 -0
  58. signwell_sdk/models/document_response_fields_inner_inner_options_inner.py +101 -0
  59. signwell_sdk/models/document_response_fields_inner_inner_recipient.py +98 -0
  60. signwell_sdk/models/document_response_fields_inner_inner_value.py +195 -0
  61. signwell_sdk/models/document_response_recipients_inner.py +208 -0
  62. signwell_sdk/models/document_template_list_response.py +138 -0
  63. signwell_sdk/models/document_template_request.py +307 -0
  64. signwell_sdk/models/document_template_response.py +323 -0
  65. signwell_sdk/models/document_template_response_copied_placeholders_inner.py +131 -0
  66. signwell_sdk/models/document_template_response_placeholders_inner.py +144 -0
  67. signwell_sdk/models/document_template_update_request.py +208 -0
  68. signwell_sdk/models/dropdown_option.py +153 -0
  69. signwell_sdk/models/error_response.py +107 -0
  70. signwell_sdk/models/error_response_meta.py +101 -0
  71. signwell_sdk/models/field_type.py +46 -0
  72. signwell_sdk/models/fields_inner_inner.py +268 -0
  73. signwell_sdk/models/file_format.py +33 -0
  74. signwell_sdk/models/file_info.py +98 -0
  75. signwell_sdk/models/files_inner.py +103 -0
  76. signwell_sdk/models/label_info.py +98 -0
  77. signwell_sdk/models/label_request.py +97 -0
  78. signwell_sdk/models/label_response.py +98 -0
  79. signwell_sdk/models/me_response.py +130 -0
  80. signwell_sdk/models/me_response_contact.py +147 -0
  81. signwell_sdk/models/me_response_user.py +117 -0
  82. signwell_sdk/models/me_response_user_preferences.py +105 -0
  83. signwell_sdk/models/nom151_certificate_response.py +107 -0
  84. signwell_sdk/models/nom151_certificate_response_nom151.py +125 -0
  85. signwell_sdk/models/nom151_url_response.py +97 -0
  86. signwell_sdk/models/pagination_meta.py +119 -0
  87. signwell_sdk/models/placeholders_inner.py +115 -0
  88. signwell_sdk/models/rate_limit_error_response.py +97 -0
  89. signwell_sdk/models/recipients_inner.py +141 -0
  90. signwell_sdk/models/reminder_recipients_inner.py +100 -0
  91. signwell_sdk/models/send_reminder_request.py +114 -0
  92. signwell_sdk/models/template_attachment_requests_inner.py +110 -0
  93. signwell_sdk/models/template_checkbox_groups_inner.py +136 -0
  94. signwell_sdk/models/template_field_values_inner.py +109 -0
  95. signwell_sdk/models/template_field_values_inner_value.py +189 -0
  96. signwell_sdk/models/template_fields_inner_inner.py +266 -0
  97. signwell_sdk/models/template_recipients_inner.py +146 -0
  98. signwell_sdk/models/text_validation.py +42 -0
  99. signwell_sdk/models/update_document_and_send_request.py +234 -0
  100. signwell_sdk/models/update_recipients_map_inner.py +118 -0
  101. signwell_sdk/models/update_recipients_request.py +111 -0
  102. signwell_sdk/models/validation_error_response.py +99 -0
  103. signwell_sdk/models/webhook_response.py +110 -0
  104. signwell_sdk/py.typed +0 -0
  105. signwell_sdk/rest.py +209 -0
  106. signwell_sdk/webhook.py +214 -0
  107. signwell_sdk_python-0.1.0.dist-info/METADATA +177 -0
  108. signwell_sdk_python-0.1.0.dist-info/RECORD +110 -0
  109. signwell_sdk_python-0.1.0.dist-info/WHEEL +4 -0
  110. signwell_sdk_python-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,99 @@
1
+ """
2
+ SignWell Developer API
3
+
4
+ API for creating, managing, and tracking electronic signature workflows.
5
+
6
+ The version of the OpenAPI document: v1
7
+ Contact: support@signwell.com
8
+ Generated by OpenAPI Generator (https://openapi-generator.tech)
9
+
10
+ Do not edit the class manually.
11
+ """ # noqa: E501
12
+
13
+ from __future__ import annotations
14
+ import pprint
15
+ import re # noqa: F401
16
+ import json
17
+
18
+ from pydantic import BaseModel, ConfigDict, Field
19
+ from typing import Any, ClassVar, Dict, List
20
+ from typing import Any, Optional, Set
21
+ from typing_extensions import Self
22
+
23
+
24
+ class ValidationErrorResponse(BaseModel):
25
+ """
26
+ Validation error response. The errors object contains field-specific error details with dynamic keys.
27
+ """ # noqa: E501
28
+
29
+ errors: Dict[str, Any] = Field(
30
+ description="Field-specific validation errors. Keys are field names (e.g., recipients, fields, files). Values can be strings, arrays of strings, or nested objects with further field-specific errors."
31
+ )
32
+ additional_properties: Dict[str, Any] = {}
33
+ __properties: ClassVar[List[str]] = ["errors"]
34
+
35
+ model_config = ConfigDict(
36
+ populate_by_name=True,
37
+ validate_assignment=True,
38
+ protected_namespaces=(),
39
+ )
40
+
41
+ def to_str(self) -> str:
42
+ """Returns the string representation of the model using alias"""
43
+ return pprint.pformat(self.model_dump(by_alias=True))
44
+
45
+ def to_json(self) -> str:
46
+ """Returns the JSON representation of the model using alias"""
47
+ # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
48
+ return json.dumps(self.to_dict())
49
+
50
+ @classmethod
51
+ def from_json(cls, json_str: str) -> Self | None:
52
+ """Create an instance of ValidationErrorResponse from a JSON string"""
53
+ return cls.from_dict(json.loads(json_str))
54
+
55
+ def to_dict(self) -> Dict[str, Any]:
56
+ """Return the dictionary representation of the model using alias.
57
+
58
+ This has the following differences from calling pydantic's
59
+ `self.model_dump(by_alias=True)`:
60
+
61
+ * `None` is only added to the output dict for nullable fields that
62
+ were set at model initialization. Other fields with value `None`
63
+ are ignored.
64
+ * Fields in `self.additional_properties` are added to the output dict.
65
+ """
66
+ excluded_fields: Set[str] = set(
67
+ [
68
+ "additional_properties",
69
+ ]
70
+ )
71
+
72
+ _dict = self.model_dump(
73
+ by_alias=True,
74
+ exclude=excluded_fields,
75
+ exclude_none=True,
76
+ )
77
+ # puts key-value pairs in additional_properties in the top level
78
+ if self.additional_properties is not None:
79
+ for _key, _value in self.additional_properties.items():
80
+ _dict[_key] = _value
81
+
82
+ return _dict
83
+
84
+ @classmethod
85
+ def from_dict(cls, obj: Dict[str, Any] | None) -> Self | None:
86
+ """Create an instance of ValidationErrorResponse from a dict"""
87
+ if obj is None:
88
+ return None
89
+
90
+ if not isinstance(obj, dict):
91
+ return cls.model_validate(obj)
92
+
93
+ _obj = cls.model_validate({"errors": obj.get("errors")})
94
+ # store additional fields in additional_properties
95
+ for _key in obj.keys():
96
+ if _key not in cls.__properties:
97
+ _obj.additional_properties[_key] = obj.get(_key)
98
+
99
+ return _obj
@@ -0,0 +1,110 @@
1
+ """
2
+ SignWell Developer API
3
+
4
+ API for creating, managing, and tracking electronic signature workflows.
5
+
6
+ The version of the OpenAPI document: v1
7
+ Contact: support@signwell.com
8
+ Generated by OpenAPI Generator (https://openapi-generator.tech)
9
+
10
+ Do not edit the class manually.
11
+ """ # noqa: E501
12
+
13
+ from __future__ import annotations
14
+ import pprint
15
+ import re # noqa: F401
16
+ import json
17
+
18
+ from pydantic import BaseModel, ConfigDict, StrictStr
19
+ from typing import Any, ClassVar, Dict, List, Optional
20
+ from typing import Any, Optional, Set
21
+ from typing_extensions import Self
22
+
23
+
24
+ class WebhookResponse(BaseModel):
25
+ """
26
+ Webhook subscription
27
+ """ # noqa: E501
28
+
29
+ id: StrictStr
30
+ callback_url: StrictStr
31
+ api_application_id: StrictStr | None = None
32
+ additional_properties: Dict[str, Any] = {}
33
+ __properties: ClassVar[List[str]] = ["id", "callback_url", "api_application_id"]
34
+
35
+ model_config = ConfigDict(
36
+ populate_by_name=True,
37
+ validate_assignment=True,
38
+ protected_namespaces=(),
39
+ )
40
+
41
+ def to_str(self) -> str:
42
+ """Returns the string representation of the model using alias"""
43
+ return pprint.pformat(self.model_dump(by_alias=True))
44
+
45
+ def to_json(self) -> str:
46
+ """Returns the JSON representation of the model using alias"""
47
+ # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
48
+ return json.dumps(self.to_dict())
49
+
50
+ @classmethod
51
+ def from_json(cls, json_str: str) -> Self | None:
52
+ """Create an instance of WebhookResponse from a JSON string"""
53
+ return cls.from_dict(json.loads(json_str))
54
+
55
+ def to_dict(self) -> Dict[str, Any]:
56
+ """Return the dictionary representation of the model using alias.
57
+
58
+ This has the following differences from calling pydantic's
59
+ `self.model_dump(by_alias=True)`:
60
+
61
+ * `None` is only added to the output dict for nullable fields that
62
+ were set at model initialization. Other fields with value `None`
63
+ are ignored.
64
+ * Fields in `self.additional_properties` are added to the output dict.
65
+ """
66
+ excluded_fields: Set[str] = set(
67
+ [
68
+ "additional_properties",
69
+ ]
70
+ )
71
+
72
+ _dict = self.model_dump(
73
+ by_alias=True,
74
+ exclude=excluded_fields,
75
+ exclude_none=True,
76
+ )
77
+ # puts key-value pairs in additional_properties in the top level
78
+ if self.additional_properties is not None:
79
+ for _key, _value in self.additional_properties.items():
80
+ _dict[_key] = _value
81
+
82
+ # set to None if api_application_id (nullable) is None
83
+ # and model_fields_set contains the field
84
+ if self.api_application_id is None and "api_application_id" in self.model_fields_set:
85
+ _dict["api_application_id"] = None
86
+
87
+ return _dict
88
+
89
+ @classmethod
90
+ def from_dict(cls, obj: Dict[str, Any] | None) -> Self | None:
91
+ """Create an instance of WebhookResponse from a dict"""
92
+ if obj is None:
93
+ return None
94
+
95
+ if not isinstance(obj, dict):
96
+ return cls.model_validate(obj)
97
+
98
+ _obj = cls.model_validate(
99
+ {
100
+ "id": obj.get("id"),
101
+ "callback_url": obj.get("callback_url"),
102
+ "api_application_id": obj.get("api_application_id"),
103
+ }
104
+ )
105
+ # store additional fields in additional_properties
106
+ for _key in obj.keys():
107
+ if _key not in cls.__properties:
108
+ _obj.additional_properties[_key] = obj.get(_key)
109
+
110
+ return _obj
signwell_sdk/py.typed ADDED
File without changes
signwell_sdk/rest.py ADDED
@@ -0,0 +1,209 @@
1
+ """
2
+ SignWell Developer API
3
+
4
+ API for creating, managing, and tracking electronic signature workflows.
5
+
6
+ The version of the OpenAPI document: v1
7
+ Contact: support@signwell.com
8
+ Generated by OpenAPI Generator (https://openapi-generator.tech)
9
+
10
+ Do not edit the class manually.
11
+ """ # noqa: E501
12
+
13
+ import io
14
+ import json
15
+ import re
16
+ import ssl
17
+
18
+ import urllib3
19
+
20
+ from signwell_sdk.exceptions import ApiConnectionError, ApiException, ApiTimeoutError, ApiValueError
21
+
22
+ SUPPORTED_SOCKS_PROXIES = {"socks5", "socks5h", "socks4", "socks4a"}
23
+ RESTResponseType = urllib3.HTTPResponse
24
+
25
+
26
+ def is_socks_proxy_url(url):
27
+ if url is None:
28
+ return False
29
+ split_section = url.split("://")
30
+ if len(split_section) < 2:
31
+ return False
32
+ else:
33
+ return split_section[0].lower() in SUPPORTED_SOCKS_PROXIES
34
+
35
+
36
+ class RESTResponse(io.IOBase):
37
+ def __init__(self, resp) -> None:
38
+ self.response = resp
39
+ self.status = resp.status
40
+ self.reason = resp.reason
41
+ self.data = None
42
+
43
+ def read(self):
44
+ if self.data is None:
45
+ self.data = self.response.data
46
+ return self.data
47
+
48
+ def getheaders(self):
49
+ """Returns a dictionary of the response headers."""
50
+ return self.response.headers
51
+
52
+ def getheader(self, name, default=None):
53
+ """Returns a given response header."""
54
+ return self.response.headers.get(name, default)
55
+
56
+
57
+ class RESTClientObject:
58
+ def __init__(self, configuration) -> None:
59
+ # urllib3.PoolManager will pass all kw parameters to connectionpool
60
+ # https://github.com/shazow/urllib3/blob/f9409436f83aeb79fbaf090181cd81b784f1b8ce/urllib3/poolmanager.py#L75 # noqa: E501
61
+ # https://github.com/shazow/urllib3/blob/f9409436f83aeb79fbaf090181cd81b784f1b8ce/urllib3/connectionpool.py#L680 # noqa: E501
62
+ # Custom SSL certificates and client certificates: http://urllib3.readthedocs.io/en/latest/advanced-usage.html # noqa: E501
63
+
64
+ # cert_reqs
65
+ if configuration.verify_ssl:
66
+ cert_reqs = ssl.CERT_REQUIRED
67
+ else:
68
+ cert_reqs = ssl.CERT_NONE
69
+
70
+ pool_args = {
71
+ "cert_reqs": cert_reqs,
72
+ "ca_certs": configuration.ssl_ca_cert,
73
+ "cert_file": configuration.cert_file,
74
+ "key_file": configuration.key_file,
75
+ "ca_cert_data": configuration.ca_cert_data,
76
+ }
77
+ if configuration.assert_hostname is not None:
78
+ pool_args["assert_hostname"] = configuration.assert_hostname
79
+
80
+ if configuration.retries is not None:
81
+ pool_args["retries"] = configuration.retries
82
+
83
+ if configuration.tls_server_name:
84
+ pool_args["server_hostname"] = configuration.tls_server_name
85
+
86
+ if configuration.socket_options is not None:
87
+ pool_args["socket_options"] = configuration.socket_options
88
+
89
+ if configuration.connection_pool_maxsize is not None:
90
+ pool_args["maxsize"] = configuration.connection_pool_maxsize
91
+
92
+ # https pool manager
93
+ self.pool_manager: urllib3.PoolManager
94
+
95
+ if configuration.proxy:
96
+ if is_socks_proxy_url(configuration.proxy):
97
+ from urllib3.contrib.socks import SOCKSProxyManager
98
+
99
+ pool_args["proxy_url"] = configuration.proxy
100
+ pool_args["headers"] = configuration.proxy_headers
101
+ self.pool_manager = SOCKSProxyManager(**pool_args)
102
+ else:
103
+ pool_args["proxy_url"] = configuration.proxy
104
+ pool_args["proxy_headers"] = configuration.proxy_headers
105
+ self.pool_manager = urllib3.ProxyManager(**pool_args)
106
+ else:
107
+ self.pool_manager = urllib3.PoolManager(**pool_args)
108
+
109
+ def request(self, method, url, headers=None, body=None, post_params=None, _request_timeout=None):
110
+ """Perform requests.
111
+
112
+ :param method: http request method
113
+ :param url: http request url
114
+ :param headers: http request headers
115
+ :param body: request json body, for `application/json`
116
+ :param post_params: request post parameters,
117
+ `application/x-www-form-urlencoded`
118
+ and `multipart/form-data`
119
+ :param _request_timeout: timeout setting for this request. If one
120
+ number provided, it will be total request
121
+ timeout. It can also be a pair (tuple) of
122
+ (connection, read) timeouts.
123
+ """
124
+ method = method.upper()
125
+ if method not in ["GET", "HEAD", "DELETE", "POST", "PUT", "PATCH", "OPTIONS"]:
126
+ raise ApiValueError(f"Unsupported HTTP method: {method}")
127
+
128
+ if post_params and body:
129
+ raise ApiValueError("body parameter cannot be used with post_params parameter.")
130
+
131
+ post_params = post_params or {}
132
+ headers = headers or {}
133
+
134
+ timeout = None
135
+ if _request_timeout:
136
+ if isinstance(_request_timeout, (int, float)):
137
+ timeout = urllib3.Timeout(total=_request_timeout)
138
+ elif isinstance(_request_timeout, tuple) and len(_request_timeout) == 2:
139
+ timeout = urllib3.Timeout(connect=_request_timeout[0], read=_request_timeout[1])
140
+
141
+ try:
142
+ # For `POST`, `PUT`, `PATCH`, `OPTIONS`, `DELETE`
143
+ if method in ["POST", "PUT", "PATCH", "OPTIONS", "DELETE"]:
144
+ # no content type provided or payload is json
145
+ content_type = headers.get("Content-Type")
146
+ if not content_type or re.search("json", content_type, re.IGNORECASE):
147
+ request_body = None
148
+ if body is not None:
149
+ request_body = json.dumps(body)
150
+ r = self.pool_manager.request(
151
+ method, url, body=request_body, timeout=timeout, headers=headers, preload_content=False
152
+ )
153
+ elif content_type == "application/x-www-form-urlencoded":
154
+ r = self.pool_manager.request(
155
+ method,
156
+ url,
157
+ fields=post_params,
158
+ encode_multipart=False,
159
+ timeout=timeout,
160
+ headers=headers,
161
+ preload_content=False,
162
+ )
163
+ elif content_type == "multipart/form-data":
164
+ # must del headers['Content-Type'], or the correct
165
+ # Content-Type which generated by urllib3 will be
166
+ # overwritten.
167
+ del headers["Content-Type"]
168
+ # Ensures that dict objects are serialized
169
+ post_params = [(a, json.dumps(b)) if isinstance(b, dict) else (a, b) for a, b in post_params]
170
+ r = self.pool_manager.request(
171
+ method,
172
+ url,
173
+ fields=post_params,
174
+ encode_multipart=True,
175
+ timeout=timeout,
176
+ headers=headers,
177
+ preload_content=False,
178
+ )
179
+ # Pass a `string` parameter directly in the body to support
180
+ # other content types than JSON when `body` argument is
181
+ # provided in serialized form.
182
+ elif isinstance(body, str) or isinstance(body, bytes):
183
+ r = self.pool_manager.request(
184
+ method, url, body=body, timeout=timeout, headers=headers, preload_content=False
185
+ )
186
+ elif headers["Content-Type"].startswith("text/") and isinstance(body, bool):
187
+ request_body = "true" if body else "false"
188
+ r = self.pool_manager.request(
189
+ method, url, body=request_body, preload_content=False, timeout=timeout, headers=headers
190
+ )
191
+ else:
192
+ # Cannot generate the request from given parameters
193
+ msg = """Cannot prepare a request message for provided
194
+ arguments. Please check that your arguments match
195
+ declared content type."""
196
+ raise ApiException(status=0, reason=msg)
197
+ # For `GET`, `HEAD`
198
+ else:
199
+ r = self.pool_manager.request(
200
+ method, url, fields={}, timeout=timeout, headers=headers, preload_content=False
201
+ )
202
+ except urllib3.exceptions.TimeoutError as e:
203
+ msg = "\n".join([type(e).__name__, str(e)])
204
+ raise ApiTimeoutError(status=0, reason=msg) from e
205
+ except urllib3.exceptions.HTTPError as e:
206
+ msg = "\n".join([type(e).__name__, str(e)])
207
+ raise ApiConnectionError(status=0, reason=msg) from e
208
+
209
+ return RESTResponse(r)
@@ -0,0 +1,214 @@
1
+ # Source: signwell-sdk-generator/extras/python/overlay/signwell_sdk/webhook.py
2
+ # Do not edit the generated SDK copy directly.
3
+
4
+ from __future__ import annotations
5
+
6
+ import hmac
7
+ import time as time_module
8
+ from collections.abc import Callable, Mapping
9
+ from threading import Lock
10
+ from typing import Any, Protocol
11
+
12
+
13
+ class ReplayStore(Protocol):
14
+ def add(self, key: str, expires_at_unix_seconds: float) -> bool:
15
+ """Atomically store key until expires_at_unix_seconds if it does not exist."""
16
+
17
+
18
+ class MemoryReplayStore:
19
+ """Small replay store for local development and single-process apps.
20
+
21
+ When capacity is exhausted by still-live entries, new events fail closed
22
+ instead of evicting active replay keys.
23
+ """
24
+
25
+ def __init__(
26
+ self,
27
+ *,
28
+ max_entries: int = 10_000,
29
+ now: Callable[[], float] | None = None,
30
+ ) -> None:
31
+ if not isinstance(max_entries, int) or max_entries <= 0:
32
+ raise ValueError("max_entries must be a positive integer")
33
+
34
+ self._max_entries = max_entries
35
+ self._now = now or _current_unix_time
36
+ self._entries: dict[str, float] = {}
37
+ self._lock = Lock()
38
+
39
+ def add(self, key: str, expires_at_unix_seconds: float) -> bool:
40
+ with self._lock:
41
+ current_time = self._now()
42
+ expired = [entry_key for entry_key, expires_at in self._entries.items() if expires_at < current_time]
43
+ for entry_key in expired:
44
+ del self._entries[entry_key]
45
+
46
+ if key in self._entries:
47
+ return False
48
+
49
+ if len(self._entries) >= self._max_entries:
50
+ return False
51
+
52
+ self._entries[key] = float(expires_at_unix_seconds)
53
+ return True
54
+
55
+
56
+ def verify_event(
57
+ *,
58
+ event: Mapping[str, Any],
59
+ webhook_id: str,
60
+ tolerance_seconds: float | None = None,
61
+ now: Callable[[], float] | None = None,
62
+ ) -> bool:
63
+ try:
64
+ verify_event_or_raise(
65
+ event=event,
66
+ webhook_id=webhook_id,
67
+ tolerance_seconds=tolerance_seconds,
68
+ now=now,
69
+ )
70
+ except (TypeError, ValueError):
71
+ return False
72
+ return True
73
+
74
+
75
+ def verify_event_once(
76
+ *,
77
+ event: Mapping[str, Any],
78
+ webhook_id: str,
79
+ replay_store: ReplayStore,
80
+ tolerance_seconds: float | None,
81
+ now: Callable[[], float] | None = None,
82
+ ) -> bool:
83
+ try:
84
+ verify_event_once_or_raise(
85
+ event=event,
86
+ webhook_id=webhook_id,
87
+ replay_store=replay_store,
88
+ tolerance_seconds=tolerance_seconds,
89
+ now=now,
90
+ )
91
+ except (TypeError, ValueError):
92
+ return False
93
+ return True
94
+
95
+
96
+ def verify_event_once_or_raise(
97
+ *,
98
+ event: Mapping[str, Any],
99
+ webhook_id: str,
100
+ replay_store: ReplayStore,
101
+ tolerance_seconds: float | None,
102
+ now: Callable[[], float] | None = None,
103
+ ) -> bool:
104
+ if tolerance_seconds is None:
105
+ raise ValueError("tolerance_seconds is required for replay protection")
106
+ if replay_store is None or not callable(getattr(replay_store, "add", None)):
107
+ raise TypeError("replay_store with an atomic add method is required")
108
+
109
+ parsed = _verify_event_data(
110
+ event=event,
111
+ webhook_id=webhook_id,
112
+ tolerance_seconds=tolerance_seconds,
113
+ now=now,
114
+ )
115
+ expires_at = float(parsed["time"]) + float(tolerance_seconds)
116
+ if not replay_store.add(_replay_key_from_parsed(parsed), expires_at):
117
+ raise ValueError("webhook event has already been processed or replay store capacity was exceeded")
118
+ return True
119
+
120
+
121
+ def verify_event_or_raise(
122
+ *,
123
+ event: Mapping[str, Any],
124
+ webhook_id: str,
125
+ tolerance_seconds: float | None = None,
126
+ now: Callable[[], float] | None = None,
127
+ ) -> bool:
128
+ _verify_event_data(
129
+ event=event,
130
+ webhook_id=webhook_id,
131
+ tolerance_seconds=tolerance_seconds,
132
+ now=now,
133
+ )
134
+ return True
135
+
136
+
137
+ def replay_key(event: Mapping[str, Any]) -> str:
138
+ return _replay_key_from_parsed(_parse_event(event))
139
+
140
+
141
+ def _verify_event_data(
142
+ *,
143
+ event: Mapping[str, Any],
144
+ webhook_id: str,
145
+ tolerance_seconds: float | None,
146
+ now: Callable[[], float] | None,
147
+ ) -> dict[str, Any]:
148
+ if not isinstance(webhook_id, str) or not webhook_id:
149
+ raise ValueError("webhook_id must be a non-empty string")
150
+
151
+ parsed = _parse_event(event)
152
+ if tolerance_seconds is not None:
153
+ _verify_fresh_timestamp(parsed["time"], tolerance_seconds, now or _current_unix_time)
154
+
155
+ signed_data = f"{parsed['type']}@{parsed['time']}".encode()
156
+ calculated = hmac.new(webhook_id.encode(), signed_data, "sha256").hexdigest()
157
+ if not hmac.compare_digest(calculated, parsed["hash"]):
158
+ raise ValueError("webhook signature is invalid")
159
+
160
+ return parsed
161
+
162
+
163
+ def _parse_event(event: Mapping[str, Any]) -> dict[str, Any]:
164
+ if not isinstance(event, Mapping):
165
+ raise TypeError("event must be a mapping")
166
+
167
+ event_type = event.get("type")
168
+ event_time = event.get("time")
169
+ event_hash = event.get("hash")
170
+ missing = [
171
+ key
172
+ for key, value in (
173
+ ("type", event_type),
174
+ ("time", event_time),
175
+ ("hash", event_hash),
176
+ )
177
+ if value is None or value == ""
178
+ ]
179
+ if missing:
180
+ raise ValueError(
181
+ "event is missing required keys: "
182
+ + ", ".join(missing)
183
+ + ". Make sure you pass payload['event'], not the full webhook payload"
184
+ )
185
+ if not isinstance(event_hash, str):
186
+ raise TypeError("event.hash must be a string")
187
+
188
+ return {"type": event_type, "time": event_time, "hash": event_hash}
189
+
190
+
191
+ def _verify_fresh_timestamp(
192
+ event_time: Any,
193
+ tolerance_seconds: float,
194
+ now: Callable[[], float],
195
+ ) -> None:
196
+ if not isinstance(tolerance_seconds, int | float) or tolerance_seconds < 0:
197
+ raise ValueError("tolerance_seconds must be a non-negative number")
198
+
199
+ try:
200
+ timestamp = float(event_time)
201
+ current_time = float(now())
202
+ except (TypeError, ValueError) as exc:
203
+ raise ValueError("event.time must be a Unix timestamp when tolerance_seconds is provided") from exc
204
+
205
+ if abs(current_time - timestamp) > float(tolerance_seconds):
206
+ raise ValueError("webhook timestamp is outside the allowed tolerance")
207
+
208
+
209
+ def _replay_key_from_parsed(event: Mapping[str, Any]) -> str:
210
+ return f"signwell:{event['type']}:{event['time']}:{event['hash']}"
211
+
212
+
213
+ def _current_unix_time() -> float:
214
+ return time_module.time()