signingstudio 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a1cca61538db4db313df61c802371547979134e4884f7862e93921b7b6d26dd7
4
+ data.tar.gz: ecf355dd098c824253323d377686ffd4a7474f8f9ad5a9799029e9504dd171b8
5
+ SHA512:
6
+ metadata.gz: dbd5edef52f10caf45ca88379f2ba4b804e47954be9a1ffc5549f4089c7a59410fbd8f5da44630eeaebd32515d88b4718bc806b4acf5cc55a9f3d94f708bf8fb
7
+ data.tar.gz: 282f51f2d61d5b6ebb0f2fea86085ba32c43ac2b6ab98cb8c83533681280eb86b18af60378c50b069cbadb60142b04eef7a26b79b0e8def650437952ae116b7c
data/CHANGELOG.md ADDED
@@ -0,0 +1,20 @@
1
+ # Changelog
2
+
3
+ All notable changes to `signingstudio` are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/).
4
+
5
+ ## [Unreleased]
6
+
7
+ ## [1.0.0] — 2026-07-22
8
+
9
+ Initial public release. Covers 100% of the Signing Studio v1 REST API.
10
+
11
+ ### Added
12
+ - `Signingstudio::Client` with `documents` and `templates` resources.
13
+ - `Documents`: list, send, get, delete (soft + hard), restore, archive, unarchive, cancel, progress, download_url, download_pdf, activity, remind.
14
+ - `Templates`: list, create (PDF path / IO / bytes), get, update, delete, pdf_url, download_pdf, replace_pdf, set_fields, duplicate, archive, history, history_pdf_url, download_history_pdf.
15
+ - `Signingstudio::Webhooks::Verifier` — constant-time HMAC-SHA256 verification via `OpenSSL.fixed_length_secure_compare`.
16
+ - Typed exception hierarchy: `Signingstudio::Error` → `ApiError` → `AuthenticationError`, `NotFoundError`, `ValidationError`, `RateLimitError`.
17
+ - Bounded retry policy — 429 (with Retry-After ≤ 60s), 5xx, and network errors retried with exponential backoff + jitter.
18
+ - Faraday-based transport with connection pooling suitable for Sidekiq / Rails / cron workloads.
19
+ - RSpec test suite with WebMock covering the transport layer, resource dispatch, and webhook verifier.
20
+ - GitHub Actions CI on Ruby 3.0 – 3.3; release workflow publishes to RubyGems via OIDC trusted publishing.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Signing Studio
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,200 @@
1
+ # Signing Studio Ruby SDK
2
+
3
+ [![Gem Version](https://img.shields.io/gem/v/signingstudio.svg)](https://rubygems.org/gems/signingstudio)
4
+ [![CI](https://github.com/alyasdds/signingstudio-ruby/actions/workflows/ci.yml/badge.svg)](https://github.com/alyasdds/signingstudio-ruby/actions/workflows/ci.yml)
5
+ [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
+
7
+ Official Ruby client for the **[Signing Studio](https://signingstudio.com)** e-signature API. Covers every public v1 endpoint — send documents from templates, poll signing progress, manage templates and their fields, and verify webhook deliveries.
8
+
9
+ Requires **Ruby 3.0+**.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ gem install signingstudio
15
+ ```
16
+
17
+ Or add to your Gemfile:
18
+
19
+ ```ruby
20
+ gem "signingstudio", "~> 1.0"
21
+ ```
22
+
23
+ ## Quick start
24
+
25
+ ```ruby
26
+ require "signingstudio"
27
+
28
+ client = Signingstudio::Client.new(api_key: ENV.fetch("SIGNING_STUDIO_API_KEY"))
29
+
30
+ doc = client.documents.send(
31
+ template_id: "11111111-2222-3333-4444-555555555555",
32
+ title: "MSA — Acme",
33
+ recipients: [{ name: "Alex Doe", email: "alex@acme.com" }],
34
+ )
35
+
36
+ puts "Sent #{doc["id"]} (#{doc["status"]})"
37
+ ```
38
+
39
+ Get your API key from **Signing Studio → Settings → API Keys**. The plaintext key is shown once at creation — store it in Rails credentials, Vault, or an environment variable.
40
+
41
+ ## Configuration
42
+
43
+ ```ruby
44
+ client = Signingstudio::Client.new(
45
+ api_key: "sk_live_...",
46
+ base_url: "https://api.signingstudio.com", # default
47
+ max_retries: 3, # 429 + 5xx + network
48
+ request_timeout: 120, # seconds
49
+ user_agent: "my-app/1.4",
50
+ )
51
+ ```
52
+
53
+ **Retry policy** — conservative and predictable:
54
+ - **429** with `Retry-After ≤ 60s` → sleep and retry, up to `max_retries`.
55
+ - **429** with `Retry-After > 60s` (typically monthly quota) → raise `RateLimitError` immediately.
56
+ - **5xx** and **network errors** → exponential backoff (500ms · 1s · 2s · 4s) with jitter.
57
+
58
+ ## Documents
59
+
60
+ ```ruby
61
+ listing = client.documents.list(status: "sent", view: "active", limit: 50)
62
+
63
+ doc = client.documents.send(
64
+ template_id: template_id,
65
+ title: "MSA — Acme",
66
+ subject: "Please sign",
67
+ message: "Signing at your convenience",
68
+ expires_at: "2026-08-01T00:00:00Z",
69
+ recipients: [
70
+ { name: "Alex", email: "alex@acme.com", signing_order: 0 },
71
+ { name: "Bo", email: "bo@acme.com", signing_order: 1 },
72
+ ],
73
+ prefill_values: [{ field_name: "company", value: "Acme Inc." }],
74
+ )
75
+
76
+ client.documents.get(doc["id"])
77
+ client.documents.progress(doc["id"]) # cheap; ideal for polling
78
+ client.documents.activity(doc["id"])
79
+
80
+ client.documents.cancel(doc["id"])
81
+ client.documents.archive(doc["id"])
82
+ client.documents.unarchive(doc["id"])
83
+ client.documents.restore(doc["id"])
84
+ client.documents.delete(doc["id"]) # soft
85
+ client.documents.delete(doc["id"], hard: true) # hard purge
86
+
87
+ remind = client.documents.remind(doc["id"], recipient_id)
88
+ fresh_url = remind["signing_url"]
89
+
90
+ url = client.documents.download_url(doc["id"])["url"]
91
+ bytes = client.documents.download_pdf(doc["id"])
92
+ ```
93
+
94
+ ## Templates
95
+
96
+ ```ruby
97
+ templates = client.templates.list
98
+
99
+ template = client.templates.create(
100
+ "./msa.pdf",
101
+ {
102
+ name: "MSA v2",
103
+ signer_count: 1,
104
+ delivery_methods: ["email"],
105
+ signers: [{ role: "Customer", delivery: ["email"] }],
106
+ },
107
+ )
108
+
109
+ client.templates.update(template["id"], name: "MSA v3")
110
+ client.templates.delete(template["id"])
111
+
112
+ client.templates.replace_pdf(template["id"], "./msa-updated.pdf")
113
+ versions = client.templates.history(template["id"])
114
+ old_pdf_url = client.templates.history_pdf_url(template["id"], versions[0]["id"])["url"]
115
+
116
+ client.templates.set_fields(template["id"], [
117
+ { field_type: "signature", page: 1, x: 60, y: 82, width: 30, height: 6, signer_index: 0, required: true },
118
+ { field_type: "text", page: 1, x: 10, y: 20, width: 30, height: 4, signer_index: 0, name: "company", label: "Company name", required: true },
119
+ ])
120
+ ```
121
+
122
+ ### PDF upload constraints
123
+
124
+ - Max **50 MB** per file.
125
+ - `application/pdf` only.
126
+ - Multipart field name must be `file` — the SDK sets this for you.
127
+
128
+ ## Webhooks
129
+
130
+ ```ruby
131
+ require "sinatra"
132
+ require "json"
133
+ require "signingstudio"
134
+
135
+ SECRET = ENV.fetch("SIGNING_STUDIO_WEBHOOK_SECRET")
136
+ verifier = Signingstudio::Client.webhook_verifier(SECRET)
137
+
138
+ post "/webhooks/signing-studio" do
139
+ raw = request.body.read # RAW body — do NOT re-serialize
140
+ sig = request.env["HTTP_X_DDS_SIGNATURE"] || ""
141
+ halt 401 unless verifier.valid?(raw, sig)
142
+
143
+ payload = JSON.parse(raw)
144
+ # payload["event"] is one of:
145
+ # "document.sent" | "document.viewed" | "document.signed"
146
+ # | "document.declined" | "document.completed"
147
+ content_type :json
148
+ { received: true }.to_json
149
+ end
150
+ ```
151
+
152
+ **Always sign the RAW body**, not a parsed-and-re-serialized body.
153
+
154
+ ## Errors
155
+
156
+ ```ruby
157
+ begin
158
+ client.documents.send(payload)
159
+ rescue Signingstudio::ValidationError => e
160
+ # e.errors is Hash{String => Array<String>}
161
+ rescue Signingstudio::RateLimitError => e
162
+ # e.window is "minute" | "day" | nil
163
+ # e.retry_after is Integer | nil
164
+ rescue Signingstudio::AuthenticationError
165
+ # Refresh the API key.
166
+ rescue Signingstudio::NotFoundError
167
+ # Doesn't exist on this tenant.
168
+ rescue Signingstudio::ApiError => e
169
+ Rails.logger.error("signingstudio request=#{e.request_id} status=#{e.status_code}")
170
+ end
171
+ ```
172
+
173
+ All SDK-raised exceptions inherit from `Signingstudio::Error`.
174
+
175
+ ## Rate limits
176
+
177
+ Every response carries:
178
+
179
+ - `X-RateLimit-Limit-Minute`, `X-RateLimit-Remaining-Minute`
180
+ - `X-RateLimit-Limit-Day`, `X-RateLimit-Remaining-Day`
181
+
182
+ Platform defaults: **120 req/min** and **20,000 req/day** per API key.
183
+
184
+ ## Testing
185
+
186
+ ```bash
187
+ bundle install
188
+ bundle exec rspec
189
+ bundle exec rubocop
190
+ ```
191
+
192
+ CI runs against Ruby 3.0 – 3.3 on every push/PR.
193
+
194
+ ## Versioning
195
+
196
+ Semantic versioning. `CHANGELOG.md` records every release.
197
+
198
+ ## License
199
+
200
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "signingstudio/configuration"
4
+ require "signingstudio/http_client"
5
+ require "signingstudio/resources/documents"
6
+ require "signingstudio/resources/templates"
7
+ require "signingstudio/webhooks/verifier"
8
+
9
+ module Signingstudio
10
+ # Top-level entry point.
11
+ #
12
+ # client = Signingstudio::Client.new(api_key: ENV["SIGNING_STUDIO_API_KEY"])
13
+ # doc = client.documents.send(
14
+ # template_id: "11111111-2222-3333-4444-555555555555",
15
+ # title: "MSA — Acme",
16
+ # recipients: [{ name: "Alex", email: "alex@acme.com" }],
17
+ # )
18
+ #
19
+ # Client instances are thread-safe — Faraday keeps a per-host connection
20
+ # pool internally, so a single instance is the recommended shape for
21
+ # Sidekiq workers, Rails apps, and cron scripts.
22
+ class Client
23
+ attr_reader :documents, :templates, :http
24
+
25
+ def initialize(api_key: nil, config: nil, **opts)
26
+ resolved = config || Configuration.new(api_key: api_key, **opts)
27
+ @http = HttpClient.new(resolved)
28
+ @documents = Resources::Documents.new(@http)
29
+ @templates = Resources::Templates.new(@http)
30
+ end
31
+
32
+ # Build a webhook verifier. Stand-alone rather than a method on Client
33
+ # so receivers can construct it without also building an API client.
34
+ def self.webhook_verifier(secret)
35
+ Webhooks::Verifier.new(secret)
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Signingstudio
4
+ # Immutable configuration for a Client. Build with keyword args or
5
+ # populate individual attributes on a fresh instance.
6
+ class Configuration
7
+ DEFAULT_BASE_URL = "https://api.signingstudio.com"
8
+ DEFAULT_MAX_RETRIES = 3
9
+ DEFAULT_REQUEST_TIMEOUT = 120 # seconds
10
+ DEFAULT_USER_AGENT = "signingstudio-ruby/1.0"
11
+
12
+ attr_reader :api_key, :base_url, :max_retries, :request_timeout, :user_agent
13
+
14
+ def initialize(
15
+ api_key:,
16
+ base_url: DEFAULT_BASE_URL,
17
+ max_retries: DEFAULT_MAX_RETRIES,
18
+ request_timeout: DEFAULT_REQUEST_TIMEOUT,
19
+ user_agent: DEFAULT_USER_AGENT
20
+ )
21
+ raise ArgumentError, "api_key must not be empty" if api_key.nil? || api_key.empty?
22
+ raise ArgumentError, "max_retries must be non-negative" if max_retries.negative?
23
+
24
+ @api_key = api_key
25
+ @base_url = base_url
26
+ @max_retries = max_retries
27
+ @request_timeout = request_timeout
28
+ @user_agent = user_agent
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Signingstudio
4
+ # Base class for every exception raised by the SDK. Catch this at the
5
+ # outer boundary of your code and inspect the subclass for specifics.
6
+ class Error < StandardError; end
7
+
8
+ # Non-2xx HTTP response from the API. Specific status codes (401, 404,
9
+ # 422, 429) are surfaced as dedicated subclasses.
10
+ class ApiError < Error
11
+ attr_reader :status_code, :request_id, :body
12
+
13
+ def initialize(message, status_code:, request_id: nil, body: nil)
14
+ super(message)
15
+ @status_code = status_code
16
+ @request_id = request_id
17
+ @body = body
18
+ end
19
+ end
20
+
21
+ # HTTP 401 — API key missing / unknown / account inactive.
22
+ class AuthenticationError < ApiError; end
23
+
24
+ # HTTP 404 — resource doesn't exist under this tenant.
25
+ class NotFoundError < ApiError; end
26
+
27
+ # HTTP 422 — Zod schema or business-rule failure. #errors is the
28
+ # field-name → messages map from the server.
29
+ class ValidationError < ApiError
30
+ attr_reader :errors
31
+
32
+ def initialize(message, status_code:, request_id: nil, body: nil, errors: {})
33
+ super(message, status_code: status_code, request_id: request_id, body: body)
34
+ @errors = errors || {}
35
+ end
36
+ end
37
+
38
+ # HTTP 429 — sliding-window rate limit or monthly quota. #retry_after is
39
+ # populated for sliding-window; nil for quota exhaustion.
40
+ class RateLimitError < ApiError
41
+ attr_reader :retry_after, :window
42
+
43
+ def initialize(message, status_code:, request_id: nil, body: nil, retry_after: nil, window: nil)
44
+ super(message, status_code: status_code, request_id: request_id, body: body)
45
+ @retry_after = retry_after
46
+ @window = window
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "faraday/multipart"
5
+ require "json"
6
+
7
+ require "signingstudio/errors"
8
+
9
+ module Signingstudio
10
+ # HTTP transport. Wraps Faraday with:
11
+ #
12
+ # - Authorization header injection
13
+ # - JSON / multipart body handling
14
+ # - Envelope unwrap ({ success, data, meta } → ApiResponse)
15
+ # - Status → typed exception mapping
16
+ # - Bounded retry on 429 (with Retry-After ≤ 60s), 5xx, and network errors
17
+ class HttpClient
18
+ # Result wrapper — data, meta, rate-limit metadata + request id.
19
+ ApiResponse = Struct.new(:status_code, :data, :meta, :request_id, :rate_limit, keyword_init: true) do
20
+ def raw_bytes
21
+ # Populated only for binary requests. #data holds the string body
22
+ # when the transport is asked for bytes; expose as an alias here for
23
+ # readability.
24
+ data
25
+ end
26
+ end
27
+
28
+ RateLimit = Struct.new(:remaining_minute, :remaining_day, :limit_minute, :limit_day, keyword_init: true)
29
+
30
+ def initialize(config)
31
+ @config = config
32
+ @faraday = Faraday.new(
33
+ url: "#{config.base_url.chomp("/")}/api/v1/",
34
+ request: { timeout: config.request_timeout, open_timeout: 10 },
35
+ ) do |f|
36
+ f.request :multipart
37
+ f.adapter Faraday.default_adapter
38
+ end
39
+ end
40
+
41
+ def request_json(method, path, query: nil, json: nil, multipart: nil)
42
+ execute(method, path, query: query, json: json, multipart: multipart, binary: false)
43
+ end
44
+
45
+ def request_binary(method, path, query: nil)
46
+ execute(method, path, query: query, binary: true)
47
+ end
48
+
49
+ private
50
+
51
+ def execute(method, path, query: nil, json: nil, multipart: nil, binary: false)
52
+ attempt = 0
53
+ loop do
54
+ response =
55
+ begin
56
+ @faraday.run_request(method, path.sub(%r{^/+}, ""), body_for(json, multipart), headers(binary)) do |req|
57
+ req.params = normalize_query(query) if query
58
+ end
59
+ rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
60
+ raise Error, "Network error contacting Signing Studio: #{e.message}" if attempt >= @config.max_retries
61
+
62
+ sleep(backoff_seconds(attempt))
63
+ attempt += 1
64
+ next
65
+ end
66
+
67
+ status = response.status
68
+ return unwrap(response, binary) if status.between?(200, 299)
69
+
70
+ if status == 429 && attempt < @config.max_retries
71
+ retry_after = extract_retry_after(response)
72
+ if retry_after && retry_after <= 60
73
+ sleep(retry_after)
74
+ attempt += 1
75
+ next
76
+ end
77
+ end
78
+
79
+ if status >= 500 && attempt < @config.max_retries
80
+ sleep(backoff_seconds(attempt))
81
+ attempt += 1
82
+ next
83
+ end
84
+
85
+ raise build_api_error(response, status)
86
+ end
87
+ end
88
+
89
+ def body_for(json, multipart)
90
+ return multipart if multipart
91
+ return json.to_json if json
92
+
93
+ nil
94
+ end
95
+
96
+ def headers(binary)
97
+ {
98
+ "Authorization" => "Bearer #{@config.api_key}",
99
+ "User-Agent" => @config.user_agent,
100
+ "Accept" => binary ? "application/pdf, application/octet-stream, */*" : "application/json",
101
+ "Content-Type" => "application/json",
102
+ }
103
+ end
104
+
105
+ def unwrap(response, binary)
106
+ request_id = response.headers["x-request-id"]
107
+ rate_limit = read_rate_limit(response)
108
+
109
+ if binary
110
+ return ApiResponse.new(
111
+ status_code: response.status,
112
+ data: response.body,
113
+ meta: {},
114
+ request_id: request_id,
115
+ rate_limit: rate_limit,
116
+ )
117
+ end
118
+
119
+ body = response.body
120
+ envelope = body.nil? || body.empty? ? {} : parse_json(body, response.status)
121
+ ApiResponse.new(
122
+ status_code: response.status,
123
+ data: envelope["data"],
124
+ meta: (envelope["meta"].is_a?(Hash) ? envelope["meta"] : {}),
125
+ request_id: request_id,
126
+ rate_limit: rate_limit,
127
+ )
128
+ end
129
+
130
+ def read_rate_limit(response)
131
+ RateLimit.new(
132
+ remaining_minute: parse_int(response.headers["x-ratelimit-remaining-minute"]),
133
+ remaining_day: parse_int(response.headers["x-ratelimit-remaining-day"]),
134
+ limit_minute: parse_int(response.headers["x-ratelimit-limit-minute"]),
135
+ limit_day: parse_int(response.headers["x-ratelimit-limit-day"]),
136
+ )
137
+ end
138
+
139
+ def build_api_error(response, status)
140
+ body = safe_parse_json(response.body)
141
+ message = body.is_a?(Hash) && body["message"].is_a?(String) ? body["message"] : "Signing Studio API returned HTTP #{status}"
142
+ request_id = response.headers["x-request-id"] || (body.is_a?(Hash) ? body["requestId"] : nil)
143
+
144
+ common = { status_code: status, request_id: request_id, body: body }
145
+
146
+ case status
147
+ when 401
148
+ AuthenticationError.new(message, **common)
149
+ when 404
150
+ NotFoundError.new(message, **common)
151
+ when 422
152
+ errors = body.is_a?(Hash) && body["errors"].is_a?(Hash) ? body["errors"] : {}
153
+ ValidationError.new(message, **common, errors: errors)
154
+ when 429
155
+ RateLimitError.new(
156
+ message,
157
+ **common,
158
+ retry_after: extract_retry_after(response),
159
+ window: response.headers["x-ratelimit-window"],
160
+ )
161
+ else
162
+ ApiError.new(message, **common)
163
+ end
164
+ end
165
+
166
+ def parse_json(body, status)
167
+ JSON.parse(body)
168
+ rescue JSON::ParserError => e
169
+ raise Error, "Malformed JSON in server response (HTTP #{status}): #{e.message}"
170
+ end
171
+
172
+ def safe_parse_json(body)
173
+ JSON.parse(body) unless body.nil? || body.empty?
174
+ rescue JSON::ParserError
175
+ nil
176
+ end
177
+
178
+ def parse_int(str)
179
+ Integer(str)
180
+ rescue ArgumentError, TypeError
181
+ nil
182
+ end
183
+
184
+ def extract_retry_after(response)
185
+ parse_int(response.headers["retry-after"])
186
+ end
187
+
188
+ def normalize_query(query)
189
+ query.each_with_object({}) do |(k, v), out|
190
+ next if v.nil?
191
+
192
+ out[k.to_s] = case v
193
+ when true then "true"
194
+ when false then "false"
195
+ else v.to_s
196
+ end
197
+ end
198
+ end
199
+
200
+ # Exponential backoff (500ms · 1s · 2s · 4s) plus up to 25% jitter to
201
+ # avoid herd effects when many callers retry at once.
202
+ def backoff_seconds(attempt)
203
+ base = 0.5 * (2**attempt)
204
+ base + rand * (base / 4.0)
205
+ end
206
+ end
207
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+
5
+ module Signingstudio
6
+ module Resources
7
+ # Documents resource — twelve endpoints under /api/v1/documents.
8
+ #
9
+ # Every method returns a plain Ruby Hash matching the shape documented
10
+ # in the Signing Studio API reference. The SDK does not coerce or
11
+ # reshape the response.
12
+ class Documents
13
+ def initialize(http)
14
+ @http = http
15
+ end
16
+
17
+ # List documents. All parameters are optional keyword args.
18
+ #
19
+ # @param status [String] filter by document status (sent/completed/…)
20
+ # @param view [String] 'active' (default) | 'archived' | 'deleted' | 'all'
21
+ # @param limit [Integer] page size (server-capped at 200; default 50)
22
+ # @param offset [Integer] zero-based offset
23
+ #
24
+ # @return [Hash] { data: [...], meta: { total:, limit:, offset:, view: } }
25
+ def list(status: nil, view: nil, limit: nil, offset: nil)
26
+ response = @http.request_json(:get, "documents", query: {
27
+ status: status, view: view, limit: limit, offset: offset,
28
+ })
29
+ { "data" => response.data || [], "meta" => response.meta }
30
+ end
31
+
32
+ # Send a new document from a template.
33
+ #
34
+ # Server rules to know about:
35
+ # * template_id is required.
36
+ # * recipients must have at least one entry.
37
+ # * prefill_values need either field_id (uuid) or field_name + value.
38
+ # * signature/initials fields cannot be prefilled.
39
+ # * required+readonly template fields MUST be prefilled.
40
+ def send(payload)
41
+ @http.request_json(:post, "documents", json: payload).data
42
+ end
43
+
44
+ def get(id)
45
+ @http.request_json(:get, "documents/#{enc(id)}").data
46
+ end
47
+
48
+ # Delete a document. hard: true purges the file from storage. Only
49
+ # valid on cancelled / completed / declined / expired documents.
50
+ def delete(id, hard: false)
51
+ query = hard ? { hard: "true" } : nil
52
+ @http.request_json(:delete, "documents/#{enc(id)}", query: query).data
53
+ end
54
+
55
+ def restore(id) = @http.request_json(:post, "documents/#{enc(id)}/restore").data
56
+ def archive(id) = @http.request_json(:post, "documents/#{enc(id)}/archive").data
57
+ def unarchive(id) = @http.request_json(:post, "documents/#{enc(id)}/unarchive").data
58
+ def cancel(id) = @http.request_json(:post, "documents/#{enc(id)}/cancel").data
59
+
60
+ # Read current signing progress — cheaper than #get when polling.
61
+ def progress(id)
62
+ @http.request_json(:get, "documents/#{enc(id)}/progress").data
63
+ end
64
+
65
+ # Presigned download URL. Returns { "url" => "..." }. TTL ~1h.
66
+ def download_url(id)
67
+ @http.request_json(:get, "documents/#{enc(id)}/download").data
68
+ end
69
+
70
+ # Fetch the PDF as raw bytes (follows the presigned redirect).
71
+ def download_pdf(id)
72
+ @http.request_binary(:get, "documents/#{enc(id)}/download", query: { redirect: "1" }).raw_bytes
73
+ end
74
+
75
+ def activity(id)
76
+ @http.request_json(:get, "documents/#{enc(id)}/activity").data || []
77
+ end
78
+
79
+ # Re-send the signing invite for a specific recipient. Response
80
+ # includes a freshly-minted signing URL — capture it if you need to
81
+ # relay it out-of-band; the URL is not stored server-side.
82
+ def remind(document_id, recipient_id)
83
+ @http.request_json(:post,
84
+ "documents/#{enc(document_id)}/recipients/#{enc(recipient_id)}/remind").data
85
+ end
86
+
87
+ private
88
+
89
+ def enc(str)
90
+ CGI.escape(str.to_s)
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require "json"
5
+ require "faraday/multipart"
6
+
7
+ module Signingstudio
8
+ module Resources
9
+ # Templates resource — twelve endpoints under /api/v1/templates.
10
+ #
11
+ # PDF upload constraints (server-enforced, 400 on violation):
12
+ # - Max 50 MB per file
13
+ # - application/pdf only
14
+ # - Multipart form field must be `file` — the SDK sets this for you
15
+ class Templates
16
+ def initialize(http)
17
+ @http = http
18
+ end
19
+
20
+ def list
21
+ @http.request_json(:get, "templates").data || []
22
+ end
23
+
24
+ # Create a template from a PDF plus metadata.
25
+ #
26
+ # @param pdf [String, Pathname, IO] path or an open IO/StringIO
27
+ # @param metadata [Hash] template metadata (name required; see README)
28
+ # @param filename [String] override the recorded filename
29
+ def create(pdf, metadata, filename: nil)
30
+ body = build_multipart(pdf, filename, metadata)
31
+ @http.request_json(:post, "templates", multipart: body).data
32
+ end
33
+
34
+ def get(id)
35
+ @http.request_json(:get, "templates/#{enc(id)}").data
36
+ end
37
+
38
+ def update(id, metadata)
39
+ @http.request_json(:patch, "templates/#{enc(id)}", json: metadata).data
40
+ end
41
+
42
+ # Soft-delete only. Hard-delete is not exposed on v1.
43
+ def delete(id)
44
+ @http.request_json(:delete, "templates/#{enc(id)}").data
45
+ end
46
+
47
+ def pdf_url(id)
48
+ @http.request_json(:get, "templates/#{enc(id)}/pdf").data
49
+ end
50
+
51
+ def download_pdf(id)
52
+ @http.request_binary(:get, "templates/#{enc(id)}/pdf", query: { redirect: "1" }).raw_bytes
53
+ end
54
+
55
+ # Replace the PDF backing an existing template. Prior versions stay
56
+ # accessible via #history and #history_pdf_url.
57
+ def replace_pdf(id, pdf, filename: nil)
58
+ body = build_multipart(pdf, filename, nil)
59
+ @http.request_json(:put, "templates/#{enc(id)}/pdf", multipart: body).data
60
+ end
61
+
62
+ # Full-replace of every field on the template. The server deletes
63
+ # existing rows and inserts the supplied set; partial updates are
64
+ # not supported.
65
+ def set_fields(id, fields)
66
+ @http.request_json(:put, "templates/#{enc(id)}/fields", json: { fields: fields }).data || []
67
+ end
68
+
69
+ def duplicate(id) = @http.request_json(:post, "templates/#{enc(id)}/duplicate").data
70
+ def archive(id) = @http.request_json(:post, "templates/#{enc(id)}/archive").data
71
+
72
+ def history(id)
73
+ @http.request_json(:get, "templates/#{enc(id)}/history").data || []
74
+ end
75
+
76
+ def history_pdf_url(template_id, history_id)
77
+ @http.request_json(:get, "templates/#{enc(template_id)}/history/#{history_id}/pdf").data
78
+ end
79
+
80
+ def download_history_pdf(template_id, history_id)
81
+ @http.request_binary(:get,
82
+ "templates/#{enc(template_id)}/history/#{history_id}/pdf",
83
+ query: { redirect: "1" }).raw_bytes
84
+ end
85
+
86
+ private
87
+
88
+ # Build the multipart hash Faraday expects. Nested arrays/objects go
89
+ # through JSON.generate so complex fields like `signers` and
90
+ # `delivery_methods` survive the form-data trip.
91
+ def build_multipart(pdf, filename, metadata)
92
+ parts = {}
93
+ (metadata || {}).each do |key, value|
94
+ next if value.nil?
95
+
96
+ parts[key] = case value
97
+ when String, Numeric then value.to_s
98
+ when TrueClass then "true"
99
+ when FalseClass then "false"
100
+ else JSON.generate(value)
101
+ end
102
+ end
103
+
104
+ io, resolved_name = resolve_pdf(pdf, filename)
105
+ parts["file"] = Faraday::Multipart::FilePart.new(io, "application/pdf", resolved_name)
106
+ parts
107
+ end
108
+
109
+ def resolve_pdf(pdf, override)
110
+ case pdf
111
+ when String
112
+ if File.file?(pdf)
113
+ [File.open(pdf, "rb"), override || File.basename(pdf)]
114
+ else
115
+ # Treat as raw bytes.
116
+ [StringIO.new(pdf), override || "template.pdf"]
117
+ end
118
+ when Pathname
119
+ [File.open(pdf.to_s, "rb"), override || File.basename(pdf.to_s)]
120
+ else
121
+ # Any object that responds to :read — File, StringIO, Tempfile.
122
+ name = override || (pdf.respond_to?(:path) && pdf.path ? File.basename(pdf.path) : "template.pdf")
123
+ [pdf, name]
124
+ end
125
+ end
126
+
127
+ def enc(str)
128
+ CGI.escape(str.to_s)
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Signingstudio
4
+ VERSION = "1.0.0"
5
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module Signingstudio
6
+ module Webhooks
7
+ # Verify incoming Signing Studio webhook deliveries.
8
+ #
9
+ # The server signs the raw JSON body with HMAC-SHA256 keyed on the
10
+ # webhook's secret and sends it as `X-DDS-Signature: sha256=<hex>`.
11
+ #
12
+ # ALWAYS pass the RAW body — a parsed & re-serialized body will fail
13
+ # verification, even if it looks identical.
14
+ class Verifier
15
+ def initialize(secret)
16
+ raise ArgumentError, "webhook secret must not be empty" if secret.nil? || secret.empty?
17
+
18
+ @secret = secret
19
+ end
20
+
21
+ def valid?(raw_body, header)
22
+ return false if header.nil? || header.empty?
23
+
24
+ received = header.start_with?("sha256=") ? header[7..] : header
25
+ # Hex is length-fixed; fast-fail rejects payloads mangled in transit
26
+ # before we spend cycles on the HMAC.
27
+ return false unless received.length == 64 && received.match?(/\A[0-9a-fA-F]+\z/)
28
+
29
+ expected = OpenSSL::HMAC.hexdigest("SHA256", @secret, raw_body)
30
+ secure_compare(expected, received.downcase)
31
+ end
32
+
33
+ private
34
+
35
+ # Constant-time string comparison. Falls back to a manual XOR loop
36
+ # so ancient Ruby installs without OpenSSL#fixed_length_secure_compare
37
+ # still get timing safety.
38
+ def secure_compare(a, b)
39
+ return false unless a.bytesize == b.bytesize
40
+
41
+ if OpenSSL.respond_to?(:fixed_length_secure_compare)
42
+ return OpenSSL.fixed_length_secure_compare(a, b)
43
+ end
44
+
45
+ result = 0
46
+ a.bytes.zip(b.bytes) { |x, y| result |= (x ^ y) }
47
+ result.zero?
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "signingstudio/version"
4
+ require "signingstudio/errors"
5
+ require "signingstudio/client"
6
+
7
+ module Signingstudio
8
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/signingstudio/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "signingstudio"
7
+ spec.version = Signingstudio::VERSION
8
+ spec.authors = ["Signing Studio"]
9
+ spec.email = ["contact@signingstudio.com"]
10
+
11
+ spec.summary = "Official Ruby SDK for the Signing Studio e-signature API."
12
+ spec.description = "Full coverage of the v1 REST endpoints — send documents, manage templates, verify webhooks — with typed errors, retry-aware transport, and a small dependency footprint."
13
+ spec.homepage = "https://signingstudio.com"
14
+ spec.license = "MIT"
15
+ spec.required_ruby_version = ">= 3.0"
16
+
17
+ spec.metadata["homepage_uri"] = spec.homepage
18
+ spec.metadata["source_code_uri"] = "https://github.com/alyasdds/signingstudio-ruby"
19
+ spec.metadata["documentation_uri"] = "https://signingstudio.com/docs"
20
+ spec.metadata["changelog_uri"] = "https://github.com/alyasdds/signingstudio-ruby/blob/main/CHANGELOG.md"
21
+ spec.metadata["bug_tracker_uri"] = "https://github.com/alyasdds/signingstudio-ruby/issues"
22
+ spec.metadata["rubygems_mfa_required"] = "true"
23
+
24
+ spec.files = Dir.glob(%w[lib/**/* LICENSE README.md CHANGELOG.md signingstudio.gemspec])
25
+ spec.require_paths = ["lib"]
26
+
27
+ spec.add_dependency "faraday", "~> 2.7"
28
+ spec.add_dependency "faraday-multipart", "~> 1.0"
29
+
30
+ spec.add_development_dependency "rspec", "~> 3.13"
31
+ spec.add_development_dependency "webmock", "~> 3.23"
32
+ spec.add_development_dependency "rubocop", "~> 1.65"
33
+ spec.add_development_dependency "rake", "~> 13.2"
34
+ end
metadata ADDED
@@ -0,0 +1,148 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: signingstudio
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Signing Studio
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-07-23 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: faraday
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.7'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.7'
27
+ - !ruby/object:Gem::Dependency
28
+ name: faraday-multipart
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.13'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.13'
55
+ - !ruby/object:Gem::Dependency
56
+ name: webmock
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '3.23'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '3.23'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rubocop
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '1.65'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '1.65'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rake
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '13.2'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '13.2'
97
+ description: Full coverage of the v1 REST endpoints — send documents, manage templates,
98
+ verify webhooks — with typed errors, retry-aware transport, and a small dependency
99
+ footprint.
100
+ email:
101
+ - contact@signingstudio.com
102
+ executables: []
103
+ extensions: []
104
+ extra_rdoc_files: []
105
+ files:
106
+ - CHANGELOG.md
107
+ - LICENSE
108
+ - README.md
109
+ - lib/signingstudio.rb
110
+ - lib/signingstudio/client.rb
111
+ - lib/signingstudio/configuration.rb
112
+ - lib/signingstudio/errors.rb
113
+ - lib/signingstudio/http_client.rb
114
+ - lib/signingstudio/resources/documents.rb
115
+ - lib/signingstudio/resources/templates.rb
116
+ - lib/signingstudio/version.rb
117
+ - lib/signingstudio/webhooks/verifier.rb
118
+ - signingstudio.gemspec
119
+ homepage: https://signingstudio.com
120
+ licenses:
121
+ - MIT
122
+ metadata:
123
+ homepage_uri: https://signingstudio.com
124
+ source_code_uri: https://github.com/alyasdds/signingstudio-ruby
125
+ documentation_uri: https://signingstudio.com/docs
126
+ changelog_uri: https://github.com/alyasdds/signingstudio-ruby/blob/main/CHANGELOG.md
127
+ bug_tracker_uri: https://github.com/alyasdds/signingstudio-ruby/issues
128
+ rubygems_mfa_required: 'true'
129
+ post_install_message:
130
+ rdoc_options: []
131
+ require_paths:
132
+ - lib
133
+ required_ruby_version: !ruby/object:Gem::Requirement
134
+ requirements:
135
+ - - ">="
136
+ - !ruby/object:Gem::Version
137
+ version: '3.0'
138
+ required_rubygems_version: !ruby/object:Gem::Requirement
139
+ requirements:
140
+ - - ">="
141
+ - !ruby/object:Gem::Version
142
+ version: '0'
143
+ requirements: []
144
+ rubygems_version: 3.5.22
145
+ signing_key:
146
+ specification_version: 4
147
+ summary: Official Ruby SDK for the Signing Studio e-signature API.
148
+ test_files: []