signyu 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ node_modules/
2
+ dist/
3
+ *.tgz
4
+ __pycache__/
5
+ *.pyc
6
+ .venv/
7
+ .pytest_cache/
8
+ *.egg-info/
signyu-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 BN Habitat Pvt Ltd (SignYu)
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.
signyu-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,209 @@
1
+ Metadata-Version: 2.5
2
+ Name: signyu
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for the SignYu Aadhaar eSign API
5
+ Project-URL: Homepage, https://signyu.com/api
6
+ Project-URL: Documentation, https://signyu.com/docs
7
+ Project-URL: Bug Tracker, https://signyu.com/contact
8
+ Author-email: SignYu <contact@mail.signyu.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: aadhaar,aadhaar-esign,digital-signature,e-signature,emudhra,esign,india,signyu
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.9
25
+ Requires-Dist: httpx<1,>=0.24
26
+ Requires-Dist: typing-extensions>=4.5; python_version < '3.11'
27
+ Provides-Extra: test
28
+ Requires-Dist: pytest>=7; extra == 'test'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # SignYu Python SDK
32
+
33
+ Official Python SDK for the [SignYu](https://signyu.com) Aadhaar eSign API. Upload a PDF, add signers, and send it for legally valid Aadhaar OTP based eSignature under the IT Act 2000, all from your backend.
34
+
35
+ - Python 3.9 or newer, built on `httpx`
36
+ - Fully typed (`py.typed`, TypedDict responses)
37
+ - Webhook signature verification with constant time comparison
38
+
39
+ API docs: https://signyu.com/docs. API overview and pricing: https://signyu.com/api (signatures from ₹15 each on credit packs of 10 or more).
40
+
41
+ ## Install
42
+
43
+ ```bash
44
+ pip install signyu
45
+ ```
46
+
47
+ ## Quickstart
48
+
49
+ ```python
50
+ from signyu import SignYu
51
+
52
+ client = SignYu(api_key="sk_live_...") # or set SIGNYU_API_KEY
53
+
54
+ # 1. Upload the PDF (a path, bytes, or a binary file object)
55
+ doc = client.documents.create(file="agreement.pdf", name="Service Agreement")
56
+
57
+ # 2. Add signers (they sign in this order, up to 6 per document)
58
+ client.documents.add_signers(doc["documentId"], [
59
+ {"name": "Asha Rao", "phone": "9876543210", "email": "asha@example.com"},
60
+ {"name": "Vikram Nair", "phone": "9812345678", "email": "vikram@example.com"},
61
+ ])
62
+
63
+ # 3. Send: deducts one credit per signer and emails each signer a link
64
+ sent = client.documents.send(doc["documentId"])
65
+ print([s["signUrl"] for s in sent["signers"]])
66
+
67
+ # 4. Check progress (or use webhooks)
68
+ status = client.documents.get(doc["documentId"])
69
+ print(status["status"]) # PENDING, SENT or COMPLETED
70
+ ```
71
+
72
+ Responses are plain dicts with the API's camelCase keys, typed as `TypedDict`s in `signyu.types`.
73
+
74
+ Get your API key from the dashboard under Developers (https://signyu.com/app/developers). Keep it on your server.
75
+
76
+ ## Configuration
77
+
78
+ ```python
79
+ client = SignYu(
80
+ api_key="sk_live_...", # or the SIGNYU_API_KEY environment variable
81
+ base_url="https://signyu.com", # optional
82
+ timeout=60.0, # optional, seconds
83
+ http_client=None, # optional, your own httpx.Client
84
+ )
85
+ ```
86
+
87
+ The client can be used as a context manager (`with SignYu(...) as client:`) or closed with `client.close()`.
88
+
89
+ ## Methods
90
+
91
+ ### `client.documents.create(file, name=None, file_name=None)`
92
+
93
+ `POST /api/v1/documents`. Uploads a PDF (at most 10MB) and creates a document in `PENDING` state. `file` can be a path (`str` or `pathlib.Path`), `bytes`, or a binary file object. It is always sent as `application/pdf`. `name` defaults to the file name.
94
+
95
+ Returns `{"documentId", "name", "status"}`.
96
+
97
+ ### `client.documents.list(limit=None, offset=None)`
98
+
99
+ `GET /api/v1/documents`. Your documents, most recent first. `limit` is 1 to 100 (default 20), `offset` defaults to 0.
100
+
101
+ Returns `{"documents": [{"documentId", "name", "status", "createdAt", "signers": {"total", "signed"}}], "limit", "offset"}`.
102
+
103
+ ### `client.documents.get(document_id)`
104
+
105
+ `GET /api/v1/documents/{documentId}`. Status and per-signer progress. Once `COMPLETED`, `downloadUrl` is a temporary presigned link to the signed PDF and `certificateUrl` points at the certificate endpoint. Each signer's `signUrl` is `None` until the document is sent.
106
+
107
+ ### `client.documents.add_signers(document_id, signers)`
108
+
109
+ `POST /api/v1/documents/{documentId}/signers`. Only while the document is `PENDING`. Each signer needs `name`, `phone` (digits only, at least 10) and `email`. At most 6 signers per document.
110
+
111
+ Optional custom stamp placement (PDF points, origin at the bottom-left of the page, box at least 140 x 110, one box per page):
112
+
113
+ ```python
114
+ client.documents.add_signers(document_id, [{
115
+ "name": "Asha Rao",
116
+ "phone": "9876543210",
117
+ "email": "asha@example.com",
118
+ "advanced": {
119
+ "signaturePlacement": {
120
+ "positions": [{"page": 2, "x": 31, "y": 257, "width": 253, "height": 110}],
121
+ },
122
+ },
123
+ }])
124
+ ```
125
+
126
+ ### `client.documents.send(document_id)`
127
+
128
+ `POST /api/v1/documents/{documentId}/send`. Deducts one credit per signer, marks the document `SENT`, emails each signer a signing link and returns the links. Call it once per document.
129
+
130
+ Returns `{"documentId", "status": "SENT", "creditsRemaining", "signers": [{"signerId", "name", "email", "signingOrder", "signUrl"}]}`.
131
+
132
+ ### `client.documents.get_certificate(document_id)`
133
+
134
+ `GET /api/v1/documents/{documentId}/certificate`. Returns the completion certificate and audit trail PDF as `bytes`. Only available once the document is `COMPLETED`, otherwise it raises `SignYuError` with code `invalid_state` (409).
135
+
136
+ ```python
137
+ with open("certificate.pdf", "wb") as fh:
138
+ fh.write(client.documents.get_certificate(document_id))
139
+ ```
140
+
141
+ ## Webhooks
142
+
143
+ SignYu sends `signer.signed` and `document.completed` events as a JSON `POST`. Each request carries an `X-SignSetu-Signature: sha256=<hex>` header, an HMAC-SHA256 of the raw request body keyed with your endpoint's signing secret (`whsec_...`, shown in the dashboard). Always verify against the raw body bytes.
144
+
145
+ ```python
146
+ import os
147
+ from flask import Flask, request, abort
148
+ import signyu
149
+
150
+ app = Flask(__name__)
151
+
152
+ @app.post("/webhooks/signyu")
153
+ def signyu_webhook():
154
+ try:
155
+ event = signyu.webhooks.construct_event(
156
+ request.get_data(), # raw bytes
157
+ request.headers.get("X-SignSetu-Signature"),
158
+ os.environ["SIGNYU_WEBHOOK_SECRET"],
159
+ )
160
+ except signyu.WebhookSignatureError:
161
+ abort(400)
162
+
163
+ if event["event"] == "document.completed":
164
+ ... # event["certificateUrl"], event["signers"]
165
+ return "", 200
166
+ ```
167
+
168
+ `signyu.webhooks.verify_signature(raw_body, header, secret)` returns a bool if you prefer to handle it yourself. Deliveries can repeat, so make your handler idempotent.
169
+
170
+ ## Errors
171
+
172
+ Every non-2xx response raises `signyu.SignYuError`:
173
+
174
+ ```python
175
+ from signyu import SignYuError
176
+
177
+ try:
178
+ client.documents.send(document_id)
179
+ except SignYuError as err:
180
+ print(err.status) # 402
181
+ print(err.code) # "insufficient_credits"
182
+ print(err.message) # "You need 2 credits to send this document."
183
+ ```
184
+
185
+ | Status | `code` | Meaning |
186
+ | --- | --- | --- |
187
+ | 400 | `invalid_content_type`, `invalid_request`, `invalid_file`, `no_signers`, `signer_limit_reached` | The request is invalid. |
188
+ | 401 | `unauthorized` | Missing or invalid API key. |
189
+ | 402 | `insufficient_credits` | Not enough credits to send. |
190
+ | 403 | `api_access_not_enabled` | The account has no API access subscription. |
191
+ | 404 | `not_found` | The document does not exist or is not yours. |
192
+ | 409 | `invalid_state` | Not allowed in the document's current state. |
193
+ | 413 | `file_too_large` | The PDF is over 10MB. |
194
+ | 429 | `rate_limited` | Too many requests, retry with backoff. |
195
+ | 500 | `internal_error` | Something went wrong on our side. |
196
+ | 0 | `connection_error`, `timeout` | The request never got a response. |
197
+
198
+ Retry `429` and `5xx` with backoff. Do not blindly retry `send`, since a successful send that timed out on your side would charge credits again.
199
+
200
+ ## Links
201
+
202
+ - Docs: https://signyu.com/docs
203
+ - API: https://signyu.com/api
204
+ - OpenAPI spec: https://signyu.com/openapi.yaml
205
+ - Support: contact@mail.signyu.com
206
+
207
+ ## License
208
+
209
+ MIT
signyu-0.1.0/README.md ADDED
@@ -0,0 +1,179 @@
1
+ # SignYu Python SDK
2
+
3
+ Official Python SDK for the [SignYu](https://signyu.com) Aadhaar eSign API. Upload a PDF, add signers, and send it for legally valid Aadhaar OTP based eSignature under the IT Act 2000, all from your backend.
4
+
5
+ - Python 3.9 or newer, built on `httpx`
6
+ - Fully typed (`py.typed`, TypedDict responses)
7
+ - Webhook signature verification with constant time comparison
8
+
9
+ API docs: https://signyu.com/docs. API overview and pricing: https://signyu.com/api (signatures from ₹15 each on credit packs of 10 or more).
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pip install signyu
15
+ ```
16
+
17
+ ## Quickstart
18
+
19
+ ```python
20
+ from signyu import SignYu
21
+
22
+ client = SignYu(api_key="sk_live_...") # or set SIGNYU_API_KEY
23
+
24
+ # 1. Upload the PDF (a path, bytes, or a binary file object)
25
+ doc = client.documents.create(file="agreement.pdf", name="Service Agreement")
26
+
27
+ # 2. Add signers (they sign in this order, up to 6 per document)
28
+ client.documents.add_signers(doc["documentId"], [
29
+ {"name": "Asha Rao", "phone": "9876543210", "email": "asha@example.com"},
30
+ {"name": "Vikram Nair", "phone": "9812345678", "email": "vikram@example.com"},
31
+ ])
32
+
33
+ # 3. Send: deducts one credit per signer and emails each signer a link
34
+ sent = client.documents.send(doc["documentId"])
35
+ print([s["signUrl"] for s in sent["signers"]])
36
+
37
+ # 4. Check progress (or use webhooks)
38
+ status = client.documents.get(doc["documentId"])
39
+ print(status["status"]) # PENDING, SENT or COMPLETED
40
+ ```
41
+
42
+ Responses are plain dicts with the API's camelCase keys, typed as `TypedDict`s in `signyu.types`.
43
+
44
+ Get your API key from the dashboard under Developers (https://signyu.com/app/developers). Keep it on your server.
45
+
46
+ ## Configuration
47
+
48
+ ```python
49
+ client = SignYu(
50
+ api_key="sk_live_...", # or the SIGNYU_API_KEY environment variable
51
+ base_url="https://signyu.com", # optional
52
+ timeout=60.0, # optional, seconds
53
+ http_client=None, # optional, your own httpx.Client
54
+ )
55
+ ```
56
+
57
+ The client can be used as a context manager (`with SignYu(...) as client:`) or closed with `client.close()`.
58
+
59
+ ## Methods
60
+
61
+ ### `client.documents.create(file, name=None, file_name=None)`
62
+
63
+ `POST /api/v1/documents`. Uploads a PDF (at most 10MB) and creates a document in `PENDING` state. `file` can be a path (`str` or `pathlib.Path`), `bytes`, or a binary file object. It is always sent as `application/pdf`. `name` defaults to the file name.
64
+
65
+ Returns `{"documentId", "name", "status"}`.
66
+
67
+ ### `client.documents.list(limit=None, offset=None)`
68
+
69
+ `GET /api/v1/documents`. Your documents, most recent first. `limit` is 1 to 100 (default 20), `offset` defaults to 0.
70
+
71
+ Returns `{"documents": [{"documentId", "name", "status", "createdAt", "signers": {"total", "signed"}}], "limit", "offset"}`.
72
+
73
+ ### `client.documents.get(document_id)`
74
+
75
+ `GET /api/v1/documents/{documentId}`. Status and per-signer progress. Once `COMPLETED`, `downloadUrl` is a temporary presigned link to the signed PDF and `certificateUrl` points at the certificate endpoint. Each signer's `signUrl` is `None` until the document is sent.
76
+
77
+ ### `client.documents.add_signers(document_id, signers)`
78
+
79
+ `POST /api/v1/documents/{documentId}/signers`. Only while the document is `PENDING`. Each signer needs `name`, `phone` (digits only, at least 10) and `email`. At most 6 signers per document.
80
+
81
+ Optional custom stamp placement (PDF points, origin at the bottom-left of the page, box at least 140 x 110, one box per page):
82
+
83
+ ```python
84
+ client.documents.add_signers(document_id, [{
85
+ "name": "Asha Rao",
86
+ "phone": "9876543210",
87
+ "email": "asha@example.com",
88
+ "advanced": {
89
+ "signaturePlacement": {
90
+ "positions": [{"page": 2, "x": 31, "y": 257, "width": 253, "height": 110}],
91
+ },
92
+ },
93
+ }])
94
+ ```
95
+
96
+ ### `client.documents.send(document_id)`
97
+
98
+ `POST /api/v1/documents/{documentId}/send`. Deducts one credit per signer, marks the document `SENT`, emails each signer a signing link and returns the links. Call it once per document.
99
+
100
+ Returns `{"documentId", "status": "SENT", "creditsRemaining", "signers": [{"signerId", "name", "email", "signingOrder", "signUrl"}]}`.
101
+
102
+ ### `client.documents.get_certificate(document_id)`
103
+
104
+ `GET /api/v1/documents/{documentId}/certificate`. Returns the completion certificate and audit trail PDF as `bytes`. Only available once the document is `COMPLETED`, otherwise it raises `SignYuError` with code `invalid_state` (409).
105
+
106
+ ```python
107
+ with open("certificate.pdf", "wb") as fh:
108
+ fh.write(client.documents.get_certificate(document_id))
109
+ ```
110
+
111
+ ## Webhooks
112
+
113
+ SignYu sends `signer.signed` and `document.completed` events as a JSON `POST`. Each request carries an `X-SignSetu-Signature: sha256=<hex>` header, an HMAC-SHA256 of the raw request body keyed with your endpoint's signing secret (`whsec_...`, shown in the dashboard). Always verify against the raw body bytes.
114
+
115
+ ```python
116
+ import os
117
+ from flask import Flask, request, abort
118
+ import signyu
119
+
120
+ app = Flask(__name__)
121
+
122
+ @app.post("/webhooks/signyu")
123
+ def signyu_webhook():
124
+ try:
125
+ event = signyu.webhooks.construct_event(
126
+ request.get_data(), # raw bytes
127
+ request.headers.get("X-SignSetu-Signature"),
128
+ os.environ["SIGNYU_WEBHOOK_SECRET"],
129
+ )
130
+ except signyu.WebhookSignatureError:
131
+ abort(400)
132
+
133
+ if event["event"] == "document.completed":
134
+ ... # event["certificateUrl"], event["signers"]
135
+ return "", 200
136
+ ```
137
+
138
+ `signyu.webhooks.verify_signature(raw_body, header, secret)` returns a bool if you prefer to handle it yourself. Deliveries can repeat, so make your handler idempotent.
139
+
140
+ ## Errors
141
+
142
+ Every non-2xx response raises `signyu.SignYuError`:
143
+
144
+ ```python
145
+ from signyu import SignYuError
146
+
147
+ try:
148
+ client.documents.send(document_id)
149
+ except SignYuError as err:
150
+ print(err.status) # 402
151
+ print(err.code) # "insufficient_credits"
152
+ print(err.message) # "You need 2 credits to send this document."
153
+ ```
154
+
155
+ | Status | `code` | Meaning |
156
+ | --- | --- | --- |
157
+ | 400 | `invalid_content_type`, `invalid_request`, `invalid_file`, `no_signers`, `signer_limit_reached` | The request is invalid. |
158
+ | 401 | `unauthorized` | Missing or invalid API key. |
159
+ | 402 | `insufficient_credits` | Not enough credits to send. |
160
+ | 403 | `api_access_not_enabled` | The account has no API access subscription. |
161
+ | 404 | `not_found` | The document does not exist or is not yours. |
162
+ | 409 | `invalid_state` | Not allowed in the document's current state. |
163
+ | 413 | `file_too_large` | The PDF is over 10MB. |
164
+ | 429 | `rate_limited` | Too many requests, retry with backoff. |
165
+ | 500 | `internal_error` | Something went wrong on our side. |
166
+ | 0 | `connection_error`, `timeout` | The request never got a response. |
167
+
168
+ Retry `429` and `5xx` with backoff. Do not blindly retry `send`, since a successful send that timed out on your side would charge credits again.
169
+
170
+ ## Links
171
+
172
+ - Docs: https://signyu.com/docs
173
+ - API: https://signyu.com/api
174
+ - OpenAPI spec: https://signyu.com/openapi.yaml
175
+ - Support: contact@mail.signyu.com
176
+
177
+ ## License
178
+
179
+ MIT
@@ -0,0 +1,58 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.21"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "signyu"
7
+ version = "0.1.0"
8
+ description = "Official Python SDK for the SignYu Aadhaar eSign API"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.9"
13
+ authors = [{ name = "SignYu", email = "contact@mail.signyu.com" }]
14
+ keywords = [
15
+ "aadhaar",
16
+ "esign",
17
+ "aadhaar-esign",
18
+ "e-signature",
19
+ "digital-signature",
20
+ "india",
21
+ "emudhra",
22
+ "signyu",
23
+ ]
24
+ classifiers = [
25
+ "Development Status :: 4 - Beta",
26
+ "Intended Audience :: Developers",
27
+ "Operating System :: OS Independent",
28
+ "Programming Language :: Python :: 3",
29
+ "Programming Language :: Python :: 3 :: Only",
30
+ "Programming Language :: Python :: 3.9",
31
+ "Programming Language :: Python :: 3.10",
32
+ "Programming Language :: Python :: 3.11",
33
+ "Programming Language :: Python :: 3.12",
34
+ "Programming Language :: Python :: 3.13",
35
+ "Topic :: Software Development :: Libraries :: Python Modules",
36
+ "Typing :: Typed",
37
+ ]
38
+ dependencies = [
39
+ "httpx>=0.24,<1",
40
+ "typing_extensions>=4.5; python_version < '3.11'",
41
+ ]
42
+
43
+ [project.optional-dependencies]
44
+ test = ["pytest>=7"]
45
+
46
+ [project.urls]
47
+ Homepage = "https://signyu.com/api"
48
+ Documentation = "https://signyu.com/docs"
49
+ "Bug Tracker" = "https://signyu.com/contact"
50
+
51
+ [tool.hatch.build.targets.wheel]
52
+ packages = ["src/signyu"]
53
+
54
+ [tool.hatch.build.targets.sdist]
55
+ include = ["src/signyu", "tests", "README.md", "LICENSE", "pyproject.toml"]
56
+
57
+ [tool.pytest.ini_options]
58
+ testpaths = ["tests"]
@@ -0,0 +1,17 @@
1
+ """Official Python SDK for the SignYu Aadhaar eSign API (https://signyu.com/api)."""
2
+
3
+ from . import types, webhooks
4
+ from ._client import DEFAULT_BASE_URL, Documents, SignYu
5
+ from ._errors import SignYuError, WebhookSignatureError
6
+ from ._version import __version__
7
+
8
+ __all__ = [
9
+ "SignYu",
10
+ "Documents",
11
+ "SignYuError",
12
+ "WebhookSignatureError",
13
+ "DEFAULT_BASE_URL",
14
+ "webhooks",
15
+ "types",
16
+ "__version__",
17
+ ]
@@ -0,0 +1,208 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from typing import IO, Any, Dict, List, Mapping, Optional, Sequence, Union, cast
6
+ from urllib.parse import quote
7
+
8
+ import httpx
9
+
10
+ from . import webhooks as _webhooks
11
+ from ._errors import SignYuError
12
+ from ._version import __version__
13
+ from .types import (
14
+ AddSignersResponse,
15
+ CreatedDocument,
16
+ Document,
17
+ DocumentList,
18
+ SendDocumentResponse,
19
+ SignerInput,
20
+ )
21
+
22
+ DEFAULT_BASE_URL = "https://signyu.com"
23
+ DEFAULT_TIMEOUT = 60.0
24
+
25
+ FileInput = Union[str, "os.PathLike[str]", bytes, bytearray, memoryview, IO[bytes]]
26
+
27
+
28
+ def _doc_path(document_id: str, suffix: str = "") -> str:
29
+ return f"/api/v1/documents/{quote(document_id, safe='')}{suffix}"
30
+
31
+
32
+ def _read_file(file: FileInput, file_name: Optional[str]) -> "tuple[str, bytes]":
33
+ if isinstance(file, (bytes, bytearray, memoryview)):
34
+ return file_name or "document.pdf", bytes(file)
35
+ if isinstance(file, (str, os.PathLike)):
36
+ path = os.fspath(file)
37
+ with open(path, "rb") as fh:
38
+ return file_name or os.path.basename(path), fh.read()
39
+ if hasattr(file, "read"):
40
+ data = file.read()
41
+ if not isinstance(data, (bytes, bytearray)):
42
+ raise TypeError("File objects must be opened in binary mode ('rb').")
43
+ default_name = os.path.basename(str(getattr(file, "name", ""))) or "document.pdf"
44
+ return file_name or default_name, bytes(data)
45
+ raise TypeError("file must be a path, bytes, or a binary file object.")
46
+
47
+
48
+ class Documents:
49
+ """The ``/api/v1/documents`` resource."""
50
+
51
+ def __init__(self, client: "SignYu") -> None:
52
+ self._client = client
53
+
54
+ def create(
55
+ self,
56
+ file: FileInput,
57
+ name: Optional[str] = None,
58
+ file_name: Optional[str] = None,
59
+ ) -> CreatedDocument:
60
+ """Upload a PDF (at most 10MB) and create a ``PENDING`` document.
61
+
62
+ ``file`` can be a path, bytes, or a binary file object. ``name``
63
+ defaults to the file name.
64
+ """
65
+ fname, content = _read_file(file, file_name)
66
+ data: Dict[str, str] = {}
67
+ if name is not None:
68
+ data["name"] = name
69
+ return cast(
70
+ CreatedDocument,
71
+ self._client._request(
72
+ "POST",
73
+ "/api/v1/documents",
74
+ files={"file": (fname, content, "application/pdf")},
75
+ data=data or None,
76
+ ),
77
+ )
78
+
79
+ def list(self, limit: Optional[int] = None, offset: Optional[int] = None) -> DocumentList:
80
+ """Your documents, most recent first. ``limit`` 1 to 100 (default 20)."""
81
+ params: Dict[str, int] = {}
82
+ if limit is not None:
83
+ params["limit"] = limit
84
+ if offset is not None:
85
+ params["offset"] = offset
86
+ return cast(DocumentList, self._client._request("GET", "/api/v1/documents", params=params or None))
87
+
88
+ def get(self, document_id: str) -> Document:
89
+ """Document status and per-signer progress."""
90
+ return cast(Document, self._client._request("GET", _doc_path(document_id)))
91
+
92
+ def add_signers(
93
+ self, document_id: str, signers: Sequence[Union[SignerInput, Mapping[str, Any]]]
94
+ ) -> AddSignersResponse:
95
+ """Add signers while the document is ``PENDING`` (max 6 per document)."""
96
+ return cast(
97
+ AddSignersResponse,
98
+ self._client._request(
99
+ "POST", _doc_path(document_id, "/signers"), json={"signers": [dict(s) for s in signers]}
100
+ ),
101
+ )
102
+
103
+ def send(self, document_id: str) -> SendDocumentResponse:
104
+ """Deduct one credit per signer, email signing links and return them.
105
+
106
+ Not idempotent: call it once per document.
107
+ """
108
+ return cast(SendDocumentResponse, self._client._request("POST", _doc_path(document_id, "/send")))
109
+
110
+ def get_certificate(self, document_id: str) -> bytes:
111
+ """Completion certificate and audit trail PDF bytes (only once ``COMPLETED``)."""
112
+ return cast(
113
+ bytes,
114
+ self._client._request("GET", _doc_path(document_id, "/certificate"), binary=True),
115
+ )
116
+
117
+
118
+ class SignYu:
119
+ """SignYu API client.
120
+
121
+ >>> client = SignYu(api_key="sk_live_...")
122
+ >>> client.documents.list()
123
+ """
124
+
125
+ webhooks = _webhooks
126
+
127
+ def __init__(
128
+ self,
129
+ api_key: Optional[str] = None,
130
+ *,
131
+ base_url: str = DEFAULT_BASE_URL,
132
+ timeout: float = DEFAULT_TIMEOUT,
133
+ http_client: Optional[httpx.Client] = None,
134
+ ) -> None:
135
+ api_key = api_key or os.environ.get("SIGNYU_API_KEY")
136
+ if not api_key:
137
+ raise ValueError("SignYu requires an api_key (or the SIGNYU_API_KEY environment variable).")
138
+ self._api_key = api_key
139
+ self.base_url = base_url.rstrip("/")
140
+ self._owns_client = http_client is None
141
+ self._http = http_client or httpx.Client(timeout=timeout)
142
+ self.documents = Documents(self)
143
+
144
+ def close(self) -> None:
145
+ if self._owns_client:
146
+ self._http.close()
147
+
148
+ def __enter__(self) -> "SignYu":
149
+ return self
150
+
151
+ def __exit__(self, *exc: object) -> None:
152
+ self.close()
153
+
154
+ def _request(
155
+ self,
156
+ method: str,
157
+ path: str,
158
+ *,
159
+ params: Optional[Mapping[str, Any]] = None,
160
+ json: Optional[Any] = None,
161
+ data: Optional[Mapping[str, str]] = None,
162
+ files: Optional[Mapping[str, Any]] = None,
163
+ binary: bool = False,
164
+ ) -> Any:
165
+ headers = {
166
+ "Authorization": f"Bearer {self._api_key}",
167
+ "Accept": "application/pdf" if binary else "application/json",
168
+ "User-Agent": f"signyu-python/{__version__}",
169
+ }
170
+ try:
171
+ res = self._http.request(
172
+ method,
173
+ self.base_url + path,
174
+ params=params,
175
+ json=json,
176
+ data=data,
177
+ files=files,
178
+ headers=headers,
179
+ )
180
+ except httpx.TimeoutException as exc:
181
+ raise SignYuError(0, "timeout", f"Request timed out: {exc}") from exc
182
+ except httpx.HTTPError as exc:
183
+ raise SignYuError(0, "connection_error", f"Could not reach the SignYu API: {exc}") from exc
184
+
185
+ if res.status_code >= 400:
186
+ raise _error_from_response(res)
187
+ if binary:
188
+ return res.content
189
+ return res.json()
190
+
191
+
192
+ def _error_from_response(res: httpx.Response) -> SignYuError:
193
+ body: Any = None
194
+ try:
195
+ body = res.json()
196
+ except (json.JSONDecodeError, ValueError):
197
+ body = res.text or None
198
+ code = "rate_limited" if res.status_code == 429 else "http_error"
199
+ message = f"SignYu API request failed with status {res.status_code}."
200
+ if isinstance(body, dict):
201
+ if isinstance(body.get("error"), str):
202
+ code = body["error"]
203
+ if isinstance(body.get("message"), str):
204
+ message = body["message"]
205
+ return SignYuError(res.status_code, code, message, body)
206
+
207
+
208
+ __all__: List[str] = ["SignYu", "Documents", "DEFAULT_BASE_URL"]
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Optional
4
+
5
+
6
+ class SignYuError(Exception):
7
+ """Raised for any non-2xx API response, network failure or timeout.
8
+
9
+ ``code`` is the API's machine readable ``error`` field (for example
10
+ ``insufficient_credits``). For responses without a JSON error body it is
11
+ ``rate_limited`` (429) or ``http_error``. Network failures use
12
+ ``connection_error`` and timeouts ``timeout``, both with ``status`` 0.
13
+ """
14
+
15
+ def __init__(self, status: int, code: str, message: str, body: Optional[Any] = None) -> None:
16
+ super().__init__(message)
17
+ self.status = status
18
+ self.code = code
19
+ self.message = message
20
+ self.body = body
21
+
22
+ def __repr__(self) -> str:
23
+ return f"SignYuError(status={self.status!r}, code={self.code!r}, message={self.message!r})"
24
+
25
+
26
+ class WebhookSignatureError(Exception):
27
+ """Raised by ``signyu.webhooks.construct_event`` when the signature does not match."""
28
+
29
+ def __init__(self, message: str = "Webhook signature verification failed.") -> None:
30
+ super().__init__(message)
@@ -0,0 +1,2 @@
1
+ # Keep in sync with pyproject.toml "version".
2
+ __version__ = "0.1.0"
File without changes
@@ -0,0 +1,154 @@
1
+ """TypedDicts mirroring the SignYu REST API JSON (camelCase keys, as returned).
2
+
3
+ Timestamps are ISO 8601 strings.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import sys
9
+ from typing import List, Optional, Union
10
+
11
+ if sys.version_info >= (3, 11):
12
+ from typing import Literal, NotRequired, TypedDict
13
+ else: # pragma: no cover
14
+ from typing import Literal
15
+
16
+ from typing_extensions import NotRequired, TypedDict
17
+
18
+ # PENDING, SENT, COMPLETED (FAILED and CANCELLED are reserved).
19
+ DocumentStatus = str
20
+
21
+
22
+ class SignaturePosition(TypedDict):
23
+ page: int
24
+ x: float
25
+ y: float
26
+ width: float
27
+ height: float
28
+
29
+
30
+ class SignaturePlacement(TypedDict):
31
+ positions: List[SignaturePosition]
32
+
33
+
34
+ class SignerAdvanced(TypedDict, total=False):
35
+ signaturePlacement: SignaturePlacement
36
+
37
+
38
+ class SignerInput(TypedDict):
39
+ name: str
40
+ phone: str
41
+ email: str
42
+ advanced: NotRequired[SignerAdvanced]
43
+
44
+
45
+ class CreatedDocument(TypedDict):
46
+ documentId: str
47
+ name: str
48
+ status: DocumentStatus
49
+
50
+
51
+ class SignerCounts(TypedDict):
52
+ total: int
53
+ signed: int
54
+
55
+
56
+ class DocumentSummary(TypedDict):
57
+ documentId: str
58
+ name: str
59
+ status: DocumentStatus
60
+ createdAt: str
61
+ signers: SignerCounts
62
+
63
+
64
+ class DocumentList(TypedDict):
65
+ documents: List[DocumentSummary]
66
+ limit: int
67
+ offset: int
68
+
69
+
70
+ class _PlacementEcho(TypedDict):
71
+ signaturePlacement: SignaturePlacement
72
+
73
+
74
+ class DocumentSigner(TypedDict):
75
+ signerId: str
76
+ name: str
77
+ email: Optional[str]
78
+ phone: str
79
+ signingOrder: int
80
+ openedAt: Optional[str]
81
+ signedAt: Optional[str]
82
+ hasSigned: bool
83
+ signUrl: Optional[str]
84
+ advanced: NotRequired[_PlacementEcho]
85
+
86
+
87
+ class Document(TypedDict):
88
+ documentId: str
89
+ name: str
90
+ status: DocumentStatus
91
+ createdAt: str
92
+ updatedAt: str
93
+ completedAt: Optional[str]
94
+ downloadUrl: Optional[str]
95
+ certificateUrl: Optional[str]
96
+ signers: List[DocumentSigner]
97
+
98
+
99
+ class AddedSigner(TypedDict):
100
+ signerId: str
101
+ name: str
102
+ email: Optional[str]
103
+ signingOrder: int
104
+ advanced: NotRequired[_PlacementEcho]
105
+
106
+
107
+ class AddSignersResponse(TypedDict):
108
+ signers: List[AddedSigner]
109
+
110
+
111
+ class SentSigner(TypedDict):
112
+ signerId: str
113
+ name: str
114
+ email: Optional[str]
115
+ signingOrder: int
116
+ signUrl: str
117
+
118
+
119
+ class SendDocumentResponse(TypedDict):
120
+ documentId: str
121
+ status: Literal["SENT"]
122
+ creditsRemaining: int
123
+ signers: List[SentSigner]
124
+
125
+
126
+ class WebhookSigner(TypedDict):
127
+ signerId: str
128
+ name: str
129
+ email: Optional[str]
130
+ signingOrder: int
131
+ signedAt: Optional[str]
132
+ hasSigned: bool
133
+
134
+
135
+ class SignerSignedEvent(TypedDict):
136
+ event: Literal["signer.signed"]
137
+ documentId: str
138
+ status: DocumentStatus
139
+ occurredAt: str
140
+ signer: Optional[WebhookSigner]
141
+ signers: List[WebhookSigner]
142
+
143
+
144
+ class DocumentCompletedEvent(TypedDict):
145
+ event: Literal["document.completed"]
146
+ documentId: str
147
+ status: DocumentStatus
148
+ occurredAt: str
149
+ completedAt: Optional[str]
150
+ certificateUrl: str
151
+ signers: List[WebhookSigner]
152
+
153
+
154
+ WebhookEvent = Union[SignerSignedEvent, DocumentCompletedEvent]
@@ -0,0 +1,63 @@
1
+ """Webhook signature verification.
2
+
3
+ SignYu signs every delivery with HMAC-SHA256 over the raw request body using
4
+ the endpoint's signing secret, and sends it as ``X-SignSetu-Signature:
5
+ sha256=<hex>``. There is no timestamp in the signed payload.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import hmac
12
+ import json
13
+ from typing import Optional, Union, cast
14
+
15
+ from ._errors import WebhookSignatureError
16
+ from .types import WebhookEvent
17
+
18
+ SIGNATURE_HEADER = "X-SignSetu-Signature"
19
+ EVENT_HEADER = "X-SignSetu-Event"
20
+
21
+ Payload = Union[bytes, bytearray, memoryview, str]
22
+
23
+
24
+ def _to_bytes(payload: Payload) -> bytes:
25
+ if isinstance(payload, str):
26
+ return payload.encode("utf-8")
27
+ return bytes(payload)
28
+
29
+
30
+ def compute_signature(payload: Payload, secret: str) -> str:
31
+ """Return the ``sha256=<hex>`` header value SignYu sends for ``payload``."""
32
+ digest = hmac.new(secret.encode("utf-8"), _to_bytes(payload), hashlib.sha256).hexdigest()
33
+ return f"sha256={digest}"
34
+
35
+
36
+ def verify_signature(payload: Payload, signature_header: Optional[str], secret: str) -> bool:
37
+ """Return True if ``signature_header`` is valid for the raw body ``payload``.
38
+
39
+ Pass the raw request body exactly as received. Comparison is constant time.
40
+ """
41
+ if not signature_header or not secret:
42
+ return False
43
+ expected = compute_signature(payload, secret)
44
+ return hmac.compare_digest(expected.encode("utf-8"), signature_header.strip().encode("utf-8"))
45
+
46
+
47
+ def construct_event(payload: Payload, signature_header: Optional[str], secret: str) -> WebhookEvent:
48
+ """Verify the signature and return the parsed event dict.
49
+
50
+ Raises ``WebhookSignatureError`` if the signature is missing or invalid.
51
+ """
52
+ if not verify_signature(payload, signature_header, secret):
53
+ raise WebhookSignatureError()
54
+ return cast(WebhookEvent, json.loads(_to_bytes(payload).decode("utf-8")))
55
+
56
+
57
+ __all__ = [
58
+ "SIGNATURE_HEADER",
59
+ "EVENT_HEADER",
60
+ "compute_signature",
61
+ "verify_signature",
62
+ "construct_event",
63
+ ]
@@ -0,0 +1,6 @@
1
+ {
2
+ "_comment": "Generated by running the exact algorithm in src/lib/webhooks/dispatch.ts (HMAC-SHA256 hex over JSON.stringify({ event, ...payload })). Do not hand-edit.",
3
+ "secret": "whsec_test_9d2Kq7xVbN4mP1sR8tYuW3zA6cE5fH0j",
4
+ "body": "{\"event\":\"document.completed\",\"documentId\":\"b6b1f0e2-1c9a-4a1e-9b1a-2f3d4e5a6b7c\",\"status\":\"COMPLETED\",\"occurredAt\":\"2026-07-17T07:15:00.000Z\",\"completedAt\":\"2026-07-17T07:15:00.000Z\",\"certificateUrl\":\"https://signyu.com/api/v1/documents/b6b1f0e2-1c9a-4a1e-9b1a-2f3d4e5a6b7c/certificate\",\"signers\":[{\"signerId\":\"s_1\",\"name\":\"Asha Rao\",\"email\":\"asha@example.com\",\"signingOrder\":1,\"signedAt\":\"2026-07-17T07:05:00.000Z\",\"hasSigned\":true}]}",
5
+ "header": "sha256=6e87c8c0b0765af4a6531143cc46099ebb5e00a18e4b98ffe18b5f8bf84a9bb5"
6
+ }
@@ -0,0 +1,143 @@
1
+ import io
2
+ import json
3
+
4
+ import httpx
5
+ import pytest
6
+
7
+ from signyu import SignYu, SignYuError
8
+
9
+ # Every request goes through httpx.MockTransport; nothing touches the network.
10
+
11
+
12
+ def make_client(handler, **kwargs):
13
+ calls = []
14
+
15
+ def wrapped(request: httpx.Request) -> httpx.Response:
16
+ calls.append(request)
17
+ return handler(request)
18
+
19
+ http = httpx.Client(transport=httpx.MockTransport(wrapped))
20
+ return SignYu(api_key="sk_live_abc", http_client=http, **kwargs), calls
21
+
22
+
23
+ def test_requires_api_key(monkeypatch):
24
+ monkeypatch.delenv("SIGNYU_API_KEY", raising=False)
25
+ with pytest.raises(ValueError):
26
+ SignYu()
27
+
28
+
29
+ def test_create_sends_multipart_pdf(tmp_path):
30
+ client, calls = make_client(
31
+ lambda r: httpx.Response(201, json={"documentId": "doc_1", "name": "Service Agreement", "status": "PENDING"})
32
+ )
33
+ res = client.documents.create(file=b"%PDF-1.4 test", name="Service Agreement", file_name="agreement.pdf")
34
+ assert res == {"documentId": "doc_1", "name": "Service Agreement", "status": "PENDING"}
35
+
36
+ req = calls[0]
37
+ assert req.method == "POST"
38
+ assert str(req.url) == "https://signyu.com/api/v1/documents"
39
+ assert req.headers["authorization"] == "Bearer sk_live_abc"
40
+ assert req.headers["content-type"].startswith("multipart/form-data; boundary=")
41
+ body = req.content
42
+ assert b'name="file"; filename="agreement.pdf"' in body
43
+ assert b"Content-Type: application/pdf" in body
44
+ assert b"%PDF-1.4 test" in body
45
+ assert b'name="name"' in body and b"Service Agreement" in body
46
+
47
+
48
+ def test_create_accepts_path_and_file_object(tmp_path):
49
+ pdf = tmp_path / "contract.pdf"
50
+ pdf.write_bytes(b"%PDF-path")
51
+ client, calls = make_client(lambda r: httpx.Response(201, json={"documentId": "d", "name": "x", "status": "PENDING"}))
52
+
53
+ client.documents.create(file=str(pdf))
54
+ assert b'filename="contract.pdf"' in calls[0].content
55
+ assert b"%PDF-path" in calls[0].content
56
+ assert b'name="name"' not in calls[0].content
57
+
58
+ client.documents.create(file=pdf)
59
+ assert b'filename="contract.pdf"' in calls[1].content
60
+
61
+ client.documents.create(file=io.BytesIO(b"%PDF-stream"))
62
+ assert b'filename="document.pdf"' in calls[2].content
63
+ assert b"%PDF-stream" in calls[2].content
64
+
65
+
66
+ def test_list_params_and_base_url():
67
+ client, calls = make_client(
68
+ lambda r: httpx.Response(200, json={"documents": [], "limit": 5, "offset": 10}),
69
+ base_url="http://localhost:3000/",
70
+ )
71
+ client.documents.list(limit=5, offset=10)
72
+ assert str(calls[0].url) == "http://localhost:3000/api/v1/documents?limit=5&offset=10"
73
+ client.documents.list()
74
+ assert str(calls[1].url) == "http://localhost:3000/api/v1/documents"
75
+
76
+
77
+ def test_get_add_signers_send_urls_and_bodies():
78
+ client, calls = make_client(lambda r: httpx.Response(200, json={"ok": True}))
79
+
80
+ client.documents.get("doc 1")
81
+ assert calls[0].url.raw_path == b"/api/v1/documents/doc%201"
82
+
83
+ signers = [
84
+ {"name": "Asha Rao", "phone": "9876543210", "email": "asha@example.com"},
85
+ {
86
+ "name": "Vikram Nair",
87
+ "phone": "9812345678",
88
+ "email": "vikram@example.com",
89
+ "advanced": {"signaturePlacement": {"positions": [{"page": 1, "x": 31, "y": 239, "width": 253, "height": 110}]}},
90
+ },
91
+ ]
92
+ client.documents.add_signers("doc_1", signers)
93
+ req = calls[1]
94
+ assert req.method == "POST"
95
+ assert str(req.url) == "https://signyu.com/api/v1/documents/doc_1/signers"
96
+ assert req.headers["content-type"] == "application/json"
97
+ assert json.loads(req.content) == {"signers": signers}
98
+
99
+ client.documents.send("doc_1")
100
+ req = calls[2]
101
+ assert req.method == "POST"
102
+ assert str(req.url) == "https://signyu.com/api/v1/documents/doc_1/send"
103
+ assert req.content == b""
104
+
105
+
106
+ def test_get_certificate_returns_bytes():
107
+ client, calls = make_client(
108
+ lambda r: httpx.Response(200, content=b"%PDF", headers={"Content-Type": "application/pdf"})
109
+ )
110
+ assert client.documents.get_certificate("doc_1") == b"%PDF"
111
+ assert str(calls[0].url) == "https://signyu.com/api/v1/documents/doc_1/certificate"
112
+
113
+
114
+ def test_api_error_shape():
115
+ client, _ = make_client(
116
+ lambda r: httpx.Response(
117
+ 402, json={"error": "insufficient_credits", "message": "You need 2 credits to send this document."}
118
+ )
119
+ )
120
+ with pytest.raises(SignYuError) as exc:
121
+ client.documents.send("doc_1")
122
+ assert exc.value.status == 402
123
+ assert exc.value.code == "insufficient_credits"
124
+ assert exc.value.message == "You need 2 credits to send this document."
125
+
126
+
127
+ def test_non_json_429():
128
+ client, _ = make_client(lambda r: httpx.Response(429, text="Too Many Requests"))
129
+ with pytest.raises(SignYuError) as exc:
130
+ client.documents.list()
131
+ assert exc.value.status == 429
132
+ assert exc.value.code == "rate_limited"
133
+
134
+
135
+ def test_connection_error():
136
+ def boom(request):
137
+ raise httpx.ConnectError("refused", request=request)
138
+
139
+ client, _ = make_client(boom)
140
+ with pytest.raises(SignYuError) as exc:
141
+ client.documents.get("x")
142
+ assert exc.value.status == 0
143
+ assert exc.value.code == "connection_error"
@@ -0,0 +1,50 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ import pytest
5
+
6
+ import signyu
7
+ from signyu import SignYu, WebhookSignatureError
8
+ from signyu.webhooks import SIGNATURE_HEADER, compute_signature, construct_event, verify_signature
9
+
10
+ # Generated with the exact algorithm in src/lib/webhooks/dispatch.ts.
11
+ FIXTURE = json.loads((Path(__file__).parent / "fixtures" / "webhook.json").read_text())
12
+ BODY = FIXTURE["body"]
13
+ SECRET = FIXTURE["secret"]
14
+ HEADER = FIXTURE["header"]
15
+
16
+
17
+ def test_matches_server_fixture():
18
+ assert SIGNATURE_HEADER == "X-SignSetu-Signature"
19
+ assert compute_signature(BODY, SECRET) == HEADER
20
+ assert verify_signature(BODY, HEADER, SECRET)
21
+ assert verify_signature(BODY.encode(), HEADER, SECRET)
22
+ assert signyu.webhooks.verify_signature(BODY, HEADER, SECRET)
23
+
24
+
25
+ @pytest.mark.parametrize(
26
+ "body,header,secret",
27
+ [
28
+ (BODY + " ", HEADER, SECRET),
29
+ (BODY, HEADER, "whsec_wrong"),
30
+ (BODY, "sha256=abc", SECRET),
31
+ (BODY, None, SECRET),
32
+ (BODY, HEADER.replace("sha256=", ""), SECRET),
33
+ ],
34
+ )
35
+ def test_rejects_invalid(body, header, secret):
36
+ assert verify_signature(body, header, secret) is False
37
+
38
+
39
+ def test_construct_event():
40
+ event = construct_event(BODY, HEADER, SECRET)
41
+ assert event["event"] == "document.completed"
42
+ assert event["signers"][0]["signerId"] == "s_1"
43
+ with pytest.raises(WebhookSignatureError):
44
+ construct_event(BODY, "sha256=00", SECRET)
45
+
46
+
47
+ def test_client_exposes_webhooks():
48
+ client = SignYu(api_key="sk_live_test")
49
+ assert client.webhooks.verify_signature(BODY, HEADER, SECRET)
50
+ client.close()