
Integrating SignNow E-Signatures into Your Django Application
A practical guide to adding e-signatures to your Django app with the SignNow REST API: OAuth2 auth, document uploads, Celery signing workflows, and webhooks.
Key takeaways
- The full SignNow Django integration is about 400 lines across four files.
- All SignNow API calls run in Celery tasks to keep the request cycle fast.
- Role-based invites need a two-step process: add fields, then re-fetch to get role IDs.
- Cache OAuth2 tokens in Redis with a 5-minute buffer to avoid extra round-trips.
Electronic signatures have gone from "nice to have" to a hard requirement for any platform that deals with contracts, agreements, or compliance documents. Whether you're onboarding beta testers, sending NDAs, or processing deposit confirmations, manually emailing PDFs and chasing wet signatures simply doesn't scale.
For BeatBuddy, I needed a way to automatically send documents for signature as part of the tester onboarding flow - without leaving the Django admin. I chose airSlate SignNow for its developer-friendly REST API, generous sandbox environment, and competitive pricing.
This article walks through how I built the integration end-to-end: authenticating with OAuth2, uploading documents, sending signing invites, handling webhooks, and managing the full document lifecycle - all from within a Django + Celery stack.
Why SignNow?
Before diving into code, here's why I picked SignNow over alternatives like DocuSign or HelloSign:
| Criteria | SignNow |
|---|---|
| Sandbox | Free, 2,000 signature invites for testing |
| API style | Clean REST API with JSON payloads |
| Authentication | Standard OAuth2 (password grant) |
| Webhooks | Per-document event subscriptions |
| Pricing | Significantly cheaper than DocuSign at scale |
| SDKs | Official SDKs for Python, Node.js, PHP, Java, C# |
For my use case - programmatically sending documents for a single signer - SignNow's API was straightforward and well-documented.
Architecture overview
Here's how the integration fits into my Django application:
flowchart TD
A["Django Admin (trigger sign)"] --> B["Celery Worker (async pipeline)"]
B --> C["SignNow API (upload, sign)"]
C -- "webhook" --> D["Webhook View (POST receiver)"]
D --> E["Celery Worker (download PDF)"]
The key design decisions:
- Async everything - All SignNow API calls happen in Celery tasks, never in the request cycle
- Generic relations - The signing tracker (
SignableDocument) can attach to any Django model viaContentType - Idempotent operations - The pipeline gracefully handles retries and duplicate webhook events
- Service layer pattern - A thin
SignNowServiceclass wraps all raw API calls
Step 1: Get your SignNow API credentials
- Create a free sandbox account at signnow.com/developers
- In the API dashboard, create a new application to get your Client ID and Client Secret
- Note your sandbox base URL:
https://api-eval.signnow.com(production useshttps://api.signnow.com)
Add these to your .env file:
# SignNow e-signature (https://www.signnow.com/developers)
SIGNNOW_API_BASE_URL=https://api-eval.signnow.com # Use api.signnow.com for production
SIGNNOW_BASIC_AUTH= # Base64-encoded client_id:client_secret
SIGNNOW_USERNAME= # Your SignNow account email
SIGNNOW_PASSWORD= # Your SignNow account password
SIGNNOW_WEBHOOK_SECRET= # For verifying webhook payloads
SIGNNOW_WEBHOOK_CALLBACK_URL= # Your public webhook endpoint
The SIGNNOW_BASIC_AUTH value must be the Base64 encoding of client_id:client_secret. You can generate it with: echo -n "your_client_id:your_client_secret" | base64
Load these in your Django settings:
# SignNow e-signature
SIGNNOW_API_BASE_URL = env.str("SIGNNOW_API_BASE_URL", default="https://api.signnow.com")
SIGNNOW_BASIC_AUTH = env.str("SIGNNOW_BASIC_AUTH", default="")
SIGNNOW_USERNAME = env.str("SIGNNOW_USERNAME", default="")
SIGNNOW_PASSWORD = env.str("SIGNNOW_PASSWORD", default="")
SIGNNOW_WEBHOOK_SECRET = env.str("SIGNNOW_WEBHOOK_SECRET", default="")
SIGNNOW_WEBHOOK_CALLBACK_URL = env.str("SIGNNOW_WEBHOOK_CALLBACK_URL", default="")
Step 2: Build the API client (service layer)
Rather than scattering HTTP calls throughout the codebase, I encapsulated all SignNow API interactions in a single SignNowService class. This makes testing, error handling, and future changes much simpler.
OAuth2 authentication with token caching
SignNow uses the OAuth2 password grant to obtain access tokens. Tokens expire after a configurable period (typically 1 hour), so I cache them in Redis with a safety buffer:
import httpx
from django.conf import settings
from django.core.cache import cache
SIGNNOW_TOKEN_CACHE_KEY = "signnow_access_token"
SIGNNOW_TOKEN_TTL_BUFFER = 300 # Refresh 5 min before expiry
class SignNowService:
"""Raw REST client for the SignNow API using httpx."""
_initialized = False
_base_url = ""
_basic_auth = ""
_username = ""
_password = ""
@classmethod
def _ensure_initialized(cls) -> bool:
"""Initialize SignNow configuration from settings."""
if cls._initialized:
return True
cls._base_url = getattr(settings, "SIGNNOW_API_BASE_URL", "")
cls._basic_auth = getattr(settings, "SIGNNOW_BASIC_AUTH", "")
cls._username = getattr(settings, "SIGNNOW_USERNAME", "")
cls._password = getattr(settings, "SIGNNOW_PASSWORD", "")
if not all([cls._base_url, cls._basic_auth, cls._username, cls._password]):
logger.warning("SignNow API not fully configured - missing required settings")
return False
cls._initialized = True
return True
@classmethod
def _get_access_token(cls) -> str | None:
"""Get a valid access token, using cache or requesting a new one."""
cached = cache.get(SIGNNOW_TOKEN_CACHE_KEY)
if cached:
return cached
with httpx.Client(timeout=30.0) as client:
resp = client.post(
f"{cls._base_url}/oauth2/token",
data={
"grant_type": "password",
"username": cls._username,
"password": cls._password,
"scope": "*",
},
headers={
"Authorization": f"Basic {cls._basic_auth}",
"Content-Type": "application/x-www-form-urlencoded",
},
)
resp.raise_for_status()
data = resp.json()
token = data["access_token"]
expires_in = data.get("expires_in", 3600)
ttl = max(expires_in - SIGNNOW_TOKEN_TTL_BUFFER, 60)
cache.set(SIGNNOW_TOKEN_CACHE_KEY, token, timeout=ttl)
return token
I use httpx instead of requests for its modern API, built-in timeout support, and async capabilities. If you later need to make concurrent API calls, httpx supports AsyncClient out of the box.
Document operations
With authentication handled, the core API methods are straightforward:
@classmethod
def upload_document(cls, pdf_bytes: bytes, filename: str) -> dict | None:
"""Upload a PDF to SignNow. Returns {"id": "document_id"} on success."""
token = cls._get_access_token()
if not token:
return None
with httpx.Client(timeout=60.0) as client:
resp = client.post(
f"{cls._base_url}/document",
headers=cls._auth_headers(token),
files={"file": (filename, pdf_bytes, "application/pdf")},
)
resp.raise_for_status()
return {"id": resp.json().get("id", "")}
@classmethod
def add_signature_fields(cls, document_id: str, fields: list[dict]) -> bool:
"""Add signature/text fields to an uploaded document."""
token = cls._get_access_token()
if not token:
return False
with httpx.Client(timeout=30.0) as client:
resp = client.put(
f"{cls._base_url}/document/{document_id}",
headers={**cls._auth_headers(token), "Content-Type": "application/json"},
json={"fields": fields},
)
resp.raise_for_status()
return True
@classmethod
def download_signed_document(cls, document_id: str) -> bytes | None:
"""Download the signed (collapsed) PDF."""
token = cls._get_access_token()
if not token:
return None
with httpx.Client(timeout=60.0) as client:
resp = client.get(
f"{cls._base_url}/document/{document_id}/download",
headers=cls._auth_headers(token),
params={"type": "collapsed"},
)
resp.raise_for_status()
return resp.content
Sending invites
SignNow supports two invite types:
- Freeform invites - The signer places their signature wherever they want
- Role-based invites - Signature fields are pre-positioned, and signers fill specific roles
I used role-based invites because I wanted to control exactly where the signature appears on each document:
@classmethod
def send_role_based_invite(
cls,
document_id: str,
signers: list[dict],
from_email: str,
subject: str = "",
message: str = "",
) -> dict | None:
"""
Send a role-based invite with specific field assignments.
Each signer dict: {"email": ..., "role": ..., "role_id": ..., "order": ...}
"""
token = cls._get_access_token()
if not token:
return None
payload = {"to": signers, "from": from_email}
if subject:
payload["subject"] = subject
if message:
payload["message"] = message
with httpx.Client(timeout=30.0) as client:
resp = client.post(
f"{cls._base_url}/document/{document_id}/invite",
headers={**cls._auth_headers(token), "Content-Type": "application/json"},
json=payload,
)
resp.raise_for_status()
return resp.json()
Webhook registration
To get notified when a document is signed, I register a webhook for each document:
@classmethod
def register_webhook(cls, event: str, entity_id: str, callback_url: str) -> bool:
"""Register a webhook callback for a specific event on a document."""
token = cls._get_access_token()
if not token:
return False
payload = {
"event": event,
"entity_id": entity_id,
"action": "callback",
"attributes": {
"callback": callback_url,
"use_tls_12": True,
},
}
webhook_secret = getattr(settings, "SIGNNOW_WEBHOOK_SECRET", "")
if webhook_secret:
payload["attributes"]["secret_key"] = webhook_secret
with httpx.Client(timeout=30.0) as client:
resp = client.post(
f"{cls._base_url}/v2/events",
headers={**cls._auth_headers(token), "Content-Type": "application/json"},
json=payload,
)
resp.raise_for_status()
return True
Step 3: Track document state with a Django model
I need to track each document's journey through the signing pipeline. The SignableDocument model uses Django's GenericForeignKey so it can be attached to any model in the system - a deposit confirmation, an NDA, a beta testing agreement, etc.
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.db import models
class SignableDocument(models.Model):
"""Tracks a document uploaded to SignNow for e-signature."""
class Status(models.TextChoices):
PENDING = "pending", "Pending Upload"
UPLOADED = "uploaded", "Uploaded to SignNow"
INVITE_SENT = "invite_sent", "Invite Sent"
SIGNED = "signed", "Signed"
DOWNLOADED = "downloaded", "Signed PDF Downloaded"
FAILED = "failed", "Failed"
# Generic relation - attach to any Django model
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField()
source_document = GenericForeignKey("content_type", "object_id")
# SignNow tracking
signnow_document_id = models.CharField(max_length=64, blank=True, db_index=True)
signnow_invite_id = models.CharField(max_length=64, blank=True)
# Signer info
signer_email = models.EmailField()
signer_name = models.CharField(max_length=255, blank=True)
# Status & errors
status = models.CharField(max_length=16, choices=Status.choices, default=Status.PENDING)
error_message = models.TextField(blank=True)
# Files
original_pdf_name = models.CharField(max_length=512, blank=True)
signed_pdf = models.FileField(upload_to="signnow/signed/%Y/%m/", blank=True)
# Timestamps
uploaded_at = models.DateTimeField(null=True, blank=True)
invite_sent_at = models.DateTimeField(null=True, blank=True)
signed_at = models.DateTimeField(null=True, blank=True)
downloaded_at = models.DateTimeField(null=True, blank=True)
The status flow is linear and predictable:
PENDING → UPLOADED → INVITE_SENT → SIGNED → DOWNLOADED
↘ FAILED (at any step)
Step 4: Build the async signing pipeline with Celery
This is where everything comes together. A single Celery task orchestrates the entire signing flow - upload the document, add signature fields, send the invite, and register the webhook:
from celery import shared_task
# Signature field position (calibrate for your PDF layout)
TESTER_SIGNATURE_FIELD = {
"x": 350,
"y": 700,
"width": 200,
"height": 50,
"page_number": 0,
"type": "signature",
"role": "Signer 1",
"required": True,
"label": "Tester Signature",
}
@shared_task(bind=True, max_retries=3, default_retry_delay=60)
def upload_and_send_for_signature(self, signable_document_id: int):
"""Full pipeline: upload PDF → add fields → send invite → register webhook."""
from signnow.models import SignableDocument
from signnow.services.signnow_service import SignNowService
signable = SignableDocument.objects.select_related("content_type").get(
id=signable_document_id
)
try:
# 1. Read PDF from storage
source = signable.source_document
pdf_bytes = source.pdf_file.read()
filename = source.pdf_file.name.split("/")[-1]
# 2. Upload to SignNow
if not signable.signnow_document_id:
result = SignNowService.upload_document(pdf_bytes, filename)
if not result:
raise RuntimeError("Failed to upload document to SignNow")
signable.signnow_document_id = result["id"]
signable.status = SignableDocument.Status.UPLOADED
signable.uploaded_at = timezone.now()
signable.save(update_fields=["signnow_document_id", "status", "uploaded_at"])
# 3. Add signature fields
SignNowService.add_signature_fields(
signable.signnow_document_id,
[TESTER_SIGNATURE_FIELD],
)
# 4. Get role_id (assigned when fields were added)
doc_data = SignNowService.get_document(signable.signnow_document_id)
roles = doc_data.get("roles", [])
signer_role = next(r for r in roles if r.get("name") == "Signer 1")
# 5. Send role-based invite
signers = [{
"email": signable.signer_email,
"role": "Signer 1",
"role_id": signer_role["unique_id"],
"order": 1,
}]
invite_result = SignNowService.send_role_based_invite(
document_id=signable.signnow_document_id,
signers=signers,
from_email=settings.SIGNNOW_USERNAME,
)
signable.status = SignableDocument.Status.INVITE_SENT
signable.invite_sent_at = timezone.now()
signable.save(update_fields=["status", "invite_sent_at"])
# 6. Register webhook for document completion
callback_url = settings.SIGNNOW_WEBHOOK_CALLBACK_URL
if callback_url:
SignNowService.register_webhook(
event="document.complete",
entity_id=signable.signnow_document_id,
callback_url=callback_url,
)
except Exception as exc:
if self.request.retries >= self.max_retries:
signable.status = SignableDocument.Status.FAILED
signable.error_message = str(exc)
signable.save(update_fields=["status", "error_message"])
return
self.retry(exc=exc)
The TESTER_SIGNATURE_FIELD coordinates (x, y, width, height) define where the signature box appears on the PDF. You'll need to calibrate these values for your specific document layout. SignNow uses a coordinate system where (0,0) is the top-left corner of the page.
Step 5: Handle webhooks
When the signer completes the document, SignNow sends a POST request to your webhook URL. I verify the payload signature and dispatch a download task:
import hashlib
import hmac
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework.views import APIView
class SignNowWebhookView(APIView):
"""Receives webhook callbacks from SignNow when documents are signed."""
permission_classes = [AllowAny]
authentication_classes = []
def post(self, request):
# Verify webhook signature
webhook_secret = getattr(settings, "SIGNNOW_WEBHOOK_SECRET", "")
if webhook_secret:
received_signature = request.headers.get("X-SignNow-Signature", "")
expected = hmac.new(
webhook_secret.encode("utf-8"),
request.body,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, received_signature):
return Response({"status": "invalid_signature"}, status=200)
# Parse event
event = request.data.get("event", "")
meta = request.data.get("meta", {})
document_id = meta.get("document_id", "")
if event in ("document.complete", "document.update"):
signable = SignableDocument.objects.get(
signnow_document_id=document_id,
status__in=["uploaded", "invite_sent"],
)
signable.status = SignableDocument.Status.SIGNED
signable.signed_at = timezone.now()
signable.save(update_fields=["status", "signed_at"])
# Download the signed PDF asynchronously
download_signed_pdf.delay(signable.id)
# Always return 200 to acknowledge receipt
return Response({"status": "ok"}, status=200)
Always return HTTP 200 from your webhook endpoint, even when processing fails. SignNow will retry delivery on non-2xx responses, which can lead to duplicate processing if your error is in business logic rather than network issues.
Wire it up in your URL configuration:
from django.urls import path
from signnow.api.views import SignNowWebhookView
urlpatterns = [
path("webhooks/", SignNowWebhookView.as_view(), name="signnow-webhook"),
]
And include it in your main API URLs:
urlpatterns = [
# ... other routes
path("v1/signnow/", include("signnow.api.urls")),
]
Step 6: Download the signed PDF
The second Celery task fetches the completed, signed document from SignNow and saves it locally:
@shared_task(bind=True, max_retries=5, default_retry_delay=120)
def download_signed_pdf(self, signable_document_id: int):
"""Download the signed PDF from SignNow and save it locally."""
from signnow.models import SignableDocument
from signnow.services.signnow_service import SignNowService
signable = SignableDocument.objects.get(id=signable_document_id)
# Skip if already downloaded (idempotent)
if signable.status == SignableDocument.Status.DOWNLOADED:
return
pdf_bytes = SignNowService.download_signed_document(signable.signnow_document_id)
if not pdf_bytes:
raise RuntimeError("Failed to download signed document")
signed_filename = f"signed-{signable.original_pdf_name.split('/')[-1]}"
signable.signed_pdf.save(signed_filename, ContentFile(pdf_bytes), save=False)
signable.status = SignableDocument.Status.DOWNLOADED
signable.downloaded_at = timezone.now()
signable.save(update_fields=["signed_pdf", "status", "downloaded_at"])
Step 7: Admin integration
Finally, I added a read-only admin panel with color-coded status badges so I can monitor signing progress at a glance:
from django.contrib import admin
from django.utils.html import format_html
@admin.register(SignableDocument)
class SignableDocumentAdmin(admin.ModelAdmin):
list_display = ["signer_name", "signer_email", "status_badge", "created_at", "signed_at"]
list_filter = ["status", "created_at"]
search_fields = ["signer_email", "signer_name", "signnow_document_id"]
@admin.display(description="Status")
def status_badge(self, obj):
colors = {
"pending": "#6b7280",
"uploaded": "#3b82f6",
"invite_sent": "#f59e0b",
"signed": "#22c55e",
"downloaded": "#059669",
"failed": "#ef4444",
}
color = colors.get(obj.status, "#6b7280")
return format_html(
'<span style="background-color: {}; color: white; padding: 2px 8px; '
'border-radius: 4px; font-size: 12px;">{}</span>',
color,
obj.get_status_display(),
)
Lessons learned
After running this in production, here are the things I wish I'd known earlier:
1. Role-based invites require a two-step process
You can't send a role-based invite immediately after adding fields. You need to re-fetch the document to get the role_id that SignNow assigns when processing the fields. This caught me off guard because freeform invites don't have this requirement.
2. Token caching is essential
SignNow's OAuth2 tokens last about an hour. Without caching, every API call would require a round-trip to the token endpoint first. I use Django's cache framework (backed by Redis) to store tokens with a 5-minute safety buffer before expiry.
3. Webhook signatures use HMAC-SHA256
Always verify webhook payloads in production. SignNow sends a X-SignNow-Signature header containing an HMAC-SHA256 digest of the request body. Use hmac.compare_digest() for timing-safe comparison.
4. Custom invite messages require a paid plan
If you're using the free sandbox, you can send invites, but custom subjects and messages are a paid feature. The default invite email from SignNow is generic but functional for testing.
5. Signature field coordinates need calibration
The x, y, width, and height values for signature fields depend entirely on your PDF layout. I recommend uploading a test document to SignNow's web UI, manually placing a signature field, and then inspecting the document's JSON to capture the exact coordinates.
Summary
Here's the complete API flow at a glance:
| Step | API Endpoint | Method | Purpose |
|---|---|---|---|
| 1 | /oauth2/token | POST | Get access token |
| 2 | /document | POST | Upload PDF |
| 3 | /document/{id} | PUT | Add signature fields |
| 4 | /document/{id} | GET | Retrieve role IDs |
| 5 | /document/{id}/invite | POST | Send signing invite |
| 6 | /v2/events | POST | Register webhook |
| 7 | /document/{id}/download | GET | Download signed PDF |
The full integration is about 400 lines of Python across four files - a service layer, a model, two Celery tasks, and a webhook view. It plugs cleanly into any Django project that needs e-signature capabilities.
Cover: Photo by Scott Graham on Unsplash
The very first attempt to implement 4DX in Bravelab.io
A real-life example of implementing the Four Disciplines of Execution framework in a software development company. Learn what worked, what didn't, and how to get started with 4DX.
Tempus Metronome and GetSongBPM API
What BPM really means and how I integrated the GetSongBPM API into Tempus Metronome using Flutter to let users look up song tempos instantly.