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.

Michael Beckett
Michael Beckett

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.

Rails Version7.0 / 7.1 / 8.0+
HTTP ClientFaraday / Net::HTTP
Background QueueSidekiq / ActiveJob
Webhook VerificationActiveSupport::SecurityUtils
Vendor SDK Needed?No (Zero SDK dependencies)

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:

Terminal — Generate Model Migration
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:

app/models/contract.rb — ActiveRecord Model
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) }
end

Step 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.

app/services/send_contract_service.rb — Service Object
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
end

Using 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.

app/jobs/send_contract_job.rb — Sidekiq Worker
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
end

Triggering the background job from a controller or ActiveRecord callback is simple:

app/controllers/contracts_controller.rb — Rails Controller
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
end

Step 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.

app/controllers/webhooks/signbee_controller.rb — HMAC Webhook Controller
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
end

Add the webhook route to your config/routes.rb file:

config/routes.rb
Rails.application.routes.draw do
  resources :contracts, only: [:create, :show]

  namespace :webhooks do
    post "signbee", to: "signbee#create"
  end
end

Step 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:

app/jobs/process_completed_contract_job.rb — PDF & Audit Archival
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
end

Step 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:

spec/services/send_contract_service_spec.rb — RSpec Unit Test
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
end
spec/requests/webhooks/signbee_spec.rb — Webhook Request Test
require "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
end

If 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.

Related resources