Metadata-Version: 2.5
Name: assinafy
Version: 1.6.0
Summary: Python SDK for Assinafy API - Digital signature platform
Project-URL: Homepage, https://github.com/assinafy/python-sdk
Project-URL: Repository, https://github.com/assinafy/python-sdk
Project-URL: Issues, https://github.com/assinafy/python-sdk/issues
Project-URL: Changelog, https://github.com/assinafy/python-sdk/blob/main/CHANGELOG.md
Project-URL: Documentation, https://github.com/assinafy/python-sdk#readme
Project-URL: API Reference, https://api.assinafy.com.br/v1/docs
Author: Assinafy API Team
License-Expression: MIT
License-File: LICENSE
Keywords: api-client,assinafy,assinafy-api,assinatura-digital,assinatura-eletronica,brazil,digital-signature,electronic-signature,pdf,python,sdk,signature,webhook
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: httpx>=0.27.0
Provides-Extra: dev
Requires-Dist: mypy>=1.10.0; extra == 'dev'
Requires-Dist: pytest-cov>=5.0.0; extra == 'dev'
Requires-Dist: pytest>=8.0.0; extra == 'dev'
Requires-Dist: ruff>=0.4.0; extra == 'dev'
Description-Content-Type: text/markdown

# Assinafy Python SDK

Python SDK for the [Assinafy API](https://api.assinafy.com.br/v1/docs).

The SDK is synchronous, uses `httpx`, and covers all 89 operations currently
published by Assinafy: accounts, users, authentication, documents, signers,
signer documents, assignments, field definitions, templates, tags, and
webhooks. Endpoint docstrings identify the published verb/path and request /
unwrapped-response shape; shared resource shapes are documented once and
referenced by methods that return them.

## Requirements

- Python 3.10+
- `httpx` (installed automatically)

## Installation

```bash
pip install assinafy
```

## Quick Start

```python
import os
from assinafy import AssinafyClient

with AssinafyClient(
    api_key=os.environ["ASSINAFY_API_KEY"],
    account_id=os.environ["ASSINAFY_ACCOUNT_ID"],
    webhook_secret=os.environ.get("ASSINAFY_WEBHOOK_SECRET"),
) as client:
    result = client.upload_and_request_signatures(
        source={"file_path": "./contract.pdf"},
        signers=[
            {"full_name": "John Doe", "email": "john@example.com"},
            {"full_name": "Jane Smith", "email": "jane@example.com"},
        ],
        message="Please sign this contract",
    )

    print(result["document"]["id"])
```

`upload_and_request_signatures` chains three calls (upload, create each signer,
create the assignment) and is not transactional — a failure partway through
does not roll back what already succeeded. It also accepts `wait_timeout` /
`wait_poll_interval` to override the default document-readiness poll.

## Authentication

Prefer `api_key`; it is sent as the documented `X-Api-Key` header. `token` sends `Authorization: Bearer <token>` for legacy/user-token flows.

```python
client = AssinafyClient(api_key="k_xxx", account_id="acc_xxx")
client = AssinafyClient(token="jwt_xxx", account_id="acc_xxx")
```

Unauthenticated clients are allowed for public and signer-access-code endpoints:

```python
public_client = AssinafyClient()
session = public_client.authentication.login("user@example.com", "password")
```

### Configuration

| Parameter | Type | Default | Description |
| --- | --- | --- | --- |
| `api_key` | str | None | Sent as `X-Api-Key`. |
| `token` | str | None | Sent as `Authorization: Bearer <token>`. |
| `account_id` | str | None | Default workspace/account ID for account-scoped methods. |
| `base_url` | str | `https://api.assinafy.com.br/v1` | API base URL. |
| `webhook_secret` | str | None | Secret used by `WebhookVerifier`. |
| `timeout` | float | `30.0` | Request timeout in seconds. |
| `logger` | object | no-op | Object with `debug/info/warning/error` methods. |

## Resources

### Authentication

```python
client.authentication.login("user@example.com", "password")
client.authentication.social_login("google", "provider-token", True)
client.authentication.link_social_login("google", "provider-token")
client.authentication.create_api_key("password")
client.authentication.get_api_key()
client.authentication.delete_api_key()
client.authentication.change_password("user@example.com", "old", "new")
client.authentication.request_password_reset("user@example.com")
client.authentication.reset_password("user@example.com", "new", token="reset-token")
```

### Accounts

```python
accounts = client.accounts.list()
created_account = client.accounts.create("SDK Example Workspace")
created_id = created_account["id"]
created_account = client.accounts.update(
    {"notification_sender_type": "Account"}, created_id
)
created_account = client.accounts.get(created_id)
created_account = client.accounts.update({"name": "SDK Example Updated"}, created_id)
theme = client.accounts.theme(created_id)
stats = client.accounts.stats("monthly", account_id=created_id)
daily_stats = client.accounts.stats("daily", "2026-08", account_id=created_id)
client.accounts.upload_logo({"file_path": "./logo.png"}, created_id)
logo_bytes = client.accounts.download_logo(created_id)
client.accounts.delete_logo(created_id)

# Delete only the disposable workspace created above.
client.accounts.delete(created_id)
```

`delete()` targets the supplied account ID (or the client's default) and makes
`force` keyword-only so a positional ID can never be mistaken for the force
flag. Use `force=True` only when you intentionally want to cancel that
account's active paid subscription as part of deletion. The current sandbox
accepts sender type changes through `update()` but
rejects that optional field during `create()`, despite the published create
schema; the two-step example works on both deployments.

### Current User

```python
user = client.users.me()
stats = client.users.stats("monthly")
preferences = client.users.notification_preferences()
preferences = client.users.update_notification_preferences({
    "DocumentCompleted": True,
    "SignerDeclined": True,
})
```

As of 2026-08-20, the sandbox returns 404 for both published stats routes and
the notification-preferences routes. The SDK exposes their current official
contracts and keeps them unit-tested, but cannot fabricate server-side data.

### Documents

```python
doc = client.documents.upload({"file_path": "./contract.pdf"})
doc = client.documents.upload({"buffer": pdf_bytes, "file_name": "contract.pdf"})

client.documents.statuses()
client.documents.list({"page": 1, "per_page": 20, "tags": "tag-id", "sort": "-updated_at"})
client.documents.search({"search": "nda", "status": "metadata_ready"})  # lightweight, compact
client.documents.get(doc["id"])
client.documents.rename(doc["id"], "Service agreement.pdf")  # before signing starts
client.documents.activities(doc["id"])
client.documents.wait_until_ready(doc["id"])
client.documents.download(doc["id"], "certificated")
client.documents.download(doc["id"], "pades")  # ICP-Brasil certificate artifact
client.documents.thumbnail(doc["id"])
client.documents.download_page(doc["id"], page_id)
client.documents.verify(signature_hash)
client.documents.public_info(doc["id"])
# Choose one form; each call sends a real token.
client.documents.send_token(doc["id"], email="signer@example.com")  # current OpenAPI
# Older deployment alternative: client.documents.send_token(doc["id"], "signer@example.com", "email")
client.documents.list_tags(doc["id"])
client.documents.replace_tags(doc["id"], [tag_id_a, tag_id_b])
client.documents.append_tags(doc["id"], [tag_id_c])
client.documents.detach_tag(doc["id"], tag_id)
client.documents.delete(doc["id"])
```

Uploads follow the documented multipart shape and are locally limited to PDF files up to 25 MB.

### Templates

```python
templates = client.templates.list({"search": "NDA", "per_page": 20})
template = client.templates.get(template_id)

client.documents.create_from_template(
    template_id,
    [{"role_id": "role-id", "id": signer_id, "verification_method": "Email"}],
    {"name": "NDA - John Doe", "message": "Please sign."},
)

client.documents.estimate_cost_from_template(
    template_id,
    [{"role_id": "role-id", "verification_method": "Email"}],
)
```

### Tags

```python
tags = client.tags.list({"search": "contract"})
tag = client.tags.create({"name": "Contracts", "color": "ff8800"})
client.tags.update(tag["id"], {"name": "Sales Contracts"})
client.tags.update(tag["id"], {"color": None})  # clears color
client.tags.delete(tag["id"])
# If the tag is attached, use this instead of the prior line:
# client.tags.delete(tag["id"], force=True)
```

### Signers

```python
signer = client.signers.create({
    "full_name": "John Doe",
    "email": "john@example.com",
})

client.signers.create({
    "full_name": "Jane Doe",
    "whatsapp_phone_number": "+5548999990000",
})

client.signers.get(signer["id"])
client.signers.list({"search": "john", "per_page": 50})
client.signers.update(signer["id"], {"full_name": "Johnny Doe"})
client.signers.find_by_email("john@example.com")
client.signers.delete(signer["id"])
```

Signer-access-code endpoints:

```python
client.signers.get_self(signer_access_code)
client.signers.accept_terms(signer_access_code)
client.signers.verify_code(signer_access_code, "123456")
# verify_email(...) remains as a backward-compatible alias.
client.signers.confirm_data(
    document_id,
    signer_access_code,
    {"full_name": "John Doe", "email": "john@example.com", "government_id": "00000000000"},
)
client.signers.upload_signature(signer_access_code, png_bytes, "signature")
# Alternative: client.signers.upload_signature(signer_access_code, png_bytes, reuse=True)
client.signers.download_signature(signer_access_code, "signature")
```

### Assignments

```python
client.assignments.list({"page": 1, "per_page": 20})  # assignments for the account
client.assignments.estimate_cost(document_id, {"signers": [{"verification_method": "Email"}]})

assignment = client.assignments.create(document_id, {
    "method": "virtual",
    "signers": [
        # `step` controls sequential signing order (signers sharing a step sign
        # in parallel; the next step is notified once the previous one finishes).
        {"id": signer_a["id"], "verification_method": "Email", "step": 1},
        {"id": signer_b["id"], "verification_method": "Email", "step": 2},
    ],
    "message": "Please review and sign",
    "expires_at": "2026-12-31T00:00:00Z",
})

client.assignments.reset_expiration(document_id, assignment["id"], "2027-01-31T00:00:00Z")
client.assignments.estimate_resend_cost(document_id, assignment["id"], signer["id"])
client.assignments.resend_notification(document_id, assignment["id"], signer["id"])
# Alternative: client.assignments.reset_expiration(document_id, assignment["id"], None)
client.assignments.whatsapp_notifications(document_id, assignment["id"])
```

Signer-facing assignment endpoints:

```python
client.assignments.get_for_signer(signer_access_code)
client.assignments.sign(
    document_id,
    assignment_id,
    [{"itemId": "item-1", "fieldId": "field-1", "pageId": "page-1", "value": "John Doe"}],
    signer_access_code,
)
# Mutually exclusive alternative:
# client.assignments.decline(document_id, assignment_id, "I do not agree.", signer_access_code)
```

`DigitalCertificate` is accepted as an assignment `verification_method`, and
the `pades` artifact is downloadable. The current official prose then directs
certificate signers to `POST /signers/certificate/start` and `/complete`, but
those operations have no published path, authentication, request, or response
schema and return no evidence of availability in the sandbox. The SDK does not
invent that security-sensitive contract; Assinafy must publish it before a safe
implementation can be added.

### Signer Documents

```python
client.signer_documents.current(signer_id, signer_access_code)
client.signer_documents.list(signer_id, signer_access_code, {"page": 1, "per_page": 20})
client.signer_documents.search(signer_id, signer_access_code, "contract")  # lightweight, compact
client.signer_documents.sign_multiple(["doc-1", "doc-2"], signer_access_code)
# Mutually exclusive alternative:
# client.signer_documents.decline_multiple(["doc-1"], "Unfavorable terms.", signer_access_code)
client.signer_documents.download(signer_id, document_id, artifact_name="original")
```

The download route is public in the current contract. Its optional
`signer_access_code` argument is retained for older deployments.

### Field Definitions

```python
field = client.fields.create({"type": "text", "name": "CPF"})
client.fields.list({"include_standard": True})
client.fields.get(field["id"])
client.fields.update(field["id"], {"name": "CPF updated"})
client.fields.validate(field["id"], "000.000.000-00", signer_access_code=signer_access_code)
client.fields.validate_multiple(
    [{"field_id": field["id"], "value": "000.000.000-00"}],  # synthetic CPF placeholder
    signer_access_code=signer_access_code,
)
client.fields.list_types()
client.fields.delete(field["id"])
```

### Webhooks

```python
client.webhooks.get()
client.webhooks.list_event_types()
client.webhooks.list_dispatches({"delivered": False, "page": 1, "per_page": 20})

# Mutating calls affect the workspace's single subscription or redeliver an
# existing event. Snapshot and restore the subscription around test changes.
# client.webhooks.register({
#     "url": "https://example.com/webhooks/assinafy",
#     "email": "admin@example.com",
#     "events": ["document_ready", "signer_signed_document"],
#     "is_active": True,
# })
# client.webhooks.inactivate()
# client.webhooks.retry_dispatch(dispatch_id)
```

A workspace has a single webhook subscription. There is no documented `DELETE`
endpoint — call `inactivate()` to stop delivery (it preserves the configured
URL/events) and `register()` again to re-enable.

### Webhooks: Parsing Payloads

Every webhook body shares the documented envelope: `id`, `event`, `message`,
`payload` (event-specific params), `origin`, `created_at`, `subject` (the entity
that acted), `object` (the entity acted on), and `account_id`.

```python
raw_body = request.get_data()

event = client.webhook_verifier.extract_event(raw_body)
event_type = client.webhook_verifier.get_event_type(event)      # e.g. "document_ready"
params = client.webhook_verifier.get_event_payload(event)       # event-specific params
subject = client.webhook_verifier.get_event_subject(event)      # actor (+ "type")
target = client.webhook_verifier.get_event_object(event)        # target (+ "type")
# get_event_data(event) is a backward-compatible alias of get_event_object(event)
```

#### Signature verification

The documented Delivery Contract specifies the HTTP method, `Content-Type`,
retry, and circuit-breaker behavior, but **does not define any signature header
or shared-secret scheme**. `verify()` is provided only for accounts that have
separately arranged an HMAC-SHA256 scheme with Assinafy:

```python
signature = request.headers.get("X-Assinafy-Signature", "")
if not client.webhook_verifier.verify(raw_body, signature):
    return "Invalid signature", 401
```

## Query Parameters

The SDK accepts Pythonic aliases for documented hyphenated query parameters. For example, `per_page` is sent as `per-page`, and `signer_access_code` is sent as `signer-access-code`.

## Response Payloads

JSON endpoints normally return `{"status": 200, "message": "", "data": ...}`;
the SDK returns `data`. No-data operations return `None` or preserve their
small `{"status", "message"}` envelope for backward compatibility, as stated
in each method's docstring. Binary methods return `bytes`; paginated methods
return `{"data": [...], "meta": {"current_page", "per_page", "total",
"last_page"}}` using the API's pagination headers.

The complete stable top-level resource payloads are:

```json
{
  "Account": {
    "resource": "account", "id": "account-id", "name": "Acme Inc.",
    "primary_color": "aabbcc", "secondary_color": "112233",
    "notification_sender_type": "User", "roles": ["owner"],
    "is_delete_allowed": true, "created_at": "2026-06-03T03:54:16Z"
  },
  "User": {
    "id": "user-id", "name": "Example User", "email": "user@example.com",
    "telephone": null, "government_id": null, "is_email_verified": true,
    "has_accepted_terms": true, "created_at": "2026-06-03T03:54:16Z",
    "to_be_deleted_at": null
  },
  "Signer": {
    "resource": "signer", "id": "signer-id", "full_name": "Example Signer",
    "email": "signer@example.com", "whatsapp_phone_number": null,
    "has_accepted_terms": false
  },
  "Document": {
    "resource": "document", "id": "document-id", "account_id": "account-id",
    "template_id": null, "name": "contract.pdf", "status": "metadata_ready",
    "artifacts": {"original": "https://api.example/document/original"},
    "is_closed": false, "signing_url": "https://app.example/sign/document-id",
    "decline_reason": null, "declined_by": null, "tags": [],
    "assignment": null, "pages": [], "created_at": "2026-06-03T03:54:16Z",
    "updated_at": "2026-06-03T03:54:17Z"
  },
  "Assignment": {
    "resource": "assignment", "id": "assignment-id",
    "sender_email": "sender@example.com", "method": "virtual",
    "expires_at": null, "message": null, "signers": [], "copy_receivers": [],
    "items": [], "summary": {"signer_count": 0, "completed_count": 0, "signers": []},
    "signing_urls": []
  },
  "CostEstimate": {
    "documents": 1, "credits": 0, "needs_extra_document": false,
    "extra_document_cost": 0, "total_credits": 0, "breakdown": [],
    "document_balance": 10, "credit_balance": 0,
    "has_sufficient_resources": true, "blocking_reason": null, "message": null
  },
  "Field": {
    "resource": "field", "id": "field-id", "name": "CPF", "type": "text",
    "regex": null, "is_pre_defined": false, "is_active": true,
    "is_required": true, "is_standard": false, "is_read_only": false,
    "is_visible": true
  },
  "Tag": {
    "resource": "tag", "id": "tag-id", "name": "Contracts", "color": null,
    "created_at": "2026-06-03T03:54:16Z",
    "updated_at": "2026-06-03T03:54:17Z"
  },
  "WebhookSubscription": {
    "events": ["document_ready"], "is_active": true,
    "url": "https://example.com/webhooks/assinafy", "email": "ops@example.com",
    "updated_at": "2026-06-03T03:54:17Z"
  }
}
```

The current OpenAPI example uses `"resource": "field"`; the sandbox has also
returned `"field_definition"`. The SDK preserves whichever value the server
returns.

Template, notification-preference, KPI, verification, webhook-dispatch, and
operation-specific contracts are documented beside their public methods;
methods returning shared resources reference the canonical shapes above.
The SDK preserves extra server fields so additive API changes remain usable.

## Errors

SDK validation, transport, HTTP, and response-shape failures raise a subclass
of `AssinafyError`.

```python
from assinafy import ApiError, AssinafyError, NetworkError, ValidationError

try:
    client.documents.upload({"file_path": "./contract.pdf"})
except ValidationError as err:
    print("Validation failed:", err.errors)
except ApiError as err:
    print(f"API error {err.status_code}:", err.response_data)
except NetworkError as err:
    print("Network error:", err)
except AssinafyError as err:
    print("SDK error:", err, err.context)
```

## Development

```bash
python -m pip install -e ".[dev]"
python -m pytest --cov=assinafy --cov-branch --cov-fail-under=90 --cov-report=term-missing
python -m mypy src
python -m ruff check src tests scripts
python -m ruff format --check src tests scripts
```

### Live smoke test

```bash
ASSINAFY_API_KEY=... \
ASSINAFY_ACCOUNT_ID=... \
ASSINAFY_BASE_URL=https://sandbox.assinafy.com.br/v1 \
ASSINAFY_TEST_EMAILS=first@example.com,second@example.com \
ASSINAFY_SEND_TEST_NOTIFICATIONS=1 \
ASSINAFY_TEST_ACCOUNT_LIFECYCLE=1 \
ASSINAFY_TEST_USER_PREFERENCES=1 \
python scripts/live_smoke.py
```

The script refuses production and missing base URLs. It confirms read
endpoints, signer/tag/field CRUD (including
clearing a field's regex), template lookup and cost estimation, document
upload, document tagging, ``wait_until_ready`` polling, cost estimation, and
cleanup end-to-end. Every created resource is captured and removed in a
`finally` block. Webhook mutation is skipped unless an explicit test endpoint
is supplied; when enabled, the prior single-workspace subscription is restored.
The notification opt-in sends real sandbox emails and may consume sandbox
credits; omit it for CRUD-only smoke coverage. Account and user-preference
mutations are separate opt-ins and are cleaned/restored in `finally`.

## License

MIT
