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.
- signyu-0.1.0/.gitignore +8 -0
- signyu-0.1.0/LICENSE +21 -0
- signyu-0.1.0/PKG-INFO +209 -0
- signyu-0.1.0/README.md +179 -0
- signyu-0.1.0/pyproject.toml +58 -0
- signyu-0.1.0/src/signyu/__init__.py +17 -0
- signyu-0.1.0/src/signyu/_client.py +208 -0
- signyu-0.1.0/src/signyu/_errors.py +30 -0
- signyu-0.1.0/src/signyu/_version.py +2 -0
- signyu-0.1.0/src/signyu/py.typed +0 -0
- signyu-0.1.0/src/signyu/types.py +154 -0
- signyu-0.1.0/src/signyu/webhooks.py +63 -0
- signyu-0.1.0/tests/fixtures/webhook.json +6 -0
- signyu-0.1.0/tests/test_client.py +143 -0
- signyu-0.1.0/tests/test_webhooks.py +50 -0
signyu-0.1.0/.gitignore
ADDED
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)
|
|
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()
|