formsmarts 2.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.
- formsmarts-2.1.0/LICENSE +21 -0
- formsmarts-2.1.0/PKG-INFO +427 -0
- formsmarts-2.1.0/README.md +410 -0
- formsmarts-2.1.0/pyproject.toml +26 -0
- formsmarts-2.1.0/setup.cfg +4 -0
- formsmarts-2.1.0/src/formsmarts/__init__.py +1920 -0
- formsmarts-2.1.0/src/formsmarts.egg-info/PKG-INFO +427 -0
- formsmarts-2.1.0/src/formsmarts.egg-info/SOURCES.txt +9 -0
- formsmarts-2.1.0/src/formsmarts.egg-info/dependency_links.txt +1 -0
- formsmarts-2.1.0/src/formsmarts.egg-info/requires.txt +2 -0
- formsmarts-2.1.0/src/formsmarts.egg-info/top_level.txt +1 -0
formsmarts-2.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Syronex LLC
|
|
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,427 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: formsmarts
|
|
3
|
+
Version: 2.1.0
|
|
4
|
+
Summary: FormSmarts API & Webhook Client
|
|
5
|
+
Author-email: Syronex LLC <api-client@formsmarts.net>
|
|
6
|
+
Project-URL: Homepage, https://formsmarts.com/api-webhook-client
|
|
7
|
+
Project-URL: Bug Tracker, https://formsmarts.com/form-builder-support#contact
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Requires-Python: >=3.8
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Requires-Dist: urllib3>=1.0.0
|
|
15
|
+
Requires-Dist: pyjwt>=2.0.0
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# FormSmarts API & Webhook Client
|
|
19
|
+
|
|
20
|
+
A Python client for the [FormSmarts](https://formsmarts.com) API and webhook system. Automate form workflows: search and edit submissions, download attachments and PDFs, submit forms programmatically, pre-populate form URLs, and verify incoming webhook callbacks.
|
|
21
|
+
|
|
22
|
+
## Requirements
|
|
23
|
+
|
|
24
|
+
- Python 3.8 or later
|
|
25
|
+
- [urllib3](https://urllib3.readthedocs.io/)
|
|
26
|
+
- [PyJWT](https://pyjwt.readthedocs.io/)
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install formsmarts
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Authentication
|
|
35
|
+
|
|
36
|
+
All API operations require an `APIAuthenticator` object, initialised with your FormSmarts **Account ID** and **API Key**. You can find both in the [Security Settings](https://formsmarts.com/account/view#security-settings) of your account.
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from formsmarts import APIAuthenticator
|
|
40
|
+
|
|
41
|
+
auth = APIAuthenticator('FSA-999999', 'your-api-key')
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
> **Security:** Never hard-code credentials. Load them from environment variables or a secrets manager.
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
import os
|
|
48
|
+
auth = APIAuthenticator(os.environ['FS_ACCOUNT_ID'], os.environ['FS_API_KEY'])
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## Searching & Retrieving Entries
|
|
54
|
+
|
|
55
|
+
### Search by date range
|
|
56
|
+
|
|
57
|
+
Returns a generator of `FormEntry` objects for all submissions to a form between two dates.
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from formsmarts import APIAuthenticator, FormEntry
|
|
61
|
+
|
|
62
|
+
auth = APIAuthenticator('FSA-999999', 'your-api-key')
|
|
63
|
+
|
|
64
|
+
for entry in FormEntry.search_by_dates(auth, form_id='lqh', start_date='2025-01-01'):
|
|
65
|
+
print(entry.reference_number, entry.submitted_at)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Search by email, phone, or ID
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
for entry in FormEntry.search(auth, query='jane@example.com'):
|
|
72
|
+
print(entry.reference_number)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Filter results by tag, and exclude entries with specific tags:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
for entry in FormEntry.search(
|
|
79
|
+
auth,
|
|
80
|
+
query='jane@example.com',
|
|
81
|
+
tags='vip',
|
|
82
|
+
exclude_tags=['archived', 'duplicate'],
|
|
83
|
+
):
|
|
84
|
+
print(entry.reference_number)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Pass `'latest'` as the query to retrieve the most recent submission:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
entry = next(FormEntry.search(auth, query='latest', form_id='lqh'))
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Fetch a single entry by Reference Number
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
entry = FormEntry.fetch(auth, ref_num='ABC-123456')
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Fetch a batch of entries
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
ref_nums = ['ABC-123456', 'ABC-123457', 'ABC-123458']
|
|
103
|
+
for entry in FormEntry.fetch_batch(auth, form_id='lqh', reference_numbers=ref_nums):
|
|
104
|
+
print(entry.reference_number)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## Working with Fields
|
|
110
|
+
|
|
111
|
+
### Iterate over all fields
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
for field in entry.fields:
|
|
115
|
+
print(field.name, field.type, field.value)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Look up fields by name, type, or ID
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
# By name (returns a list, as names may not be unique)
|
|
122
|
+
email = entry.fields_by_name('Email')[0].value
|
|
123
|
+
|
|
124
|
+
# By datatype
|
|
125
|
+
uploads = entry.fields_by_type('upload')
|
|
126
|
+
|
|
127
|
+
# By field ID
|
|
128
|
+
field = entry.field_by_id(12345)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Field types and their values
|
|
132
|
+
|
|
133
|
+
| Type | `Field` subclass | `.value` returns |
|
|
134
|
+
|---|---|---|
|
|
135
|
+
| `text`, `email`, `phone`, … | `Field` | `str` |
|
|
136
|
+
| `boolean` (checkbox) | `Boolean` | `bool` |
|
|
137
|
+
| `number` | `Field` | `Decimal` |
|
|
138
|
+
| `posint` | `Field` | `int` |
|
|
139
|
+
| `date` | `Date` | `datetime.date` |
|
|
140
|
+
| `option` (drop-down, radio) | `Option` | `str` |
|
|
141
|
+
| `upload` | `Upload` | upload ID (`str`) |
|
|
142
|
+
| `signature` | `Signature` | `str` |
|
|
143
|
+
| `country` | `Country` | ISO country code (`str`) |
|
|
144
|
+
| `csd` (country subdivision) | `CSD` | ISO subdivision code (`str`) |
|
|
145
|
+
|
|
146
|
+
#### Country and subdivision names
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
country_field = entry.fields_by_type('country')[0]
|
|
150
|
+
print(country_field.country_code) # 'US'
|
|
151
|
+
print(country_field.country_name) # 'United States'
|
|
152
|
+
|
|
153
|
+
csd_field = entry.fields_by_type('csd')[0]
|
|
154
|
+
print(csd_field.subdivision_code) # 'NY'
|
|
155
|
+
print(csd_field.subdivision_name) # 'New York'
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### Edit a field value
|
|
159
|
+
|
|
160
|
+
Assigning to `field.value` immediately persists the change via the API.
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
entry.field_by_id(12345).value = 'Updated text'
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
> This is different from `field.set_value()`, which only updates the local object (used when building a submission).
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## Submitting Forms
|
|
171
|
+
|
|
172
|
+
### Simple submission with a field value dict
|
|
173
|
+
|
|
174
|
+
Keys are field IDs; values are converted to strings automatically.
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
ref_num = FormEntry.submit(auth, form_id='lqh', field_values={
|
|
178
|
+
122619: 'Jane Doe',
|
|
179
|
+
122620: 'jane@example.com',
|
|
180
|
+
122621: 'Hello, thanks for your help.',
|
|
181
|
+
})
|
|
182
|
+
print(f'Submitted: {ref_num}')
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### Submission using Field objects
|
|
186
|
+
|
|
187
|
+
Use the `Form` class to look up fields by name, then submit:
|
|
188
|
+
|
|
189
|
+
```python
|
|
190
|
+
from formsmarts import APIAuthenticator, FormEntry, Form
|
|
191
|
+
|
|
192
|
+
auth = APIAuthenticator('FSA-999999', 'your-api-key')
|
|
193
|
+
form = Form(auth, 'lqh')
|
|
194
|
+
|
|
195
|
+
fields = []
|
|
196
|
+
for name, value in {'First Name': 'Jane', 'Last Name': 'Doe'}.items():
|
|
197
|
+
field = form.fields_by_name(name)[0]
|
|
198
|
+
field.set_value(value)
|
|
199
|
+
fields.append(field)
|
|
200
|
+
|
|
201
|
+
ref_num = FormEntry.submit(auth, 'lqh', fields)
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### Submission with a file attachment
|
|
205
|
+
|
|
206
|
+
```python
|
|
207
|
+
from formsmarts import Upload
|
|
208
|
+
|
|
209
|
+
FORM_ID = 'lqh'
|
|
210
|
+
UPLOAD_FIELD_ID = 122621
|
|
211
|
+
|
|
212
|
+
# Step 1: upload the file (accepts a path string or a file-like object)
|
|
213
|
+
upload = Upload.upload(
|
|
214
|
+
auth,
|
|
215
|
+
form_id=FORM_ID,
|
|
216
|
+
field_id=UPLOAD_FIELD_ID,
|
|
217
|
+
io_stream='/path/to/report.pdf',
|
|
218
|
+
filename='report.pdf',
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
# Step 2: submit the form, passing the Upload object as the field value
|
|
222
|
+
ref_num = FormEntry.submit(auth, FORM_ID, {
|
|
223
|
+
122619: 'Jane Doe',
|
|
224
|
+
122620: 'jane@example.com',
|
|
225
|
+
UPLOAD_FIELD_ID: upload.value, # the upload ID
|
|
226
|
+
})
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
---
|
|
230
|
+
|
|
231
|
+
## Attachments & PDFs
|
|
232
|
+
|
|
233
|
+
### Download an attachment
|
|
234
|
+
|
|
235
|
+
```python
|
|
236
|
+
upload_field = entry.fields_by_type('upload')[0]
|
|
237
|
+
|
|
238
|
+
# To a file path:
|
|
239
|
+
upload_field.download(f'/downloads/{upload_field.filename}')
|
|
240
|
+
|
|
241
|
+
# Or to a file-like object:
|
|
242
|
+
upload_field.download(open('copy.pdf', 'wb'))
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### Replace an attachment
|
|
246
|
+
|
|
247
|
+
```python
|
|
248
|
+
upload_field.replace('/path/to/new-file.pdf', filename='new-file.pdf')
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
### Download an entry as a PDF
|
|
252
|
+
|
|
253
|
+
```python
|
|
254
|
+
FormEntry.download_pdf(auth, ref_num='ABC-123456', io_stream='/downloads/entry.pdf')
|
|
255
|
+
|
|
256
|
+
# With timezone localisation:
|
|
257
|
+
FormEntry.download_pdf(auth, ref_num='ABC-123456', io_stream='/downloads/entry.pdf', timezone='Europe/London')
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
---
|
|
261
|
+
|
|
262
|
+
## Tags
|
|
263
|
+
|
|
264
|
+
```python
|
|
265
|
+
# Read tags
|
|
266
|
+
print(entry.tags) # user-defined tags
|
|
267
|
+
print(entry.system_tags) # tags assigned by FormSmarts
|
|
268
|
+
|
|
269
|
+
# Add and remove tags
|
|
270
|
+
entry.add_tag('reviewed')
|
|
271
|
+
entry.add_tags(['approved', 'priority'])
|
|
272
|
+
entry.remove_tag('reviewed')
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
---
|
|
276
|
+
|
|
277
|
+
## Sharing an Entry
|
|
278
|
+
|
|
279
|
+
Send a copy of a form submission by email:
|
|
280
|
+
|
|
281
|
+
```python
|
|
282
|
+
entry.share('manager@example.com')
|
|
283
|
+
|
|
284
|
+
# Multiple recipients, with an optional note:
|
|
285
|
+
entry.share(
|
|
286
|
+
['alice@example.com', 'bob@example.com'],
|
|
287
|
+
note='Please review this submission.',
|
|
288
|
+
sign_note=True,
|
|
289
|
+
)
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
---
|
|
293
|
+
|
|
294
|
+
## Pre-filling Forms
|
|
295
|
+
|
|
296
|
+
Generate a signed URL that opens the form with fields pre-populated. Optionally lock fields to prevent the user from changing them.
|
|
297
|
+
|
|
298
|
+
```python
|
|
299
|
+
from formsmarts import APIAuthenticator, PreFilledForm
|
|
300
|
+
import os
|
|
301
|
+
|
|
302
|
+
auth = APIAuthenticator(os.environ['FS_ACCOUNT_ID'], os.environ['FS_API_KEY'])
|
|
303
|
+
pf = PreFilledForm(auth, form_id='lqh', prefill_key=os.environ['FS_PREFILL_KEY'])
|
|
304
|
+
|
|
305
|
+
data = {
|
|
306
|
+
'Client ID': {'value': 789034, 'readonly': True},
|
|
307
|
+
'First Name': {'value': 'Jane', 'readonly': True},
|
|
308
|
+
'Last Name': {'value': 'Doe', 'readonly': True},
|
|
309
|
+
'Email': {'value': 'jane@example.com'},
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
for name, opts in data.items():
|
|
313
|
+
fields = pf.fields_by_name(name)
|
|
314
|
+
if fields:
|
|
315
|
+
pf.pre_fill(fields[0], opts['value'], readonly=opts.get('readonly', False))
|
|
316
|
+
|
|
317
|
+
url = pf.get_url()
|
|
318
|
+
print(url)
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
---
|
|
322
|
+
|
|
323
|
+
## Webhooks
|
|
324
|
+
|
|
325
|
+
### Verifying a webhook callback
|
|
326
|
+
|
|
327
|
+
Always verify that incoming requests originate from FormSmarts before processing them.
|
|
328
|
+
|
|
329
|
+
```python
|
|
330
|
+
from formsmarts import WebhookAuthenticator, FormEntry, AuthenticationError
|
|
331
|
+
import json, os
|
|
332
|
+
|
|
333
|
+
wh_auth = WebhookAuthenticator(os.environ['FS_WEBHOOK_KEY'])
|
|
334
|
+
|
|
335
|
+
def handle_webhook(headers, body):
|
|
336
|
+
try:
|
|
337
|
+
wh_auth.verify_request(headers['Authorization'])
|
|
338
|
+
except AuthenticationError:
|
|
339
|
+
return 403
|
|
340
|
+
|
|
341
|
+
entry = FormEntry.create(json.loads(body))
|
|
342
|
+
|
|
343
|
+
for field in entry.fields:
|
|
344
|
+
print(field.name, field.value)
|
|
345
|
+
|
|
346
|
+
return 200
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
### Accessing webhook-only properties
|
|
350
|
+
|
|
351
|
+
```python
|
|
352
|
+
print(entry.submitted_at) # datetime the form was submitted
|
|
353
|
+
print(entry.amount_due) # for forms with deferred payment
|
|
354
|
+
print(entry.is_validation_hook) # True for validation webhooks
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
### Downloading attachments from a webhook
|
|
358
|
+
|
|
359
|
+
Webhook entries include presigned URLs (valid for 5 minutes) for direct attachment access, in addition to the standard `download()` method which requires API authentication.
|
|
360
|
+
|
|
361
|
+
```python
|
|
362
|
+
upload_field = entry.fields_by_type('upload')[0]
|
|
363
|
+
print(upload_field.presigned_url) # direct download URL, no auth needed
|
|
364
|
+
upload_field.download('/tmp/attachment.pdf') # uses API auth
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
---
|
|
368
|
+
|
|
369
|
+
## Concurrency
|
|
370
|
+
|
|
371
|
+
The client is synchronous. For workloads that require parallel API calls — such as fetching entries for a dashboard or processing a large batch — use `concurrent.futures.ThreadPoolExecutor`:
|
|
372
|
+
|
|
373
|
+
```python
|
|
374
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
375
|
+
from formsmarts import FormEntry
|
|
376
|
+
|
|
377
|
+
entries = list(FormEntry.search_by_dates(auth, form_id='lqh', start_date='2025-01-01'))
|
|
378
|
+
|
|
379
|
+
with ThreadPoolExecutor(max_workers=5) as executor:
|
|
380
|
+
futures = {
|
|
381
|
+
executor.submit(
|
|
382
|
+
FormEntry.download_pdf, auth, entry.reference_number,
|
|
383
|
+
f'/downloads/{entry.reference_number}.pdf'
|
|
384
|
+
): entry
|
|
385
|
+
for entry in entries
|
|
386
|
+
}
|
|
387
|
+
for future in as_completed(futures):
|
|
388
|
+
future.result() # re-raises any exception from the worker
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
The client handles HTTP 429 (rate limit) responses automatically with exponential backoff. The default is 3 retries; pass `max_retries` to the underlying request if you need to adjust this.
|
|
392
|
+
|
|
393
|
+
---
|
|
394
|
+
|
|
395
|
+
## Error Handling
|
|
396
|
+
|
|
397
|
+
```python
|
|
398
|
+
from formsmarts import APIRequestError, AuthenticationError
|
|
399
|
+
|
|
400
|
+
try:
|
|
401
|
+
entry = FormEntry.fetch(auth, ref_num='INVALID')
|
|
402
|
+
except AuthenticationError as e:
|
|
403
|
+
print('Auth failed:', e)
|
|
404
|
+
except APIRequestError as e:
|
|
405
|
+
print(f'API error {e.status}: {e.text}')
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
| Exception | When raised |
|
|
409
|
+
|---|---|
|
|
410
|
+
| `AuthenticationError` | Invalid credentials or expired/malformed JWT |
|
|
411
|
+
| `APIRequestError` | Non-200 HTTP response from the API; exposes `.status`, `.text`, `.json` |
|
|
412
|
+
| `APIError` | Base class for all client errors |
|
|
413
|
+
|
|
414
|
+
---
|
|
415
|
+
|
|
416
|
+
## Links
|
|
417
|
+
|
|
418
|
+
- [FormSmarts API & Webhook documentation](https://formsmarts.com/api-webhook-client)
|
|
419
|
+
- [Form Submission API](https://formsmarts.com/form-submission-api)
|
|
420
|
+
- [Form Response API](https://formsmarts.com/form-response-api)
|
|
421
|
+
- [Webhooks](https://formsmarts.com/online-form-webhook)
|
|
422
|
+
- [Pre-populate a form](https://formsmarts.com/pre-populate-a-form)
|
|
423
|
+
- [Account settings](https://formsmarts.com/account/view)
|
|
424
|
+
|
|
425
|
+
## License
|
|
426
|
+
|
|
427
|
+
Copyright © Syronex LLC. All rights reserved.
|