Metadata-Version: 2.4
Name: formsmarts
Version: 2.1.2
Summary: FormSmarts API & Webhook Client
Author-email: Syronex LLC <api-client@formsmarts.net>
Project-URL: Homepage, https://formsmarts.com/api-webhook-client
Project-URL: Bug Tracker, https://formsmarts.com/form-builder-support#contact
Classifier: Programming Language :: Python :: 3
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Requires-Python: >=3.8
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: urllib3>=1.0.0
Requires-Dist: pyjwt>=2.0.0
Dynamic: license-file

# FormSmarts API & Webhook Client

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.

## Requirements

- Python 3.8 or later
- [urllib3](https://urllib3.readthedocs.io/)
- [PyJWT](https://pyjwt.readthedocs.io/)

## Installation

```bash
pip install formsmarts
```

## Authentication

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.

```python
from formsmarts import APIAuthenticator

auth = APIAuthenticator('FSA-999999', 'your-api-key')
```

> **Security:** Never hard-code credentials. Load them from environment variables or a secrets manager.

```python
import os
auth = APIAuthenticator(os.environ['FS_ACCOUNT_ID'], os.environ['FS_API_KEY'])
```

---

## Searching & Retrieving Entries

### Search by date range

Returns a generator of `FormEntry` objects for all submissions to a form between two dates.

```python
from formsmarts import APIAuthenticator, FormEntry

auth = APIAuthenticator('FSA-999999', 'your-api-key')

for entry in FormEntry.search_by_dates(auth, form_id='lqh', start_date='2025-01-01'):
    print(entry.reference_number, entry.submitted_at)
```

### Search by email, phone, or ID

```python
for entry in FormEntry.search(auth, query='jane@example.com'):
    print(entry.reference_number)
```

Filter results by tag, and exclude entries with specific tags:

```python
for entry in FormEntry.search(
    auth,
    query='jane@example.com',
    tags='vip',
    exclude_tags=['archived', 'duplicate'],
):
    print(entry.reference_number)
```

Pass `'latest'` as the query to retrieve the most recent submission:

```python
entry = next(FormEntry.search(auth, query='latest', form_id='lqh'))
```

### Fetch a single entry by Reference Number

```python
entry = FormEntry.fetch(auth, ref_num='3KTM7N9X2QP4R8WVHJ5F6DYC1')
```

### Fetch a batch of entries

```python
ref_nums = ['3KTM7N9X2QP4R8WVHJ5F6DYC1', '7BF2HJ4KN9M3PT6X8DQWYRC5L', '5LR8QV1WN7K4BM2XHTF9GJ3CP']
for entry in FormEntry.fetch_batch(auth, form_id='lqh', reference_numbers=ref_nums):
    print(entry.reference_number)
```

---

## Working with Fields

### Iterate over all fields

```python
for field in entry.fields:
    print(field.name, field.type, field.value)
```

### Look up fields by name, type, or ID

```python
# By name (returns a list, as names may not be unique)
email = entry.fields_by_name('Email')[0].value

# By datatype
uploads = entry.fields_by_type('upload')

# By field ID
field = entry.field_by_id(12345)
```

### Field types and their values

| Type | `Field` subclass | `.value` returns |
|---|---|---|
| `text`, `email`, `phone`, … | `Field` | `str` |
| `boolean` (checkbox) | `Boolean` | `bool` |
| `number` | `Field` | `Decimal` |
| `posint` | `Field` | `int` |
| `date` | `Date` | `datetime.date` |
| `option` (drop-down, radio) | `Option` | `str` |
| `upload` | `Upload` | upload ID (`str`) |
| `signature` | `Signature` | `str` |
| `country` | `Country` | ISO country code (`str`) |
| `csd` (country subdivision) | `CSD` | ISO subdivision code (`str`) |

#### Country and subdivision names

```python
country_field = entry.fields_by_type('country')[0]
print(country_field.country_code)   # 'US'
print(country_field.country_name)   # 'United States'

csd_field = entry.fields_by_type('csd')[0]
print(csd_field.subdivision_code)   # 'NY'
print(csd_field.subdivision_name)   # 'New York'
```

### Edit a field value

Assigning to `field.value` immediately [persists the change](https://formsmarts.com/weblog/collaboration/how-to-edit-submissions-made-on-your-forms) via the API.

```python
entry.field_by_id(12345).value = 'Updated text'
```

> This is different from `field.set_value()`, which only updates the local object (used when building a submission).

---

## Submitting Forms

### Simple submission with a field value dict

Keys are field IDs; values are converted to strings automatically.

```python
ref_num = FormEntry.submit(auth, form_id='lqh', field_values={
    122619: 'Jane Doe',
    122620: 'jane@example.com',
    122621: 'Hello, thanks for your help.',
})
print(f'Submitted: {ref_num}')
```

### Submission using Field objects

Use the `Form` class to look up fields by name, then submit:

```python
from formsmarts import APIAuthenticator, FormEntry, Form

auth = APIAuthenticator('FSA-999999', 'your-api-key')
form = Form(auth, 'lqh')

fields = []
for name, value in {'First Name': 'Jane', 'Last Name': 'Doe'}.items():
    field = form.fields_by_name(name)[0]
    field.set_value(value)
    fields.append(field)

ref_num = FormEntry.submit(auth, 'lqh', fields)
```

### Submission with a file attachment

```python
from formsmarts import Upload

FORM_ID = 'lqh'
UPLOAD_FIELD_ID = 122621

# Step 1: upload the file (accepts a path string or a file-like object)
upload = Upload.upload(
    auth,
    form_id=FORM_ID,
    field_id=UPLOAD_FIELD_ID,
    io_stream='/path/to/report.pdf',
    filename='report.pdf',
)

# Step 2: submit the form, passing the Upload object as the field value
ref_num = FormEntry.submit(auth, FORM_ID, {
    122619: 'Jane Doe',
    122620: 'jane@example.com',
    UPLOAD_FIELD_ID: upload.value,   # the upload ID
})
```

---

## Attachments & PDFs

### Download an attachment

```python
upload_field = entry.fields_by_type('upload')[0]

# To a file path:
upload_field.download(f'/downloads/{upload_field.filename}')

# Or to a file-like object:
upload_field.download(open('copy.pdf', 'wb'))
```

### Replace an attachment

```python
upload_field.replace('/path/to/new-file.pdf', filename='new-file.pdf')
```

### Download an entry as a PDF

```python
FormEntry.download_pdf(auth, ref_num='3KTM7N9X2QP4R8WVHJ5F6DYC1', io_stream='/downloads/entry.pdf')

# With timezone localisation:
FormEntry.download_pdf(auth, ref_num='3KTM7N9X2QP4R8WVHJ5F6DYC1', io_stream='/downloads/entry.pdf', timezone='Europe/London')
```

---

## Tags

```python
# Read tags
print(entry.tags)         # user-defined tags
print(entry.system_tags)  # tags assigned by FormSmarts

# Add and remove tags
entry.add_tag('reviewed')
entry.add_tags(['approved', 'priority'])
entry.remove_tag('reviewed')
```

---

## Sharing an Entry

Send a copy of a form submission by email:

```python
entry.share('manager@example.com')

# Multiple recipients, with an optional note:
entry.share(
    ['alice@example.com', 'bob@example.com'],
    note='Please review this submission.',
    sign_note=True,
)
```

---

## Pre-filling Forms

Generate a signed URL that opens the form with fields pre-populated. Optionally lock fields to prevent the user from changing them.

```python
from formsmarts import APIAuthenticator, PreFilledForm
import os

auth = APIAuthenticator(os.environ['FS_ACCOUNT_ID'], os.environ['FS_API_KEY'])
pf = PreFilledForm(auth, form_id='lqh', prefill_key=os.environ['FS_PREFILL_KEY'])

data = {
    'Client ID': {'value': 789034, 'readonly': True},
    'First Name': {'value': 'Jane', 'readonly': True},
    'Last Name':  {'value': 'Doe',  'readonly': True},
    'Email':      {'value': 'jane@example.com'},
}

for name, opts in data.items():
    fields = pf.fields_by_name(name)
    if fields:
        pf.pre_fill(fields[0], opts['value'], readonly=opts.get('readonly', False))

url = pf.get_url()
print(url)
```

---

## Webhooks

### Verifying a webhook callback

Always verify that incoming requests originate from FormSmarts before processing them.

```python
from formsmarts import WebhookAuthenticator, FormEntry, AuthenticationError
import json, os

wh_auth = WebhookAuthenticator(os.environ['FS_WEBHOOK_KEY'])

def handle_webhook(headers, body):
    try:
        wh_auth.verify_request(headers['Authorization'])
    except AuthenticationError:
        return 403

    entry = FormEntry.create(json.loads(body))

    for field in entry.fields:
        print(field.name, field.value)

    return 200
```

### Accessing webhook-only properties

```python
print(entry.submitted_at)       # datetime the form was submitted
print(entry.amount_due)         # for forms with deferred payment
print(entry.is_validation_hook) # True for validation webhooks
```

### Downloading attachments from a webhook

Webhook entries include presigned URLs (valid for 5 minutes) for direct attachment access, in addition to the standard `download()` method which requires API authentication.

```python
upload_field = entry.fields_by_type('upload')[0]
print(upload_field.presigned_url)  # direct download URL, no auth needed
upload_field.download('/tmp/attachment.pdf')  # uses API auth
```

---

## Concurrency

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`:

```python
from concurrent.futures import ThreadPoolExecutor, as_completed
from formsmarts import FormEntry

entries = list(FormEntry.search_by_dates(auth, form_id='lqh', start_date='2025-01-01'))

with ThreadPoolExecutor(max_workers=5) as executor:
    futures = {
        executor.submit(
            FormEntry.download_pdf, auth, entry.reference_number,
            f'/downloads/{entry.reference_number}.pdf'
        ): entry
        for entry in entries
    }
    for future in as_completed(futures):
        future.result()  # re-raises any exception from the worker
```

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.

---

## Error Handling

```python
from formsmarts import APIRequestError, AuthenticationError

try:
    entry = FormEntry.fetch(auth, ref_num='INVALID')
except AuthenticationError as e:
    print('Auth failed:', e)
except APIRequestError as e:
    print(f'API error {e.status}: {e.text}')
```

| Exception | When raised |
|---|---|
| `AuthenticationError` | Invalid credentials or expired/malformed JWT |
| `APIRequestError` | Non-200 HTTP response from the API; exposes `.status`, `.text`, `.json` |
| `APIError` | Base class for all client errors |

---

## Links

- [FormSmarts API & Webhook documentation](https://formsmarts.com/api-webhook-client)
- [Form Submission API](https://formsmarts.com/form-submission-api)
- [Form Response API](https://formsmarts.com/form-response-api)
- [Webhooks](https://formsmarts.com/online-form-webhook)
- [Pre-populate a form](https://formsmarts.com/pre-populate-a-form)
- [Account settings](https://formsmarts.com/account/view)

## License

Copyright © Syronex LLC. All rights reserved.
