
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.
At BeatBuddy, we needed a way to automatically send documents for signature as part of our tester onboarding flow - without leaving the Django admin. We chose airSlate SignNow for its developer-friendly REST API, generous sandbox environment, and competitive pricing.
This article walks through how we 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.
Before diving into code, here's why we 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 our use case - programmatically sending documents for a single signer - SignNow's API was straightforward and well-documented.
Here's how the integration fits into our Django application:
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Django Admin │────▶│ Celery Worker │────▶│ SignNow API │
│ (trigger sign) │ │ (async pipeline) │ │ (upload, sign) │
└─────────────────┘ └──────────────────┘ └────────┬────────┘
│
│ webhook
▼
┌──────────────────┐ ┌─────────────────┐
│ Celery Worker │◀───│ Webhook View │
│ (download PDF) │ │ (POST receiver) │
└──────────────────┘ └─────────────────┘
The key design decisions:
SignableDocument) can attach to any Django model via ContentTypeSignNowService class wraps all raw API callshttps://api-eval.signnow.com (production uses https://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
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" | base64Load 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="")
Rather than scattering HTTP calls throughout the codebase, we encapsulated all SignNow API interactions in a single SignNowService class. This makes testing, error handling, and future changes much simpler.
SignNow uses the OAuth2 password grant to obtain access tokens. Tokens expire after a configurable period (typically 1 hour), so we 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
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.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
SignNow supports two invite types:
We used role-based invites because we 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()
To get notified when a document is signed, we 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
We 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)
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)
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.When the signer completes the document, SignNow sends a POST request to your webhook URL. We 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)
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")),
]
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"])
Finally, we added a read-only admin panel with color-coded status badges so our team 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(),
)
After running this in production, here are the things we wish we'd known earlier:
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 us off guard because freeform invites don't have this requirement.
SignNow's OAuth2 tokens last about an hour. Without caching, every API call would require a round-trip to the token endpoint first. We use Django's cache framework (backed by Redis) to store tokens with a 5-minute safety buffer before expiry.
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.
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.
The x, y, width, and height values for signature fields depend entirely on your PDF layout. We 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.
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.
Need to add e-signatures or other third-party integrations to your platform? Music Tech Lab builds production-grade integrations for music and entertainment tech companies. Get in touch.
Building something similar or facing technical challenges? We've been there.
Let's talk — no sales pitch, just honest engineering advice.
Installing Proxmox on dedicated server from OVH
A step-by-step guide to installing Proxmox on a dedicated OVH server, including network configuration, LXC containers, and backup management tips.
Tempus Metronome and GetSongBPM API
What BPM really means and how we integrated the GetSongBPM API into Tempus Metronome using Flutter to let users look up song tempos instantly.
Get music tech insights, case studies, and industry news delivered to your inbox.