API Integration·

Integrating SignNow E-Signatures into Your Django Application

A practical guide to adding e-signature capabilities to your Django app using the SignNow REST API. We walk through the full integration - from OAuth2 authentication and document uploads to asynchronous signing workflows with Celery and real-time webhook handling - based on our production implementation at BeatBuddy.
Integrating SignNow E-Signatures into Your Django Application

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.

Why SignNow?

Before diving into code, here's why we picked SignNow over alternatives like DocuSign or HelloSign:

CriteriaSignNow
SandboxFree, 2,000 signature invites for testing
API styleClean REST API with JSON payloads
AuthenticationStandard OAuth2 (password grant)
WebhooksPer-document event subscriptions
PricingSignificantly cheaper than DocuSign at scale
SDKsOfficial 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.

Architecture overview

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:

  1. Async everything - All SignNow API calls happen in Celery tasks, never in the request cycle
  2. Generic relations - The signing tracker (SignableDocument) can attach to any Django model via ContentType
  3. Idempotent operations - The pipeline gracefully handles retries and duplicate webhook events
  4. Service layer pattern - A thin SignNowService class wraps all raw API calls

Step 1: Get your SignNow API credentials

  1. Create a free sandbox account at signnow.com/developers
  2. In the API dashboard, create a new application to get your Client ID and Client Secret
  3. Note your sandbox base URL: https://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
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, we 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 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
We 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

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()

Webhook registration

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

Step 3: Track document state with a Django model

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)

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. 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)
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, 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(),
        )

Lessons learned

After running this in production, here are the things we wish we'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 us 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. We 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. 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.

Summary

Here's the complete API flow at a glance:

StepAPI EndpointMethodPurpose
1/oauth2/tokenPOSTGet access token
2/documentPOSTUpload PDF
3/document/{id}PUTAdd signature fields
4/document/{id}GETRetrieve role IDs
5/document/{id}/invitePOSTSend signing invite
6/v2/eventsPOSTRegister webhook
7/document/{id}/downloadGETDownload 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.

Need Help with This?

Building something similar or facing technical challenges? We've been there.

Let's talk — no sales pitch, just honest engineering advice.