July 25, 2026 · Tutorial
Add E-Signatures to Ruby on Rails Applications in 15 Minutes (2026)
Stop fighting complex legacy Ruby gems or multi-step OAuth handshakes just to collect signatures. Learn how to build a clean Rails Service Object using Faraday, process background tasks with Sidekiq, and secure incoming webhooks with ActiveSupport::SecurityUtils.secure_compare.
Founder, Signbee
TL;DR
Integrate e-signatures into Ruby on Rails with zero external vendor SDKs. Use a plain Ruby Service Object with Faraday to post contract Markdown to Signbee, process outbound dispatches asynchronously with Sidekiq background jobs, verify incoming status webhooks using timing-safe ActiveSupport::SecurityUtils.secure_compare, and automatically attach completed PDFs to ActiveRecord models via Active Storage.
Why Rails Developers Avoid Traditional E-Signature SDKs
Ruby on Rails has long prioritized developer happiness, clean architecture, and rapid shipping. However, adding e-signature capabilities to a Rails codebase traditionally meant battling legacy enterprise APIs like DocuSign or HelloSign (now Dropbox Sign).
The official DocuSign Ruby gem (docusign_esign) is over 15 megabytes in size, introduces dozens of transitive dependencies, and requires configuring complex RSA private keys for JWT grant authentication. Before sending a single document, developers must construct envelope definition objects, tabs, document byte streams, and template roles across 80+ lines of verbose setup code.
If you want to automate contract signing in your application, there is a much cleaner paradigm. Signbee provides a single REST endpoint that accepts raw Markdown, generates a compliant PDF on the server, emails the recipient a secure signing link, and notifies your Rails application via webhooks upon completion.
Step 1: Setting Up the Rails Database Schema
To track contract dispatches and signature state in PostgreSQL or MySQL, generate a dedicated Contract model in your Rails application:
bin/rails generate model Contract title:string recipient_name:string recipient_email:string status:string signbee_document_id:string signing_url:string signed_at:datetime bin/rails db:migrate
Next, update your app/models/contract.rb file to include enums, Active Storage attachments for the signed PDF and audit trail, and helper scopes:
class Contract < ApplicationRecord
has_one_attached :signed_pdf
has_one_attached :audit_trail_pdf
enum status: {
draft: "draft",
pending_signature: "pending_signature",
completed: "completed",
declined: "declined",
expired: "expired"
}, _default: "draft"
validates :title, :recipient_name, :recipient_email, presence: true
validates :recipient_email, format: { with: URI::MailTo::EMAIL_REGEXP }
scope :pending, -> { where(status: :pending_signature) }
scope :signed, -> { where(status: :completed) }
endStep 2: Building the Faraday Service Object
Instead of placing third-party API calls directly in controllers or models, wrap all Signbee API interactions inside a clean, single-purpose Rails Service Object: SendContractService.
We use the popular faraday gem (or Ruby's standard Net::HTTP library) to issue HTTP requests with automatic JSON parsing and timeout handling. If you haven't added Faraday to your Gemfile, simply add gem "faraday" and run bundle install.
class SendContractService
BASE_URL = "https://signb.ee/api/v1".freeze
TIMEOUT = 15 # seconds
class Error < StandardError; end
class ApiError < Error; end
class RateLimitError < Error; end
def initialize(contract:)
@contract = contract
@api_key = Rails.application.credentials.dig(:signbee, :api_key) || ENV["SIGNBEE_API_KEY"]
end
def call
raise Error, "API key is missing" if @api_key.blank?
raise Error, "Contract must be persisted" unless @contract.persisted?
response = client.post("send") do |req|
req.body = {
title: @contract.title,
markdown: generate_markdown_content,
recipient_name: @contract.recipient_name,
recipient_email: @contract.recipient_email,
metadata: {
contract_id: @contract.id,
environment: Rails.env
}
}
end
handle_response(response)
rescue Faraday::TimeoutError, Faraday::ConnectionFailed => e
Rails.logger.error("[SendContractService] Connection failed for Contract ##{@contract.id}: #{e.message}")
raise ApiError, "Network failure contacting e-signature service: #{e.message}"
end
private
def client
@client ||= Faraday.new(url: BASE_URL) do |f|
f.request :json
f.response :json
f.headers["Authorization"] = "Bearer #{@api_key}"
f.headers["Content-Type"] = "application/json"
f.headers["User-Agent"] = "Signbee-Rails/#{Rails.version}"
f.options.timeout = TIMEOUT
f.options.open_timeout = 5
end
end
def generate_markdown_content
<<~MARKDOWN
# #{@contract.title}
**Contract Reference:** CR-#{@contract.id}
**Date:** #{Time.zone.today.strftime('%B %d, %Y')}
---
### 1. Parties
This Service Agreement is entered into between **Acme Corporation** ("Provider") and **#{@contract.recipient_name}** ("Client").
### 2. Terms & Deliverables
Provider agrees to deliver engineering services as specified in Statement of Work ##{@contract.id}. Client agrees to compensate Provider in accordance with agreed payment milestones.
### 3. Binding Acknowledgment
By applying a digital signature below, the Client agrees to be bound by all terms, conditions, and liability releases specified herein.
MARKDOWN
end
def handle_response(response)
case response.status
when 200, 201
body = response.body
@contract.update!(
signbee_document_id: body["document_id"],
signing_url: body["signing_url"],
status: :pending_signature
)
{ success: true, contract: @contract, signing_url: body["signing_url"] }
when 429
retry_after = response.headers["Retry-After"] || 5
Rails.logger.warn("[SendContractService] Rate limited. Retry after #{retry_after}s")
raise RateLimitError, "Signbee API rate limit exceeded. Retry after #{retry_after} seconds."
else
error_msg = response.body.is_a?(Hash) ? response.body["error"] : response.body
Rails.logger.error("[SendContractService] HTTP #{response.status} - #{error_msg}")
raise ApiError, "Signbee API call failed (#{response.status}): #{error_msg}"
end
end
endUsing Ruby's Built-In Net::HTTP Alternative
Prefer zero additional HTTP dependencies? You can replace Faraday with Ruby's standard library Net::HTTP. Simply instantiate Net::HTTP::Post.new(URI("https://signb.ee/api/v1/send")), set headers for Authorization: Bearer #{key} and Content-Type: application/json, serialize with req.body = payload.to_json, and execute via http.request(req).
Step 3: Background Job Processing with Sidekiq
In production Rails applications running Puma or Unicorn, executing external HTTP network requests inside synchronous web controller actions can choke your thread pool, leading to request timeouts under high concurrency.
By delegating outbound document dispatches to Sidekiq, you instantly return HTTP 202 Accepted to your frontend users while worker threads process background operations safely with automatic exponential backoff retries.
class SendContractJob
include Sidekiq::Job
# Automatically retry transient failures with exponential backoff
sidekiq_options queue: :contracts, retry: 5
sidekiq_retry_in do |count, exception|
case exception
when SendContractService::RateLimitError
60 * (count + 1) # Wait longer if rate limited
else
10 * (count ** 2) # Standard backoff: 10s, 40s, 90s, 160s
end
end
def perform(contract_id)
contract = Contract.find(contract_id)
# Idempotency check: Skip if already dispatched
return if contract.signbee_document_id.present? && contract.pending_signature?
SendContractService.new(contract: contract).call
rescue ActiveRecord::RecordNotFound => e
Rails.logger.error("[SendContractJob] Contract ##{contract_id} not found: #{e.message}")
end
endTriggering the background job from a controller or ActiveRecord callback is simple:
class ContractsController < ApplicationController
def create
@contract = Contract.new(contract_params)
if @contract.save
# Enqueue Sidekiq job asynchronously
SendContractJob.perform_async(@contract.id)
render json: {
message: "Contract created. Dispatching for signature in background.",
contract: @contract
}, status: :accepted
else
render json: { errors: @contract.errors.full_messages }, status: :unprocessable_entity
end
end
private
def contract_params
params.require(:contract).permit(:title, :recipient_name, :recipient_email)
end
endStep 4: Timing-Safe Webhook Receiver in Rails
When a recipient opens, reviews, signs, or declines a document, Signbee pushes real-time event payloads to your application's webhook URL. Understanding the complete catalog of payload parameters is covered in our detailed e-signature API webhook events guide.
Security is paramount when processing webhooks. Attackers may attempt to spoof request bodies or alter event statuses. To verify incoming requests, compute an HMAC-SHA256 digest of the unparsed raw HTTP request body using your secret webhook key, and compare it against the X-Signbee-Signature header.
Crucial Security Rule: Never use standard Ruby string comparison (==) to compare signatures. Standard equality operators short-circuit on the first non-matching character, opening your application to timing side-channel attacks. Always use ActiveSupport::SecurityUtils.secure_compare, which runs in constant time.
module Webhooks
class SignbeeController < ActionController::API
before_action :verify_signature!
def create
event_type = params[:event]
document_id = params.dig(:data, :document_id)
metadata = params.dig(:data, :metadata) || {}
Rails.logger.info("[Webhooks::Signbee] Received event '#{event_type}' for Document #{document_id}")
case event_type
when "contract.signed", "document.completed"
handle_completed_contract(document_id, metadata)
when "contract.declined"
handle_declined_contract(document_id, params.dig(:data, :reason))
when "contract.expired"
handle_expired_contract(document_id)
else
Rails.logger.info("[Webhooks::Signbee] Unhandled event type: #{event_type}")
end
# Always respond with 200 OK immediately to prevent webhook retries
head :ok
end
private
def handle_completed_contract(document_id, metadata)
contract = find_contract(document_id, metadata)
return unless contract
contract.update!(
status: :completed,
signed_at: Time.current
)
# Enqueue Sidekiq worker to fetch signed PDF and audit trail asynchronously
ProcessCompletedContractJob.perform_async(contract.id, document_id)
end
def handle_declined_contract(document_id, reason)
contract = Contract.find_by(signbee_document_id: document_id)
contract&.update!(status: :declined)
Rails.logger.warn("[Webhooks::Signbee] Contract ##{contract&.id} declined. Reason: #{reason}")
end
def handle_expired_contract(document_id)
contract = Contract.find_by(signbee_document_id: document_id)
contract&.update!(status: :expired)
end
def find_contract(document_id, metadata)
Contract.find_by(id: metadata["contract_id"]) ||
Contract.find_by(signbee_document_id: document_id)
end
def verify_signature!
signature = request.headers["X-Signbee-Signature"]
secret = Rails.application.credentials.dig(:signbee, :webhook_secret) || ENV["SIGNBEE_WEBHOOK_SECRET"]
if signature.blank? || secret.blank?
Rails.logger.error("[Webhooks::Signbee] Missing signature header or secret key")
render json: { error: "Unauthorized: Missing signature" }, status: :unauthorized
return
end
raw_payload = request.raw_post
computed_hmac = OpenSSL::HMAC.hexdigest("SHA256", secret, raw_payload)
# Constant-time comparison to prevent timing attacks
unless ActiveSupport::SecurityUtils.secure_compare(computed_hmac, signature)
Rails.logger.error("[Webhooks::Signbee] HMAC verification failed! Computed: #{computed_hmac}, Received: #{signature}")
render json: { error: "Unauthorized: Invalid signature" }, status: :unauthorized
end
end
end
endAdd the webhook route to your config/routes.rb file:
Rails.application.routes.draw do
resources :contracts, only: [:create, :show]
namespace :webhooks do
post "signbee", to: "signbee#create"
end
endStep 5: Downloading Signed PDFs & Audit Trails Asynchronously
Once a contract is marked as completed, legal compliance requires downloading and retaining both the signed PDF document and its associated SHA-256 cryptographic audit trail.
The ProcessCompletedContractJob worker queries the Signbee export API, fetches the binary PDF streams, and attaches them directly to your Contract model using Rails Active Storage:
require "open-uri"
class ProcessCompletedContractJob
include Sidekiq::Job
sidekiq_options queue: :contracts, retry: 3
def perform(contract_id, document_id)
contract = Contract.find(contract_id)
api_key = Rails.application.credentials.dig(:signbee, :api_key) || ENV["SIGNBEE_API_KEY"]
# 1. Fetch Signed PDF binary stream
pdf_url = "https://signb.ee/api/v1/documents/#{document_id}/download"
pdf_response = Faraday.get(pdf_url) do |req|
req.headers["Authorization"] = "Bearer #{api_key}"
end
if pdf_response.success?
contract.signed_pdf.attach(
io: StringIO.new(pdf_response.body),
filename: "contract_#{contract.id}_signed.pdf",
content_type: "application/pdf"
)
Rails.logger.info("[ProcessCompletedContractJob] Attached signed PDF to Contract ##{contract.id}")
end
# 2. Fetch Cryptographic Audit Trail PDF
audit_url = "https://signb.ee/api/v1/documents/#{document_id}/audit-trail"
audit_response = Faraday.get(audit_url) do |req|
req.headers["Authorization"] = "Bearer #{api_key}"
end
if audit_response.success?
contract.audit_trail_pdf.attach(
io: StringIO.new(audit_response.body),
filename: "contract_#{contract.id}_audit_trail.pdf",
content_type: "application/pdf"
)
Rails.logger.info("[ProcessCompletedContractJob] Attached audit trail to Contract ##{contract.id}")
end
end
endStep 6: Automated Testing with RSpec
Maintaining high test coverage for critical financial and legal workflows is essential. Here is a complete RSpec test suite demonstrating how to stub Signbee API calls with WebMock and verify HMAC webhook requests:
require "rails_helper"
RSpec.describe SendContractService, type: :service do
let(:contract) { create(:contract, title: "ND Agreement", recipient_name: "Alice Smith", recipient_email: "alice@example.com") }
let(:api_key) { "sb_test_secret_12345" }
before do
allow(Rails.application.credentials).to receive(:dig).with(:signbee, :api_key).and_return(api_key)
end
describe "#call" do
it "dispatches document and updates contract attributes" do
stub_request(:post, "https://signb.ee/api/v1/send")
.with(
headers: { "Authorization" => "Bearer #{api_key}" },
body: hash_including(
title: "ND Agreement",
recipient_name: "Alice Smith",
recipient_email: "alice@example.com"
)
)
.to_return(
status: 200,
body: { document_id: "doc_998877", signing_url: "https://signb.ee/s/doc_998877" }.to_json,
headers: { "Content-Type" => "application/json" }
)
result = described_class.new(contract: contract).call
expect(result[:success]).to be true
expect(contract.reload.signbee_document_id).to eq("doc_998877")
expect(contract.status).to eq("pending_signature")
end
end
endrequire "rails_helper"
RSpec.describe "Webhooks::Signbee", type: :request do
let(:webhook_secret) { "whsec_test_secret_abc123" }
let!(:contract) { create(:contract, signbee_document_id: "doc_998877", status: :pending_signature) }
before do
allow(Rails.application.credentials).to receive(:dig).with(:signbee, :webhook_secret).and_return(webhook_secret)
end
describe "POST /webhooks/signbee" do
let(:payload) do
{
event: "contract.signed",
data: {
document_id: "doc_998877",
metadata: { contract_id: contract.id }
}
}.to_json
end
context "with valid HMAC signature" do
let(:valid_signature) { OpenSSL::HMAC.hexdigest("SHA256", webhook_secret, payload) }
it "verifies payload and enqueues contract processing job" do
expect {
post "/webhooks/signbee", params: payload, headers: { "X-Signbee-Signature" => valid_signature, "CONTENT_TYPE" => "application/json" }
}.to change(ProcessCompletedContractJob.jobs, :size).by(1)
expect(response).to have_http_status(:ok)
expect(contract.reload.status).to eq("completed")
end
end
context "with invalid HMAC signature" do
it "rejects request with 401 Unauthorized" do
post "/webhooks/signbee", params: payload, headers: { "X-Signbee-Signature" => "invalid_signature", "CONTENT_TYPE" => "application/json" }
expect(response).to have_http_status(:unauthorized)
expect(contract.reload.status).to eq("pending_signature")
end
end
end
endIf you want to compare quick integrations across frontend frameworks alongside Ruby, check out our guide on how to add e-signatures to any web application in 10 minutes.
Frequently Asked Questions
How do I securely integrate an e-signature API in Ruby on Rails without introducing SDK bloat?
Integrating e-signatures into a Ruby on Rails application can be accomplished cleanly without adding heavy third-party vendor gems or complex OAuth authentication wrappers. By using standard HTTP clients like Faraday or Ruby's built-in Net::HTTP library inside an idiomatic Rails Service Object (such as SendContractService), you can construct direct HTTP POST requests to the Signbee REST API. You simply pass your API key as a Bearer token in the Authorization header along with a JSON payload containing document Markdown, recipient name, and email address. The Signbee API returns a unique document ID and direct signing URL instantly. This service-oriented architecture keeps your Gemfile minimal, simplifies debugging, avoids version conflicts with external OAuth gems, and allows complete control over network timeouts and retry logic directly within your Rails application stack.
Why is ActiveSupport::SecurityUtils.secure_compare mandatory for verifying Rails webhooks?
When receiving incoming HTTP webhook callbacks from an e-signature service, validating the HMAC signature header is essential to prevent spoofing and tampered payload injection. However, standard Ruby string comparison operators like == short-circuit early upon discovering the first non-matching character, which introduces measurable timing differences in request processing duration. Malicious actors can exploit microsecond response variations in timing attacks to forge valid signature headers character by character. To mitigate this vulnerability in Ruby on Rails, developers must compute the expected HMAC-SHA256 digest using OpenSSL::HMAC and compare it against the incoming header using ActiveSupport::SecurityUtils.secure_compare. This method executes in constant time regardless of string similarity, ensuring that attackers cannot extract your secret webhook signature through timing analysis.
How should background job processing with Sidekiq handle e-signature state transitions and PDF downloads?
Background job processing with Sidekiq ensures that long-running operations—such as sending outbound contract payloads, handling retries during transient API downtime, and fetching completed PDFs—never block Puma or Unicorn web request threads. When a document dispatch is initiated, a Sidekiq job (e.g., SendContractJob) formats the contract Markdown, makes the API call via Faraday, and updates the local ActiveRecord model status to pending_signature. When Signbee emits a contract.signed webhook, your controller validates the HMAC signature, enqueues a secondary job (e.g., ProcessCompletedContractJob), and immediately responds with HTTP 200 OK. Sidekiq then asynchronously downloads the signed PDF and cryptographic SHA-256 audit trail from the Signbee storage endpoint and attaches them directly to your ActiveRecord model via Active Storage or Amazon S3, providing zero-downtime, fully resilient background processing.
Conclusion
Adding e-signatures to a Ruby on Rails application shouldn't require bulky gems, OAuth setup screens, or complex PDF coordinate mapping. By leveraging a single REST endpoint, a Faraday service object, Sidekiq background queues, and timing-safe webhook verification with ActiveSupport::SecurityUtils, you can build a production-grade signature workflow in 15 minutes.
Ready to test out the integration? Get your free developer API key at signb.ee and start sending contract dispatches today.