assinafy 1.5.2 → 1.7.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.
data/README.en.md ADDED
@@ -0,0 +1,815 @@
1
+ # Assinafy Ruby SDK
2
+
3
+ *[Leia em português](README.md) · English*
4
+
5
+ [![CI](https://github.com/assinafy/ruby-sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/assinafy/ruby-sdk/actions/workflows/ci.yml)
6
+ [![Gem Version](https://img.shields.io/gem/v/assinafy.svg)](https://rubygems.org/gems/assinafy)
7
+
8
+ Ruby SDK for the [Assinafy API v1](https://api.assinafy.com.br/v1/docs).
9
+
10
+ The SDK exposes every Assinafy API v1 operation — including the OAuth 2.1 flow — and the complete supported template lifecycle. The checked-in [`spec/api_coverage_spec.rb`](spec/api_coverage_spec.rb) validates that each route maps uniquely to a public SDK method.
11
+
12
+ - **Source:** <https://github.com/assinafy/ruby-sdk>
13
+ - **Issues:** <https://github.com/assinafy/ruby-sdk/issues>
14
+ - **API docs:** <https://api.assinafy.com.br/v1/docs>
15
+ - **Ruby SDK API reference:** [`docs/API_REFERENCE.md`](docs/API_REFERENCE.md)
16
+
17
+ ## Requirements
18
+
19
+ - Ruby 3.2+ (maintained support: 3.3+; 3.2 is legacy/EOL compatibility)
20
+ - Bundler
21
+
22
+ ## Installation
23
+
24
+ From RubyGems.org:
25
+
26
+ ```ruby
27
+ gem 'assinafy'
28
+ ```
29
+
30
+ ```bash
31
+ bundle install
32
+ ```
33
+
34
+ From GitHub Packages (mirror):
35
+
36
+ ```ruby
37
+ source 'https://rubygems.pkg.github.com/assinafy' do
38
+ gem 'assinafy'
39
+ end
40
+ ```
41
+
42
+ You'll need a personal access token with `read:packages` scope, configured via:
43
+
44
+ ```bash
45
+ bundle config https://rubygems.pkg.github.com/assinafy USERNAME:TOKEN
46
+ ```
47
+
48
+ ## Quick Start
49
+
50
+ ```ruby
51
+ require 'assinafy'
52
+
53
+ client = Assinafy::Client.new(
54
+ api_key: ENV.fetch('ASSINAFY_API_KEY'),
55
+ account_id: ENV.fetch('ASSINAFY_ACCOUNT_ID')
56
+ )
57
+
58
+ document = client.documents.upload({ file_path: './contract.pdf' })
59
+ signer = client.signers.create(full_name: 'Alice Silva', email: 'alice@example.com')
60
+
61
+ assignment = client.assignments.create(
62
+ document['id'],
63
+ method: 'virtual',
64
+ signers: [{ id: signer['id'] }],
65
+ message: 'Please sign the attached contract.'
66
+ )
67
+
68
+ puts assignment['id']
69
+ ```
70
+
71
+ ## Configuration
72
+
73
+ ```ruby
74
+ client = Assinafy::Client.new(
75
+ api_key: 'your-api-key',
76
+ token: nil,
77
+ account_id: 'your-account-id',
78
+ base_url: 'https://api.assinafy.com.br/v1',
79
+ webhook_secret: nil,
80
+ timeout: 30,
81
+ logger: Logger.new($stdout)
82
+ )
83
+ ```
84
+
85
+ - `api_key:` sends `X-Api-Key` (preferred).
86
+ - `token:` sends `Authorization: Bearer ...`. Pass either a login session token or an OAuth 2.1 access token
87
+ (see [OAuth 2.1](#oauth-21)).
88
+ - Configure exactly one credential. If both are supplied, the SDK sends only `X-Api-Key`.
89
+ - A client can also be created with no credentials for authentication, OAuth, and public/signer endpoints.
90
+ - `base_url:` must be an absolute `http`/`https` URL. Anything else — a scheme-less host, a relative path, or
91
+ another scheme — raises `Assinafy::ValidationError` instead of attaching your credentials to it. A trailing
92
+ slash is stripped.
93
+ - Account-scoped methods document a per-call account override for multi-workspace tenants.
94
+ - Provide a `Logger`-compatible `logger:` to observe upload/assignment/webhook lifecycle messages.
95
+ - Requests send `User-Agent: Assinafy-Ruby-SDK/v<Assinafy::VERSION>`; the suffix always follows the gem version.
96
+
97
+ `Client.from_config(hash)` accepts string- or symbol-keyed hashes (e.g. parsed YAML).
98
+
99
+ ## Resources
100
+
101
+ `Assinafy::Client` exposes thirteen accessors — twelve API resources for the current
102
+ OpenAPI operations and sandbox-live template routes, plus the local `webhook_verifier` helper:
103
+
104
+ | Accessor | What it covers |
105
+ | --------------------------- | -------------------------------------------------------------- |
106
+ | `client.auth` | Login, social login, password reset, API keys |
107
+ | `client.oauth` | OAuth 2.1 token exchange, refresh, revocation, userinfo, discovery |
108
+ | `client.accounts` | Account CRUD, theme, KPI stats, brand logo |
109
+ | `client.users` | User profile, notification preferences, cross-account KPIs |
110
+ | `client.documents` | Upload, list, search, rename, download, delete, verify, tags |
111
+ | `client.signers` | Workspace signer CRUD + signer self-service endpoints |
112
+ | `client.signer_documents` | Signer-authenticated multi-document operations + search |
113
+ | `client.assignments` | List/create/sign/decline/resend/estimate assignments |
114
+ | `client.templates` | Template creation (file upload), get, list, update, delete |
115
+ | `client.tags` | Workspace tags |
116
+ | `client.fields` | Field definitions + validation + type catalog |
117
+ | `client.webhooks` | Subscription, event-type catalog, dispatch history, retries |
118
+ | `client.webhook_verifier` | Optional HMAC-SHA256 verifier for signed deliveries |
119
+
120
+ ### Authentication
121
+
122
+ ```ruby
123
+ client.auth.login(email: 'user@example.com', password: 'secret')
124
+ client.auth.social_login(provider: 'google', token: 'id-token', has_accepted_terms: true)
125
+ client.auth.link_social_login(provider: 'google', token: 'id-token')
126
+ client.auth.create_api_key(password: 'secret')
127
+ client.auth.get_api_key
128
+ client.auth.delete_api_key
129
+ client.auth.change_password(email: 'user@example.com', password: 'old', new_password: 'new')
130
+ client.auth.request_password_reset(email: 'user@example.com')
131
+ client.auth.reset_password(email: 'user@example.com', new_password: 'new', token: 'reset-token')
132
+ ```
133
+
134
+ ### OAuth 2.1
135
+
136
+ Use OAuth when an application acts **on behalf of a user**, with that user's permission — marketplace apps,
137
+ third-party integrations, AI assistants. Unlike an API key, the token is scoped to one workspace and carries only
138
+ the scopes the user approved. It never reaches billing, account lifecycle, credential management, or admin
139
+ surfaces, whatever scopes it holds.
140
+
141
+ The flow is authorization-code with **mandatory PKCE**; the authorization server accepts `S256` only.
142
+
143
+ | Scope | Grants |
144
+ | --- | --- |
145
+ | `documents:read` | Read documents, pages, tags, signers, assignments, activity |
146
+ | `documents:write` | Create, update, delete documents and manage their signers and assignments |
147
+ | `templates:read` | Read templates, pages, roles, fields, tags |
148
+ | `templates:write` | Create, update, delete templates |
149
+ | `account:read` | Read the workspace profile, theme, logo |
150
+ | `openid` | Identify the user (`sub` claim) and enable `/oauth/userinfo` |
151
+ | `profile` | Include the user's name in the claims |
152
+ | `email` | Include the user's email and verification status in the claims |
153
+ | `offline_access` | Be issued a refresh token |
154
+
155
+ **1. Redirect the user.** Store the verifier and state in the session first.
156
+
157
+ ```ruby
158
+ verifier = Assinafy::OAuth.generate_code_verifier
159
+ state = Assinafy::OAuth.generate_state
160
+
161
+ session[:assinafy_code_verifier] = verifier
162
+ session[:assinafy_state] = state
163
+
164
+ redirect_to Assinafy::OAuth.authorization_url(
165
+ client_id: ENV.fetch('ASSINAFY_CLIENT_ID'),
166
+ redirect_uri: 'https://app.example.com/oauth/callback',
167
+ code_verifier: verifier,
168
+ scope: %w[documents:read documents:write offline_access],
169
+ state: state
170
+ )
171
+ ```
172
+
173
+ The `code_challenge` is derived from the verifier; the verifier itself never appears in the URL.
174
+
175
+ **2. Exchange the code.** Compare `state` before exchanging — that is the CSRF check.
176
+
177
+ ```ruby
178
+ raise 'state mismatch' unless params[:state] == session.delete(:assinafy_state)
179
+
180
+ tokens = Assinafy::Client.new.oauth.exchange_code(
181
+ code: params.fetch(:code),
182
+ client_id: ENV.fetch('ASSINAFY_CLIENT_ID'),
183
+ code_verifier: session.delete(:assinafy_code_verifier),
184
+ redirect_uri: 'https://app.example.com/oauth/callback'
185
+ )
186
+ # => { 'access_token' => ..., 'token_type' => 'Bearer', 'expires_in' => 3600,
187
+ # 'refresh_token' => ..., 'scope' => 'documents:read documents:write' }
188
+ ```
189
+
190
+ **3. Act as the user.**
191
+
192
+ ```ruby
193
+ user_client = Assinafy::Client.new(
194
+ token: tokens.fetch('access_token'),
195
+ account_id: ENV.fetch('ASSINAFY_ACCOUNT_ID')
196
+ )
197
+
198
+ user_client.documents.list
199
+ user_client.oauth.userinfo # => { 'sub' => ..., 'name' => ..., 'email' => ... }
200
+ ```
201
+
202
+ **Refresh and revoke.**
203
+
204
+ ```ruby
205
+ client.oauth.refresh(refresh_token: stored_refresh_token, client_id: client_id)
206
+
207
+ client.oauth.revoke(
208
+ token: stored_refresh_token, client_id: client_id, token_type_hint: 'refresh_token'
209
+ )
210
+ ```
211
+
212
+ A refresh token exists only when `offline_access` was requested *and* consented. Revoking a refresh token also
213
+ invalidates the access tokens issued from it. Every revoke outcome returns `200` — including an unknown or
214
+ already-revoked token — so the endpoint cannot be used to probe whether a token exists.
215
+
216
+ > The SDK does **not** refresh automatically. Persist `expires_in`, refresh before expiry, and treat
217
+ > `invalid_grant` as a signal to restart the authorization flow.
218
+
219
+ **Discovery**, instead of hardcoding endpoints:
220
+
221
+ ```ruby
222
+ client.oauth.protected_resource_metadata['authorization_servers']
223
+ # => ["https://auth.assinafy.com.br"]
224
+
225
+ client.oauth.authorization_server_metadata['code_challenge_methods_supported']
226
+ # => ["S256"]
227
+ ```
228
+
229
+ `authorization_server_metadata` reaches a different host, so — like `/oauth/token` and `/oauth/revoke` — the SDK
230
+ sends it with no workspace credentials attached.
231
+
232
+ **Errors.** OAuth routes answer with the flat RFC 6749 object rather than this API's envelope, so the SDK raises
233
+ `Assinafy::OAuthError` (a subclass of `Assinafy::ApiError`):
234
+
235
+ ```ruby
236
+ rescue Assinafy::OAuthError => e
237
+ e.error # => "invalid_grant"
238
+ e.error_description # => "The authorization code is invalid or has expired."
239
+ e.context[:www_authenticate] # on a 403, names the missing scope
240
+ ```
241
+
242
+ ### Accounts
243
+
244
+ ```ruby
245
+ client.accounts.list # accounts the user can access
246
+ client.accounts.get # the current account (or pass an id)
247
+ client.accounts.create(name: 'Acme Inc.')
248
+ client.accounts.update({ name: 'Acme Renamed' })
249
+ client.accounts.delete(force: true, account_id_override: 'account-id')
250
+ client.accounts.theme # { account_name, primary_color, secondary_color, logo }
251
+ client.accounts.stats(granularity: 'monthly', month: '2026-06') # account KPI rows
252
+ client.accounts.upload_logo({ file_path: './logo.png' })
253
+ client.accounts.download_logo # raw bytes; raises ApiError on HTTP 404 when unset
254
+ client.accounts.delete_logo
255
+ ```
256
+
257
+ ### Users
258
+
259
+ ```ruby
260
+ client.users.me # OpenAPI: AuthUser; some sandboxes: { user:, accounts: }; data is passed through
261
+ client.users.stats(granularity: 'monthly') # cross-account KPI rows
262
+ client.users.notification_preferences # returns all nine owner-email preferences
263
+ client.users.update_notification_preferences(SignerDeclined: false) # partial request; returns all nine
264
+ ```
265
+
266
+ Both stats methods return rows with `period`, `documents_uploaded`, `documents_sent`, `signature_requests`,
267
+ `signature_requests_notification_email`, `signature_requests_notification_whatsapp`,
268
+ `signature_requests_notification_bypass`, `signature_requests_verification_email`,
269
+ `signature_requests_verification_whatsapp`, `signature_requests_verification_bypass`,
270
+ `signature_requests_verification_digital_certificate`, `signature_requests_viewed`,
271
+ `signature_requests_completed`, and `documents_certified`.
272
+
273
+ ### Documents
274
+
275
+ ```ruby
276
+ client.documents.statuses # GET /documents/statuses
277
+ client.documents.list(page: 1, per_page: 20, status: 'pending_signature')
278
+ client.documents.search('contract') # lightweight GET .../documents/search
279
+ client.documents.upload({ file_path: './contract.pdf' }, name: 'Contract v1')
280
+ client.documents.upload({ buffer: pdf_bytes, file_name: 'contract.pdf' })
281
+ client.documents.rename('document-id', 'renamed.pdf') # PATCH /documents/{id}
282
+ client.documents.get('document-id') # alias of .details
283
+ client.documents.wait_until_ready('document-id', max_wait_seconds: 60)
284
+ client.documents.activities('document-id')
285
+ client.documents.thumbnail('document-id') # binary PNG/JPEG
286
+ client.documents.download('document-id', 'certificated') # binary PDF
287
+ client.documents.download('document-id', 'pades') # signed PAdES artifact
288
+ client.documents.download_page('document-id', 'page-id')
289
+ client.documents.delete('document-id')
290
+ client.documents.verify('signature-hash')
291
+ client.documents.public_info('document-id')
292
+ client.documents.send_token('document-id') # current OpenAPI also permits no body
293
+ client.documents.send_token('document-id', email: 'alice@example.com') # current OpenAPI body
294
+ # Current sandbox deployment: recipient: 'alice@example.com', channel: 'email'
295
+ client.documents.list_tags('document-id')
296
+ client.documents.replace_tags('document-id', ['tag-id-1', 'tag-id-2'])
297
+ client.documents.append_tags('document-id', ['tag-id-3'])
298
+ # The deployed sandbox also accepts existing tag names in these arrays.
299
+ client.documents.detach_tag('document-id', 'tag-id')
300
+
301
+ # Template-driven creation
302
+ client.documents.create_from_template(
303
+ 'template-id',
304
+ [{ role_id: 'role', id: 'signer-id', verification_method: 'Email', notification_methods: ['Email'] }],
305
+ { name: 'Contract', message: 'Please sign', expires_at: '2026-12-31T23:59:00Z' }
306
+ )
307
+ client.documents.estimate_cost_from_template(
308
+ 'template-id',
309
+ [{ role_id: 'role', id: 'signer-id', verification_method: 'Whatsapp' }]
310
+ )
311
+
312
+ # Convenience: signing progress derived from the embedded assignment summary
313
+ client.documents.fully_signed?('document-id')
314
+ client.documents.signing_progress('document-id')
315
+ # => { signed: 1, total: 2, pending: 1, percentage: 50.0 }
316
+ ```
317
+
318
+ ### Signers (workspace CRUD)
319
+
320
+ ```ruby
321
+ client.signers.create(full_name: 'Alice Silva', email: 'alice@example.com')
322
+ client.signers.create(full_name: 'Bob Costa', phone: '+5548999990000') # phone -> whatsapp_phone_number
323
+ client.signers.get('signer-id')
324
+ client.signers.list(search: 'alice', per_page: 50) # returns { data:, meta: }
325
+ client.signers.update('signer-id', full_name: 'Alice S.', government_id: '00000000000')
326
+ client.signers.delete('signer-id')
327
+
328
+ # Convenience: case-insensitive lookup; nil means a successful search found no match
329
+ client.signers.find_by_email('alice@example.com')
330
+ ```
331
+
332
+ ### Signers (self-service, signer-access-code)
333
+
334
+ ```ruby
335
+ client.signers.self_data(signer_access_code: 'code') # includes has_signature, has_initial, is_signature_reusable
336
+ client.signers.accept_terms(signer_access_code: 'code')
337
+ client.signers.verify_email(verification_code: '123456', signer_access_code: 'code')
338
+ client.signers.confirm_data('document-id', { full_name: 'Alice Silva', email: 'alice@example.com', government_id: '00000000000' }, signer_access_code: 'code')
339
+ client.signers.upload_signature(png_bytes, signer_access_code: 'code', type: 'signature', content_type: 'image/png')
340
+ # => nil for the documented no-data envelope; some deployments return []
341
+ client.signers.download_signature(signer_access_code: 'code', type: 'signature')
342
+ ```
343
+
344
+ ### Assignments
345
+
346
+ Per-signer verification and notification values are published as
347
+ `AssignmentResource::VERIFICATION_METHODS` (`Email`, `Whatsapp`, `DigitalCertificate`) and
348
+ `::NOTIFICATION_METHODS` (`Email`, `Whatsapp`). `build_payload` validates both — and `step` — locally, because
349
+ the API's enums are closed and it reports an unknown value as a `422` only after the request has been sent, and
350
+ after any signers created for that assignment already exist.
351
+
352
+ ```ruby
353
+ # Virtual (no positioned fields)
354
+ client.assignments.create(
355
+ 'document-id',
356
+ method: 'virtual',
357
+ signers: [{ id: 'signer-1', verification_method: 'Email', notification_methods: ['Email'], step: 1 }],
358
+ message: 'Please sign',
359
+ expires_at: '2026-12-31T23:59:00Z',
360
+ copy_receivers: ['cc-signer-id']
361
+ )
362
+
363
+ # Collect (positioned fields)
364
+ client.assignments.create(
365
+ 'document-id',
366
+ method: 'collect',
367
+ signers: [{ id: 'signer-1' }],
368
+ entries: [{ page_id: 'page-id', fields: [{ signer_id: 'signer-1', field_id: 'field-id',
369
+ display_settings: { left: 100, top: 100, width: 240,
370
+ height: 48, fontSize: 16 } }] }]
371
+ )
372
+
373
+ client.assignments.list # GET /assignments (scoped to the account)
374
+ client.assignments.estimate_cost('document-id', signers: [{ verification_method: 'Whatsapp' }])
375
+ client.assignments.reset_expiration('document-id', 'assignment-id', '2026-12-31T23:59:00Z')
376
+ client.assignments.reset_expiration('document-id', 'assignment-id', nil) # clears the expiry
377
+ client.assignments.resend_notification('document-id', 'assignment-id', 'signer-id')
378
+ client.assignments.estimate_resend_cost('document-id', 'assignment-id', 'signer-id')
379
+ client.assignments.whatsapp_notifications('document-id', 'assignment-id')
380
+
381
+ # Signer perspective (signer-access-code authentication)
382
+ client.assignments.signer_document(signer_access_code: 'code', has_accepted_terms: true)
383
+ client.assignments.sign(
384
+ 'document-id',
385
+ 'assignment-id',
386
+ [{ item_id: 'i1', field_id: 'f1', page_id: 'p1', value: 'Alice' }],
387
+ signer_access_code: 'code'
388
+ )
389
+ client.assignments.decline('document-id', 'assignment-id', decline_reason: 'Clause 2', signer_access_code: 'code')
390
+ ```
391
+
392
+ > The `sign` request body is the API's camelCase body-key exception. This SDK accepts the snake_case keys (`item_id`, `field_id`, `page_id`, `value`) shown above and maps them to `itemId/fieldId/pageId/value` automatically. CamelCase input is also passed through unchanged. Assignment listing separately uses the live-required `accountId` query parameter.
393
+
394
+ ### Signer documents (multi-document workflows)
395
+
396
+ ```ruby
397
+ client.signer_documents.current('signer-id', signer_access_code: 'code')
398
+ client.signer_documents.list('signer-id', { status: 'pending_signature' }, signer_access_code: 'code')
399
+ client.signer_documents.search('signer-id', 'contract', signer_access_code: 'code')
400
+ client.signer_documents.sign_multiple(%w[doc-1 doc-2], signer_access_code: 'code')
401
+ client.signer_documents.decline_multiple(%w[doc-1 doc-2], decline_reason: 'No', signer_access_code: 'code')
402
+ client.signer_documents.download('signer-id', 'document-id', 'pades') # public: no access code needed
403
+ ```
404
+
405
+ ### Templates
406
+
407
+ ```ruby
408
+ client.templates.list(search: 'contract', per_page: 25)
409
+ client.templates.get('template-id')
410
+ client.templates.create({ file_path: './contract.pdf' }) # multipart file upload
411
+ client.templates.create({ buffer: pdf_bytes, file_name: 'contract.pdf' })
412
+ client.templates.update('template-id', name: 'Renamed template')
413
+ client.templates.delete('template-id')
414
+ client.templates.download_page('template-id', 'page-id') # binary image bytes
415
+ ```
416
+
417
+ > Template endpoints (`get`/`create`/`update`/`delete`/`download_page`) are live-verified against the sandbox but are not part of the current OpenAPI document. `create` requires a source file (`multipart/form-data`); the template name defaults to the uploaded file's name.
418
+
419
+ ### Tags
420
+
421
+ ```ruby
422
+ client.tags.list(search: 'contract')
423
+ client.tags.create(name: 'Contracts', color: 'ff8800')
424
+ client.tags.update('tag-id', name: 'Sales Contracts', color: nil)
425
+ client.tags.delete('tag-id') # fails with 409 if the tag is in use
426
+ client.tags.delete('tag-id', force: true) # detaches from documents/templates first
427
+ ```
428
+
429
+ ### Fields
430
+
431
+ ```ruby
432
+ client.fields.types # GET /field-types
433
+ client.fields.list(include_inactive: true, include_standard: false)
434
+ client.fields.create(type: 'text', name: 'Internal code', regex: '/[A-Z]{3}-[0-9]{4}/')
435
+ client.fields.get('field-id')
436
+ client.fields.update('field-id', name: 'Renamed')
437
+ client.fields.delete('field-id')
438
+
439
+ # Authenticated as a workspace user
440
+ client.fields.validate('field-id', 'ABC-1234')
441
+ # Or authenticated via signer-access-code
442
+ client.fields.validate('field-id', 'ABC-1234', signer_access_code: 'code')
443
+ client.fields.validate_multiple(
444
+ [{ field_id: 'a', value: '1' }, { field_id: 'b', value: 'value@example.com' }],
445
+ signer_access_code: 'code'
446
+ )
447
+ ```
448
+
449
+ ### Webhooks
450
+
451
+ ```ruby
452
+ client.webhooks.list_event_types # GET /webhooks/event-types
453
+ client.webhooks.get # current subscription (nil on 404)
454
+ client.webhooks.register(
455
+ url: 'https://example.com/webhooks/assinafy',
456
+ email: 'ops@example.com',
457
+ events: %w[document_ready document_prepared signer_signed_document]
458
+ )
459
+ client.webhooks.inactivate # stop deliveries, keep the event set
460
+
461
+ client.webhooks.list_dispatches(delivered: false, per_page: 50)
462
+ client.webhooks.retry_dispatch('dispatch-id')
463
+ ```
464
+
465
+ > To stop deliveries, use `inactivate` (the API has no delete-subscription route).
466
+
467
+ #### Webhook signature verification
468
+
469
+ The Assinafy API does not currently document a body-signing scheme for outbound webhook deliveries. The SDK ships an opt-in HMAC-SHA256 verifier you can use when a gateway in front of your receiver is configured to sign payloads with a shared secret:
470
+
471
+ ```ruby
472
+ verifier = Assinafy::Support::WebhookVerifier.new(ENV.fetch('ASSINAFY_WEBHOOK_SECRET'))
473
+ raw_body = request.body.read
474
+
475
+ # The signature header is one your gateway injects (e.g. Cloudflare / API Gateway).
476
+ # Assinafy v1 does not send a signature header itself.
477
+ if verifier.verify(raw_body, request.headers['X-Webhook-Signature'])
478
+ event = verifier.extract_event(raw_body)
479
+ verifier.event_type(event) # => "assignment_created" (the top-level `event`)
480
+ verifier.event_payload(event) # => event-specific params, or nil
481
+ verifier.event_object(event) # => the entity acted on, e.g. the document
482
+ verifier.event_subject(event) # => the actor, e.g. the user
483
+ end
484
+ ```
485
+
486
+ If no `webhook_secret` is configured, `verify` always returns `false` — safe-by-default.
487
+
488
+ ## Responses
489
+
490
+ Most JSON successes use a `{ "status": ..., "message": ..., "data": ... }` envelope. The SDK
491
+ returns the `data` payload (a Hash for single resources, an Array for collection bodies). For
492
+ documented no-data envelopes containing only `status`/`message`, it returns `nil`; deployed API
493
+ versions that add `data` are passed through. Binary endpoints (`download`, `thumbnail`,
494
+ `download_page`, `download_signature`) return raw bytes as an ASCII-8BIT `String`, and
495
+ delete-style endpoints return `nil`.
496
+
497
+ OAuth endpoints are the exception: `/oauth/token`, `/oauth/revoke`, `/oauth/userinfo`, and the `.well-known`
498
+ metadata documents answer with flat RFC 6749 / OIDC / RFC 8615 objects rather than the envelope, and the SDK
499
+ returns those bodies unchanged.
500
+
501
+ Errors surface the envelope/framework error body through `Assinafy::ApiError`:
502
+
503
+ ```ruby
504
+ begin
505
+ client.documents.details('missing-id')
506
+ rescue Assinafy::ApiError => e
507
+ e.status_code # => 404
508
+ e.message # => "Documento não encontrado."
509
+ e.error_name # => nil (or "Not Found" for framework errors)
510
+ e.error_code # => nil (or an integer code)
511
+ e.response_data # => the raw parsed body
512
+ end
513
+ ```
514
+
515
+ The [SDK API reference](docs/API_REFERENCE.md) maps every API operation to its Ruby method and documents exact
516
+ authentication, parameters, request bodies, success responses, and every published schema property.
517
+
518
+ ## Complete document workflow
519
+
520
+ Every call below returns the SDK value after response-envelope handling. Use the
521
+ [API operation table](docs/API_REFERENCE.md#api-operations) for each method's exact HTTP authentication,
522
+ parameters, request body, and success wire response. Named response objects link to the complete
523
+ [schema catalog](docs/API_REFERENCE.md#schema-catalog).
524
+
525
+ ### 1. Configure a workspace client
526
+
527
+ ```ruby
528
+ client = Assinafy::Client.new(
529
+ api_key: ENV.fetch('ASSINAFY_API_KEY'),
530
+ account_id: ENV.fetch('ASSINAFY_ACCOUNT_ID'),
531
+ base_url: ENV.fetch('ASSINAFY_BASE_URL', 'https://api.assinafy.com.br/v1')
532
+ )
533
+ ```
534
+
535
+ Use one authentication credential per client. Account-scoped methods use the configured account unless an
536
+ explicit `account_id_override` is supplied.
537
+
538
+ ### 2. Upload a document or select a template
539
+
540
+ ```ruby
541
+ document = client.documents.upload(
542
+ { file_path: './contract.pdf' },
543
+ name: 'Customer agreement'
544
+ )
545
+ # => Document
546
+
547
+ template = client.templates.get('template-id')
548
+ # => Template
549
+ ```
550
+
551
+ For in-memory data, pass `{ buffer: pdf_bytes, file_name: 'contract.pdf' }`. Upload requests are multipart;
552
+ template-based document creation is shown after the signer is available.
553
+
554
+ ### 3. Wait for document processing
555
+
556
+ ```ruby
557
+ document = client.documents.wait_until_ready(
558
+ document.fetch('id'),
559
+ max_wait_seconds: 60,
560
+ poll_interval_seconds: 2
561
+ )
562
+ # => Document with a ready status and populated pages
563
+ ```
564
+
565
+ The helper raises `Assinafy::ValidationError` for invalid interval values and `Assinafy::Error` for a failed
566
+ terminal document status or timeout.
567
+
568
+ ### 4. Create or reuse a signer
569
+
570
+ ```ruby
571
+ signer = client.signers.find_by_email('signer@example.com') ||
572
+ client.signers.create(full_name: 'Example Signer', email: 'signer@example.com')
573
+ # => Signer
574
+ ```
575
+
576
+ ### 5. Estimate and request signatures
577
+
578
+ ```ruby
579
+ signer_request = {
580
+ id: signer.fetch('id'),
581
+ verification_method: 'Email',
582
+ notification_methods: ['Email'],
583
+ step: 1
584
+ }
585
+
586
+ cost = client.assignments.estimate_cost(
587
+ document.fetch('id'),
588
+ signers: [signer_request]
589
+ )
590
+ # => CostEstimate
591
+
592
+ assignment = client.assignments.create(
593
+ document.fetch('id'),
594
+ method: 'virtual',
595
+ signers: [signer_request],
596
+ message: 'Please review and sign.',
597
+ expires_at: '2026-12-31T23:59:00Z'
598
+ )
599
+ # => Assignment, including signing_urls
600
+ ```
601
+
602
+ The template alternative binds a signer to a template role and creates the document and assignment together:
603
+
604
+ ```ruby
605
+ role_signer = {
606
+ role_id: template.fetch('roles').first.fetch('id'),
607
+ id: signer.fetch('id'),
608
+ verification_method: 'Email',
609
+ notification_methods: ['Email']
610
+ }
611
+
612
+ template_cost = client.documents.estimate_cost_from_template(template.fetch('id'), [role_signer])
613
+ # => CostEstimate
614
+
615
+ template_document = client.documents.create_from_template(
616
+ template.fetch('id'),
617
+ [role_signer],
618
+ name: 'Customer agreement', message: 'Please review and sign.'
619
+ )
620
+ # => Document with an embedded Assignment
621
+ ```
622
+
623
+ ### 6. Complete the signer flow
624
+
625
+ Signer-facing calls use the one-time access code delivered by Assinafy. They do not use the workspace API key.
626
+
627
+ ```ruby
628
+ signing = Assinafy::Client.new(base_url: ENV.fetch('ASSINAFY_BASE_URL'))
629
+ access_code = ENV.fetch('ASSINAFY_SIGNER_ACCESS_CODE')
630
+ verification_code = ENV.fetch('ASSINAFY_VERIFICATION_CODE')
631
+
632
+ signer_data = signing.signers.self_data(signer_access_code: access_code)
633
+ signing.signers.accept_terms(signer_access_code: access_code) unless signer_data['has_accepted_terms']
634
+ signing.signers.verify_email(
635
+ verification_code: verification_code,
636
+ signer_access_code: access_code
637
+ )
638
+
639
+ signing_document = signing.assignments.signer_document(signer_access_code: access_code)
640
+ signing.signer_documents.sign_multiple(
641
+ [signing_document.fetch('id')],
642
+ signer_access_code: access_code
643
+ )
644
+ ```
645
+
646
+ The virtual assignment created above uses `sign_multiple`. For a collect assignment, submit each positioned
647
+ item through `assignments.sign`:
648
+
649
+ ```ruby
650
+ collect_items = signing_document.fetch('assignment').fetch('items').map do |item|
651
+ {
652
+ item_id: item.fetch('id'),
653
+ field_id: item.dig('field', 'id'),
654
+ page_id: item.dig('page', 'id'),
655
+ value: 'Accepted'
656
+ }
657
+ end
658
+
659
+ signing.assignments.sign(
660
+ signing_document.fetch('id'),
661
+ signing_document.dig('assignment', 'id'),
662
+ collect_items,
663
+ signer_access_code: access_code
664
+ )
665
+ ```
666
+
667
+ The SDK maps the documented snake_case item keys to the API's camelCase request keys.
668
+
669
+ `DigitalCertificate` is one of the three verification methods in `AssignmentResource::VERIFICATION_METHODS`,
670
+ alongside the `Email` and `Whatsapp` one-time codes. It has the signer sign with their own ICP-Brasil certificate
671
+ (A1 or A3) via the Web PKI browser extension, producing a qualified PAdES signature. It needs the Digital
672
+ Certificate account feature, a CPF or CNPJ in the signer's `government_id`, and the signer alone in its signing
673
+ step; it costs 2 credits per signer. `build_payload` validates the enum locally, so a typo fails before any
674
+ signer is created.
675
+
676
+ Requesting the method is fully supported. Completing the signature is not: the two-step handshake
677
+ (`/signers/certificate/start`, `/signers/certificate/complete`) is absent from the API v1 machine contract — its
678
+ authentication and request/response schemas are not published — so the SDK does not expose those completion
679
+ calls rather than guess at their payloads. Contact Assinafy before enabling a digital-certificate signing flow.
680
+ Once it completes, the `pades` artifact returns the qualified signature.
681
+
682
+ ### 7. Inspect, tag, download, and verify
683
+
684
+ ```ruby
685
+ document_id = document.fetch('id')
686
+
687
+ activities = client.documents.activities(document_id) # => Array<DocumentActivity>
688
+ tags = client.documents.append_tags(document_id, ['tag-id']) # => Array<Tag>
689
+ client.documents.replace_tags(document_id, tags.map { |tag| tag.fetch('id') })
690
+
691
+ original_pdf = client.documents.download(document_id, 'original')
692
+ signed_pdf = client.documents.download(document_id, 'pades')
693
+ certificate_pdf = client.documents.download(document_id, 'certificated')
694
+
695
+ verification = client.documents.verify('signature-hash-from-assinafy')
696
+ # => DocumentVerification; inspect verification['is_valid']
697
+ ```
698
+
699
+ `verify` returns Assinafy's verification result. It does not independently validate the PDF signature or its
700
+ certificate chain; see [Authentication and safety](docs/API_REFERENCE.md#authentication-and-safety).
701
+
702
+ ### 8. Handle errors and clean up
703
+
704
+ ```ruby
705
+ begin
706
+ client.documents.details(document_id)
707
+ rescue Assinafy::ValidationError => e
708
+ warn e.errors.inspect
709
+ rescue Assinafy::ApiError => e
710
+ warn "Assinafy returned #{e.status_code}: #{e.message}"
711
+ rescue Assinafy::NetworkError => e
712
+ warn "Network failure: #{e.message}"
713
+ end
714
+
715
+ client.documents.delete(document_id)
716
+ client.signers.delete(signer.fetch('id'))
717
+ client.templates.delete(template.fetch('id')) if template
718
+ ```
719
+
720
+ Delete resources only after downstream work is complete. Some resources can return `409` while processing or
721
+ while still referenced; retry only after resolving the reported state.
722
+
723
+ ## Pagination
724
+
725
+ Most `*.list*` methods return `{ data: [...], meta: { ... } }` when the API includes pagination
726
+ headers. Ruby-style `per_page:` is transparently converted to the documented `per-page` query
727
+ parameter (values above the API's maximum are clamped server-side; the sandbox caps it at 50).
728
+ A few endpoints (e.g. `fields.list`) do not paginate and return `meta: nil`.
729
+
730
+ ```ruby
731
+ result = client.documents.list(page: 2, per_page: 25)
732
+ result[:data] # => Array<Hash>
733
+ result[:meta] # => { current_page: 2, per_page: 25, total: 138, last_page: 6 }
734
+ ```
735
+
736
+ ## High-level workflow helper
737
+
738
+ `Client#upload_and_request_signatures` bundles upload + signer creation + virtual assignment into a single call:
739
+
740
+ ```ruby
741
+ result = client.upload_and_request_signatures(
742
+ source: { file_path: './contract.pdf' },
743
+ signers: [
744
+ { full_name: 'Alice Silva', email: 'alice@example.com' },
745
+ { full_name: 'Bob Costa', whatsapp_phone_number: '+5548999990000' }
746
+ ],
747
+ message: 'Please sign.',
748
+ expires_at: '2026-12-31T23:59:00Z'
749
+ )
750
+
751
+ puts result[:document]['id']
752
+ puts result[:assignment]['id']
753
+ result[:signer_ids] # => ['<sid-1>', '<sid-2>']
754
+ ```
755
+
756
+ ## Errors
757
+
758
+ The SDK raises one of:
759
+
760
+ - `Assinafy::ValidationError` — caller-side input invalid (missing IDs, bad email, etc.).
761
+ - `Assinafy::ApiError` — the API returned a non-2xx status. Includes `status_code` and `response_data`.
762
+ - `Assinafy::OAuthError` — an OAuth endpoint failed. Subclasses `ApiError`, and adds `error` and
763
+ `error_description` from the flat RFC 6749 body; on a `403`, `context[:www_authenticate]` names the missing
764
+ scope.
765
+ - `Assinafy::NetworkError` — Faraday connection error or timeout.
766
+ - `Assinafy::Error` — base class; other unexpected errors get wrapped here with the operation label.
767
+
768
+ All inherit a `#context` Hash with debugging metadata.
769
+
770
+ ## Tests
771
+
772
+ ```bash
773
+ bundle exec rake spec # 400+ RSpec examples, including a coverage matrix
774
+ bundle exec rubocop # Linting
775
+ bundle exec bundler-audit check # Dependency CVEs
776
+ ruby scripts/check_api_contract.rb --file path/to/openapi.json # validate a local contract document
777
+ ```
778
+
779
+ The coverage spec validates the committed route-to-method inventory, public wrappers, aliases, and resource mappings without network access.
780
+
781
+ Ordinary pull-request CI remains network-independent. A weekly scheduled job runs
782
+ `scripts/check_api_contract.rb` against the upstream document. When Assinafy publishes a new contract, update
783
+ the SDK implementation, tests, API reference, and expected fixture together.
784
+
785
+ ### Live integration tests
786
+
787
+ The suite in [`spec/integration/`](spec/integration/live_sandbox_spec.rb) exercises safe sandbox workflows across
788
+ every workspace resource. Signer-code, OTP, password, social-login, API-key mutation, signature upload, and
789
+ irreversible sign/decline calls are wire-contract tested in the default suite but are not live-automated without
790
+ their one-time credentials or explicit state changes. The live suite is excluded from the default run and only
791
+ executes when `ASSINAFY_LIVE=1` is set with credentials:
792
+
793
+ ```bash
794
+ ASSINAFY_LIVE=1 \
795
+ ASSINAFY_API_KEY=... \
796
+ ASSINAFY_ACCOUNT_ID=... \
797
+ ASSINAFY_TEST_EMAIL=recipient1@example.com \
798
+ ASSINAFY_TEST_EMAIL2=recipient2@example.com \
799
+ ASSINAFY_BASE_URL=https://sandbox.assinafy.com.br/v1 \
800
+ bundle exec rspec spec/integration
801
+ ```
802
+
803
+ > These tests create and clean up real resources and, for the assignment flow, send real signature-request emails to the addresses in `ASSINAFY_TEST_EMAIL` / `ASSINAFY_TEST_EMAIL2`.
804
+
805
+ GitHub Actions provides the manual **Live Sandbox** workflow. Select a protected environment containing
806
+ `ASSINAFY_API_KEY`, `ASSINAFY_ACCOUNT_ID`, `ASSINAFY_TEST_EMAIL`, and `ASSINAFY_TEST_EMAIL2`; environment
807
+ branch/tag rules and approvals are applied before GitHub exposes those secrets to the job.
808
+
809
+ ## Contributing
810
+
811
+ Pull requests and issues are welcome at <https://github.com/assinafy/ruby-sdk>.
812
+
813
+ ## License
814
+
815
+ MIT. See [LICENSE](LICENSE).