Metadata-Version: 2.4
Name: formsmarts
Version: 2.1.6
Summary: FormSmarts API & Webhook Client
Author-email: Syronex LLC <api-client@formsmarts.net>
Project-URL: Homepage, https://formsmarts.com/api-webhook-client
Project-URL: Changelog, https://formsmarts.com/api-webhook-client-changelog
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, tag and annotate them, download attachments and PDFs, submit forms programmatically, pre-populate forms, 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 Form 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` | the `Upload` object itself (`None` if empty): call `read()` or `download()` to get the file |
| `signature` | `Signature` | the text of the 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).

Upload fields are replaced with `replace()` (see [Replace an attachment](#replace-an-attachment)); assigning to the `value` of an `Upload` or `Signature` raises `NotImplementedError`. To see who changed a field and when, use `field.revisions()` (see [Edited Fields](#edited-fields)).

---

## 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,
})
```

---

## 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'))

# Or as bytes:
content = upload_field.read()
```

### 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')
```

---

## Edited Fields

Field values can be edited after a form is submitted. `edited_fields` lists the fields whose value differs from the original submission:

```python
entry = FormEntry.fetch(auth, ref_num, return_edited_status=True)
for field in entry.fields:
    marker = ' (edited)' if field.is_edited else ''
    print(f'{field.name}: {field.value}{marker}')
print([f.name for f in entry.edited_fields])
```

`return_edited_status=True` (also accepted by `fetch_batch()`, `search()` and `search_by_dates()`) returns the edited status with the entry; without it, the first access makes an extra API request.

`Field.revisions()` returns the field's revision history: the value submitted (`None` if the field was never edited), and the list of revisions, oldest first:

```python
for field in entry.edited_fields:
    original, revisions = field.revisions(timezone='America/New_York')
    print(f'{field.name}: submitted as {original}')
    for rev in revisions:
        # rev.value is None for upload fields: use rev.upload_id, rev.filename and rev.file_hash
        print(rev.created_at, rev.author, rev.is_from_api, rev.value)
```

Revisions made with the API have *API* as the author. `rev.previous_value` is the value before the revision, and `rev.diffs` the changes as a list of `(operation, text)` tuples (`Revision.DIFF_DELETE`, `DIFF_EQUAL` or `DIFF_INSERT`).

---

## Notes

Notes added with the API are shown with the entry in FormSmarts, with *API* as the author.

```python
from formsmarts import Note

# Read notes, newest first
for note in entry.notes:
    print(note.created_at, note.author, note.text)

# Add a note (up to 400 characters)
note = entry.add_note('Exported to CRM, contact 0035f00000XyZ')

# Edit or delete a note added with the API
note.update('Exported to CRM, contact 0035f00000XyZ (updated)')
note.delete()

# Fetch a note by ID
note = Note.fetch(auth, note_id=1234)
```

- Only notes added with the API can be edited or deleted with the API (`note.can_modify`, `note.can_delete`). Trying to change other notes raises `APIRequestError` with status 403.
- `note.is_from_api` tells notes added with the API from those written by you or your users; `note.author_group` is `'admin'` or `'user'`.
- `note.created_at` and `note.modified_at` are timezone-aware `datetime` objects; `modified_at` is `None` for a note that was never edited.
- `entry.notes` is fetched once and cached; it returns up to the 350 most recent notes.

---

## 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,
)
```

---

## Replying to the Submitter

Reply to the person who submitted the form with a note, shown at the top of an email that includes the form entry:

```python
entry.reply('Your appointment is confirmed for Monday at 10am.')

# All the email addresses entered on the form (up to 3), signing the note with your name:
entry.reply_all('We received your application and will be in touch shortly.', sign_note=True)
```

- Recipients are the email addresses submitted in the form's email fields: `reply()` uses the first one, `reply_all()` up to 3.
- `reply()` returns the address the entry was sent to and `reply_all()` the list of addresses. If the email fields were left empty, nothing is sent and they return `None` and `[]`.
- The note is required: an empty note raises `ValueError`, as does replying to an entry of a form that has no email field.

---

## 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)       # timezone-aware datetime the form was submitted (UTC)
print(entry.amount_due)         # for forms with deferred payment
```

### 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` |
| `APITimeoutError` | The request timed out; a subclass of `APIConnectionError` |
| `APIConnectionError` | The request failed without an HTTP response (DNS failure, connection refused, …) |
| `APIError` | Base class for all client errors |

---

## Links

- [FormSmarts API & Webhook documentation](https://formsmarts.com/api-webhook-client)
- [Changelog](https://formsmarts.com/api-webhook-client-changelog)
- [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.
