Send a document for signature with Python
This walks through a minimal, working Python example that uploads a PDF and creates a signature request through Esignapi's REST API — enough to see the whole flow working before building a fuller integration on top of it.
What you need first
- An Esignapi account with API access (available starting on the Pro plan).
- An API key from your account settings, in the
key_id.key_secret format.
- A PDF file to send, and an email address you control to act as the test signer.
- Python with the
requests library installed (pip install requests).
Step 1: upload the document
import base64
import os
import requests
API_URL = "https://api.esignapi.me/v1"
API_KEY = os.environ["ESIGNAPI_KEY"] # key_id.key_secret
def upload_document(file_path, filename):
with open(file_path, "rb") as f:
content_b64 = base64.b64encode(f.read()).decode("utf-8")
response = requests.post(
f"{API_URL}/upload-document",
headers={"x-api-key": API_KEY, "Content-Type": "application/json"},
json={
"filename": filename,
"content": content_b64,
"mime_type": "application/pdf",
},
)
response.raise_for_status()
return response.json()
Step 2: create the signature request
def create_signature_request(document_id, signers):
response = requests.post(
f"{API_URL}/create-signature-request",
headers={"x-api-key": API_KEY, "Content-Type": "application/json"},
json={
"document_id": document_id,
"name": "Service Agreement",
"message": "Please review and sign this document.",
"signers": signers,
"expires_in_days": 30,
},
)
response.raise_for_status()
return response.json()
Putting it together
def main():
upload_result = upload_document("./contract.pdf", "contract.pdf")
document_id = upload_result["document_id"]
request_result = create_signature_request(
document_id,
[{"email": "signer@example.com", "name": "Jane Signer"}],
)
print(f"Signature request created: {request_result['id']}")
if __name__ == "__main__":
main()
Running this sends the test signer the same kind of secure link they'd get if the request had been created through the dashboard.
Handling errors properly
response.raise_for_status() is enough to surface a failure during development, but it raises a generic HTTPError without distinguishing why the request failed. In practice, you'll want to catch specific cases:
try:
result = create_signature_request(document_id, signers)
except requests.HTTPError as e:
status = e.response.status_code
if status == 429:
# Rate limited — back off before retrying
pass
elif status == 401:
# API key is invalid or revoked — not worth retrying as-is
pass
else:
error_body = e.response.json()
print(f"Request failed: {error_body.get('error')}")
See Error handling and idempotency in e-signature APIs and Rate limits and best practices for e-signature APIs for more on handling these cases well.
Checking a request's status
def get_request_status(request_id):
response = requests.get(
f"{API_URL}/get-signature-request",
headers={"x-api-key": API_KEY},
params={"id": request_id},
)
response.raise_for_status()
return response.json()
For production use, a webhook is usually a better fit than polling — see E-signature webhooks explained for the tradeoffs.
Keeping your API key secure
Reading the key from an environment variable, as shown above with os.environ, keeps it out of your source files and version control. If you're running this in a script that might be shared or committed, double-check it doesn't hardcode the key anywhere before sharing it.
Why raise_for_status() alone isn't quite enough
response.raise_for_status() is a convenient one-liner for surfacing any non-2xx response as an exception, which is fine while you're developing and want failures to be loud and visible. The limitation is that it treats every kind of failure the same way — a 429 rate-limit response raises the exact same kind of exception as a 500 server error or a 400 validation error, even though the right response to each is different. As your integration matures past the initial "does this work at all" stage, it's worth catching requests.HTTPError specifically and branching on e.response.status_code, as shown above, rather than letting a single broad except block treat every failure identically.
Structuring this as a small client class
For anything beyond a one-off script, it's worth wrapping the two functions shown above in a small class that holds your API key and base URL once, rather than relying on module-level constants referenced from multiple functions:
class EsignapiClient:
def __init__(self, api_key, base_url="https://api.esignapi.me/v1"):
self.api_key = api_key
self.base_url = base_url
def _headers(self):
return {"x-api-key": self.api_key, "Content-Type": "application/json"}
def upload_document(self, file_path, filename):
with open(file_path, "rb") as f:
content_b64 = base64.b64encode(f.read()).decode("utf-8")
response = requests.post(
f"{self.base_url}/upload-document",
headers=self._headers(),
json={"filename": filename, "content": content_b64, "mime_type": "application/pdf"},
)
response.raise_for_status()
return response.json()
This makes it straightforward to instantiate a client once with your configuration and reuse it across your application, rather than passing the API key and base URL around separately everywhere they're needed.
Adding fields when creating the request
The example above creates a signature request with no fields specified, which means the signer has nothing positioned to actually complete — a usable request needs a fields list added to the create_signature_request call's JSON body, specifying each field's page, coordinates, type, and the signer it belongs to. See Placing signature fields in PDFs via API for the exact structure to add here.
Using this inside a Django or Flask view
If you're calling this from inside a web framework rather than a standalone script, the same functions work unchanged — call upload_document and create_signature_request from within your view or route handler, store the returned request ID against whatever model represents the document in your own application, and return a response to your own frontend once the request has been created. The main thing to watch for in a web framework context is making sure the API call doesn't block your request-handling thread for longer than your framework's timeout allows, especially for larger file uploads.
Running this in an async context
If your application is built around asyncio rather than synchronous code, requests itself is blocking and doesn't fit naturally into an async codebase — a library like httpx (which offers an async client with a very similar API) or aiohttp is a more natural fit, with the same endpoints, headers, and JSON bodies described above.
Confirming the response shape during development
Before writing code that depends on a specific key being present in the parsed JSON response, print the raw response during your first few real calls — print(response.json()) is enough to confirm the actual structure matches what you're assuming, which is a quick check worth doing before building logic around fields like document_id or id further down your integration.
Handling the base64 encoding step for larger files
For most contracts and agreements, reading the whole file into memory and base64-encoding it in one step, as shown in upload_document, is perfectly fine — PDFs in the typical business-document range are small enough that this doesn't meaningfully affect memory usage or request time. If you're working with unusually large files, it's worth being aware that base64 encoding happens entirely in memory in the version shown here, so for a very large document you'd want to check your available memory and request size limits before assuming the same approach scales without changes.
Logging requests and responses while developing
Since debugging an HTTP integration without visibility into what was actually sent and received is slow going, it's worth temporarily logging the full request body and response body (being careful not to log the API key itself) while you're first getting a new call working, then dialing that logging back to something more targeted once the integration is stable and you've confirmed the shapes match your expectations.
Running this alongside existing Django or Flask apps
If your project already has an established pattern for calling third-party APIs — a shared requests.Session with default headers configured once, or a settings module where external service configuration lives — fold the Esignapi base URL and API key into that same pattern rather than introducing a second, inconsistent way of calling external services. Consistency here pays off later when someone else on your team needs to debug an integration issue and doesn't have to learn a one-off calling convention just for this particular service.
Try it yourself
Esignapi's API documentation includes this full example alongside request and response details for every endpoint used here.
Frequently asked questions
Do I need a specific version of Python for this?
No specific minimum beyond having requests available — the example uses straightforward, widely supported Python features that work across modern Python 3 versions.
Can I use Python's built-in urllib instead of requests?
Yes, requests is used here for readability, but the same calls can be made with urllib.request or any other HTTP client capable of sending JSON over HTTPS with custom headers.
How do I send a document to multiple signers with Python?
Pass additional signer objects in the signers list — each with at least an email, and an order value if the document needs a specific signing sequence.
What's the fastest way to test this without building a full script?
Run the upload and request-creation functions directly in a Python REPL or a short script against your own test email address, which lets you see the resulting signing link and completion flow without any additional tooling.
Where can I find the exact fields the create-signature-request endpoint accepts?
Esignapi's API documentation lists every supported field, including optional ones like field positioning and signing order, beyond what's shown in this minimal example.