How to integrate e-signatures into a Python application

Matt Kim
Matt Kim
Cover Image for How to integrate e-signatures into a Python application

Adding e-signatures to a Python application comes down to four moving parts: a reusable document template, a signature request, a way to know when signing finishes, and the signed PDF itself. With the official formable-sdk package, each of those is one or two function calls, and you can have a working integration running in an afternoon.

This guide walks through the whole thing with real, copy-pasteable code: a Flask backend that creates signature requests, an iframe embed for in-app signing, a webhook handler with HMAC verification, an async variant for FastAPI, and the final download of the signed document with its audit trail.

What you'll build:

  • A Formable client initialized once and reused across your app
  • A template created from a PDF or DOCX with signer roles
  • Two signing flows: email delivery or embedded in your own UI
  • A webhook handler that verifies signatures and reacts to document_completed
  • A download path for the signed PDF with its audit trail

Table of Contents

Why use an e-signature API instead of building it yourself?

Building signing in-house means owning PDF manipulation, tamper-evident audit logs, key management, and legal admissibility under ESIGN and UETA. Teams consistently underestimate that scope. An e-signature API gives you a legally compliant flow, a hosted signing UI, and an audit trail with timestamps, actors, and IP addresses appended to every signed PDF, for a predictable per-document cost.

For a deeper look at the integration patterns (hosted redirect, embedded iframe, API-only) and the legal background, see our guide on how to implement e-signing onto your site. This post assumes you've picked the API route and focuses on the Python implementation. Working in Node instead? There's a Node.js version of this guide too.


Step 1: install and initialize the SDK

You'll need a Formable account and an API key from Settings. The SDK is fully typed, ships both sync and async clients, and runs on Python 3.9+.

pip install formable-sdk

Create one client and reuse it everywhere. The API key is a bearer token, so it belongs in an environment variable on your server, never in browser code.

# formable_client.py
import os

from formable import Formable

formable = Formable(api_key=os.environ["FORMABLE_API_KEY"])

Step 2: create a template from your document

A template is your document plus the fields signers fill in: signatures, dates, text, checkboxes. Upload the file once, then reuse the template for every signature request.

from formable_client import formable

with open("agreement.pdf", "rb") as f:
    result = formable.templates.create(
        file=f.read(),
        filename="agreement.pdf",
        signer_roles=[
            {"name": "Client", "order": 0},
            {"name": "Witness", "order": 1},
        ],
    )

template_id = result["templateId"]
print(result["editTemplateAccess"]["editUrl"])

Open the editUrl in a browser to place at least one required signature field and assign it a signer role like Client. Edit URLs expire after a day; mint a fresh one anytime:

edit = formable.templates.create_edit_url(template_id)
print(edit["editUrl"])

In a typical integration you upload the template once (or let your customers upload theirs through your product), store the templateId in your database, and reuse it for every request from then on.


Step 3: choose a signing flow

Formable supports two delivery models, and both start from the same template.

Non-embeddedEmbedded
DeliveryFormable emails each signer a linkYou mint a signing URL and render it in an iframe
UIFormable hosted pageInside your product
SDK callssignature_requests.createsignature_requests.create_embedded + create_signing_url
Best forEmail-based signing outside your appIn-app signing flows

If email delivery is enough, you're one call away from done. Formable creates the request and emails every signer a signing link:

request = formable.signature_requests.create(
    template_id=template_id,
    signers=[
        {"email": "jane@example.com", "name": "Jane Doe", "role": "Client"},
        {"email": "bob@example.com", "name": "Bob Smith", "role": "Witness"},
    ],
    test_mode=True,
)

print(request["signatureRequestId"])  # save for tracking and download

If you want signing to happen inside your product, keep reading.


Step 4: embed signing in your Python app

The embedded signing flow has two server-side steps: create an embedded signature request, then mint a short-lived signing URL for each signer. Signing URLs expire one hour after creation, so generate them right before the signer needs one.

Here's a minimal Flask backend exposing both steps to your frontend:

# app.py
from flask import Flask, jsonify, request

from formable_client import formable

app = Flask(__name__)


@app.post("/api/signature-requests")
def create_signature_request():
    body = request.get_json()

    result = formable.signature_requests.create_embedded(
        template_id=body["templateId"],
        signers=[
            {
                "email": body["signer"]["email"],
                "name": body["signer"]["name"],
                "role": "Client",
            }
        ],
        test_mode=True,
    )

    # Persist signatureRequestId and each signer's recipientSignatureId
    return jsonify(
        signatureRequestId=result["signatureRequestId"],
        recipientSignatureId=result["signers"][0]["recipientSignatureId"],
    )


@app.get("/api/signing-url")
def create_signing_url():
    signing = formable.signature_requests.create_signing_url(
        request.args["recipientSignatureId"]
    )
    return jsonify(signingUrl=signing["signingUrl"], expiresAt=signing["expiresAt"])

On the client, fetch the URL from your own backend and render it in an iframe. Formable posts a message to the parent window when signing finishes, which you can use to update your UI immediately:

const { signingUrl } = await fetch(
  `/api/signing-url?recipientSignatureId=${recipientSignatureId}`
).then((res) => res.json());

document.getElementById("signing-frame").src = signingUrl;

window.addEventListener("message", (event) => {
  if (event.origin !== "https://app.formabledocs.com") return;
  if (event.data?.type === "onSigningComplete") {
    // close the iframe or show a success state
  }
});
<iframe
  id="signing-frame"
  width="100%"
  height="800"
  allow="fullscreen"
  style="border: none;"
></iframe>

Two rules keep this secure. First, the browser only ever sees the short-lived signing URL, never your API key. Second, treat onSigningComplete as a UX signal for closing the iframe, and confirm actual completion with a webhook before downloading anything.

Pro tip: if the template has non-signature fields like company name or effective date, prefill them at request creation with the fields argument:

result = formable.signature_requests.create_embedded(
    template_id=template_id,
    signers=[{"email": "jane@example.com", "name": "Jane Doe", "role": "Client"}],
    fields=[
        {"field_id": "field_company_name", "value": "Acme Corporation"},
        {"field_id": "field_effective_date", "value": "2026-02-01"},
    ],
    test_mode=True,
)

Step 5: handle webhooks reliably

Polling works, but webhooks are the recommended source of truth for signing progress. Register an endpoint in Settings and store the signing secret it shows you once as an environment variable.

The signing events you'll receive:

EventWhen it fires
document_viewedA signer opened the document
document_signedA signer finished their part
document_completedAll signers done and the signed PDF is ready

Every delivery is signed with HMAC-SHA256 over the raw request body, sent in the Content-Sha256 header. Verify it before trusting the event. The important details: read the raw bytes with request.get_data() before any JSON parsing, base64-decode the secret before using it as the key, and compare with hmac.compare_digest.

# webhook.py
import base64
import hashlib
import hmac
import os

from flask import Flask, abort, request

from formable_client import formable

app = Flask(__name__)


def is_valid_signature(raw_body: bytes, received: str) -> bool:
    secret = base64.b64decode(os.environ["FORMABLE_WEBHOOK_SECRET"])
    digest = hmac.new(secret, raw_body, hashlib.sha256).digest()
    expected = base64.b64encode(digest).decode()
    return hmac.compare_digest(expected, received)


@app.post("/webhooks/formable")
def formable_webhook():
    received = request.headers.get("Content-Sha256", "")
    if not is_valid_signature(request.get_data(), received):
        abort(401)

    payload = request.get_json()

    if payload["event"]["event_type"] == "document_completed":
        signature_request_id = payload["signing"]["signature_request_id"]
        envelope = formable.signature_requests.get_signed_envelope(
            signature_request_id
        )
        # download envelope["signedEnvelopePresignedUrl"], store the PDF,
        # mark the record complete, notify your user

    return "", 200

Don't download on document_signed. It fires when an individual signer finishes, before the completed file is available. Wait for document_completed.

For local development, expose your handler with a tunneling tool like ngrok, or fall back to polling:

from datetime import datetime, timedelta, timezone

current = formable.signature_requests.get(signature_request_id)

if current["status"] == "Completed":
    # safe to download
    ...

# Reconcile in bulk after downtime
recent = formable.signature_requests.list(
    updated_since=datetime.now(timezone.utc) - timedelta(days=1)
)

Step 6: download the signed PDF

Once the request is complete, fetch a presigned URL for the signed PDF, called the signed envelope. The link is short-lived, so download promptly and store the file in your own object storage.

import httpx

envelope = formable.signature_requests.get_signed_envelope(signature_request_id)
pdf_bytes = httpx.get(envelope["signedEnvelopePresignedUrl"]).content

The signed PDF includes an audit trail with created, sent, signed, and completed events, each with timestamps, actors, and IP addresses. That audit trail is what makes the signature defensible under ESIGN and UETA, so store the PDF unmodified.


Going async with FastAPI

Every method is also available on AsyncFormable with the same signatures, so the SDK drops straight into FastAPI or any asyncio codebase:

import os

from fastapi import FastAPI
from formable import AsyncFormable

app = FastAPI()
formable = AsyncFormable(api_key=os.environ["FORMABLE_API_KEY"])


@app.get("/api/signing-url")
async def create_signing_url(recipient_signature_id: str):
    signing = await formable.signature_requests.create_signing_url(
        recipient_signature_id
    )
    return {"signingUrl": signing["signingUrl"], "expiresAt": signing["expiresAt"]}

Error handling and test mode

Every non-2xx response raises a FormableError carrying the HTTP status, server message, and parsed body. The one you'll hit most during integration is 409 from get_signed_envelope, which means the document isn't signed yet:

from formable import FormableError

try:
    formable.signature_requests.get_signed_envelope(signature_request_id)
except FormableError as error:
    if error.status == 409:
        # not signed yet — wait for the document_completed webhook
        ...
    else:
        raise

Other statuses to handle: 400 for invalid input like a field_id that doesn't exist on the template, 401 for a missing or invalid API key, and 404 for an unknown template or signature request ID.

While integrating, pass test_mode=True on every signature request. Test documents are watermarked, not legally binding, and don't count toward billing. Drop the flag when you go live.


Key takeaways

PointDetails
Four SDK calls end to endtemplates.create, signature_requests.create_embedded, create_signing_url, get_signed_envelope take you from a raw PDF to a signed document.
Keep keys server-sideThe browser only ever sees a short-lived signing URL; the API key stays in your backend environment.
Webhooks are the source of truthVerify the Content-Sha256 HMAC over the raw body with hmac.compare_digest, and act on document_completed.
Sync or asyncFormable for Flask and Django, AsyncFormable for FastAPI, with identical method signatures.
Test mode firsttest_mode=True gives you watermarked, non-billable documents for the whole integration phase.

FAQ

How do I add e-signatures to a Python application?

Install the formable-sdk package, create a template from your PDF or DOCX, then create a signature request. Formable can email signers directly, or you can generate a short-lived signing URL and embed it in an iframe in your own UI. A webhook tells you when signing completes so you can download the signed PDF.

Which Python web frameworks does the SDK work with?

Any of them. The sync Formable client fits Flask and Django, and the AsyncFormable client offers the same methods with async signatures for FastAPI and other asyncio frameworks.

How do I know when a document has been signed?

Register a webhook endpoint and handle the document_completed event, which fires once the signed PDF is ready. You can also poll the signature request until its status is Completed.

Can I test the integration without sending legally binding documents?

Yes. Pass test_mode=True when creating signature requests. Test documents are watermarked, not legally binding, and don't count toward billing.

Does the signed PDF include an audit trail?

Yes. Every signed document includes an appended audit trail with created, sent, signed, and completed events, including timestamps, actors, and IP addresses, which supports enforceability under ESIGN and UETA.

Formable
© 2026 Formable Inc. All rights reserved