helprange 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,10 @@
1
+ .venv*/
2
+ __pycache__/
3
+ *.pyc
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .pytest_cache/
8
+ .mypy_cache/
9
+ .ruff_cache/
10
+ .DS_Store
@@ -0,0 +1,37 @@
1
+ # Changelog
2
+
3
+ All notable changes to this package are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the package uses [Semantic Versioning](https://semver.org/).
5
+
6
+ ## [Unreleased]
7
+
8
+ ## [0.1.0] – unreleased
9
+
10
+ First release, generated from HelpRange API spec version 2026-11-15 (not published yet; this entry describes
11
+ everything that will ship in it).
12
+
13
+ ### Added
14
+
15
+ - Client with every REST API v2 endpoint, grouped by tag: account, folders, documents, links, triggers, data rooms,
16
+ NDAs, e-signature, offers, store, lead forms, activity, webhook endpoints.
17
+ - Data rooms end to end: `data_rooms.create` (with folders and NDA settings), `update`, `delete(confirm=True)`;
18
+ sub-resources `data_rooms.guests` (`list`, `invite`, `remove`), `data_rooms.documents` (`list`, `add`, `remove`,
19
+ `move`), `data_rooms.folders`, `data_rooms.questions`, `data_rooms.request_items`, `data_rooms.access_requests`,
20
+ `data_rooms.signature_requests`; `ndas.list`.
21
+ - `links.list` (every share link of the team, with filters and pagination) and `links.views` (the views of one link).
22
+ - `webhook_endpoints`: `list`, `create`, `get`, `update`, `delete`, `rotate_secret`, `send_test`, `deliveries`,
23
+ `redeliver`.
24
+ - Typed request and response models for every endpoint.
25
+ - `share()` – upload a file and create a share link in one call.
26
+ - Automatic `Idempotency-Key` (UUID v4) on every POST / PATCH, reused across retries; `idempotency_key=` on every
27
+ POST / PATCH method (or `request_options={"idempotency_key": …}`) to set your own.
28
+ - Retries with backoff on 429 (any method, honouring Retry-After), on 408/5xx/connection errors/timeouts (GET and
29
+ keyed POST / PATCH) and on 409 `idempotency_key_in_progress` (up to 5 extra attempts).
30
+ - Typed errors with status, code and body (incl. `IdempotencyKeyInProgressError`, `UnprocessableEntityError`,
31
+ `IdempotencyKeyReusedError`); test-mode (`hr_test_`) detection; rate-limit headers after each response.
32
+ - Webhook signature verification (`HelpRange-Signature`, v1 scheme, rotation-aware) and a signing helper for tests.
33
+ - Sync (`HelpRange`) and async (`AsyncHelpRange`) clients.
34
+ - `helprange` command-line tool: login, whoami, share, documents, `links` (all links, filters `--document`
35
+ `--folder` `--room` `--status` `--label`), `views --link LINK_ID` / `views DOCUMENT_ID`, `rooms create NAME
36
+ [--folder …] [--nda] [--invite EMAIL …]`, `rooms delete ID --yes`, rooms list/show/index/documents/guests/add/
37
+ invite/questions; `--json` output everywhere.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 HelpRange
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,435 @@
1
+ Metadata-Version: 2.5
2
+ Name: helprange
3
+ Version: 0.1.0
4
+ Summary: Official HelpRange API client: upload documents, create tracked share links, run data rooms, e-signature, offers and analytics.
5
+ Project-URL: Homepage, https://www.helprange.com/developers
6
+ Project-URL: Documentation, https://www.helprange.com/developers/api
7
+ Project-URL: Repository, https://github.com/HelpRange/helprange-python
8
+ Project-URL: Issues, https://github.com/HelpRange/helprange-python/issues
9
+ Project-URL: Changelog, https://github.com/HelpRange/helprange-python/blob/main/CHANGELOG.md
10
+ Author-email: HelpRange <hello@helprange.com>
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Keywords: api,data room,document sharing,e-signature,helprange,sdk,virtual data room,webhooks
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Programming Language :: Python :: 3.9
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
25
+ Classifier: Typing :: Typed
26
+ Requires-Python: >=3.9
27
+ Requires-Dist: httpx<1,>=0.23
28
+ Provides-Extra: dev
29
+ Requires-Dist: mypy>=1.8; extra == 'dev'
30
+ Requires-Dist: pytest>=7; extra == 'dev'
31
+ Requires-Dist: ruff>=0.5; extra == 'dev'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # HelpRange Python SDK
35
+
36
+ Official Python client and CLI for the [HelpRange API](https://www.helprange.com/developers): upload documents, create
37
+ tracked share links, see who viewed what (page by page, link by link), create and run data rooms (folders, NDA,
38
+ guests, Q&A, request lists), send documents for e-signature, create offers and manage your webhook endpoint.
39
+
40
+ - Sync (`HelpRange`) and asyncio (`AsyncHelpRange`) clients with the same methods
41
+ - Typed responses (`TypedDict`s generated from the [OpenAPI spec](https://www.helprange.com/developers/openapi.json) –
42
+ plain dicts at runtime, full completion in your editor and mypy/pyright checks)
43
+ - Automatic `Idempotency-Key` on every POST / PATCH, so retries on 429 / 5xx / network errors are safe for writes too
44
+ - Typed exceptions, test-mode detection
45
+ - Webhook signature verification
46
+ - A `helprange` command-line tool with `--json` output for scripts and agents
47
+
48
+ Python 3.9+. One dependency: [httpx](https://www.python-httpx.org/).
49
+
50
+ ## Install
51
+
52
+ ```sh
53
+ pip install helprange
54
+ ```
55
+
56
+ ## Quickstart
57
+
58
+ ```python
59
+ from helprange import HelpRange
60
+
61
+ hr = HelpRange() # reads HELPRANGE_API_KEY; or HelpRange("hr_live_…")
62
+
63
+ # Upload a PDF and create a tracked share link in one call
64
+ result = hr.share("proposal.pdf", label="ACME - Anna", expires_on="2026-12-31", allow_download=False, watermark=True)
65
+ print(result["link"]["url"]) # https://www.helprange.com/view/…/proposal.pdf
66
+
67
+ # Who opened it, when and for how long
68
+ stats = hr.documents.get(result["document"]["id"], recent_views=20)
69
+ for view in stats["recent_views"]:
70
+ print(view["viewer"], view["opened_at"], view["seconds_read"])
71
+ ```
72
+
73
+ Async:
74
+
75
+ ```python
76
+ import asyncio
77
+ from helprange import AsyncHelpRange
78
+
79
+ async def main():
80
+ async with AsyncHelpRange() as hr:
81
+ rooms = await hr.data_rooms.list()
82
+ print([r["name"] for r in rooms["data_rooms"]])
83
+
84
+ asyncio.run(main())
85
+ ```
86
+
87
+ ## Authentication
88
+
89
+ Create a team API key in HelpRange → **Settings → API keys**. It acts as the administrator who created it, limited to
90
+ its **scopes** (`documents:read`, `documents:write`, `links:read`, `links:write`, `rooms:read`, `rooms:write`,
91
+ `analytics:read`, `team:read`). Every method's docstring names the scope it needs.
92
+
93
+ The key is taken from the `api_key` argument, else `HELPRANGE_API_KEY`. The SDK sends `Authorization: Bearer <key>`.
94
+ The REST API is part of the Max and Unlimited plans, the Deal Room Pass and the trial; other plans get
95
+ `PaymentRequiredError` (402).
96
+
97
+ ## Sandbox (test mode)
98
+
99
+ Keys starting with `hr_test_` work on a separate sandbox of your team with sample data – never on live data:
100
+
101
+ - no e-mail is sent (only addresses at reserved test domains such as `example.com` are accepted; others raise
102
+ `BadRequestError` with `code == "sandbox_email_domain"`), nothing reaches Stripe, sandbox activity sends no webhooks;
103
+ - every JSON response carries `"livemode": False`;
104
+ - sandbox data is deleted 30 days after the sandbox was created or reset (Settings → API keys → Reset sandbox).
105
+
106
+ ```python
107
+ hr = HelpRange("hr_test_…")
108
+ hr.test_mode # True
109
+ hr.account.me()["livemode"] # False
110
+ ```
111
+
112
+ ## Resources
113
+
114
+ | Attribute | What |
115
+ | --- | --- |
116
+ | `hr.account` | Who is connected, the plan and its limits |
117
+ | `hr.folders` | Folders and subfolders |
118
+ | `hr.documents` | Uploads, documents, statistics and share links |
119
+ | `hr.links` | Every share link of the team (filters, pagination) and the views of one link |
120
+ | `hr.triggers` | Newest views and uploads (polling) |
121
+ | `hr.data_rooms` | Create, change and delete data rooms; the index. Sub-resources: `.guests`, `.documents`, `.folders`, `.questions`, `.request_items`, `.access_requests`, `.signature_requests` |
122
+ | `hr.ndas` | The team's NDAs (for a data room's `nda_id`) and whether the plan has NDAs |
123
+ | `hr.signature_requests` | E-signature requests and signed PDFs |
124
+ | `hr.offers` | Offers with their own link |
125
+ | `hr.store` | Store sales and buyer access |
126
+ | `hr.lead_forms` | Lead-magnet / landing-page templates |
127
+ | `hr.activity` | The team activity feed |
128
+ | `hr.webhook_endpoints` | The team's webhook endpoint: create, change, delete, rotate the secret, test, delivery log, redeliver |
129
+
130
+ Path parameters are positional, everything else is a keyword argument named as in the API reference:
131
+ `hr.documents.create_link(250, label="ACME", passcode="oak-41")`. Arguments you leave out are not sent. For the few
132
+ fields where `null` means something (e.g. `subfolder_id=None` takes documents out of their subfolder), pass `None`
133
+ explicitly. Every method also takes `request_options={"timeout": …, "max_retries": …, "headers": {…}}`; every
134
+ POST / PATCH method also takes `idempotency_key=` (see [Retries](#retries-and-limits)).
135
+
136
+ Uploads accept a path, `bytes` or a binary file object: `hr.documents.upload(file="deck.pdf", folder_id=31)`, or a
137
+ public URL: `hr.documents.upload(file_url="https://example.com/deck.pdf")`.
138
+
139
+ Types live in `helprange.types` (`from helprange.types import ShareLink, GetDocumentResponse`). For an endpoint this
140
+ version does not wrap yet: `hr.request("GET", "/api/v2/…")`.
141
+
142
+ ### All endpoints
143
+
144
+ <!-- BEGIN GENERATED ENDPOINTS -->
145
+ | Method | Endpoint | Scope |
146
+ | --- | --- | --- |
147
+ | `hr.account.me()` | `GET /api/v2/me` | `team:read` |
148
+ | `hr.account.plan()` | `GET /api/v2/plan` | `team:read` |
149
+ | `hr.folders.list()` | `GET /api/v2/folders` | `documents:read` |
150
+ | `hr.folders.get(folder_id)` | `GET /api/v2/folders/{folder_id}` | `documents:read` |
151
+ | `hr.folders.set_subfolders_enabled(folder_id, ...)` | `POST /api/v2/folders/{folder_id}` | `documents:write` |
152
+ | `hr.folders.list_subfolders(folder_id)` | `GET /api/v2/folders/{folder_id}/subfolders` | `documents:read` |
153
+ | `hr.folders.create_subfolder(folder_id, ...)` | `POST /api/v2/folders/{folder_id}/subfolders` | `documents:write` |
154
+ | `hr.folders.move_documents(folder_id, ...)` | `POST /api/v2/folders/{folder_id}/subfolders/move` | `documents:write` |
155
+ | `hr.folders.rename_subfolder(subfolder_id, ...)` | `POST /api/v2/subfolders/{subfolder_id}` | `documents:write` |
156
+ | `hr.folders.delete_subfolder(subfolder_id)` | `POST /api/v2/subfolders/{subfolder_id}/delete` | `documents:write` |
157
+ | `hr.documents.list(...)` | `GET /api/v2/documents` | `documents:read` |
158
+ | `hr.documents.upload(...)` | `POST /api/v2/files` | `documents:write` |
159
+ | `hr.documents.create_link(file_id, ...)` | `POST /api/v2/files/{file_id}/links` | `links:write` |
160
+ | `hr.documents.get(file_id, ...)` | `GET /api/v2/documents/{file_id}` | `documents:read` |
161
+ | `hr.documents.list_links(file_id)` | `GET /api/v2/documents/{file_id}/links` | `links:read` |
162
+ | `hr.links.list(...)` | `GET /api/v2/links` | `links:read` |
163
+ | `hr.links.views(link_id, ...)` | `GET /api/v2/links/{link_id}/views` | `analytics:read` |
164
+ | `hr.triggers.recent_views()` | `GET /api/v2/views` | `analytics:read` |
165
+ | `hr.triggers.recent_uploads()` | `GET /api/v2/uploads` | `documents:read` |
166
+ | `hr.store.grant_buyer_access(folder_id, ...)` | `POST /api/v2/folders/{folder_id}/buyers` | `links:write` |
167
+ | `hr.store.revoke_buyer_access(folder_id, ...)` | `POST /api/v2/folders/{folder_id}/buyers/remove` | `links:write` |
168
+ | `hr.store.list_sales(...)` | `GET /api/v2/store/sales` | `analytics:read` |
169
+ | `hr.lead_forms.list_templates()` | `GET /api/v2/landing-templates` | `links:read` |
170
+ | `hr.activity.list(...)` | `GET /api/v2/activity` | `analytics:read` |
171
+ | `hr.signature_requests.list(...)` | `GET /api/v2/signature-requests` | `documents:read` |
172
+ | `hr.signature_requests.create(...)` | `POST /api/v2/signature-requests` | `documents:write` |
173
+ | `hr.signature_requests.get(envelope_id)` | `GET /api/v2/signature-requests/{envelope_id}` | `documents:read` |
174
+ | `hr.signature_requests.remind(envelope_id)` | `POST /api/v2/signature-requests/{envelope_id}/remind` | `documents:write` |
175
+ | `hr.signature_requests.void(envelope_id, ...)` | `POST /api/v2/signature-requests/{envelope_id}/void` | `documents:write` |
176
+ | `hr.signature_requests.get_signed_pdf(envelope_id)` | `GET /api/v2/signature-requests/{envelope_id}/signed-pdf` | `documents:read` |
177
+ | `hr.data_rooms.list()` | `GET /api/v2/data-rooms` | `rooms:read` |
178
+ | `hr.data_rooms.create(...)` | `POST /api/v2/data-rooms` | `rooms:write` |
179
+ | `hr.data_rooms.get(data_room_id)` | `GET /api/v2/data-rooms/{data_room_id}` | `rooms:read` |
180
+ | `hr.data_rooms.update(data_room_id, ...)` | `POST /api/v2/data-rooms/{data_room_id}` | `rooms:write` |
181
+ | `hr.data_rooms.delete(data_room_id, ...)` | `POST /api/v2/data-rooms/{data_room_id}/delete` | `rooms:write` |
182
+ | `hr.ndas.list()` | `GET /api/v2/ndas` | `rooms:read` |
183
+ | `hr.data_rooms.get_index(data_room_id)` | `GET /api/v2/data-rooms/{data_room_id}/index` | `rooms:read` |
184
+ | `hr.data_rooms.guests.list(data_room_id)` | `GET /api/v2/data-rooms/{data_room_id}/guests` | `rooms:read` |
185
+ | `hr.data_rooms.guests.invite(data_room_id, ...)` | `POST /api/v2/data-rooms/{data_room_id}/guests` | `rooms:write` |
186
+ | `hr.data_rooms.guests.remove(data_room_id, ...)` | `POST /api/v2/data-rooms/{data_room_id}/guests/remove` | `rooms:write` |
187
+ | `hr.data_rooms.access_requests.list(data_room_id, ...)` | `GET /api/v2/data-rooms/{data_room_id}/access-requests` | `rooms:read` |
188
+ | `hr.data_rooms.signature_requests.list(data_room_id)` | `GET /api/v2/data-rooms/{data_room_id}/signature-requests` | `rooms:read` |
189
+ | `hr.data_rooms.questions.list(data_room_id, ...)` | `GET /api/v2/data-rooms/{data_room_id}/questions` | `rooms:read` |
190
+ | `hr.data_rooms.questions.add(data_room_id, ...)` | `POST /api/v2/data-rooms/{data_room_id}/questions` | `rooms:write` |
191
+ | `hr.data_rooms.request_items.list(data_room_id, ...)` | `GET /api/v2/data-rooms/{data_room_id}/requests` | `rooms:read` |
192
+ | `hr.data_rooms.request_items.add(data_room_id, ...)` | `POST /api/v2/data-rooms/{data_room_id}/requests` | `rooms:write` |
193
+ | `hr.data_rooms.folders.list(data_room_id)` | `GET /api/v2/data-rooms/{data_room_id}/folders` | `rooms:read` |
194
+ | `hr.data_rooms.folders.create(data_room_id, ...)` | `POST /api/v2/data-rooms/{data_room_id}/folders` | `rooms:write` |
195
+ | `hr.data_rooms.documents.move(data_room_id, ...)` | `POST /api/v2/data-rooms/{data_room_id}/folders/move` | `rooms:write` |
196
+ | `hr.data_rooms.folders.rename(data_room_id, folder_id, ...)` | `POST /api/v2/data-rooms/{data_room_id}/folders/{folder_id}` | `rooms:write` |
197
+ | `hr.data_rooms.folders.delete(data_room_id, folder_id)` | `POST /api/v2/data-rooms/{data_room_id}/folders/{folder_id}/delete` | `rooms:write` |
198
+ | `hr.data_rooms.documents.list(data_room_id)` | `GET /api/v2/data-rooms/{data_room_id}/documents` | `rooms:read` |
199
+ | `hr.data_rooms.documents.add(data_room_id, ...)` | `POST /api/v2/data-rooms/{data_room_id}/documents` | `rooms:write` |
200
+ | `hr.data_rooms.documents.remove(data_room_id, ...)` | `POST /api/v2/data-rooms/{data_room_id}/documents/remove` | `rooms:write` |
201
+ | `hr.offers.list(...)` | `GET /api/v2/offers` | `links:read` |
202
+ | `hr.offers.create(...)` | `POST /api/v2/offers` | `links:write` |
203
+ | `hr.offers.get(offer_id)` | `GET /api/v2/offers/{offer_id}` | `links:read` |
204
+ | `hr.offers.update(offer_id, ...)` | `PATCH /api/v2/offers/{offer_id}` | `links:write` |
205
+ | `hr.offers.add_files(offer_id, ...)` | `POST /api/v2/offers/{offer_id}/files` | `links:write` |
206
+ | `hr.offers.remove_files(offer_id, ...)` | `POST /api/v2/offers/{offer_id}/files/remove` | `links:write` |
207
+ | `hr.offers.list_responses(offer_id, ...)` | `GET /api/v2/offers/{offer_id}/responses` | `analytics:read` |
208
+ | `hr.offers.list_all_responses(...)` | `GET /api/v2/offer-responses` | `analytics:read` |
209
+ | `hr.webhook_endpoints.list()` | `GET /api/v2/webhook-endpoints` | `webhooks:manage` |
210
+ | `hr.webhook_endpoints.create(...)` | `POST /api/v2/webhook-endpoints` | `webhooks:manage` |
211
+ | `hr.webhook_endpoints.get(endpoint_id)` | `GET /api/v2/webhook-endpoints/{endpoint_id}` | `webhooks:manage` |
212
+ | `hr.webhook_endpoints.update(endpoint_id, ...)` | `POST /api/v2/webhook-endpoints/{endpoint_id}` | `webhooks:manage` |
213
+ | `hr.webhook_endpoints.delete(endpoint_id)` | `POST /api/v2/webhook-endpoints/{endpoint_id}/delete` | `webhooks:manage` |
214
+ | `hr.webhook_endpoints.rotate_secret(endpoint_id)` | `POST /api/v2/webhook-endpoints/{endpoint_id}/rotate-secret` | `webhooks:manage` |
215
+ | `hr.webhook_endpoints.send_test(endpoint_id)` | `POST /api/v2/webhook-endpoints/{endpoint_id}/test` | `webhooks:manage` |
216
+ | `hr.webhook_endpoints.deliveries(endpoint_id, ...)` | `GET /api/v2/webhook-endpoints/{endpoint_id}/deliveries` | `webhooks:manage` |
217
+ | `hr.webhook_endpoints.redeliver(endpoint_id, delivery_id)` | `POST /api/v2/webhook-endpoints/{endpoint_id}/deliveries/{delivery_id}/redeliver` | `webhooks:manage` |
218
+ <!-- END GENERATED ENDPOINTS -->
219
+
220
+ ## Data rooms
221
+
222
+ ```python
223
+ # NDAs need the Max plan (or the Deal Room Pass / trial)
224
+ nda_available = hr.ndas.list()["nda_available"]
225
+
226
+ room = hr.data_rooms.create(
227
+ name="Project Falcon",
228
+ folders=["1. Corporate", "2. Financials", "3. Legal"],
229
+ settings={"download": False, "watermark": True, "nda": nda_available, "nda_template": "investor", "nda_mode": "click"},
230
+ )
231
+ print(room["links"]["guest_link"]) # the room's shareable link
232
+
233
+ hr.data_rooms.documents.add(room["id"], document_ids=[250, 251], folder_id=room["folders"][1]["id"])
234
+
235
+ guest = hr.data_rooms.guests.invite(room["id"], email="jane@example.com")
236
+ print(guest["link"]) # Jane's own link – also e-mailed to her unless send_invite=False
237
+
238
+ hr.data_rooms.guests.list(room["id"])
239
+ hr.data_rooms.guests.remove(room["id"], email="jane@example.com")
240
+ hr.data_rooms.update(room["id"], settings={"expires_on": "2026-12-31"})
241
+
242
+ # Deleting a room (and its guest access) needs an explicit confirmation
243
+ hr.data_rooms.delete(room["id"], confirm=True)
244
+ ```
245
+
246
+ ## Links and views
247
+
248
+ ```python
249
+ # Every share link of the team – filter by document_id, folder_id, room_id, label, status, created_after/_before
250
+ page = 1
251
+ while page:
252
+ res = hr.links.list(document_id=250, status="active", page=page, limit=200)
253
+ for link in res["links"]:
254
+ print(link["id"], link["label"], link["views"], link["url"])
255
+ page = res["next_page"]
256
+
257
+ # Who opened one link, when, for how long
258
+ for v in hr.links.views(264, limit=100)["views"]:
259
+ print(v["viewer"], v["opened_at"], v["seconds_read"], v["pages_read"])
260
+ ```
261
+
262
+ ## Errors
263
+
264
+ Every exception is a `helprange.HelpRangeError` with `status`, `code` (the API's machine-readable reason, when it
265
+ sends one), `message` and the parsed `body`:
266
+
267
+ | Exception | When |
268
+ | --- | --- |
269
+ | `BadRequestError` | 400 – e.g. a duplicate link label, `sandbox_email_domain` |
270
+ | `AuthenticationError` | 401 – `invalid_key`, `key_revoked`, `key_expired`, `key_owner_removed` |
271
+ | `PaymentRequiredError` | 402 – the plan does not include the API / feature |
272
+ | `PermissionDeniedError` | 403 – `insufficient_scope` (see `.required_scope`) |
273
+ | `NotFoundError` | 404 |
274
+ | `ConflictError` | 409 – e.g. a frozen room, the team already has a webhook endpoint |
275
+ | `IdempotencyKeyInProgressError` | 409 `idempotency_key_in_progress` (a `ConflictError`) – still running after the in-progress retries |
276
+ | `UnprocessableEntityError` | 422 |
277
+ | `IdempotencyKeyReusedError` | 422 `idempotency_key_reused` (an `UnprocessableEntityError`) – the key was used for a different request |
278
+ | `PayloadTooLargeError` | 413 – `FILE_TOO_LARGE`, `REQUEST_TOO_LARGE` |
279
+ | `LockedError` | 423 – e.g. `ROOM_FROZEN` |
280
+ | `RateLimitError` | 429 – `RATE_LIMITED`, `DAILY_LIMIT`, `PAUSED`, `UPLOAD_LIMIT` (see `.retry_after`) |
281
+ | `InternalServerError` | 5xx |
282
+ | `APIConnectionError` / `APITimeoutError` | no HTTP answer / timed out |
283
+ | `WebhookVerificationError` | `verify_webhook()` rejected a delivery |
284
+
285
+ ```python
286
+ from helprange import PermissionDeniedError, RateLimitError
287
+
288
+ try:
289
+ hr.data_rooms.guests.invite(10, email="jane@example.com")
290
+ except PermissionDeniedError as e:
291
+ print("missing scope:", e.required_scope)
292
+ except RateLimitError as e:
293
+ print(f"over a limit ({e.code}); retry in {e.retry_after}s")
294
+ ```
295
+
296
+ ## Retries and limits
297
+
298
+ Every `POST` and `PATCH` carries an `Idempotency-Key` header – a new UUID v4 per call, **the same one for every retry
299
+ of that call**. The API runs a keyed request once and answers a repeat with the stored response (header
300
+ `Idempotent-Replayed: true`), so a retried write never creates a second data room, share link or invitation.
301
+
302
+ - **429** (any method) – the API did not carry out the request. The client waits `Retry-After` and retries, unless the
303
+ wait is longer than `max_retry_after` (default 60 s – e.g. a daily cap or a paused API); then it raises
304
+ `RateLimitError` at once.
305
+ - **408 / 5xx, connection errors and timeouts** – retried for `GET` and for keyed `POST` / `PATCH`, with exponential
306
+ backoff (0.5 s, 1 s, 2 s … up to 8 s, with jitter; a short `Retry-After` is respected).
307
+ - **409 `idempotency_key_in_progress`** – the first attempt is still running on the server (typically after a
308
+ timeout). The client waits with backoff (~1, 2, 4, 8, 8 s) and asks again, up to 5 times, without using up
309
+ `max_retries`; then it raises `IdempotencyKeyInProgressError`.
310
+ - **422 `idempotency_key_reused`** – never retried; raises `IdempotencyKeyReusedError`. It means the key was used in the
311
+ last 24 hours for a different method, path or body.
312
+
313
+ Pass your own key to make a write safe to repeat across processes or restarts (e.g. a job id):
314
+
315
+ ```python
316
+ hr.data_rooms.create(name="Project Falcon", idempotency_key=f"deal-{deal.id}-room")
317
+ # or, on any method: request_options={"idempotency_key": "…"}
318
+ ```
319
+
320
+ Keys are kept for 24 hours, per API key. `hr.share(..., request_options={"idempotency_key": k})` uses `k` for the
321
+ upload and `k:link` for the share link.
322
+
323
+ `max_retries` (default 2) and `timeout` (default 60 s per attempt) can be set on the client or per request.
324
+ After each response `hr.rate_limit` holds `limit`, `remaining`, `reset` and `livemode` from the response headers.
325
+ Team limits: 3,600 requests an hour, 120 a minute, 300 writes an hour, 2,000 share links and 1,000 guests a day.
326
+
327
+ ## Webhooks
328
+
329
+ HelpRange POSTs signed JSON events (`document.opened`, `file_request.uploaded`, `room.access_requested`,
330
+ `nda.accepted`, `document.acknowledged`, `offer.accepted`, `offer.declined`, `offer.contact_requested`) to the endpoint
331
+ you set in Settings → Integrations or through `hr.webhook_endpoints`. Verify each delivery with the **raw** body:
332
+
333
+ ```python
334
+ # Flask
335
+ import os
336
+
337
+ from flask import Flask, request
338
+ from helprange import verify_webhook, WebhookVerificationError
339
+
340
+ app = Flask(__name__)
341
+
342
+ @app.post("/webhooks/helprange")
343
+ def helprange_webhook():
344
+ try:
345
+ event = verify_webhook(request.get_data(), request.headers, os.environ["HELPRANGE_WEBHOOK_SECRET"])
346
+ except WebhookVerificationError:
347
+ return "", 400
348
+ # deduplicate on event["id"] – retries carry the same id
349
+ if event["event"] == "document.opened":
350
+ print(event["data"])
351
+ return "", 200 # answer 2xx within 10 seconds
352
+ ```
353
+
354
+ Django: `verify_webhook(request.body, request.headers, secret)`; FastAPI: `verify_webhook(await request.body(),
355
+ request.headers, secret)`.
356
+
357
+ The scheme: `HelpRange-Signature: t=<unix>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>`. `verify_webhook` accepts
358
+ a timestamp within 5 minutes (`tolerance=` to change it) and any `v1` that matches – during the 24 hours after a secret
359
+ rotation HelpRange sends two. Pass a list of secrets if you rotate on your side. `sign_webhook_payload()` builds a valid
360
+ header for your tests. See the [webhooks guide](https://www.helprange.com/developers/webhooks).
361
+
362
+ Manage the endpoint from code:
363
+
364
+ ```python
365
+ ep = hr.webhook_endpoints.create(url="https://example.com/webhooks/helprange")
366
+ print(ep["secret"]) # shown once – store it
367
+ hr.webhook_endpoints.send_test(ep["id"]) # a test event, delivered right away
368
+ failed = hr.webhook_endpoints.deliveries(ep["id"], status="failed")["deliveries"]
369
+ if failed:
370
+ hr.webhook_endpoints.redeliver(ep["id"], failed[0]["id"])
371
+ secret = hr.webhook_endpoints.rotate_secret(ep["id"])["secret"] # the old secret keeps signing for 24 hours
372
+ hr.webhook_endpoints.update(ep["id"], enabled=True) # turn it back on after 410 Gone / repeated failures
373
+ hr.webhook_endpoints.delete(ep["id"])
374
+ ```
375
+
376
+ ## Command line
377
+
378
+ ```sh
379
+ helprange login # prompts for the key, checks it, saves ~/.config/helprange/config (0600)
380
+ helprange whoami
381
+ helprange share proposal.pdf --to anna@acme.com --to bob@acme.com --expires 2026-12-31 --no-download --watermark
382
+ helprange share https://example.com/deck.pdf # a public URL works too
383
+ helprange documents
384
+ helprange links # every active share link of the team
385
+ helprange links --document 250 --status all # filters: --document --folder --room --status --label (+ --page --limit)
386
+ helprange views --link 264 # who opened link 264, when, how long
387
+ helprange views 250 # a document's totals and latest views
388
+ helprange rooms create "Project Falcon" --folder Corporate --folder Financials --nda \
389
+ --invite jane@example.com --invite max@example.com # prints the guest link and each guest's own link
390
+ helprange rooms list
391
+ helprange rooms show 10 | index 10 | documents 10 | guests 10 | questions 10 --status open
392
+ helprange rooms add 10 250 251 # add documents 250 and 251 to room 10
393
+ helprange rooms invite 10 jane@example.com # HelpRange e-mails the invitation
394
+ helprange rooms delete 10 --yes # without --yes nothing is deleted
395
+ ```
396
+
397
+ - `--json` on any command prints JSON (errors go to stderr as JSON too) – meant for scripts and AI agents.
398
+ - The key: `--api-key`, else `HELPRANGE_API_KEY`, else the saved one. `helprange logout` forgets it.
399
+ - `share --to` creates **one link per recipient**, labelled with their e-mail, so the statistics show who opened what.
400
+ HelpRange does not e-mail links created through the API – send them yourself.
401
+ - Exit codes: 0 ok, 1 API error, 2 usage / configuration error.
402
+
403
+ `python -m helprange …` works the same.
404
+
405
+ ## Examples
406
+
407
+ [`examples/`](examples): [`share_pdf.py`](examples/share_pdf.py) (share a PDF and get a link),
408
+ [`who_viewed.py`](examples/who_viewed.py) (who viewed a document, through which link, time per page),
409
+ [`data_room.py`](examples/data_room.py) (async: create a room with folders and an NDA, add documents, invite guests
410
+ and print their links, index, Q&A),
411
+ [`webhook_endpoints.py`](examples/webhook_endpoints.py) (create / update the endpoint, test event, redeliver, rotate
412
+ the secret),
413
+ [`webhook_server.py`](examples/webhook_server.py) (stdlib webhook receiver).
414
+
415
+ ## Why httpx
416
+
417
+ httpx is the one dependency: it gives the sync and the async client the same request model, real connect/read
418
+ timeouts, connection pooling, correct multipart uploads and `MockTransport` for tests, and is already installed in
419
+ most modern Python stacks (it is pure Python). The stdlib (`urllib`) has no async client, so an async SDK on top of it
420
+ would need threads, and it lacks pooling and multipart.
421
+
422
+ ## Development
423
+
424
+ ```sh
425
+ python3 -m venv .venv && .venv/bin/pip install -e '.[dev]'
426
+ .venv/bin/pytest -q # fake transport, no network
427
+ .venv/bin/ruff check src tests examples && .venv/bin/mypy src examples
428
+ ```
429
+
430
+ `src/helprange/_generated/` is generated from the OpenAPI spec – don't edit it by hand; see the generator in the
431
+ `helprange-sdks` workspace.
432
+
433
+ ## License
434
+
435
+ MIT © HelpRange