Leia em português · English
Python SDK for the Assinafy API — the Brazilian electronic-signature platform.
The SDK is synchronous, built on httpx, and covers all 93 operations
currently published by Assinafy: accounts, users, authentication, OAuth,
documents, signers, signer documents, assignments, field definitions,
templates, tags, and webhooks. Every public method names the verb and path it
calls and documents its request body and unwrapped response; shared resource
shapes are documented once and referenced by the methods that return them.
- Requirements
- Installation
- Authentication
- Quick start
- Signer verification methods
- The signing lifecycle
- OAuth for marketplace applications
- Resource reference
- Query parameters
- Response payloads
- Errors
- Development
- License
- Python 3.10+
httpx(installed automatically)- TLS 1.2 or newer: the client refuses TLS 1.0 and 1.1
pip install assinafyPrefer api_key; it is sent as the documented X-Api-Key header. token
sends Authorization: Bearer <token> for legacy/user-token flows. If both are
provided, the API key takes precedence.
client = AssinafyClient(api_key="k_xxx", account_id="acc_xxx")
client = AssinafyClient(token="jwt_xxx", account_id="acc_xxx")The SDK withholds both credentials on routes whose published security is
public or signer-access-code-only, so an API key is never sent to an endpoint
that does not expect one. Every outbound production and sandbox request uses
User-Agent: Assinafy-Python-SDK/v<package-version>.
Unauthenticated clients are allowed, for public and signer-access-code endpoints:
public_client = AssinafyClient()
session = public_client.authentication.login("user@example.com", "password")There are four ways to authenticate, and which one you want depends on whose workspace you are acting on:
| Mode | Sent as | Use it when |
|---|---|---|
| API key | X-Api-Key |
you automate your own workspace — recommended for back ends |
| Access token | Authorization: Bearer |
you hold a user session from authentication.login() |
| Signer access code | signer-access-code query parameter |
you drive the signer-facing endpoints |
| OAuth 2.1 + PKCE | Authorization: Bearer |
you build an app that other people connect to their own workspace — see OAuth for marketplace applications |
| 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. |
Use https://sandbox.assinafy.com.br/v1 as base_url to work against the
sandbox. base_url must carry only scheme, host, port, and path — the
constructor rejects a URL that embeds credentials (https://user:pass@host/v1,
which would silently replace your API key or token with HTTP Basic auth) or
that carries a query string or fragment (which would glue the request path into
the wrong URL component). Plaintext http:// is rejected for every non-loopback
host — login, social_login, change_password, reset_password and
create_api_key send secrets in the request body even when the client carries
no api_key or token, so the loopback interface is the only place plaintext
is safe. Point local and mock servers at localhost/127.0.0.1.
The client is a context manager and holds an HTTP connection pool; use with
or call close() when you are finished.
upload_and_request_signatures runs the common case end to end:
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"])It 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. Phone-only signers use WhatsApp
verification and notification, which requires account availability and consumes
credits; use assignments.estimate_cost() when the cost must be known before
sending.
When you need explicit IDs, cost control, or cleanup, drive the same lifecycle through the individual resources instead — that is what the next section walks through.
Set per signer through verification_method when you create the assignment.
Verification and notification are coupled: send one, both, or neither — the
missing side is inferred, and sending neither defaults both to Email.
| Method | How it works | Cost per signer |
|---|---|---|
Email (default) |
A one-time code (OTP) by email, required before signing | Free |
Whatsapp |
A one-time code (OTP) over WhatsApp | Verification free; the notification it requires costs 0.45 credits, on paid plans only |
DigitalCertificate |
The signer signs with their own ICP-Brasil certificate (A1/A3) through the Web PKI browser extension, producing a qualified PAdES signature | 2 credits, plus its notification |
Allowed pairings: Email → notify by Email; Whatsapp → notify by
Whatsapp; DigitalCertificate → notify by Email or Whatsapp. Only one
notification method per signer, and an invalid pairing is a 400.
Requires the Digital Certificate feature on the account (Standard and Pro
plans), a CPF or CNPJ in the signer's government_id, and exactly one
certificate signer per signing step. A CPF requires that person's own
certificate (an e-CPF, or an e-CNPJ naming them as legal representative); a CNPJ
requires the company's e-CNPJ.
signer = client.signers.update(signer["id"], {"government_id": "39053344705"})
estimate = client.assignments.estimate_cost(document["id"], {
"method": "virtual",
"signers": [{"verification_method": "DigitalCertificate",
"notification_methods": ["Email"]}],
})
client.assignments.create(document["id"], {
"method": "virtual",
"signers": [{
"id": signer["id"],
"step": 1,
"verification_method": "DigitalCertificate",
"notification_methods": ["Email"],
}],
})Before the signer can open the assignment they must confirm their identity data
and accept the terms — confirm_data(..., {"has_accepted_terms": True})
covers both in one call, and accept_terms() is never gated. Sending
has_accepted_terms to get_for_signer() is too late to open that gate.
The ordinary signing endpoint rejects certificate signers: their signature is produced by a two-step handshake with the Web PKI browser extension.
POST /v1/signers/certificate/start -> data.token (Web PKI operation token)
v the browser signs that token with the signer's certificate
POST /v1/signers/certificate/complete -> data.signerName
Both routes are deployed on production and sandbox, but they are not
published as operations in the OpenAPI document, so their request and response
contract is unspecified and this SDK does not call them — drive that step
through the Assinafy-hosted signing flow. Everything around it is covered:
creating the assignment, confirm_data, cost estimation, and downloading the
resulting pades artifact once the flow completes.
A document goes from upload to a certified PDF in seven stages. Each stage below is a real call you can run in order.
import os
from assinafy import AssinafyClient
client = AssinafyClient(
api_key=os.environ["ASSINAFY_API_KEY"],
account_id=os.environ["ASSINAFY_ACCOUNT_ID"],
)Signers are workspace-level records, reused across documents. Look one up before creating a duplicate:
signer = client.signers.find_by_email("signer@example.com")
if signer is None:
signer = client.signers.create({
"full_name": "Example Signer",
"email": "signer@example.com",
})A signer needs full_name plus at least one contact channel: email, or
whatsapp_phone_number in E.164 form (+5548999990000). The channel you give
determines how that signer can be verified and notified in stage 4.
Uploads use the documented multipart shape and are limited locally to PDF files up to 25 MB (the API additionally caps documents at 2000 pages):
document = client.documents.upload({"file_path": "./contract.pdf"})
# or, from memory: client.documents.upload({"buffer": pdf_bytes, "file_name": "contract.pdf"})The document lands in uploaded status while Assinafy renders page images and
extracts metadata. Wait for that to finish before placing fields or requesting
signatures:
document = client.documents.wait_until_ready(document["id"])wait_until_ready polls documents.get until the status reaches
metadata_ready, pending_signature, or certificated; it raises on a
terminal failure status and does not retry a 404, which waiting can never
resolve. Rename the document here if you need to — the API locks the name once
an assignment exists:
document = client.documents.rename(document["id"], "Service agreement.pdf")Creating an assignment sends real notifications and consumes credits. Price it first when the cost matters:
estimate = client.assignments.estimate_cost(document["id"], {
"method": "virtual",
"signers": [{
"verification_method": "Email",
"notification_methods": ["Email"],
}],
})
if not estimate["has_sufficient_resources"]:
raise SystemExit(estimate["blocking_reason"])The estimate body takes only pricing descriptors — the SDK strips signer IDs from the wire, since they are not part of the estimate contract.
A virtual assignment asks each signer for a signature with no field
placement. step controls signing order: signers sharing a step sign in
parallel, and the next step is notified once the previous one completes.
assignment = client.assignments.create(document["id"], {
"method": "virtual",
"signers": [{
"id": signer["id"],
"verification_method": "Email",
"notification_methods": ["Email"],
"step": 1,
}],
"message": "Please review and sign.",
"expires_at": "2030-12-31T23:59:59Z",
})A collect assignment additionally places reusable fields on specific pages,
using entries:
page = document["pages"][0]
field = client.fields.list()["data"][0]
assignment = client.assignments.create(document["id"], {
"method": "collect",
"signers": [{
"id": signer["id"],
"verification_method": "Email",
"notification_methods": ["Email"],
"step": 1,
}],
"entries": [{
"page_id": page["id"],
"fields": [{
"signer_id": signer["id"],
"field_id": field["id"],
"display_settings": {
"left": 69,
"top": 282,
"width": 421,
"height": 45.86,
"fontSize": 18,
"fontFamily": "Arial",
"backgroundColor": "#D5EBFF",
},
}],
}],
})left, top, width, height, and fontSize are required 150-DPI
page-image pixel values measured from the upper-left corner; width, height, and
font size must be positive and coordinates non-negative. The API does not clamp
out-of-bounds rectangles, so keep the placement inside the page's reported
width/height. fontFamily and backgroundColor are optional presentation
metadata.
DigitalCertificate is also accepted as a verification_method; see
ICP-Brasil digital certificate (A1/A3)
for its requirements, its cost, and why the SDK stops short of the signature
handshake itself.
Once the assignment exists you can adjust or re-drive its notifications:
client.assignments.reset_expiration(document["id"], assignment["id"], "2031-01-31T00:00:00Z")
client.assignments.reset_expiration(document["id"], assignment["id"], None) # clear expiration
client.assignments.estimate_resend_cost(document["id"], assignment["id"], signer["id"])
client.assignments.resend_notification(document["id"], assignment["id"], signer["id"])
client.assignments.whatsapp_notifications(document["id"], assignment["id"])resend_notification() sends a real message and charges the notification
channel again; call estimate_resend_cost() first when the cost must be known.
Signers act with a one-time signer access code, not with your API key. The
SDK sends it as the documented signer-access-code query parameter and never
attaches your workspace credentials to these routes.
# The signer opens their link and verifies the emailed/WhatsApp code.
client.signers.verify_code(signer_access_code, "123456")
client.signers.accept_terms(signer_access_code)
# Read what this signer is allowed to see.
view = client.assignments.get_for_signer(signer_access_code)
me = client.signers.get_self(signer_access_code)
# Confirm identity data, then submit.
client.signers.confirm_data(
document["id"],
signer_access_code,
{"full_name": "Example Signer", "email": "signer@example.com",
"government_id": "00000000000"},
)A virtual assignment submits an empty item list; a collect assignment submits one entry per completed field:
client.assignments.sign(document["id"], assignment["id"], [], 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,
)Declining is the mutually exclusive alternative to signing:
client.assignments.decline(
document["id"], assignment["id"], "I do not agree with the terms.", signer_access_code
)A signer with several pending documents can act on all of them at once — see Signer documents.
Webhooks are the reliable channel. Register the workspace's single subscription, then parse deliveries:
client.webhooks.register({
"url": "https://example.com/webhooks/assinafy",
"email": "ops@example.com",
"events": ["document_ready", "signer_signed_document", "signer_rejected_document"],
"is_active": True,
})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"
target = client.webhook_verifier.get_event_object(event) # the document acted on
if event_type == "document_ready":
signed_pdf = client.documents.download(target["id"], "certificated")Polling and the activity log work too:
document = client.documents.get(document["id"])
print(document["status"])
client.documents.activities(document["id"])Once every signer has completed, the document reaches certificated and its
artifacts become downloadable:
document = client.documents.get(document["id"])
if document["status"] == "certificated":
signed_pdf = client.documents.download(document["id"], "certificated")
certificate_page = client.documents.download(document["id"], "certificate-page")
everything = client.documents.download(document["id"], "bundle")Valid artifacts are original, certificated, certificate-page, pades,
and bundle. pades exists only for documents signed with an ICP-Brasil
certificate. Anyone holding the signature hash can verify a document with no
credentials at all:
AssinafyClient().documents.verify(signature_hash)Keep the returned document, signer, and assignment IDs. Delete only disposable resources you created, and do so in reverse dependency order.
When the document layout is fixed, create it from a template and skip stages 2 and 4 — field placement and roles already live on the template:
templates = client.templates.list({"search": "NDA"})
template = client.templates.get(templates["data"][0]["id"])
role_id = template["roles"][0]["id"]
client.documents.estimate_cost_from_template(
template["id"],
[{"role_id": role_id, "verification_method": "Email"}],
)
document = client.documents.create_from_template(
template["id"],
[{"role_id": role_id, "id": signer["id"], "verification_method": "Email"}],
{"name": "NDA - John Doe", "message": "Please sign."},
)Template signers take one entry per template role, allow at most one
notification method each, and follow the same contiguous-step rules as
assignments (copy-receiver roles ignore step). options may also carry
expires_at, editor_fields ({"field_id": ..., "value": ...} pairs baked
into the generated document), and tags — tag names that do not exist are
auto-created and merged with the template's default document tags.
Everything above assumes you automate your own workspace with an API key. If you are building a product that other people connect to their own Assinafy workspace, do not ask them for their API key: run the OAuth 2.1 authorization-code flow with PKCE and receive a token limited to what they approved, for the one workspace they picked, that they can switch off at any time.
| API key | OAuth | |
|---|---|---|
| Acts on | your own workspace | someone else's workspace, with their permission |
| Can do | everything your account can do | only what the user approved |
| The user can switch it off | no | yes, at any time |
OAuth is served in production only today.
https://sandbox.assinafy.com.br/v1/oauth/*answers404.
The resource is a factory rather than an attribute, because an OAuth application has credentials of its own:
from assinafy import AssinafyClient
# A credential-free client is enough for the whole flow up to the exchange.
oauth = AssinafyClient().oauth(
os.environ["ASSINAFY_OAUTH_CLIENT_ID"],
os.environ.get("ASSINAFY_OAUTH_CLIENT_SECRET"), # confidential apps only
)The SDK stores no tokens, holds no refresh locks, and renews nothing on its own. Those are your application's decisions, and the sections below say exactly where they land.
In the Assinafy app (https://app.assinafy.com.br) open Settings → OAuth applications → New application. You must own the workspace that will own the application, and its plan must include OAuth applications.
| Field | What to put |
|---|---|
| Name, Description, Logo URL | what users read on the approval screen |
| Redirect URIs | where users return, e.g. https://myapp.example/oauth/callback. Must be https://, carry no #, and is matched exactly — …/callback and …/callback/ are different. Register one per environment; for local development use an HTTPS tunnel, because http://localhost is not accepted |
| Permissions | the most your app will ever request; you can ask for less at connect time, never more |
| Type | Confidential if your code runs on a server you control, Public if it runs on the user's device. This cannot be changed later |
You receive a client_id and, for confidential applications, a client_secret
shown once. Store the secret in your server's secret storage; never ship it in
browser code, a mobile app, or a repository.
The scopes:
| Scope | Lets your app |
|---|---|
documents:read |
read documents, their signers, assignments and activity |
documents:write |
create documents and send them for signature |
templates:read / templates:write |
read / change templates |
account:read |
read the workspace's profile, theme and logo |
webhooks:write |
configure and deactivate the workspace's webhook subscription |
openid, profile, email |
identify the user, and read their name and email |
offline_access |
receive a refresh token, to keep working while the user is away |
Request the minimum: every permission is another line the user reads before deciding, and they approve everything or nothing. Billing, workspace membership, credential management and administration are never reachable with an OAuth token, whatever its scopes.
start_authorization() mints a fresh PKCE verifier and state (and a nonce
when you request openid), and returns the URL plus the transaction those later
steps need. It makes no HTTP request.
start = oauth.start_authorization(
"https://myapp.example/oauth/callback",
["documents:read", "documents:write", "offline_access"],
)
session["assinafy_oauth"] = start # the user's server-side session
return redirect(start["authorization_url"]) # a full page navigation, not AJAX{
"authorization_url": "https://auth.assinafy.com.br/oauth/authorize?response_type=code&...",
"state": "<random per attempt>",
"code_verifier": "<random per attempt>",
"code_challenge": "<base64url sha256 of the verifier>",
"redirect_uri": "https://myapp.example/oauth/callback",
"issuer": "https://auth.assinafy.com.br",
"resource": "https://api.assinafy.com.br",
}Call it once per connection attempt: a fresh verifier and state every time
is the whole protection against one attempt's material being replayed against
another. Keep the return value in the user's authenticated session, not in a
cookie or a URL.
If the client_id or redirect_uri is wrong the user is not sent back to you at
all — the authorization server shows an error on its own page, because
redirecting to an unverified address would be unsafe. Users stuck on an Assinafy
error page usually means one of those two values is wrong.
handle_callback() is the security-critical step. It compares state and iss
in constant time before the code is used anywhere, and raises the RFC error
code when the user declined.
from assinafy import ApiError, ValidationError
transaction = session.pop("assinafy_oauth")
try:
code = oauth.handle_callback(request.args, transaction)
except ValidationError:
return "This response is not ours", 400 # state or iss mismatch
except ApiError as err:
if str(err) == "access_denied":
return "You declined the connection", 200
raise
tokens = oauth.exchange_code(code, transaction){
"access_token": "<access-token>",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "documents:read documents:write",
"refresh_token": "<refresh-token>", # only with offline_access
"id_token": "<signed-id-token>", # only with openid
}The code is single-use and expires 60 seconds after approval, so exchange it
server-side and immediately. Read the returned scope instead of assuming every
requested permission was granted; offline_access is a request-time signal and
never appears there.
OAuth responses are flat JSON — they are the only calls in this API that are not
wrapped in the {status, message, data} envelope. On failure, str(err) is the
RFC error code (invalid_grant, invalid_client, invalid_target,
unsupported_grant_type) and err.response_data carries error_description, so
branch on the message.
A token belongs to the one workspace the user picked. With an OAuth token the workspace list returns exactly that workspace, so store its ID next to the tokens:
api = AssinafyClient(token=tokens["access_token"])
workspace_id = api.accounts.list()[0]["id"]
documents = api.documents.list({"per_page": 20}, account_id=workspace_id)Always send the token in Authorization: Bearer; as X-Api-Key or in the query
string it is refused. Calling any other workspace returns 403, even one the
same user belongs to — this is the integration mistake we see most often. If your
customer uses several workspaces, connect each one separately and keep tokens per
workspace.
A missing permission answers 403 with a challenge naming it:
WWW-Authenticate: Bearer error="insufficient_scope", scope="documents:write", ...
Treat that as a prompt to reconnect with that scope added, not as something to
retry. A 403 without that header has another cause: a different workspace, the
user's own role, or an area OAuth tokens can never reach.
Access tokens last 1 hour. With offline_access you renew them without the
user. A refresh token is valid for 30 days, and every refresh returns a new
one valid for another 30 days: the connection only expires if your app goes 30
days without refreshing, and after that the user has to reconnect.
tokens = oauth.refresh(stored_refresh_token)
save(tokens["refresh_token"]) # before doing anything else with the responseEvery refresh retires the token it used. A replayed refresh token cannot be told apart from a stolen one, so the server ends the whole connection when it sees one: every token stops working and the user must connect again. Persist the new refresh token before you use the access token, hold one refresh at a time per connection, and treat a timeout as "maybe it worked" — re-read what you stored instead of retrying with the old token.
When a user disconnects in your product, revoke instead of only deleting your copy. Revoking the refresh token ends the whole connection:
oauth.revoke(load_saved_refresh_token(), "refresh_token") # the latest saved token, never a stale copy
delete_saved_tokens()The endpoint answers 200 for every token outcome — revoked, already revoked,
unknown, malformed — so it can never be used to probe whether a token exists, and
success is not evidence the token was real. Only failed client authentication
answers 401. Users can also revoke your app themselves under Connected apps;
handle the resulting 401 by asking them to connect again.
Rather than hardcoding endpoints, read them. protected_resource_metadata()
(RFC 9728) describes this API and names its authorization server;
authorization_server_metadata() (RFC 8414) describes that server. Both live at
their host's origin root, above /v1, and are fetched without your workspace
credentials.
resource = oauth.protected_resource_metadata()
server = oauth.authorization_server_metadata(resource["authorization_servers"][0])
start = oauth.start_authorization(
"https://myapp.example/oauth/callback",
["documents:read", "openid", "email"],
issuer=server["issuer"],
authorization_endpoint=server["authorization_endpoint"],
)Request openid (plus profile and/or email) to sign users in. Validate the
id_token with a maintained OpenID Connect library — RS256, keys at the
server's jwks_uri, iss equal to the issuer, aud equal to your client_id,
exp in the future, and nonce matching the one in your transaction. For the
user's name and email, read the claims rather than the token:
claims = oauth.userinfo(tokens["access_token"])
# {"sub": "...", "name": "Example User", "email": "person@example.com",
# "email_verified": True}sub is the user's stable identifier. userinfo() authenticates like any other
API route, so unlike the token endpoints its 401/403 arrive in the ordinary
envelope; the SDK drops the client's X-Api-Key for this call so a workspace key
can never answer for the wrong identity.
If your framework owns the session material, the PKCE primitives are available directly:
verifier = OAuthResource.create_code_verifier() # 43 chars, RFC 7636 grammar
challenge = OAuthResource.code_challenge(verifier)
state = OAuthResource.create_state()- A new PKCE verifier and
statefor every connection attempt stateandisschecked on your redirect URI —handle_callback()does bothclient_secreton your server only, never in browser code, a mobile app, or a repository- The new refresh token saved before use, and one refresh at a time per connection
401handled: refresh, and if that fails, ask the user to reconnect- The workspace ID stored per connection, and the returned
scoperead - Every production redirect URI registered,
https://, and exact - Only the permissions you need
- Tokens revoked when a user disconnects
New applications are unverified: the approval screen says Assinafy has not reviewed the app, and it can connect to at most 25 workspaces. The authorize and token endpoints accept 50 requests per minute per IP — hitting that normally means a refresh loop.
Every method below is covered above in context; this section is the flat index.
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 = 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. Creating the account
first and setting notification_sender_type with update() is supported
across deployed API versions.
user = client.users.me()
stats = client.users.stats("monthly")
preferences = client.users.notification_preferences()
preferences = client.users.update_notification_preferences({
"DocumentCompleted": True,
"SignerDeclined": True,
})update_notification_preferences merges a partial map; omitted keys keep their
values, and the response is always the complete nine-key map.
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")
# Legacy body 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"])list() filters on status, method, search, tags (comma-separated IDs,
matching documents that carry all of them), and sort (name or
updated_at). search() is the compact counterpart, returning documents
without the expanded assignment/pages fields. Deletion is only permitted
while the document is in a deletable status.
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"}],
)The published OpenAPI exposes list only. get is retained because the route
is deployed and answers on the live API, and the published schema text
describes a single-template response that adds default_document_tags.
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)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"])create() takes full_name, email and whatsapp_phone_number.
government_id — the CPF or CNPJ the digital certificate requires — exists only
on update(), so create the signer and then complete their record.
Signer-access-code endpoints:
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")update() cannot change a channel that has already been verified for an
in-flight document; the API enforces that server-side.
client.assignments.list({"page": 1, "per_page": 20})
client.assignments.estimate_cost(document_id, {"signers": [{"verification_method": "Email"}]})
client.assignments.create(document_id, {"method": "virtual", "signers": [...]})
client.assignments.reset_expiration(document_id, assignment_id, "2031-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)
client.assignments.whatsapp_notifications(document_id, assignment_id)
# Signer-facing:
client.assignments.get_for_signer(signer_access_code)
client.assignments.sign(document_id, assignment_id, [], signer_access_code)
client.assignments.decline(document_id, assignment_id, "I do not agree.", signer_access_code)list() is scoped by the API to the authenticated credential's current
account. The SDK forwards an accountId context parameter, but passing a
different account_id does not re-scope this endpoint — use a credential
belonging to that workspace instead.
oauth = client.oauth("client-id", "client-secret")
start = oauth.start_authorization(
"https://myapp.example/oauth/callback",
["documents:read", "documents:write", "offline_access"],
)
code = oauth.handle_callback(callback_query, start)
tokens = oauth.exchange_code(code, start)
tokens = oauth.refresh(tokens["refresh_token"])
claims = oauth.userinfo(tokens["access_token"])
oauth.revoke(tokens["refresh_token"], "refresh_token")
oauth.protected_resource_metadata()
oauth.authorization_server_metadata()
# Static PKCE primitives, for frameworks that own the session material.
OAuthResource.create_code_verifier()
OAuthResource.create_state()
OAuthResource.code_challenge(verifier)start_authorization() and handle_callback() make no HTTP request. See
OAuth for marketplace applications for
the whole flow.
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
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. Its optional signer_access_code argument is
available for deployments that require it.
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.update(field["id"], {"regex": None}) # clears the regex
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"])create() takes type (one of the values from list_types()) and name,
optionally regex and is_required. is_read_only / is_visible are
server-controlled response fields, not create input.
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. Because the subscription is
singular, register() fills an omitted events or is_active from the
current subscription, so a partial call (rotating only url, say) cannot
silently reactivate an inactivated subscription or collapse a custom event
list. Pass an explicit events=[] to genuinely clear all events.
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.
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:
signature = request.headers.get("X-Assinafy-Signature", "")
if not client.webhook_verifier.verify(raw_body, signature):
return "Invalid signature", 401The 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. None values are dropped rather than sent as empty
parameters.
JSON endpoints normally return {"status": 200, "message": "", "data": ...};
the SDK returns data. The five OAuth calls are the documented exception: RFC
6749, RFC 8414 and OpenID Connect all mandate a flat body, so exchange_code,
refresh, userinfo and the two discovery methods return top-level JSON, and
revoke returns None. 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:
{
"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": [{
"resource": "signer", "id": "signer-id", "full_name": "Example Signer",
"email": "signer@example.com", "whatsapp_phone_number": null,
"has_accepted_terms": false, "verification_method": "Email",
"notification_methods": ["Email"], "step": 1, "notified": true,
"completed": false, "notification_history": [{
"event": "signature_request", "status": "sent", "error_code": null,
"error_message": null, "sent_at": "2026-08-26T12:00:00Z",
"failed_at": null
}]
}],
"copy_receivers": [],
"items": [{
"id": "item-id",
"page": {"id": "page-id", "number": 1, "height": 2100,
"width": 1275, "download_url": "https://api.example/page"},
"signer": {"id": "signer-id", "full_name": "Example Signer",
"email": "signer@example.com"},
"field": {"id": "field-id", "name": "Signature", "type": "signature"},
"display_settings": {"left": 69, "top": 282, "width": 421,
"height": 45.86, "fontFamily": "Arial",
"fontSize": 18, "backgroundColor": "#D5EBFF"},
"value": null, "completed": false
}],
"summary": {"signer_count": 1, "completed_count": 0,
"signers": [{"id": "signer-id", "full_name": "Example Signer",
"email": "signer@example.com", "completed": false}]},
"signing_urls": [{"signer_id": "signer-id",
"url": "https://api.example/sign/document-id"}]
},
"CostEstimate": {
"documents": 1, "credits": 0.45, "needs_extra_document": false,
"extra_document_cost": 0, "total_credits": 0.45,
"breakdown": [{"code": "NotificationWhatsapp",
"name": "Whatsapp Notification", "cost": 0.45,
"quantity": 1, "unit_cost": 0.45}],
"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"
}
}Field resource values are returned verbatim. The documented value is "field";
"field_definition" is also supported.
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.
SDK validation, transport, HTTP, and response-shape failures raise a subclass
of AssinafyError, so a single except AssinafyError catches every documented
failure mode.
from assinafy import ApiError, AssinafyError, NetworkError, ValidationError
try:
client.documents.upload({"file_path": "./contract.pdf"})
except ValidationError as err: # rejected before the request was sent
print("Validation failed:", err.errors)
except ApiError as err: # the API returned a non-2xx response
print(f"API error {err.status_code}:", err.response_data)
except NetworkError as err: # no response arrived (DNS, TLS, timeout)
print("Network error:", err)
except AssinafyError as err: # unexpected response shape, etc.
print("SDK error:", err, err.context)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 scripts/live_smoke.py
python -m ruff check src tests scripts
python -m ruff format --check src tests scriptsFor CI-safe, non-mutating coverage (no test email required):
ASSINAFY_API_KEY=... \
ASSINAFY_ACCOUNT_ID=... \
ASSINAFY_BASE_URL=https://sandbox.assinafy.com.br/v1 \
ASSINAFY_READ_ONLY=1 \
python scripts/live_smoke.pyFor the disposable write flow:
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.pyThe 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
successfully returned resource ID is captured and its cleanup is attempted 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.
- Bump
src/assinafy/_version.pyand add user-facing release notes toCHANGELOG.md. - Run the development gates above, install release tooling with
python -m pip install build twine, then runpython -m buildandpython -m twine check dist/*. - Push the GitLab source and verify that the branch and CI result reached the GitHub mirror.
- Create and push an annotated
v<version>tag matching__version__. - Approve the protected
pypienvironment and verify Trusted Publishing provenance after release.
MIT