Skip to content

Repository files navigation

☑️ clickwrap - Make your Rails users accept your Terms and legal documents

Gem Version Build Status

Tip

🚀 Ship your next Rails app 10x faster! I've built RailsFast, a production-ready Rails boilerplate template that comes with everything you need to launch a software business in days, not weeks — including versioned Terms and Privacy Notice acceptance powered by this gem. Go check it out!

clickwrap makes your Rails users accept your Terms of Service, acknowledge your Privacy Notice, give (and withdraw) consent, make declarations, and authorize one-time actions — and keeps evidence of all of it that you can still reproduce and verify years later.

✨ Perfect for SaaS signups, marketplaces, fintech payouts, health apps, and any Rails app where "the user agreed to this" needs to be provable long after the fact.

Ordinary Terms acceptance is one line in your signup form:

<%= form.clickwrap :signup, submit: "Create account" %>

…and it renders one line on the page. One checkbox, one sentence, your legal pages linked inside it:

☐ I agree to the Terms of Service and I acknowledge the Privacy Policy.

No "Required" flag, no "(opens in a new tab)" printed beside every link, no version label sitting under a checkbox. Behind that single control, the receipt still records two separate acts — an agreement to the Terms and an acknowledgment of the Privacy Notice — with their own versions, digests, and lifecycles.

And when an action is consequential enough that it must never happen without its evidence (a payout, a data handoff, a contract), the evidence and the action commit in the same database transaction:

Clickwrap.capture_and!(:withdrawal_authorization, actor: current_user, subject: withdrawal,
  http_request: request, submission: clickwrap_submission) do |pending_receipt|
  withdrawal.submit!(authorized_by_clickwrap_event: pending_receipt.event_id)
end

If the evidence can't be recorded, the action doesn't happen. If the action fails, the evidence doesn't pretend it succeeded.

No JavaScript package. No Redis. No background jobs. No external accounts or per-event API calls. No legal-document vendor. Just Rails, your database, and a DSL that reads like plain English.

Important

Status: built and tested, not yet proven in production. Everything in this README is implemented and covered by the test suite, but the gem hasn't been through its planned production integrations, an unfamiliar-developer usability test, or legal review of its default wording yet. Treat it as a release candidate for evaluation — don't put it under a payout flow just yet. The stability promise applies from 0.1.0 onward.

👨‍💻 Example

Define your documents and a policy in plain Ruby. Each document points at the file that is your legal text — and that file names its own version, in the front matter it probably already has. The top of app/content/legal/terms.md:

---
title: Terms of Service
last_updated: 2026-08-15
---

# Terms of Service
# clickwrap-doc-test: syntax-only — terms.md and privacy.md are files in your app
# config/clickwrap.rb
Clickwrap.document :terms,
  from: Rails.root.join("app/content/legal/terms.md"),
  link: "/legal/terms"

Clickwrap.document :privacy_notice,
  from: Rails.root.join("app/content/legal/privacy.md"),
  link: "/legal/privacy"

from: is the bytes Clickwrap freezes, digests, and keeps as evidence. link: is where a person reads them — your own formatted page, with your typography and your navigation — and it is the path Clickwrap both renders and signs, so the receipt never cites a different target from the link somebody pressed. Leave link: off and the sentence links to the engine's rendering of the exact published version instead. (Your page shows whatever is current; that trade is yours to make, and it is written down in the declaration where a reviewer will see it.)

Changing your Terms is then one edit in one file: new words, new last_updated:, publish. There is no second copy of the version label anywhere to drift — and a file carrying neither clickwrap_version: nor last_updated: fails the boot with a sentence instead of getting a label Clickwrap invented. Sources that can't carry front matter still name their label the explicit way:

Clickwrap.document :handbook,
  version: "2026-08-15",
  from: Rails.root.join("app/content/legal/handbook.pdf")

Reading that front matter yourself: Clickwrap::FrontMatter

Your own pages usually need the same two answers, and it is the same block, so use the same reader rather than writing a third one:

Clickwrap::FrontMatter.version_label_in(File.read(path))  # => "2026-08-15", or nil
Clickwrap::FrontMatter.strip(File.read(path))             # the body, without the block

It reads a leading --- block closed by --- or ..., takes simple top-level key: value lines only, and answers with clickwrap_version: when present, last_updated: otherwise — a same-day correction that still changes bytes needs a fresh label while the date readers see stays put. Two details are exactly where hand-rolled readers diverge, so they are worth naming: a quoted value has its quotes removed, and an unquoted trailing YAML comment is not part of the value, so last_updated: 2026-11-01 # was 2026-08-15 is the label 2026-11-01, precisely as YAML reads it.

strip removes the block from the rendered representation only. The source digest still covers the exact file bytes, front matter included, because that is what the file was.

Then say how long the evidence lives and what the server offers:

Clickwrap.retention :ordinary_agreement_evidence do
  retain_core_event_for 6.years
end

Clickwrap.policy :signup do
  agree_to :terms
  acknowledge :privacy_notice

  retain_with :ordinary_agreement_evidence
end

(Yes, the payload-retention decision is mandatory — clickwrap will not silently default captured evidence or request evidence to "keep forever" and will not pick a period for you. A minimal, digest-linked disposition tombstone remains after a reviewed core deletion so the deletion itself does not become an unexplained hole.)

Add one macro to your model:

class User < ApplicationRecord
  has_clickwraps
end

Render the line and the submit button as one bound presentation:

<%= form.clickwrap :signup, submit: "Create account" %>

☐ I agree to the Terms of Service and I acknowledge the Privacy Policy.

From that moment on, you can ask readable questions everywhere:

user.clickwraps.agreed_to?(:terms)              # => true
user.clickwraps.acknowledged?(:privacy_notice)  # => true
user.clickwraps.current_for?(:signup)           # => true

And every acceptance produces a receipt you can export and verify — even outside your app, without your app's source code:

receipt = user.clickwraps.receipts.last
receipt.verify.success?    # => true
receipt.to_canonical_json  # canonical JSON for the standalone verifier
receipt.to_html            # human-readable version of the same evidence

Sounds good? Let's get started!

Quick start

Add the gem and run the installer:

# Gemfile
gem "clickwrap", github: "rameerez/clickwrap"
bundle install
bin/rails generate clickwrap:install
bin/rails db:migrate

bundle add clickwrap would install version 0.0.0, a deliberately empty name placeholder on RubyGems — install from GitHub until the first real version is published there.

The installer detects Rails authentication vs. Devise, integer vs. UUID primary keys, and your database adapter, then generates adaptive migrations, one annotated initializer, and a conventional signup policy. It emits only the tables your installation can actually write to; the capabilities that are off by default bring their own migration when you want them:

bin/rails generate clickwrap:install --with-request-evidence   # the IP / user-agent / geolocation annex
                                     --with-integrity          # event chaining, anchoring, timestamps
                                     --with-retention-ops      # legal holds and disposition plans
                                     --with-external-actions   # the outbox for external handoffs
                                     --with-persisted-presentations

Add any of them later by re-running the generator with the flag. Turning a capability on without its migration is caught at boot, by bin/rails clickwrap:doctor, and at the call itself — always with the exact command that fixes it. Enabling a request-evidence field brings the annex table automatically, because an installation that records IP addresses into a table it never created is not a schema choice. If your legal pages already live in the app, it points from: at those exact files and writes no version: line — the pages name their own versions. It never invents legal text and never silently guesses your actor model.

Point the generated policy at the documents your app already owns (see the example above), add has_clickwraps to your user model, and drop form.clickwrap into your signup form:

<%= form.clickwrap :signup, submit: "Create account" %>

Then wire the door that creates the account, because the form is only half the circuit — some line has to write the account and its evidence in the same transaction:

# Devise — app/controllers/users/registrations_controller.rb
class Users::RegistrationsController < Devise::RegistrationsController
  clickwraps_registration_with :signup
end
# Rails authentication, an OAuth finish screen, a service object — any door
# that builds the record itself.
unless register_with_clickwrap(:signup, user: @user) { @user.save! }
  return render :new, status: :unprocessable_entity
end

Do not skip that step. Leave it out and everything still looks right — the checkbox renders, the person ticks it, the account is created — and there is no evidence at all. It is the one omission this gem cannot warn you about at runtime, because an app with no door simply never calls it.

Finally, publish immutable snapshots of your documents:

bin/rails clickwrap:publish

That's the only time you run that by hand: publishing rides db:prepare, so a deploy that runs it also freezes the snapshots for whatever you declared, before the server takes traffic (config.publish_documents_after_database_preparation = false if you'd rather own the step).

That's it! Your app now records which exact document versions the server offered, which explicit answers it accepted, the bound presentation wording, and when—atomically with account creation. Let's see how it works.

What that one line renders

One line:

☐ I agree to the Terms of Service and I acknowledge the Privacy Policy.

One checkbox, one label, one sentence, with the documents linked inside it. The label is the line, so pressing the words toggles the control and a screen reader announces the sentence and the box together. There is no "Required" flag, no "(opens in a new tab)" printed beside every link, and no version label under the checkbox. (The required attribute is still there as progressive enhancement — the server decides — the "opens in a new tab" truth is still announced to screen readers when the link really does open one, and versions still appear on receipts, where somebody is actually reading the record.)

Behind that single control the evidence is unchanged: two statements, two kinds, two document versions, two lifecycles. Ticking the box records an agreement to the Terms and an acknowledgment of the Privacy Notice; leaving it empty refuses both. The manifest signs the exact composed sentence and which statements the one control answered, so the substitution defense holds over the wording a person actually read.

Clickwrap composes that line only when every statement in the policy is an ordinary, required, default-worded agree_to or acknowledge. Anything else keeps a control of its own, below the line:

Clickwrap.policy :signup do
  # These two compose into the line.
  agree_to :terms, link_label: "Terms of Service"
  acknowledge :privacy_notice, link_label: "Privacy Policy"

  # This one gets its own box, below the line, with its withdrawal route.
  consent_to :product_updates,
    document: :marketing_notice,
    optional: true,
    withdrawal_path: "/settings/privacy"

  retain_with :ordinary_agreement_evidence
end

An optional consent is never folded in — bundling it would silently make it required, and unbundled consent is the whole point of the consent_to verb. Neither is a recorded yes/no, a statement with a withdrawal route, or copy your application wrote itself. And a policy with nothing composable — the operator attestation rails, the payout authorization — renders exactly as it always has, one control per act.

Want the itemized shape anyway? One boolean, and it reaches the presenter, so the manifest signs the shape that was actually offered:

<%= form.clickwrap :signup, submit: "Create account", combined: false %>

The words are yours. clickwrap.sentence.agreement and clickwrap.sentence.acknowledgment are ordinary translations with %{documents} marking where the links go, and each document's link text comes from link_label: on the statement — which is how "Privacy Notice" becomes "Privacy Policy" without touching what the statement asserts.

Legal pages in Markdown? config.document_renderer = :markdown renders through whichever Markdown library you already bundle, and :markdown_rails renders through your application's own registered markdown-rails renderer — the exact pipeline your public /legal pages go through, so the snapshot people accept comes out byte-for-byte identical to the rendered text those pages serve, by construction rather than by careful copying.

Wiring the gem into an existing production app — or handing the job to an AI agent? The integrating guide is the step-by-step playbook from a full production migration, in the exact order that avoids every mistake we made.

Hotwire Native? One setting answers both halves of the native question — the href and the link attributes:

Clickwrap.configure do |config|
  config.hotwire_native_document_links = {
    open_in: :external_browser,
    canonical_host: "https://www.example.com"
  }
end

Here's why that matters: on a native authentication sheet, a same-host document link is routed by the app itself, which pops the sheet and takes the half-filled signup form with it. :external_browser absolutizes the signed document path against your canonical host and opens it outside the WebView, so the form is still there when the person comes back. :same_screen keeps a plain same-host link for your own native path configuration to route (a document sheet inside a signed-in funnel, say).

One app often needs both — the auth sheet must escape, the signed-in funnel routes its own sheet — so open_in: also takes a callable:

config.hotwire_native_document_links = {
  open_in: ->(controller) { controller.signing_up? ? :external_browser : :same_screen },
  canonical_host: "https://www.example.com"
}

It is asked once when the href is signed and once when the link is rendered, with the same controller both times, so the two halves of a link cannot disagree.

Another client needs different attributes, or different ones per screen? Keep the gem's canonical partial and set config.document_link_html_options_with. It can add data: { turbo: false }, target, or rel; it cannot replace the immutable href that Clickwrap signs into the presentation. When the native setting above is set it answers native renders entirely, and this hook goes on answering every other render.

How it works

Most apps eventually accumulate an accepted_terms_at column, a terms_version string, a few hidden form fields, an after_create callback, and some IP columns. Each part looks reasonable alone. Together they produce partial writes, client-owned policy decisions, mutable history, and evidence only the original engineer can explain.

clickwrap replaces that plumbing with one coherent primitive:

  1. Documents are immutable. Publishing reads the exact bytes, digests them, and freezes a snapshot. A changed document requires a new version — the task refuses to reuse a version label for different bytes.
  2. Policies are server-owned. The browser may answer; it may never choose the policy, document version, validity, subject, or what gets recorded. Policies compile at boot and fail loudly when misconfigured.
  3. Presentations are signed. form.clickwrap creates a short-lived signed manifest of what the server generated for the form: documents, digests, statements, choices, and the submit button text. Stale, swapped, expired, or cross-account tokens are rejected at submit. A deploy between render and submit cannot record a version that was not bound to the accepted submission. This does not prove human perception or comprehension.
  4. Capture is atomic. Evidence and the protected database action commit together or not at all. Replays of the same submission return the original result instead of running twice.
  5. Lifecycle history appends. Through Clickwrap's public/model APIs, withdrawal, expiry, correction, and supersession append new events instead of rewriting the earlier event. Optional PostgreSQL hardening rejects additional direct database mutation paths; the integrity verifier detects covered changes rather than pretending a fully privileged database actor is impossible.
  6. Receipts have a standalone verifier. Canonical JSON (RFC 8785) with versioned schemas and SHA-256 digests can be checked by the bundled clickwrap CLI without booting Rails. The result distinguishes fully verified, failed, and incomplete checks; document-byte checks need the exported artifacts, and reviewed disposition is reported as disposition rather than ordinary verification.

Six verbs, six honest meanings

Not every checkbox is "consent," and not every timestamp is a "signature." Each verb gets the lifecycle it actually needs:

Policy verb Meaning Typical lifecycle
agree_to Assent to contractual terms agreed → superseded by new version
acknowledge Affirmative receipt of a notice or risk acknowledged → superseded / expired
consent_to Purpose-specific permission granted → withdrawn / renewed
declare A factual statement made by the actor declared → corrected / expired
attest An operational fact affirmed by an operator attested → corrected / superseded
authorize Narrow permission bound to one protected action authorized → consumed / expired

The DSL is intentionally verbal:

Clickwrap.policy :example do
  agree_to :terms
  acknowledge :privacy_notice
  consent_to :product_updates, optional: true, withdrawal_path: "/settings/privacy"
  declare :information_is_accurate
  attest :bank_transfer_was_accepted
  authorize :withdrawal, one_time: true, valid_for: 10.minutes

  retain_with :ordinary_agreement_evidence
end

The policy compiler rejects incoherent combinations at boot, in full sentences that tell you what's wrong and what to do about it: a one-time authorization can't be indefinite, consent needs a withdrawal path, and withdrawing future consent never rewrites a historical agreement.

Protect an action with its evidence

capture_and! is the gem's signature move. In one supported database transaction it verifies the presentation, appends the evidence event, yields to your domain action, records the outcome, and commits both together:

Declare the exact post-action snapshot once. The callback receives the value returned by the protected-action block—not the pre-action subject—and Clickwrap.protected_outcome owns the stable reference and canonical fingerprint:

Clickwrap.policy :withdrawal_authorization do
  authorize :withdrawal,
    one_time: true,
    valid_for: 10.minutes,
    protected_outcome_version: "submitted-withdrawal-v1",
    record_protected_outcome_with: lambda { |withdrawal|
      Clickwrap.protected_outcome(
        action: :submitted,
        record: withdrawal,
        state: withdrawal.status,
        facts: {
          amount_in_cents: withdrawal.amount_cents,
          currency: withdrawal.currency,
          destination_reference: withdrawal.destination_reference
        }
      )
    }

  retain_with :regulated_evidence
end
def create
  withdrawal = current_user.withdrawals.build(withdrawal_params)

  capture_clickwrap_and!(:withdrawal_authorization, actor: current_user, subject: withdrawal) do |pending_receipt|
    withdrawal.submit!(authorized_by_clickwrap_event: pending_receipt.event_id)
    withdrawal # the exact completed result given to the outcome recorder
  end

  redirect_to withdrawal
end

If the event write fails, the action rolls back. If your block raises, the event rolls back. Repeating an identical submission returns the original result without running the block twice; a conflicting replay fails with a stable Clickwrap::ReplayRejected. That remains true when the successful action itself changes the fingerprinted subject: once the signed nonce committed, replay verifies the frozen event context and exact answers instead of requiring the old pre-action state to still exist.

Link the row to the evidence that authorized it, so the connection survives years and engineers:

bin/rails generate clickwrap:link withdrawals && bin/rails db:migrate
class Withdrawal < ApplicationRecord
  has_clickwrap_evidence policy: :withdrawal_authorization,
                         statement: :withdrawal,
                         actor: :user,
                         subject: :self
end

capture_clickwrap_and!(:withdrawal_authorization, actor: current_user, subject: withdrawal) do |pending_receipt|
  withdrawal.clickwrap_event_id = pending_receipt.event_id
  withdrawal.save!
  withdrawal
end

withdrawal.clickwrap_receipt.verify.success?   # one line, years later

When the protected domain row needs the person's submitted choice, read it from the pending receipt rather than parsing controller params a second time:

capture_clickwrap_and!(:privacy_preferences, subject: membership) do |pending_receipt|
  membership.show_on_public_profile =
    pending_receipt.granted?(:public_profile_visibility)
  membership.save!
end

answer_for, answered?, granted?, and declined? read the validated, server-bound event being committed. An optional control left unselected returns nil/false; a statement name the policy never declared raises. Silence can therefore never become permission, while a typo cannot silently disable a feature. This keeps the browser's raw params out of protected domain logic.

This model-first deployment order is safe. Before the generated column exists, has_clickwrap_evidence stays inert and clickwrap_receipt returns nil; as soon as the migration adds clickwrap_event_id, every new row is fail-closed by default. That also lets historical data migrations replay schemas from before Clickwrap without loading a model method for a column that did not yet exist. It does not weaken current rows: after the column exists, missing, mismatched, or replaced links fail validation.

And when a person causes the refusal — a stale token, a required box left unticked — every such case is one exception family carrying a sentence you can actually show them:

def create
  # ... capture_clickwrap_and! as above ...
rescue Clickwrap::CaptureRefused => refusal
  redirect_to new_withdrawal_path, alert: refusal.user_facing_message, status: :see_other
end

Or drop the bang and let it read like save. capture_clickwrap_and and capture_clickwrap absorb exactly that family, return false, put the per-statement message beside the control it belongs to, and leave the whole refusal on clickwrap_refusal:

def create
  receipt = capture_clickwrap_and(:withdrawal_authorization, subject: withdrawal) do |pending_receipt|
    withdrawal.submit!(authorized_by_clickwrap_event: pending_receipt.event_id)
  end

  unless receipt
    flash.now[:alert] = clickwrap_refusal.user_facing_message
    return render :new, status: :unprocessable_entity
  end

  redirect_to withdrawal
end

Nothing else is absorbed, by either form. Infrastructure failures stay outside that family and stay loud: an evidence write that fails refuses the protected action instead of being swallowed. So do lifecycle conflicts — a conflicting replay (Clickwrap::ReplayRejected) or an already-consumed one-time authorization (Clickwrap::OneTimeAuthorizationConflict) still raises, because "this was already done" needs a domain answer that no generic rescue can supply honestly.

That atomicity has one exact boundary: Clickwrap's event and the protected domain write must use the same database connection. If a host model uses another Rails database/connection, its transaction cannot commit atomically with Clickwrap's tables. Put Clickwrap on the same connection for database-local work; use an explicit outbox/reconciliation design for another database or service.

For capture without a protected action, use Clickwrap.capture!. For external providers (Stripe, identity services) that can't share your database transaction, use Clickwrap.authorize_external_action! — a pending authorization plus idempotent outbox, so a provider timeout never becomes a fictional success or a double debit.

Controllers get the ambient actor, tenant, request, authentication context, and submitted presentation automatically:

authorization = authorize_clickwrap_external_action!(
  :identity_provider_handoff,
  subject: verification,
  provider_name: "identity_provider"
)

If a migration requires a legacy/domain projection to commit with the event and pending outbox row, use the deliberately named local-transaction callback (or the equivalent block form):

authorization = authorize_clickwrap_external_action!(
  :identity_provider_handoff,
  subject: verification,
  provider_name: "identity_provider",
  after_pending_action_is_saved_inside_transaction: lambda do |pending_action:, pending_receipt:|
    LegacyAuditLog.create!(
      event_id: pending_receipt.event_id,
      external_action_id: pending_action.id
    )
  end
)

It runs once, only on initial capture, inside that local database transaction; if it raises, all three local writes roll back. Never call the provider or do network work there. The provider call starts only after the helper returns.

One-time, subject-bound authorizations

Clickwrap.policy :withdrawal_authorization do
  acknowledge :withdrawal_requirements

  declare :coverage_exclusivity,
    subject_fingerprint_version: "covered-orders-v1",
    subject_fingerprint_with: ->(withdrawal) { withdrawal.covered_orders_fingerprint }

  authorize :withdrawal,
    one_time: true,
    valid_for: 10.minutes,
    requires: %i[withdrawal_requirements coverage_exclusivity]

  retain_with :regulated_evidence
end

The authorization is locked and consumed in the same transaction as the withdrawal. Another withdrawal, a changed subject, a stale declaration, or a concurrent replay cannot reuse it. This is the difference between "the user once accepted something" and "this exact evidence authorized this exact operation."

Ask readable questions everywhere

The actor proxy is the everyday API:

user.clickwraps.current_for?(:signup)
user.clickwraps.agreed_to?(:terms)
user.clickwraps.consented_to?(:product_updates)
user.clickwraps.declared?(:independent_contractor, subject: scheme)
user.clickwraps.authorized?(:withdrawal, subject: withdrawal)

When "no" needs an explanation, verify returns a structured result with a stable error symbol (:declaration_expired, :consent_withdrawn, :wrong_subject, …), a matching predicate, and a localized message — you never parse English to make an authorization decision. Clickwrap.require! raises a typed error carrying the same result. Service boundaries read aloud:

preparation = Clickwrap.verify(:withdrawal_preparation, actor: user,
                               require_current_revision: true)
declaration = Clickwrap.verify(:coverage_exclusivity, actor: user, subject: user,
                               require_current_revision: true)

declaration.stale_policy_revision?         # legal reworded it → re-ask
declaration.subject_fingerprint_mismatch?  # what it covers changed since capture
declaration.recorded_after?(preparation)   # ordering enforced, not assumed

recorded_after? answers from a database-assigned recording sequence, so it stays true across actors, application processes, and same-microsecond writes — ULID lexical order is deliberately not used as chronology. Read its false carefully: it means "not after", or that one of the two has no sequence at all, which is the case for evidence recorded before the ordering migration and for a missing event. An upgrade cannot invent honest order for rows written before it, so false is the answer it gives rather than a guess. Branch on it as a guard (return unless declaration.recorded_after?(preparation)), never as proof of the opposite.

require_current_revision: true fails evidence recorded under a superseded policy revision, so "we changed the wording, everyone re-accepts" is one keyword instead of a hand-rolled revision comparison.

The same call takes an event id, which is how you re-ask about one specific recorded act years later:

Clickwrap.verify(event_id, subject: order_batch, require_current_revision: true)

Both keywords mean exactly what they mean above: subject: re-derives the fingerprint from the record as it is now, and require_current_revision: compares the act's recorded revision against the wording compiled today. That is the complete "is this old evidence still good?" question, so nothing needs to reach into Clickwrap::PolicyRevision or Clickwrap::SubjectFingerprint to ask it. If the policy is no longer declared at all, the result says :unknown_policy — "we can no longer check this" never gets spelled the same way as "this is fine".

Controller gates redirect users to a ready-made remediation screen and bring them back when they're done:

class BillingController < ApplicationController
  requires_clickwrap :current_terms, only: :show
end

Reacceptance when documents change

Clickwrap.policy :current_terms do
  agree_to :terms, require_current_version: true

  retain_with :ordinary_agreement_evidence
end

Publish a new version and current_for? flips to false for everyone who accepted the old one. Preview the blast radius before you activate it:

bin/rails clickwrap:reacceptance:plan POLICY=current_terms

Consent that can actually be withdrawn

Consent is purpose-specific, initially unselected, and separate from Terms:

Clickwrap.policy :marketing_preferences do
  consent_to :product_updates, optional: true, withdrawal_path: "/settings/privacy"
  consent_to :partner_offers,  optional: true, withdrawal_path: "/settings/privacy"

  retain_with :marketing_consent_evidence
end
Clickwrap.withdraw!(:product_updates, actor: current_user, http_request: request,
  because: "The user withdrew this purpose in privacy settings")

Withdrawal appends an event — it never deletes or mutates the historical grant. Declarations work the same way: they expire, get corrected, or get superseded through linked lifecycle events, without pretending the original statement never happened.

Three of those transitions are new statements by the same person rather than administrative flags, so each one is captured through a real presentation and submission, exactly like the first statement was:

# The facts someone declared changed. A correction never implies the original
# was false when it was made.
Clickwrap.correct_declaration!(:contractor_status, actor: current_user, subject: engagement,
  submission: clickwrap_submission,
  because: "The person told us their circumstances changed")

# A new validity period, starting now — never the old expiry pushed along, so a
# stale expiry cannot quietly survive a renewal.
Clickwrap.renew!(:contractor_status, actor: current_user, subject: engagement,
  submission: clickwrap_submission,
  because: "The person renewed their declaration before it lapsed")

# Consent that now covers something narrower or wider. Rescoping is not
# withdrawal: the permission stays active, under new terms.
Clickwrap.change_consent_scope!(:product_updates, actor: current_user,
  submission: clickwrap_submission,
  because: "The person narrowed this permission in privacy settings")

Every one of them appends a linked event, leaves the earlier event exactly as it was recorded, and produces a receipt that verifies on its own.

Seeds, imports, and admin-created accounts never fake a human click either — Clickwrap.exempt! records an explicit exemption with who created it and why, and exemptions never satisfy agreed_to?.

Receipts show exactly what the application recorded

Every event has one canonical JSON receipt and one human-readable HTML projection:

receipt = Clickwrap.receipt(event_id)
receipt.to_canonical_json
receipt.to_html
receipt.verify

An abbreviated receipt:

{
  "schema": "clickwrap.receipt.v1",
  "event_id": "01K2Y8T5QY0N4V6N1H4G4CQY8J",
  "policy": { "key": "signup", "revision": "sha256:..." },
  "acts": [
    { "statement": "terms", "kind": "agreement", "action": "agreed" },
    { "statement": "privacy_notice", "kind": "acknowledgment", "action": "acknowledged" }
  ],
  "documents": [
    {
      "key": "terms",
      "version": "2026-08-15",
      "locale": "en",
      "source_digest": "sha256:...",
      "rendered_digest": "sha256:..."
    }
  ],
  "presentation": {
    "manifest_digest": "sha256:...",
    "submit_button_text": "Create account",
    "offered_at": "2026-08-15T12:34:56.123456Z"
  },
  "integrity": { "digest_algorithm": "sha256", "receipt_digest": "sha256:..." }
}

Verify it inside the app, or completely outside it with the bundled CLI:

clickwrap verify receipt.json --documents ./receipt-documents

Golden fixtures make a verifier regression for any released receipt schema fail the test suite.

With the engine mounted, users can view and download their own receipts, and operator access is always host-authorized. Read the receipts and verification guide for exports, bundles, and what each verification tier does and doesn't establish.

Request evidence is off by default

clickwrap always records its event ID, server time, capture channel, and policy version. It records no IP addresses, browser user-agents, or IP geolocation unless a policy names the field with a purpose and a retention rule:

Clickwrap.policy :regulated_authorization do
  authorize :regulated_action, one_time: true, valid_for: 10.minutes

  record_ip_address(
    encrypted: true,
    retain_until: :regulated_evidence_retention_ends,
    because: "Investigate account compromise and disputes about this action"
  )

  retain_with :regulated_evidence
end

Recorded values live in a separately encrypted annex with their own retention, so they can be deleted later without rewriting the core event payload. Core payloads have their own reviewed disposition path and leave a digest-linked tombstone. There is deliberately no gdpr_compliant_mode or maximum_evidence switch — every field is named individually, in plain English.

For IP geolocation, trackdown 0.4 or newer is the optional official resolver:

Trackdown.configure do |trackdown|
  trackdown.verify_request_came_through_trusted_cloudflare_path_with do |request|
    request.env["my_app.cloudflare_origin_was_verified"] == true
  end
end

config.ip_geolocation_resolver = Clickwrap::IpGeolocation::TrackdownResolver.new

Clickwrap passes the exact Rack request to Trackdown and records the provider that actually answered, its source and database provenance, and Trackdown's per-request trust result. It never treats CDN header presence as proof of a trusted path. The host must derive the Rack flag above from its real origin protection; Trackdown documents the supported patterns in “Did the request really come through your CDN?”.

The request evidence guide covers every field, the provenance model, and the privacy boundaries.

Retention, deletion, and legal holds

Every policy chooses an application-defined retention class:

Clickwrap.retention :ordinary_agreement_evidence do
  retain_core_event_for 6.years
  delete_recorded_ip_address_after 90.days
  delete_recorded_browser_user_agent_after 90.days
end

Disposition is previewed, planned, and applied explicitly — and rechecked at apply time, so a newly placed legal hold or changed policy stops a stale plan:

bin/rails clickwrap:retention:plan
bin/rails clickwrap:retention:apply PLAN=01K2Y8T5QY0N4V6N1H4G4CQY8J

Destructive methods say exactly what they delete (Clickwrap.delete_recorded_ip_address!), deletions append a disposition event, and deleting a user account never silently cascades evidence away. Each event keeps the schedule recorded when that event was created; linked lifecycle events do not inherit their root's elapsed time or get deleted merely because the root became due. Legal holds pause disposition and are recorded through named append/release transitions. Details in the retention and legal holds guide.

Progressive integrity, honestly labeled

Start useful with an ordinary Rails database; add assurance without changing the capture API:

Tier What it adds
Baseline Canonical receipts, immutable snapshots, SHA-256 digests, standalone verifier
Database hardening Adapter-specific update/delete protections
Chained history Per-tenant event chains and checkpoints
Independent anchoring A verified publication of an exact event-chain snapshot outside the primary database
Third-party timestamps A provider token over an exact event digest, with the adapter's verification result

Each tier states exactly what threat it addresses. A local hash is never called tamper-proof, server time is never called trusted time, and an IP address is never called identity. The integrity guide has the threat model.

bin/rails clickwrap:verify        # verify continuously in production

Works with Devise, Rails authentication, Hotwire, and APIs

The installer detects your authentication stack and prints the exact door line to add, with your own file path and class name filled in. You add it yourself: this is an explicit adapter you can read in your own controller, not a hidden after_create callback the gem installs behind your back.

# Devise
class Users::RegistrationsController < Devise::RegistrationsController
  clickwraps_registration_with :signup
end
# Rails authentication generator, or any hand-rolled signup door
def create
  @user = User.new(user_params)

  unless register_with_clickwrap(:signup, user: @user) { @user.save! }
    return render :new, status: :unprocessable_entity
  end

  start_new_session_for @user
  redirect_to after_authentication_url
end

Both make account activation and its evidence commit together, with a prospective-actor flow that's honest about the fact that no authenticated user exists yet at render time.

The door helpers come in a pair, exactly like save and save!. The non-bang form absorbs a refused signup — a stale presentation, an unticked box, a validation the account failed — into the same human sentences the Devise adapter paints (inline beside the control, once on the record's :base) and returns false, ready for that 422 re-render. register_with_clickwrap! raises instead, for flows that handle the exceptions themselves. An infrastructure failure escapes both forms: a broken database is not a refusal to dress up as validation, so the sign-in, the welcome email, and the redirect that would normally follow simply do not happen. That's the difference between a refused signup and a live account nobody can explain.

During a legacy migration, keep a required dual-write inside that same transaction without replacing Devise's controller action:

class Users::RegistrationsController < Devise::RegistrationsController
  clickwraps_registration_with :signup,
    after_account_is_saved_inside_transaction: :record_legacy_acceptance!

  private

  def record_legacy_acceptance!(account:, pending_receipt:)
    account.terms_acceptances.create!(
      clickwrap_event_id: pending_receipt.event_id,
      accepted_at: Time.current
    )
  end
end

If that required legacy write fails, the account and Clickwrap evidence roll back with it. Remove the hook after parity and cutover are proved.

Want the gem's controls but your own button markup? form.clickwrap_fields takes a block and hands you the signed presentation, so the wording is read rather than retyped:

<%= form.clickwrap_fields :signup, submit_button_text: "Create account" do |clickwrap| %>
  <button type="submit" class="btn btn--primary"><%= clickwrap.submit_button_text %></button>
<% end %>

submit: and submit_button_text: are a deliberate pair: form.clickwrap :signup, submit: "Create account" binds the words and renders the button, while form.clickwrap_fields :signup, submit_button_text: "Create account" binds the words and leaves the action to you.

Everything is server-rendered HTML: full-page requests, Turbo Drive and Frames, no-JavaScript validation, and Hotwire Native all work with the same helper. JSON/API clients use Clickwrap.present to get the server-owned manifest and submit answers with the signed token. Views are ejectable with bin/rails generate clickwrap:views, or build fully custom UI on Clickwrap.present plus the view helpers — clickwrap_presentation_token_field, clickwrap_statement_check_box, clickwrap_statement_radio_button, and clickwrap_submit_button own the envelope name, the control names, and a call to action worded by the signed manifest itself, while you own every class and wrapper around them. The integrating guide shows a full custom surface.

Works with organizations

A human User can bind an Organizations::Organization without collapsing the two identities:

Clickwrap.policy :organization_terms do
  agree_to :organization_terms
  permit_acting_for_organization when_actor_is_at_least: :admin
  retain_with :ordinary_agreement_evidence
end
<%= form.clickwrap :organization_terms,
      acting_for: current_organization,
      submit: "Accept for #{current_organization.name}" %>

Authority is checked when the form is presented and reread from the membership inside the capture transaction. The receipt records the human actor, represented organization, actual role at both moments, authority criterion, source, and verification times separately. Clickwrap records the configured application-authorization fact; it does not decide whether that role is legally sufficient. The same integration can create a brand-new organization and its owner membership atomically through create_represented_party_with_clickwrap. See Binding an organization through a human actor.

Recipes

Two situations come up in almost every real app. Here's exactly how to handle both.

"Accept the new Terms to continue" — wall the app until updated Terms are accepted

You know how Apple Developer releases new terms every few months and walls off the entire dashboard until you accept them? Same pattern here: legal ships a new version of your Terms, and nobody uses your app again until they've agreed to it. Accepting the new version supersedes the old one — and you keep a receipt for every version each user ever agreed to, so you always know exactly who agreed to exactly what, and when.

Bump the document version when the new text ships — in the file itself, beside the words that changed. The top of app/content/legal/terms.md:

---
title: Terms of Service
last_updated: 2026-11-01
---

It was 2026-08-15; new words mean a new label. (A trailing # comment on that line is read as YAML reads it — not part of the label.)

And require the current version in the policy:

# config/clickwrap.rb
Clickwrap.policy :current_terms do
  agree_to :terms, require_current_version: true

  retain_with :ordinary_agreement_evidence
end

Mount the built-in acceptance screen and wall the app:

# config/routes.rb
mount Clickwrap::Engine => "/agreements"
class ApplicationController < ActionController::Base
  # Nobody gets past this until they've accepted the current Terms. Clickwrap's
  # own acceptance, receipt, withdrawal, and document screens stay reachable
  # automatically, so this cannot redirect-loop its remediation page.
  requires_clickwrap :current_terms
end

Publish the new version and every signed-in user gets redirected to the acceptance screen on their next request — and sent back to wherever they were going the moment they accept. Preview the blast radius before you activate it:

bin/rails clickwrap:reacceptance:plan POLICY=current_terms

What you get for free: the new acceptance supersedes the old one (agreed → superseded) without rewriting anything, and every receipt pins the exact version, locale, and byte digest of what each user agreed to — so "which exact Terms did this person accept, and when?" stays answerable years later.

Want to wall off only parts of the app instead? Gates are per-controller and per-action, and different areas can require different policies:

class BillingController < ApplicationController
  requires_clickwrap :current_terms
end

class Api::DashboardController < ApplicationController
  requires_clickwrap :developer_terms, only: %i[show update]
end

"I agree" before the account even exists — signup, Google sign-in

At signup, people click "I agree" before they have an account with you: there's no current_user to hang the acceptance on yet, and the acceptance has to survive account creation. clickwrap models this honestly as a prospective-actor flow — the acceptance binds to a short-lived signed registration flow, then the account and its acceptance evidence commit in one database transaction, and the receipt records that this was an account registration (not an authenticated session).

For plain email/password signup, the Devise and Rails-authentication adapters above already do all of this — form.clickwrap :signup in your signup form is the whole integration.

For Google sign-in (OAuth, One Tap), the click happens on Google's side, so put the acceptance on a "finish creating your account" screen after the callback:

# The OAuth callback doesn't create the account yet — it stashes what Google
# said and sends the person to finish signing up.
def google
  session[:pending_oauth] = request.env["omniauth.auth"].slice("provider", "uid", "info")
  redirect_to new_finish_signup_path
end
<%# The finish screen: name and email prefilled from Google, plus your Terms. %>
<%= form_with model: @user, url: finish_signup_path do |form| %>
  <%= form.clickwrap :signup, submit: "Create account" %>
<% end %>
def create
  @user = User.new(user_attributes_from(session[:pending_oauth]))

  # Account + acceptance commit together, or neither happens. A refused
  # submission re-renders the finish screen with the reason beside the control.
  unless register_with_clickwrap(:signup, user: @user) { @user.save! }
    return render :new, status: :unprocessable_entity
  end

  session.delete(:pending_oauth)
  sign_in @user
  redirect_to root_path
end

The registration flow lives in your session and the presentation token is valid for two hours by default, so both comfortably survive the round-trip to Google and back. One thing clickwrap will not do, on purpose: record an agreement from the OAuth callback alone. "By continuing you agree" with no affirmative act isn't evidence of anything — a real acceptance step has to happen somewhere, and the finish screen is where it belongs.

One person accepts for the whole company — organization agreements

Your customer is a company — but companies don't click checkboxes, people do. When an admin accepts your business terms "for Acme Inc.", two facts matter and must never blur into each other: the organization is the party the terms are for, and a specific human performed the acceptance on its behalf. Years later, the question is always the same: exactly which person accepted for the company, and what authority did they have when they did?

Declare in the policy who is allowed to accept for an organization — membership alone is deliberately not enough:

Clickwrap.policy :organization_terms do
  agree_to :business_terms

  permit_acting_for_organization when_actor_is_at_least: :admin

  retain_with :ordinary_agreement_evidence
end

Make the represented company conspicuous in the UI, and pass it as acting_for::

<p>You are accepting these terms for <strong><%= current_organization.name %></strong>.</p>

<%= form.clickwrap :organization_terms,
      acting_for: current_organization,
      submit: "Accept for #{current_organization.name}" %>

Then capture the acceptance and stamp the organization in one transaction, so the rest of your app can ask a plain domain question:

def create
  organization = current_organization

  capture_clickwrap_and!(:organization_terms, acting_for: organization) do |pending_receipt|
    organization.update!(terms_accepted_with_clickwrap_event_id: pending_receipt.event_id)
  end

  redirect_to organization_settings_path
end

When the form is rendered, clickwrap verifies authority and signs that presentation-time source, role, criterion, and verification time into the manifest. At submit it requires a current membership in that exact organization and rereads and locks the membership role inside the capture transaction. An admin demoted between render and submit is refused; a still- authorized role change is recorded honestly as two different snapshots. A token rendered for one organization is rejected for another. The receipt keeps the human actor, represented organization, both authority checks, and the protected outcome as separate facts. An organizational acceptance never quietly answers a personal one, and vice versa:

user.clickwraps.current_for?(:organization_terms, acting_for: organization)  # => true
user.clickwraps.current_for?(:organization_terms)                            # => false

That receipt is exactly what you'll be asked to produce if the agreement is ever disputed: who accepted, for which company, in what role, verified when. Whether that role was sufficient to bind the company is a question for your counsel when they choose the when_actor_is_at_least: criterion — clickwrap records the facts that answer it. Works out of the box with the organizations gem, or with your own authority model via a registered adapter. The organizations guide has the full walkthrough.

If the organization does not exist until this same form creates it, opt into that materially different flow explicitly:

Clickwrap.policy :organization_creation do
  declare :authority_and_content_rights,
    statement: "I am authorized to create and act for this organization and may use the content I submit.",
    document: nil,
    protected_outcome_version: "created-organization-v1",
    record_protected_outcome_with: ->(organization) {
      Clickwrap.protected_outcome(
        action: :created,
        record: organization,
        facts: { name: organization.name }
      )
    }

  permit_acting_for_organization(
    when_actor_is_at_least: :owner,
    including_when_this_action_creates_the_organization: true
  )

  retain_with :ordinary_agreement_evidence
end
<%= form_with model: @organization do |form| %>
  <%= form.clickwrap :organization_creation,
        acting_for: @organization,
        submit: "Create organization" %>
<% end %>
create_represented_party_with_clickwrap(
  :organization_creation,
  represented_party: @organization
) do |pending_receipt|
  @organization.save!
  @organization.add_member!(current_user, role: :owner)
  @organization.update!(creation_clickwrap_event_id: pending_receipt.event_id)
  @organization
end

The form helper creates a server-owned browser-flow binding automatically. The manifest says authority is not_yet_verifiable because the membership does not exist yet; after the protected block returns the persisted organization and creates its owner membership, the adapter verifies them and Clickwrap rebinds the final GlobalID before commit. If any part fails, none of the organization, membership, evidence, or protected outcome commits. The explicit declaration is still what records the human's claim of pre-existing real-world authority: an owner role created by the transaction proves an application fact, not the truth or legal sufficiency of that claim.

Testing your integration

Documents must be published in the test database too — presentations refuse unpublished documents in tests exactly as in production:

# test/test_helper.rb
class ActiveSupport::TestCase
  include Clickwrap::TestHelpers
  parallelize_setup { Clickwrap.publish! }  # once per parallel worker...
end
Clickwrap.publish!                           # ...and once per process
receipt = submit_clickwrap(:signup, actor: user, answers: { terms: true, privacy_notice: true })

assert_clickwrap_current :signup, actor: user
assert_clickwrap_agreed_to :terms, actor: user
assert_clickwrap_receipt_verifies receipt

submit_clickwrap is the test factory: it presents the policy through the real presenter, answers it, and captures — and it raises when the capture is refused, because in a test a failed capture is a failed test. That is deliberately a different verb from the controller's capture_clickwrap, which captures a submission a person actually sent and absorbs refusals into false. Same word for both would mean one name with two opposite answers to "what happens when this is refused".

Integration tests can't fabricate a signed presentation token by hand — that's the point — so they read it off the rendered page the way a browser does:

post user_registration_path, params: {
  user: { email: "person@example.com", password: "a-real-password" },
  **clickwrap_params_from(new_user_registration_path)   # GET the page, affirm everything
}

# Decline one statement instead:
declined = clickwrap_params_from(new_user_registration_path, answers: { terms: false })

# Choice statements submit their real rendered values. By default the helper
# selects the first offered radio choice; name a different choice explicitly:
contractor = clickwrap_params_from(
  new_user_registration_path,
  answers: { employment_kind: "contractor" }
)

Checkbox statements default to their affirmative value. Radio statements default to the first choice rendered by the application, so tests exercise a value the server actually offered instead of a fabricated checkbox value. Pass the exact choice key when the choice matters. For a conventional yes/no radio group, false selects no; explicit choice keys remain the clearest option for domain-specific choices.

If one page renders several independent Clickwrap forms, select the exact form; the helper refuses an ambiguous page instead of combining one form's token with another form's answers:

submission = clickwrap_submission_params_from(
  response,
  form_css_selector: "form[action='/withdrawals/confirm']"
)

Fault injection proves the atomicity claim in your own suite:

Clickwrap::Testing.fail_next_event_write do
  assert_raises(Clickwrap::EventWriteFailed) { perform_signup }
end
assert_not User.exists?(email: "person@example.com")

Configuration

The generated initializer is fully annotated and every setting reads like a sentence. The essentials:

# config/initializers/clickwrap.rb
Clickwrap.configure do |config|
  config.actor_class_name = "User"
  config.current_actor_method_name = :current_user

  config.authorize_receipt_access_with = lambda do |controller, receipt|
    controller.current_user == receipt.actor
  end

  # Safe defaults: no IP address, browser user-agent, or IP geolocation is stored.
  # Enable fields per policy, each with a plain-English purpose and retention rule.

  # Optional hooks run only after evidence and domain state have committed:
  config.after_event_is_committed = ->(event) { }
end

Only your decisions are live in that file. Every setting left at the gem's default appears commented with its value, under prose explaining what it does, so a reader can tell at a glance which lines somebody chose. The one deliberate exception is the request-evidence block: each record_*_by_default line is written even when it says false, because each is an answer to a question the installer asked, and "we decided not to collect this" is worth reading rather than inferring from a file that does not mention it.

Class names are strings resolved lazily for autoloading, and ambiguity fails at boot instead of becoming a surprising runtime default. Optional external integrations are explicit: anchoring and timestamping are off (nil) until an adapter is configured; optional hook procs have working no-op defaults; and geolocation/document integrations run only when their corresponding policy or storage choice asks for them.

The presentation linter

In development and test, every render is scanned for the mistakes a form can make silently — a preselected consent control, a consent sentence carrying two purposes, a document link below the submit button, a missing presentation token. Findings go to the log as warnings and never raise: a lint finding is a thing to look at, not a reason to stop a page from rendering. It is off in production, because a production request has no business scanning its own HTML on the way out.

config.lint_presentations = false   # or true to run it in another environment

nil (the default) means "decide from the environment".

Operations

bin/rails clickwrap:doctor              # objective health report, never prints "compliant"
bin/rails clickwrap:publish             # freeze document snapshots (idempotent; also rides db:prepare)
bin/rails clickwrap:verify              # verify event digests
bin/rails clickwrap:retention:plan      # preview disposition
bin/rails clickwrap:privacy:inventory   # every configured personal-data field, purpose, and rule
bin/rails clickwrap:import:fine_print   # migrate from FinePrint without inventing history

Migrating from FinePrint or a bare accepted_terms_at column? Clickwrap's importer appends provenance-labeled events through its supported API: fields the old system never recorded stay unknown instead of being laundered into modern certainty. Direct database privileges remain outside that API's boundary. See the migration guide.

Will this hold up in court?

Here's the honest version, in plain words, because you deserve better than marketing copy on this question.

Electronic form alone is not a reason to deny a contract legal effect under the US E-SIGN Act (15 U.S.C. § 7001), and the EU's eIDAS regulation says an electronic signature may not be denied legal effect or admissibility solely because it is electronic or not qualified (Regulation 910/2014, Article 25). That does not decide what happens around the control in a particular downstream application:

Courts read your whole page, not your checkbox. In Berman v. Freedom Financial Network (a 2022 Ninth Circuit decision, opinion), the terms lost: the notice was in tiny gray font, the links to the terms didn't look like links, and the button said "Continue" without mentioning them — even though an acceptance flow existed. Other federal appeals courts run the same whole-interface analysis (Tejon v. Zeus Networks, Toth v. Everly Well). Placement, font size, contrast, clutter, the words on the button: all decided by your page. clickwrap renders one accessible, initially-unselected component and records exactly what that component said — it cannot see, or fix, the rest of your screen.

The words in your documents matter more than the click. In the EU, an unfair term in a consumer contract doesn't bind the consumer even when the assent flow was otherwise effective (Directive 93/13/EEC). A strong record of acceptance does not change the underlying term. The gem records your words; it can't make them fair.

Who acted, and in which capacity. clickwrap records the actor and the authentication and authority facts your application supplies; it does not establish identity, capacity, or legal authority. For an organization, it keeps the human actor distinct from the represented party and records the role or permission criterion your application checked—see the recipe.

Your jurisdiction and your document type. The US E-SIGN Act expressly excludes categories including wills, specified family-law matters, and specified notices (15 U.S.C. § 7003). In the EU, a qualified electronic signature has the equivalent legal effect of a handwritten signature; Article 25 separately says other electronic signatures may not be denied legal effect or admissibility solely because they are electronic or not qualified (eIDAS Article 25). This gem does not produce or claim a qualified electronic signature.

And GDPR consent is its own animal. Consent has to be demonstrable and withdrawable (GDPR Article 7) — clickwrap gives you both mechanics — but merely acknowledging a privacy notice is not consent (regulator guidance from Spain's AEPD, FAQ 02.48). That's why acknowledge and consent_to are different verbs here, with different lifecycles.

Notice what's left after all of that: evidence. Those opinions examine what interface the application offered and what action it recorded—not whether checkboxes are valid in the abstract. Which exact version of the terms did the server bind to the form? What did the presentation manifest say beside the control? Was the control initially unselected? Which explicit submission did the server accept? Was consent later withdrawn? Most apps genuinely cannot reconstruct those application-side facts; clickwrap exists so you can, with a receipt verifiable without the producing application's source code. It still does not prove that a person perceived or understood the interface.

That's also why nothing in this gem prints "legally binding" or "court-proof": those are conclusions a court reaches about your agreement, under your jurisdiction's law, looking at your whole page and your terms. The gem's job is narrower and more useful — making sure that when that day comes, your lawyer is holding the receipt.

What clickwrap is not

This gem provides evidence mechanics — excellent ones — and nothing else. It does not:

  • draft or approve legal documents, or decide whether a change is "material";
  • claim compliance, enforceability, admissibility, or "court-proof" anything;
  • verify identity or age, or decide whether a configured role or permission is legally sufficient to bind an organization (identity, KYC, and legal capacity belong elsewhere);
  • become DocuSign, a notary, a cookie CMP, or a contract-lifecycle platform;
  • call a local hash tamper-proof, server time trusted time, or an IP address a person;
  • hide data collection behind a compliant: true switch.

Your application and its counsel own the legal text, lawful basis, retention periods, and jurisdiction-specific requirements. clickwrap makes configured decisions executable and traceable in evidence—it doesn't make them for you.

FAQ

Is this an electronic-signature gem?

It captures electronic evidence of explicit actions and can import provider signature receipts. It does not call a checkbox a qualified electronic signature.

Does the user have to scroll through the document?

No — and clickwrap never equates scrolling with reading. It makes documents available before action and records the exact presentation. A policy can require an observed open/review interaction if your app truly needs one.

Should I record IP addresses?

Only for policies with a real, documented purpose. They corroborate request context; they don't prove identity or location. Everything defaults off.

Can I keep my domain models?

Yes, and you should. clickwrap owns presentation, evidence, lifecycle, and receipts — not your payout, eligibility, or employment rules.

Is this GDPR compliant?

No gem can answer that. clickwrap gives you privacy-aware mechanisms, truthful defaults, and an inventory of exactly what you configured. Lawful basis, necessity, and data-subject rights remain yours.

Compatibility

  • Ruby 3.2+, Rails 7.1 through 8.x
  • PostgreSQL, SQLite, and MySQL for all portable core behavior (hardening is adapter-specific and labeled)
  • Integer and UUID primary keys; Devise and Rails authentication both optional
  • Runtime dependencies are only the Rails components the gem actually uses (activerecord, actionpack, actionview, activesupport, railties) — never Redis, a job backend, a JS runtime, or an external service

Persisted evidence gets a stricter promise than semver: every released receipt schema has a permanent golden fixture, new versions keep verifying old receipts, and released migrations are never edited underneath your app — see Stability and upgrade promise.

Stability and upgrade promise

clickwrap follows semantic versioning for its Ruby APIs. Evidence formats are stricter: a format change gets a new explicit schema and verifier, never a silent reinterpretation; upgrade generators add migrations and report their effects; and deprecations name their replacement and remain executable for a documented window.

Development

bin/setup
bundle exec rake test
bundle exec rubocop

The project uses Minitest with a dummy Rails app, SimpleCov, RuboCop, Appraisal matrices, and SQLite/PostgreSQL/MySQL CI lanes. Fault-injection, concurrency, replay, stale-token, disposition, and golden-receipt tests are load-bearing, not extras.

Contributing

Bug reports and focused pull requests are welcome at https://github.com/rameerez/clickwrap. Please run bundle exec rake test and bundle exec rubocop first.

Two kinds of change need extra care: anything touching public vocabulary or an evidence claim (docs change alongside code, plus a note on receipts already written), and anything touching canonicalization, receipt schemas, digests, or migrations — released evidence formats are permanent. Security reports go through SECURITY.md, privately.

License

The gem is available as open source under the terms of the MIT License.

Releases

Packages

Contributors

Languages