Ruby client for the Signlift electronic signature API: signature requests, document upload, webhook signature verification and rate limit awareness.
Built on the standard library. Its one runtime dependency is logger, which
used to be part of that library and stops being a default gem in Ruby 4.0;
nothing else is asked of the applications that embed it. The Redis-backed rate
limit store takes its client by injection, so it needs no redis either.
Tested on Ruby 3.1 and 3.4.
The gem is not published on RubyGems. Add it from its repository, pinned to a tag:
git_source(:capsens) { |repo| "git@github.com:CapSens/#{repo}.git" }
gem "signlift", capsens: "signlift-ruby", tag: "v0.1.0"Signlift.configure do |config|
config.api_key = ENV["SIGNLIFT_API_KEY"] # required
config.api_url = ENV["SIGNLIFT_ENDPOINT"] # default: https://app.signlift.eu/api/v1
config.webhook_secret = ENV["SIGNLIFT_WEBHOOK_SECRET"]
config.branding_profile_id = ENV["SIGNLIFT_BRANDING_PROFILE_ID"]
config.open_timeout = 5 # seconds
config.read_timeout = 30 # seconds
config.logger = Rails.logger
config.rate_limit_store = Signlift::RateLimitStore::Memory.new
endSignlift.configuration.validate! raises Signlift::ConfigurationError when
the API key is missing. Signlift.reset! drops both the memoised configuration
and the memoised client — useful between tests.
response = Signlift::SignatureRequests.create(
signature_request: {
mode: Signlift::DEFAULT_MODE, # "sequential"
validity_days: Signlift::DEFAULT_VALIDITY_DAYS, # 30
send_email: false,
branding_profile_id: Signlift.configuration.branding_profile_id,
signers: [
{
ref: "subscription-42",
first_name: "Ada",
last_name: "Lovelace",
email: "ada@example.com",
order: 1,
otp_channel: "email", # "sms" also takes a :phone
},
],
documents: [
{
id: document_id,
signers: [
{
signer_ref: "subscription-42",
stamp: {type: "magic_field", value: {tag: "SIGNATURE_1"}},
},
],
},
],
}
)
Signlift::SignatureRequests.find(id)
Signlift::SignatureRequests.audit_logs(id)Reading a response goes through the module rather than through the raw hash, so knowledge of the JSON shape stays in one place:
Signlift::SignatureRequests.signing_url(response)
Signlift::SignatureRequests.signed_document_url(response)
response["status"] == Signlift::SignatureRequests::COMPLETED_STATUS
Signlift::SignatureRequests::TERMINAL_FAILED_STATUSES.include?(response["status"])The API has four statuses: draft, pending, completed and expired. Only
expired is a terminal failure.
Signers carry a status of their own (pending → notified → signed), and it
is the only explicit signal that a signature is acquired while the request is
still assembling its documents:
Signlift::SignatureRequests.all_signers_signed?(response)In that window signing_url is already null. Reading that absence as a
statement — rather than asking the signers — is how a resumed session ends up
handing an empty page to someone who has already signed.
File.open(path, "rb") do |file|
Signlift::Documents.upload(io: file, filename: "contract.pdf")
endVerify the signature before doing anything with the body, using the raw request body — a re-serialised hash will not match:
Signlift::Webhooks::SignatureVerifier.valid?(
raw_body: request.raw_post,
header: request.headers["X-Signlift-Signature"],
secret: Signlift.configuration.webhook_secret
)The request.completed webhook carries presigned URLs under a different shape
than the API response — a root-level documents array. Two readers cover it:
Signlift::SignatureRequests.webhook_signed_document_url(payload, document_id: id)
Signlift::SignatureRequests.webhook_document_ids(payload)webhook_signed_document_url falls back to the sole entry when the id does not
line up, which keeps a single-document request working; a request listing
several documents returns nil rather than risk handing back the wrong PDF.
| Status | Class |
|---|---|
| 401 | Signlift::AuthenticationError |
| 402 | Signlift::PaymentRequiredError |
| 404 | Signlift::NotFoundError |
| 422 | Signlift::ValidationError |
| 429 | Signlift::RateLimitedError |
| 5xx | Signlift::ServerError |
| other | Signlift::ApiError |
All descend from Signlift::ApiError, which carries status, code,
details and rate_limit. Transport failures raise
Signlift::ConnectionError (with the underlying cause_class), and everything
descends from Signlift::Error.
Signlift counts per organisation and per environment, over fixed windows aligned on the clock. Every authenticated response carries the budget, so the client records it on each call and refuses locally — without sending anything — a call made while the budget is known to be spent.
Signlift.rate_limit.limit # 500
Signlift.rate_limit.remaining # 0
Signlift.rate_limit.resets_at # unix timestamp
Signlift.rate_limit.unknown? # true before the first call
Signlift.rate_limit.exhausted?
Signlift.rate_limit.throttled?
Signlift.rate_limit.retry_after # seconds, clamped to the widest window (3600)A refused call raises Signlift::RateLimitedError, whose #retry_after gives
the delay to wait — taken from Retry-After when the refusal announced one,
and derived from the reset instant otherwise. It is the same exception whether
the refusal came from the server or from the local guard, so a caller has one
thing to rescue:
sidekiq_retry_in do |_count, exception, _job|
next unless exception.is_a?(Signlift::RateLimitedError)
exception.retry_after + rand(30)
endThe default store keeps state per process, which is enough for a single
process but not for a web server and a worker fleet that share one budget:
each would have to earn its own 429 before regulating itself. RedisStore
shares it. It takes its client by injection and only ever calls get and
set, so the gem needs no dependency on redis:
config.rate_limit_store = Signlift::RateLimitStore::RedisStore.new(
client: Redis.new(url: ENV["REDIS_URL"]),
key: "signlift:rate_limit:#{Digest::SHA256.hexdigest(api_key)[0, 16]}",
on_error: ->(exception) { Appsignal.report_error(exception) }
)Keying on a digest of the API key separates the two budgets Signlift counts separately, and stops two deployments sharing a Redis database from reading each other's.
The store fails open: when it cannot be reached it answers "nothing known" and the call goes through. Refusing every call because Redis is down would turn an outage of ours into an outage of the signature journey.
Both the configuration and the client are memoised on the module, and the rate limit state outlives a single call by design. Left alone, all three carry from one example into the next, and which ones they reach depends on the seed.
RSpec.configure do |config|
config.before { Signlift.reset! }
endreset! drops the memoised configuration and client. It does not empty a
store you injected: a RedisStore shared with the rest of the platform will
hand your suite a budget some other process spent, and one example recording an
exhausted budget makes the client refuse locally in every example running inside
the window. Give the suite a store of its own:
config.before do
Signlift.configuration.rate_limit_store = Signlift::RateLimitStore::Memory.new
endRequests are plain Net::HTTP, so WebMock or any equivalent stubs them without
help from the gem.
bin/setup # install dependencies and run the suite
bin/console # IRB with the gem loaded, configured with nothing
bundle exec rake specThe suite runs outside Rails and covers 100% of lines and branches; SimpleCov fails the build below either. Tested on Ruby 3.1 and 3.4 in CI, the two ends of what the gemspec promises.
The gem is not published on RubyGems — rake release is disabled for that
reason. Consumers install it straight from this repository, pinned to a tag.
- Bump
Signlift::VERSIONinlib/signlift/version.rb - Describe the change in
CHANGELOG.md - Commit, then tag:
git tag vX.Y.Z && git push origin master --tags - Point consumers at the new tag in their
Gemfile
bundle install on a fresh checkout picks up the new commit while an existing
one keeps the old, and nothing in either repository records the difference.
Ship a new version instead.
MIT