# How to Create a Custom Document Signing Feature in FlutterFlow

- Tool: FlutterFlow
- Difficulty: Beginner
- Time required: 55-65 min
- Compatibility: FlutterFlow Pro+ (code export required for Canvas signature widget and pdf-lib integration)
- Last updated: March 2026

## TL;DR

Build legally defensible document signing in FlutterFlow using the pdfx package for PDF rendering, a Custom Widget with a Canvas for signature capture, a Cloud Function that merges the signature onto the PDF using pdf-lib, and a Firestore audit trail with SHA-256 document hashes. Store signatures as Firebase Storage file paths — never as base64 strings in Firestore, which would bloat each document by 500KB or more.

## Building Document Signing That Holds Up

Document signing in a mobile app must satisfy three requirements to be legally useful: the signer's identity must be established (authentication), the signature must be captured with intent (deliberate action), and the signed document must be tamper-evident (hash-verified). FlutterFlow's Firebase authentication handles identity. A Canvas-based signature widget captures intent. A SHA-256 hash of the original PDF stored before signing detects any post-signing tampering. Together, these create an audit trail that is defensible in most jurisdictions for lower-stakes agreements like NDAs, consent forms, and service contracts.

## Before you start

- A FlutterFlow project with Firebase Storage and Firestore connected
- FlutterFlow Pro plan for code export and Custom Widgets
- Firebase project on Blaze plan for Cloud Functions (required for server-side PDF merging with pdf-lib)
- PDF documents to be signed, hosted in Firebase Storage

## Step-by-step guide

### 1. Set up the Firestore schema for signing requests and audit trail

Create a Firestore collection named 'signingRequests'. Each document represents one document requiring signature(s) and contains: documentId (String), documentUrl (String, Firebase Storage URL to the original PDF), documentHash (String, SHA-256 of the original PDF), documentTitle (String), status (String: 'pending', 'partially_signed', 'completed'), signers (Array of Maps: {userId, email, displayName, signedAt (null or Timestamp), signatureStoragePath (String)}), createdAt (Timestamp), createdBy (String userId), signedDocumentUrl (String, populated after all signatures collected). Also create a 'auditTrail' sub-collection under each signing request document: one document per signing event with fields: action, userId, email, timestamp, ipAddress (optional), documentHashAtTime.

**Expected result:** Firestore shows the signingRequests collection with the correct field structure and an empty signingRequests/{id}/auditTrail sub-collection.

### 2. Render the PDF for review before signing

Add the pdfx package to your exported Flutter project's pubspec.yaml. Create a Custom Widget named 'PdfViewer' that accepts a pdfUrl (String) parameter. Inside, initialize a PdfController by loading the PDF from the network URL using PdfDocument.openFile(). Render it using the PdfView widget from pdfx, which provides page-by-page rendering with pinch-to-zoom. Add previous/next page buttons and a page counter ('Page 2 of 5'). Show a loading spinner while the PDF loads. Below the PdfViewer, add a 'I have read and agree to the terms above' CheckboxListTile that the user must check before the signature canvas appears. This mandatory review step is important for consent validity.

```
// Custom Widget: PdfViewer
import 'package:pdfx/pdfx.dart';
import 'package:flutter/material.dart';

class PdfViewerWidget extends StatefulWidget {
  final String pdfUrl;
  const PdfViewerWidget({Key? key, required this.pdfUrl}) : super(key: key);

  @override
  State<PdfViewerWidget> createState() => _PdfViewerWidgetState();
}

class _PdfViewerWidgetState extends State<PdfViewerWidget> {
  late final PdfControllerPinch _pdfController;
  int _currentPage = 1;
  int _totalPages = 1;

  @override
  void initState() {
    super.initState();
    _pdfController = PdfControllerPinch(
      document: PdfDocument.openFile(widget.pdfUrl),
    );
  }

  @override
  void dispose() {
    _pdfController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Column(children: [
      Expanded(
        child: PdfViewPinch(
          controller: _pdfController,
          onDocumentLoaded: (doc) =>
              setState(() => _totalPages = doc.pagesCount),
          onPageChanged: (page) =>
              setState(() => _currentPage = page),
        ),
      ),
      Padding(
        padding: const EdgeInsets.all(8),
        child: Text(
          'Page $_currentPage of $_totalPages',
          style: const TextStyle(fontSize: 14),
        ),
      ),
    ]);
  }
}
```

**Expected result:** The PdfViewer widget renders the PDF document with page navigation and pinch-to-zoom working on a physical device.

### 3. Build the signature capture Canvas Custom Widget

Create a Custom Widget named 'SignatureCanvas'. It renders a white rectangular Canvas area with a 'Sign here' prompt. Capture touch points using a GestureDetector with onPanStart, onPanUpdate, and onPanEnd handlers that build a list of Offset points. Use CustomPainter to draw the signature path from these points. Add a 'Clear' button to reset the points list. Add a 'Save Signature' button that converts the Canvas to a PNG image using the RenderRepaintBoundary technique, then calls a callback function with the PNG bytes. The widget should expose a 'isSigned' boolean that returns true if any points have been drawn — use this to disable the Save button until the user has drawn something.

```
// Key signature capture logic
import 'dart:ui' as ui;
import 'package:flutter/rendering.dart';
import 'package:flutter/material.dart';

class SignatureCanvas extends StatefulWidget {
  final Function(Uint8List bytes) onSigned;
  const SignatureCanvas({Key? key, required this.onSigned}) : super(key: key);

  @override
  State<SignatureCanvas> createState() => _SignatureCanvasState();
}

class _SignatureCanvasState extends State<SignatureCanvas> {
  final List<List<Offset>> _strokes = [];
  List<Offset> _currentStroke = [];
  final GlobalKey _repaintKey = GlobalKey();

  Future<void> _saveSignature() async {
    final boundary = _repaintKey.currentContext!.findRenderObject()
        as RenderRepaintBoundary;
    final image = await boundary.toImage(pixelRatio: 2.0);
    final byteData = await image.toByteData(
      format: ui.ImageByteFormat.png,
    );
    widget.onSigned(byteData!.buffer.asUint8List());
  }

  @override
  Widget build(BuildContext context) {
    return Column(children: [
      RepaintBoundary(
        key: _repaintKey,
        child: GestureDetector(
          onPanStart: (d) {
            setState(() => _currentStroke = [d.localPosition]);
          },
          onPanUpdate: (d) {
            setState(() => _currentStroke.add(d.localPosition));
          },
          onPanEnd: (_) {
            setState(() {
              _strokes.add(List.from(_currentStroke));
              _currentStroke = [];
            });
          },
          child: Container(
            height: 150,
            decoration: BoxDecoration(
              color: Colors.white,
              border: Border.all(color: Colors.grey.shade300),
              borderRadius: BorderRadius.circular(8),
            ),
            child: CustomPaint(
              painter: _SignaturePainter(
                  strokes: _strokes, current: _currentStroke),
              size: Size.infinite,
            ),
          ),
        ),
      ),
      Row(children: [
        TextButton(
          onPressed: () => setState(() {
            _strokes.clear();
            _currentStroke = [];
          }),
          child: const Text('Clear'),
        ),
        ElevatedButton(
          onPressed: _strokes.isEmpty ? null : _saveSignature,
          child: const Text('Save Signature'),
        ),
      ]),
    ]);
  }
}
```

**Expected result:** The signature canvas accepts touch drawing, renders strokes in real time, and exports a clean PNG of the signature on Save tap.

### 4. Upload signature image and merge PDF via Cloud Function

When the user saves their signature, a Custom Action named 'submitSignature' runs: Step 1 — upload the signature PNG bytes to Firebase Storage at path 'signatures/{signingRequestId}/{userId}.png' and get the download URL. Step 2 — call a Cloud Function named 'mergeSignatureIntoPdf'. The Cloud Function downloads the original PDF from Firebase Storage, loads it with pdf-lib, creates an image object from the signature PNG, and stamps it at the designated signature field coordinates in the PDF. It saves the merged PDF to 'signed_documents/{signingRequestId}/{userId}_signed.pdf' in Firebase Storage. Step 3 — update the Firestore signing request document: set the signer's 'signedAt' timestamp and 'signatureStoragePath'. Step 4 — write an audit trail document with userId, timestamp, and document hash.

```
// Cloud Function: mergeSignatureIntoPdf (Node.js)
const functions = require('firebase-functions');
const admin = require('firebase-admin');
const { PDFDocument } = require('pdf-lib');
const fetch = require('node-fetch');

exports.mergeSignatureIntoPdf = functions.https.onCall(
  async (data, context) => {
    if (!context.auth) {
      throw new functions.https.HttpsError('unauthenticated', 'Login required');
    }
    const { requestId, signatureUrl, signatureX, signatureY,
      signatureWidth, signatureHeight } = data;
    const db = admin.firestore();
    const bucket = admin.storage().bucket();

    // Load original PDF
    const reqDoc = await db.collection('signingRequests').doc(requestId).get();
    const { documentUrl } = reqDoc.data();
    const pdfResponse = await fetch(documentUrl);
    const pdfBytes = await pdfResponse.arrayBuffer();
    const pdfDoc = await PDFDocument.load(pdfBytes);

    // Load signature PNG
    const sigResponse = await fetch(signatureUrl);
    const sigBytes = await sigResponse.arrayBuffer();
    const sigImage = await pdfDoc.embedPng(sigBytes);

    // Stamp signature on last page (or target page)
    const pages = pdfDoc.getPages();
    const targetPage = pages[pages.length - 1];
    targetPage.drawImage(sigImage, {
      x: signatureX,
      y: signatureY,
      width: signatureWidth,
      height: signatureHeight,
    });

    // Save merged PDF
    const mergedBytes = await pdfDoc.save();
    const outPath = `signed_documents/${requestId}/${context.auth.uid}_signed.pdf`;
    const file = bucket.file(outPath);
    await file.save(Buffer.from(mergedBytes), {
      metadata: { contentType: 'application/pdf' },
    });
    const [signedUrl] = await file.getSignedUrl({
      action: 'read',
      expires: Date.now() + 7 * 24 * 60 * 60 * 1000,
    });
    return { signedDocumentUrl: signedUrl };
  }
);
```

**Expected result:** After signing, a merged PDF appears in Firebase Storage with the signature visually embedded at the correct position on the document.

### 5. Implement the multi-signer workflow and completion logic

After each signature is collected, a Cloud Function triggered on Firestore update checks if all required signers have signed (all signer.signedAt values are non-null). If yes, it updates the document status to 'completed', sends a completion email to all signers via SendGrid with the final signed document attached, and creates a final merged PDF that includes all signatures. For the admin view, create a 'Document Status' page showing a list of signing requests with status indicators: pending (grey), partially signed (yellow, with count 'Signed by 2 of 3'), and completed (green). Clicking a request shows the signer list with individual sign timestamps and a download button for the completed document.

**Expected result:** When all required signers complete their signatures, the request status updates to 'completed' and all parties receive an email with the final signed document.

## Complete code example

File: `document_signing_helpers.dart`

```dart
// Document Signing Custom Actions
// Add to FlutterFlow Custom Code panel

import 'dart:convert';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:firebase_storage/firebase_storage.dart';
import 'package:cloud_functions/cloud_functions.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:cloud_firestore/cloud_firestore.dart';

// ─── Upload signature PNG to Firebase Storage ─────────────────────────────────
Future<String> uploadSignature(
  Uint8List signatureBytes,
  String requestId,
) async {
  final user = FirebaseAuth.instance.currentUser;
  if (user == null) throw Exception('Not authenticated');
  final path = 'signatures/$requestId/${user.uid}.png';
  final ref = FirebaseStorage.instance.ref(path);
  await ref.putData(
    signatureBytes,
    SettableMetadata(contentType: 'image/png'),
  );
  return await ref.getDownloadURL();
}

// ─── Compute SHA-256 hash of PDF bytes ───────────────────────────────────────
String computeSha256(Uint8List bytes) {
  final digest = sha256.convert(bytes);
  return digest.toString();
}

// ─── Write audit trail event to Firestore ────────────────────────────────────
Future<void> writeAuditEvent(
  String requestId,
  String action,
  String documentHashAtTime,
) async {
  final user = FirebaseAuth.instance.currentUser;
  if (user == null) return;
  await FirebaseFirestore.instance
      .collection('signingRequests')
      .doc(requestId)
      .collection('auditTrail')
      .add({
        'action': action,
        'userId': user.uid,
        'email': user.email,
        'documentHashAtTime': documentHashAtTime,
        'timestamp': FieldValue.serverTimestamp(),
      });
}

// ─── Full signing submission flow ─────────────────────────────────────────────
Future<String> submitSignature({
  required Uint8List signatureBytes,
  required String requestId,
  required String originalDocumentHash,
  required double signatureX,
  required double signatureY,
  required double signatureWidth,
  required double signatureHeight,
}) async {
  // 1. Upload signature image
  final signatureUrl =
      await uploadSignature(signatureBytes, requestId);

  // 2. Call Cloud Function to merge PDF
  final cf = FirebaseFunctions.instance;
  final result = await cf
      .httpsCallable('mergeSignatureIntoPdf')
      .call({
        'requestId': requestId,
        'signatureUrl': signatureUrl,
        'signatureX': signatureX,
        'signatureY': signatureY,
        'signatureWidth': signatureWidth,
        'signatureHeight': signatureHeight,
      });

  // 3. Write audit trail
  await writeAuditEvent(
      requestId, 'document_signed', originalDocumentHash);

  return result.data['signedDocumentUrl'] as String;
}
```

## Common mistakes

- **Storing signature images as base64 strings directly in Firestore documents** — A typical signature PNG at 2x pixel ratio is 300-700KB. Stored as base64, it grows by roughly 33% to 400-950KB. Firestore documents have a 1MB limit per document. A signing request with 2-3 signers storing base64 signatures exceeds the limit. Additionally, every time the signing request document is read, the full base64 bytes are transferred — bloating every query that touches this collection. Fix: Upload signature images to Firebase Storage and store only the Storage path or download URL in Firestore (a string of 100-200 characters). Firebase Storage is built for binary file storage and serves the image efficiently on demand.
- **Merging PDF signatures client-side on the mobile device using a Dart PDF library** — PDF manipulation packages for Dart/Flutter are limited in capability compared to pdf-lib in Node.js. More critically, running PDF generation on a mobile device for a 10-20MB legal document is slow (2-5 seconds) and can cause memory pressure on low-end devices. The merged PDF should be the authoritative version stored on your servers, not a device-local file. Fix: Always merge signatures server-side via a Cloud Function using pdf-lib. The server has consistent, ample memory, produces a reliable signed PDF, and immediately stores it in Firebase Storage where all parties can access it.
- **Not establishing the document hash before the first signature is collected** — The audit trail's legal value depends on proving the document has not been tampered with between creation and signing. If you compute the hash after some signers have already signed, the hash of the original document is unknown — you cannot prove the first signer saw the same document as the last signer. Fix: Compute the SHA-256 hash of the original PDF when the signing request is created (before any signature), and store it in the signingRequests document as 'originalDocumentHash'. Record this hash in every audit trail event.

## Best practices

- Always require users to scroll through or explicitly confirm they have reviewed the document before revealing the signature canvas — this establishes evidence of informed consent.
- Use Firebase Authentication's verified identity (email confirmed) for all signers — unsigned-in users cannot be legally identified as document signers.
- Send an immediate email confirmation to each signer with the signed document attached after they sign, creating a contemporaneous record outside your own system.
- Set an expiration date on signing requests — documents that have been pending for 30 days with some signers who have not yet signed should expire and require re-issuance.
- Store signed documents in a separate Firebase Storage bucket or folder with different retention rules from user-uploaded content — signed legal documents should never be auto-deleted.
- For high-stakes agreements, consider integrating with a dedicated e-signature API (DocuSign, HelloSign) that provides legally recognized e-signature infrastructure instead of building your own.
- Include the RapidDev Engineering Team contact in your admin UI for any custom document signing workflows that require special multi-party orchestration logic beyond standard FlutterFlow capabilities.

## Frequently asked questions

### Is an in-app signature legally binding?

In most jurisdictions (US, EU, UK, Australia), electronic signatures are legally valid under the ESIGN Act, eIDAS, and equivalent laws, provided you can demonstrate: the signer's identity (authenticated user), their intent to sign (deliberate action with consent confirmation), and document integrity (hash verification shows the document wasn't altered after signing). For high-value contracts, consult a lawyer and consider a dedicated e-signature service like DocuSign that has established legal precedent.

### How do I handle a signer who wants to decline or refuse to sign?

Add a 'Decline to Sign' button alongside the signature canvas. On tap, update the Firestore signing request document with the decliner's reason, write a 'document_declined' audit trail event, update the status to 'declined', and notify the document creator by email. Design your workflow so document creators can choose whether a single declination voids the entire request or only prevents that signer's participation.

### Can I pre-fill signature fields at specific positions on the PDF?

Yes. Define signature field coordinates in your signingRequests document as a 'signatureFields' Array of Maps: [{signerIndex: 0, pageNumber: 3, x: 100, y: 50, width: 200, height: 80}]. When rendering the PDF for signing, overlay a visual 'Sign here' marker at those coordinates using a Stack widget. Pass the coordinates to your Cloud Function when merging the signature so it places it at the exact correct position.

### How do I support multiple signature pages or signatures on the same document?

Allow each signing session to include multiple signature captures — one per designated field. In the signature canvas workflow, loop through each required signature field, collect a signature for each, upload all to Storage, and pass the full array of {signatureUrl, pageNumber, x, y, width, height} to the Cloud Function. The Cloud Function iterates over all signature entries and applies each to its designated page and coordinates before saving the merged PDF.

### What happens to the audit trail if a user disputes having signed the document?

Your Firestore audit trail records: the user's authenticated UID and email, the timestamp (from server-side FieldValue.serverTimestamp()), the hash of the document at the time of signing, and the signature image Storage path. Firebase Authentication ensures the UID cannot be impersonated by another user. The document hash proves the document content was not changed after signing. This combination creates a strong, timestamped record of the signing event.

### How do I allow users to void or revoke a signed document?

Add a 'Void Document' action visible only to the document creator in the admin UI. Voiding should not delete the signed document or audit trail — these are permanent records. Instead, write a 'document_voided' event to the audit trail and update the status field to 'voided' with a reason field. Notify all signers by email that the document has been voided. The original signed PDF and audit trail remain in Firestore and Storage as evidence of the original transaction.

---

Source: https://www.rapidevelopers.com/flutterflow-tutorials/how-to-create-a-custom-document-signing-feature-in-flutterflow
© RapidDev — https://www.rapidevelopers.com/flutterflow-tutorials/how-to-create-a-custom-document-signing-feature-in-flutterflow
