Problem
Simple signature capture workflows (contracts, consent forms, delivery confirmations) do not justify $25-50/month for DocuSign or HelloSign. These services are designed for complex multi-party signing workflows with legal compliance features that most small applications do not need. For basic "draw your signature and attach it to a document" functionality, a custom solution is significantly cheaper and more flexible.
Solution
Step 1: Add signature_pad.js to your frontend
<!-- app/views/signatures/new.html.erb -->
<div class="signature-container">
<canvas id="signature-pad" width="600" height="200"
style="border: 1px solid #ccc; border-radius: 4px;">
</canvas>
<div class="signature-actions">
<button type="button" id="clear-btn">Clear</button>
<button type="button" id="save-btn">Save Signature</button>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/signature_pad@4.2.0/dist/signature_pad.umd.min.js"></script>
<script>
const canvas = document.getElementById('signature-pad');
const signaturePad = new SignaturePad(canvas, {
backgroundColor: 'rgb(255, 255, 255)',
penColor: 'rgb(0, 0, 0)'
});
document.getElementById('clear-btn').addEventListener('click', () => {
signaturePad.clear();
});
document.getElementById('save-btn').addEventListener('click', async () => {
if (signaturePad.isEmpty()) {
alert('Please provide a signature.');
return;
}
const dataUrl = signaturePad.toDataURL('image/png');
const response = await fetch('/signatures', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('[name="csrf-token"]').content
},
body: JSON.stringify({ signature: dataUrl })
});
if (response.ok) window.location.href = '/signatures/success';
});
</script>
Step 2: Handle the signature on the Rails backend
# app/controllers/signatures_controller.rb
class SignaturesController < ApplicationController
def create
data_url = params[:signature]
image_data = Base64.decode64(data_url.split(',')[1])
signature = Signature.new(
user: current_user,
document_id: params[:document_id],
signed_at: Time.current
)
signature.image.attach(
io: StringIO.new(image_data),
filename: "signature_#{Time.current.to_i}.png",
content_type: 'image/png'
)
if signature.save
GenerateSignedPdfJob.perform_later(signature.id)
render json: { status: 'ok' }
else
render json: { errors: signature.errors }, status: :unprocessable_entity
end
end
end
Step 3: Generate the signed PDF
# app/jobs/generate_signed_pdf_job.rb
class GenerateSignedPdfJob < ApplicationJob
def perform(signature_id)
signature = Signature.find(signature_id)
pdf = Prawn::Document.new do |doc|
doc.text "Agreement signed by #{signature.user.name}"
doc.text "Date: #{signature.signed_at.strftime('%B %d, %Y')}"
doc.move_down 20
doc.image StringIO.new(signature.image.download), width: 200
end
signature.signed_pdf.attach(
io: StringIO.new(pdf.render),
filename: "signed_agreement_#{signature.id}.pdf",
content_type: 'application/pdf'
)
end
end
Why It Works
signature_pad.js is a lightweight library (8KB) that captures smooth, natural-looking signatures on a canvas element. The signature is exported as a base64-encoded PNG, which Rails stores via Active Storage. Prawn handles PDF generation with the embedded signature image. The entire stack costs nothing beyond your existing hosting, compared to per-signature fees from e-signature providers.
Context
signature_pad.jsworks on both desktop (mouse) and mobile (touch) with pressure sensitivity- For legal compliance, store a timestamp, IP address, and user agent alongside the signature
- Add
toSVG()instead oftoDataURL()for vector signatures that scale cleanly in PDFs - The Prawn gem can also overlay signatures onto existing PDF templates using
pdf.imageat specific coordinates - This pattern works with any backend (Node, Django, Phoenix) -- only the storage and PDF generation code changes