assinafy 2.1.0 → 2.1.1

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.md CHANGED
@@ -5,1156 +5,176 @@
5
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
6
  [![Gem Version](https://img.shields.io/gem/v/assinafy.svg)](https://rubygems.org/gems/assinafy)
7
7
 
8
- SDK Ruby para a [API Assinafy v1](https://api.assinafy.com.br/v1/docs) — plataforma brasileira de
9
- assinatura eletrônica de documentos.
8
+ SDK Ruby 3.2+ para todas as operações publicadas da [API Assinafy v1](https://api.assinafy.com.br/v1/docs).
9
+ O SDK é para servidores: nunca coloque chave de API, segredo OAuth ou código de signatário em aplicativo móvel,
10
+ navegador, log ou repositório. A referência completa, com payloads de cada operação, está em
11
+ [docs/API_REFERENCE.md](docs/API_REFERENCE.md); a lista detalhada por recurso está em
12
+ [README.en.md](README.en.md).
10
13
 
11
- O SDK expõe **todas** as operações da API Assinafy v1, incluindo OAuth 2.1, autenticação em dois
12
- fatores, endpoints de webhook com assinatura nativa, e o ciclo de vida completo de templates. O
13
- [`spec/api_coverage_spec.rb`](spec/api_coverage_spec.rb), versionado no repositório, valida que cada
14
- rota mapeia de forma única para um método público do SDK — se uma operação deixar de ter cobertura,
15
- a suíte falha.
16
-
17
- Este documento acompanha uma integração do início ao fim, na ordem em que ela acontece. Para
18
- consulta rápida por recurso, veja **[README.en.md](README.en.md)**; para consulta por operação,
19
- [docs/API_REFERENCE.md](docs/API_REFERENCE.md).
20
-
21
- ---
22
-
23
- ## Sumário
24
-
25
- 1. [Instalação, configuração e ambientes](#1-instalação-configuração-e-ambientes)
26
- 2. [Autenticação](#2-autenticação)
27
- 3. [Conexão OAuth 2.1 (apps de marketplace)](#3-conexão-oauth-21-apps-de-marketplace)
28
- 4. [Enviar o documento e esperar os metadados](#4-enviar-o-documento-e-esperar-os-metadados)
29
- 5. [Signatários](#5-signatários)
30
- 6. [Métodos de verificação e notificação](#6-métodos-de-verificação-e-notificação)
31
- 7. [Estimar o custo](#7-estimar-o-custo)
32
- 8. [Abrir o assignment](#8-abrir-o-assignment)
33
- 9. [A experiência do signatário](#9-a-experiência-do-signatário)
34
- 10. [Webhooks: endpoints e verificação de assinatura](#10-webhooks-endpoints-e-verificação-de-assinatura)
35
- 11. [Andamento, download e verificação](#11-andamento-download-e-verificação)
36
- 12. [Organizar e limpar](#12-organizar-e-limpar)
37
- 13. [Erros](#13-erros)
38
- 14. [Paginação](#14-paginação)
39
- 15. [Recursos do cliente](#15-recursos-do-cliente)
40
- 16. [Assinaturas RBS](#16-assinaturas-rbs)
41
-
42
- ---
43
-
44
- ## 1. Instalação, configuração e ambientes
45
-
46
- - Ruby 3.2+ (suporte mantido: 3.3+; 3.2 é compatibilidade legada/EOL)
47
- - Bundler
48
- - TLS 1.2 ou superior (o SDK recusa TLS 1.0 e 1.1)
49
-
50
- Do RubyGems.org:
14
+ ## Instalação e cliente
51
15
 
52
16
  ```ruby
53
17
  # Gemfile
54
18
  gem 'assinafy'
55
19
  ```
56
20
 
57
- ```bash
58
- bundle install
59
- ```
60
-
61
- Do GitHub Packages (mirror), com um personal access token de escopo `read:packages`:
62
-
63
- ```ruby
64
- source 'https://rubygems.pkg.github.com/assinafy' do
65
- gem 'assinafy'
66
- end
67
- ```
68
-
69
- ```bash
70
- bundle config https://rubygems.pkg.github.com/assinafy USUARIO:TOKEN
71
- ```
72
-
73
- ### Ambientes
74
-
75
- | Ambiente | `base_url` | Servidor de autorização OAuth |
76
- | --- | --- | --- |
77
- | Produção | `https://api.assinafy.com.br/v1` (padrão) | `https://auth.assinafy.com.br` |
78
- | Sandbox | `https://sandbox.assinafy.com.br/v1` | `https://auth-sandbox.assinafy.com.br` |
79
-
80
- O sandbox é gratuito: use-o para testar a integração antes de ir para produção, trocando a
81
- `base_url` e, no OAuth, o servidor de autorização. Nunca envie uma chave de API do sandbox para a
82
- produção, nem o contrário. Os endpoints de webhook e a autenticação em dois fatores estão
83
- disponíveis em produção (`api.assinafy.com.br`).
84
-
85
- ### Configuração
86
-
87
21
  ```ruby
88
22
  require 'assinafy'
89
- require 'logger'
90
23
 
91
24
  client = Assinafy::Client.new(
92
- api_key: ENV.fetch('ASSINAFY_API_KEY'),
93
- account_id: ENV.fetch('ASSINAFY_ACCOUNT_ID'),
94
- base_url: ENV.fetch('ASSINAFY_BASE_URL', 'https://api.assinafy.com.br/v1'),
95
- webhook_secret: ENV['ASSINAFY_WEBHOOK_SECRET'], # whsec_... do endpoint de webhook
96
- timeout: 30,
97
- logger: Logger.new($stdout)
25
+ api_key: ENV.fetch('ASSINAFY_API_KEY'),
26
+ account_id: ENV.fetch('ASSINAFY_ACCOUNT_ID'),
27
+ base_url: ENV.fetch('ASSINAFY_BASE_URL', 'https://api.assinafy.com.br/v1'),
28
+ timeout: 30
98
29
  )
99
30
  ```
100
31
 
101
- - `base_url:` precisa ser uma URL `http`/`https` absoluta. Qualquer outra coisa — um host sem
102
- esquema, um caminho relativo, outro esquema — levanta `Assinafy::ValidationError` em vez de
103
- anexar suas credenciais a ela. A barra final é removida. `Configuration#base_url=` e
104
- `#timeout=` validam da mesma forma que o construtor.
105
- - Métodos com escopo de conta aceitam uma sobrescrita de `account_id` por chamada, para tenants com
106
- múltiplas workspaces.
107
- - O `logger:` recebe mensagens de ciclo de vida do SDK, nunca corpos de requisição ou credenciais.
108
- - Toda requisição envia `User-Agent: Assinafy-Ruby-SDK/v<Assinafy::VERSION>`.
109
- - `Client.from_config(hash)` aceita hashes com chaves string ou símbolo (por exemplo, YAML já
110
- interpretado).
111
-
112
- ---
32
+ Use `https://sandbox.assinafy.com.br/v1` no sandbox. O SDK exige URL absoluta, usa TLS 1.2+ e aceita exatamente
33
+ uma credencial por cliente: `api_key:` para a workspace ou `token:` para sessão/OAuth. Endpoints públicos e de
34
+ signatário removem essas credenciais e enviam apenas o código próprio da operação.
113
35
 
114
- ## 2. Autenticação
115
-
116
- A Assinafy aceita três credenciais. Escolha pela pergunta "**quem** está agindo?".
117
-
118
- | Credencial | Quem age | Quando usar |
119
- | --- | --- | --- |
120
- | **Chave de API** (`api_key:`) | a própria workspace | integrações de back-end. Permanente. Enviada como `X-Api-Key`. |
121
- | **Token de sessão** (`token:`) | o usuário que fez login | depois de `client.auth.login`. JWT, expira em ~1 hora. |
122
- | **OAuth 2.1** (`token:`) | um aplicativo, **em nome de** um usuário | apps de marketplace, integrações de terceiros, assistentes de IA. Veja a [seção 3](#3-conexão-oauth-21-apps-de-marketplace). |
123
-
124
- Configure **exatamente uma** credencial por cliente; se `api_key:` e `token:` forem informados, o
125
- SDK envia apenas `X-Api-Key`. Um cliente **sem credenciais** serve para login, OAuth e endpoints
126
- públicos ou de signatário — o SDK remove `X-Api-Key`/`Authorization` dessas chamadas de qualquer
127
- forma.
128
-
129
- ### Login com e-mail e senha
130
-
131
- ```ruby
132
- publico = Assinafy::Client.new
133
- sessao = publico.auth.login(email: 'usuario@example.com', password: ENV.fetch('ASSINAFY_PASSWORD'))
134
- ```
36
+ ## OAuth 2.1 para marketplace
135
37
 
136
- Quando o usuário tem **autenticação em dois fatores** ativa, o login não devolve `access_token`:
137
- devolve um desafio com `mfa_token`. Troque-o pelo código do aplicativo autenticador, ou por um
138
- código de recuperação:
38
+ O fluxo é authorization code com PKCE `S256`. Registre um callback HTTPS e, para cada tentativa, guarde o
39
+ `code_verifier`, `state` e emissor esperado no servidor.
139
40
 
140
41
  ```ruby
141
- if sessao['mfa_token']
142
- sessao = publico.auth.verify_mfa(
143
- mfa_token: sessao['mfa_token'],
144
- code: '123456' # ou um código de recuperação, como "ABCD-EFGH-JKMN"
145
- )
146
- end
147
-
148
- usuario = Assinafy::Client.new(
149
- token: sessao.fetch('access_token'),
150
- account_id: sessao.fetch('accounts').first.fetch('id')
151
- )
152
- ```
153
-
154
- `verify_mfa` é enviado sem credenciais de workspace. O desafio vale uma única vez e expira 5
155
- minutos depois do login; um código errado responde `400`, e um desafio expirado, já usado ou com
156
- tentativas demais responde `401` — nesse caso, faça login de novo. O login social
157
- (`client.auth.social_login`) devolve o mesmo formato.
158
-
159
- ### Gerenciar a autenticação em dois fatores
160
-
161
- Estas chamadas agem sobre o usuário autenticado:
162
-
163
- ```ruby
164
- inscricao = usuario.auth.start_totp_enrollment(label: 'Meu celular')
165
- inscricao['provisioning_uri'] # => "otpauth://totp/...": mostre como QR code
166
- inscricao['secret'] # exibido só nesta resposta
167
-
168
- codigos = usuario.auth.confirm_totp_enrollment(method_id: inscricao.fetch('id'), code: '123456')
169
- codigos['recovery_codes'] # => ["ABCD-EFGH-JKMN", ...] — exibidos uma única vez
170
-
171
- usuario.auth.mfa_methods
172
- # => { 'methods' => [{ 'id' => 'mfa-method-id', 'type' => 'Totp', 'label' => 'Meu celular', ... }],
173
- # 'recovery_codes_remaining' => 10 }
42
+ verifier = Assinafy::OAuth.generate_code_verifier
43
+ state = Assinafy::OAuth.generate_state
174
44
 
175
- usuario.auth.regenerate_recovery_codes(password: ENV.fetch('ASSINAFY_PASSWORD'))
176
- usuario.auth.delete_mfa_method('mfa-method-id', code: '123456') # => { 'is_mfa_enabled' => false }
177
- ```
178
-
179
- - A autenticação em dois fatores só fica ativa depois de `confirm_totp_enrollment`.
180
- - Confirmar um novo autenticador quando já existe um **substitui** o anterior e exige
181
- reautenticação: `password:` ou `reauth_code:` (código do dispositivo atual ou de recuperação).
182
- - `regenerate_recovery_codes` e `delete_mfa_method` exigem `password:` ou `code:`; sem nenhum dos
183
- dois, o SDK levanta `ValidationError` antes de enviar. Um código de recuperação usado como prova é
184
- consumido. Remover o último método também descarta os códigos de recuperação.
185
-
186
- ### Gerenciar a chave de API
187
-
188
- ```ruby
189
- usuario.auth.create_api_key(password: ENV.fetch('ASSINAFY_PASSWORD')) # => { 'api_key' => '...' } — exibida uma única vez
190
- usuario.auth.get_api_key # => { 'api_key' => '****...' } — mascarada
191
- usuario.auth.delete_api_key # => nil
192
- ```
193
-
194
- Gerar uma chave nova **invalida a anterior**. Nunca exponha a chave em um front-end.
195
-
196
- ---
197
-
198
- ## 3. Conexão OAuth 2.1 (apps de marketplace)
199
-
200
- Use OAuth quando um aplicativo age **em nome de um usuário**, com a permissão dele. Diferente da
201
- chave de API, o token vale para **uma** workspace e carrega apenas os escopos que o usuário aprovou.
202
- O fluxo é *authorization code* com **PKCE obrigatório** (o servidor aceita apenas `S256`).
203
-
204
- ### Escopos
205
-
206
- | Escopo | Concede |
207
- | --- | --- |
208
- | `documents:read` | ler documentos, páginas, tags, signatários, assignments, atividades e entregas de webhook |
209
- | `documents:write` | criar, alterar e excluir documentos e gerenciar seus signatários e assignments |
210
- | `templates:read` | ler templates, páginas, papéis, campos e tags |
211
- | `templates:write` | criar, alterar e excluir templates |
212
- | `account:read` | ler perfil, tema, logo e endpoints de webhook da workspace |
213
- | `webhooks:write` | criar, alterar, desativar e excluir endpoints de webhook da workspace |
214
- | `openid` | identificar o usuário (claim `sub`) e habilitar `/oauth/userinfo` |
215
- | `profile` | incluir o nome do usuário nas claims |
216
- | `email` | incluir o e-mail e seu status de verificação nas claims |
217
- | `offline_access` | receber um *refresh token* (nunca aparece no `scope` devolvido) |
218
-
219
- Um token OAuth **nunca** alcança faturamento, ciclo de vida da conta, gerenciamento de credenciais,
220
- segredos de assinatura de webhook ou superfícies administrativas — independentemente do escopo.
221
-
222
- ### Passo 1 — registrar o callback e redirecionar o usuário
223
-
224
- Registre no aplicativo Assinafy uma URL de callback HTTPS. Para cada tentativa de autorização, gere
225
- e guarde o verificador PKCE, o `state` e o emissor esperado:
226
-
227
- ```ruby
228
- verificador = Assinafy::OAuth.generate_code_verifier
229
- state = Assinafy::OAuth.generate_state
230
-
231
- session[:assinafy_code_verifier] = verificador
232
- session[:assinafy_state] = state
233
- session[:assinafy_issuer] = Assinafy::OAuth::AUTHORIZATION_SERVER # emissor desta tentativa
234
-
235
- redirect_to Assinafy::OAuth.authorization_url(
45
+ url = Assinafy::OAuth.authorization_url(
236
46
  client_id: ENV.fetch('ASSINAFY_CLIENT_ID'),
237
47
  redirect_uri: 'https://app.example.com/oauth/callback',
238
- code_verifier: verificador,
48
+ code_verifier: verifier,
239
49
  state: state,
240
50
  scope: %w[documents:read documents:write offline_access]
241
51
  )
242
52
  ```
243
53
 
244
- O `code_challenge` é derivado do verificador (`S256`) — o verificador nunca vai para a URL.
245
- `Assinafy::OAuth::AUTHORIZATION_SERVER` é o emissor de produção. No sandbox, passe
246
- `authorization_endpoint: 'https://auth-sandbox.assinafy.com.br/oauth/authorize'` e guarde
247
- `https://auth-sandbox.assinafy.com.br` como emissor.
248
-
249
- ### Passo 2 — validar o callback e trocar o código
54
+ No callback, valide `state` e `iss` antes de ler `code` ou `error`; o emissor de produção é
55
+ `https://auth.assinafy.com.br`. Troque o código uma única vez, em até 60 segundos:
250
56
 
251
57
  ```ruby
252
- unless params[:state] == session.delete(:assinafy_state) &&
253
- params[:iss] == session.delete(:assinafy_issuer)
254
- raise 'resposta de autorização inválida'
255
- end
256
- raise "autorização não concedida: #{params[:error]}" if params[:error] # access_denied, invalid_scope, ...
257
-
258
58
  tokens = Assinafy::Client.new.oauth.exchange_code(
259
59
  code: params.fetch(:code),
260
60
  client_id: ENV.fetch('ASSINAFY_CLIENT_ID'),
261
- code_verifier: session.delete(:assinafy_code_verifier),
61
+ code_verifier: verifier,
262
62
  redirect_uri: 'https://app.example.com/oauth/callback'
263
63
  )
264
64
 
265
- tokens['access_token'] # => "..."
266
- tokens['expires_in'] # => 3600
267
- tokens['refresh_token'] # => presente apenas com offline_access
268
- tokens['scope'] # => "documents:read documents:write"
269
- ```
270
-
271
- Confira `state` e `iss` contra os valores guardados nesta tentativa **antes de qualquer outra
272
- coisa**, inclusive num retorno com `error=` — é a proteção contra CSRF e contra respostas que não
273
- são suas. O código vale uma vez e expira 60 segundos depois da aprovação: troque-o na hora, sem
274
- repetir. O SDK confere localmente o formato do `code_verifier`, porque o servidor reporta um
275
- verificador malformado como `invalid_grant`, indistinguível de um código expirado.
276
-
277
- ### Passo 3 — persistir a conexão e agir como o usuário
278
-
279
- ```ruby
280
- access_token = tokens.fetch('access_token')
281
-
282
- # O token só vale na workspace que o usuário escolheu.
283
- workspace_id = Assinafy::Client.new(token: access_token).accounts.list[:data].first.fetch('id')
284
-
285
- conexao = Conexao.create!(
286
- workspace_id: workspace_id,
287
- scope: tokens.fetch('scope'),
288
- access_token: access_token, # guarde criptografado
289
- refresh_token: tokens['refresh_token'], # guarde criptografado
290
- expires_at: Time.now + tokens.fetch('expires_in')
291
- )
292
-
293
- usuario = Assinafy::Client.new(token: conexao.access_token, account_id: conexao.workspace_id)
294
- usuario.documents.list
295
- usuario.oauth.userinfo # => { 'sub' => ..., 'name' => ..., 'email' => ... } (escopo openid)
296
- ```
297
-
298
- ### Renovar com rotação, uma vez por conexão
299
-
300
- Cada renovação devolve um refresh token **novo**, válido por mais 30 dias, e aposenta o anterior: a
301
- conexão só expira se passar 30 dias sem renovar. Reutilizar um refresh token aposentado encerra a
302
- conexão inteira. Por isso, renove sob um lock por conexão e guarde os tokens novos atomicamente,
303
- antes de usá-los:
304
-
305
- ```ruby
306
- conexao.with_lock do # lock de linha: uma renovação por vez por conexão
307
- next if conexao.expires_at > Time.now + 60 # outro processo já renovou
308
-
309
- novos = Assinafy::Client.new.oauth.refresh(
310
- refresh_token: conexao.refresh_token,
311
- client_id: ENV.fetch('ASSINAFY_CLIENT_ID')
312
- )
313
-
314
- conexao.update!(
315
- refresh_token: novos.fetch('refresh_token'),
316
- access_token: novos.fetch('access_token'),
317
- expires_at: Time.now + novos.fetch('expires_in')
318
- )
319
- end
320
-
321
- usuario = Assinafy::Client.new(token: conexao.access_token, account_id: conexao.workspace_id)
322
- ```
323
-
324
- > O SDK **não renova o token automaticamente** e envia cada pedido de token uma única vez — nunca
325
- > adicione middleware de retry à conexão. `refresh` levanta `Assinafy::Error` em vez de devolver um
326
- > sucesso sem refresh token novo; trate como `invalid_grant`.
327
- >
328
- > Se a renovação falhar sem resposta clara — timeout, conexão interrompida, `5xx` —, o servidor
329
- > pode ter trocado o token sem que a resposta chegasse. Releia o refresh token guardado: se ainda
330
- > for o que você enviou, **nunca o reenvie**; peça ao usuário para conectar de novo. Siga em frente
331
- > só se outro processo já tiver guardado um token diferente. Só é seguro repetir uma falha que
332
- > comprovadamente aconteceu antes do envio: DNS, conexão recusada, handshake TLS. Num `401` da API,
333
- > renove uma vez; se falhar, ou em `invalid_grant`, peça ao usuário para conectar de novo.
334
-
335
- Sem `offline_access` não há refresh token: quando o access token expirar, mande o usuário pelo
336
- fluxo de autorização de novo.
337
-
338
- ### Revogar ao desconectar
339
-
340
- Revogue o refresh token guardado **agora** — o mais recente — e só depois apague os tokens:
341
-
342
- ```ruby
343
- Assinafy::Client.new.oauth.revoke(
344
- token: conexao.reload.refresh_token,
345
- client_id: ENV.fetch('ASSINAFY_CLIENT_ID'),
346
- token_type_hint: 'refresh_token'
347
- )
348
- conexao.destroy!
349
- ```
350
-
351
- Revogar um refresh token invalida também os access tokens emitidos a partir dele. A revogação
352
- responde `200` inclusive para um token já aposentado, então revogar uma cópia antiga parece dar
353
- certo enquanto a conexão continua ativa.
354
-
355
- ### Descoberta
356
-
357
- ```ruby
358
- recurso = client.oauth.protected_resource_metadata
359
- recurso['authorization_servers'] # => ["https://auth.assinafy.com.br"]
360
-
361
- servidor = client.oauth.authorization_server_metadata
362
- servidor['token_endpoint'] # => "https://api.assinafy.com.br/v1/oauth/token"
363
- servidor['code_challenge_methods_supported'] # => ["S256"]
364
- ```
365
-
366
- `authorization_server_metadata` acessa outro host e é enviado sem credenciais de workspace. Ele
367
- aceita uma URL alternativa, desde que seja HTTPS absoluta; qualquer outra levanta
368
- `ValidationError`.
369
-
370
- ### Clientes internos de serviço
371
-
372
- O servidor também publica o grant RFC 8693 `urn:ietf:params:oauth:grant-type:token-exchange`,
373
- reservado a clientes confidenciais internos provisionados pela Assinafy; apps de marketplace usam
374
- autorização com PKCE e renovação. `client.oauth.token` aceita esse grant, com `client_secret`,
375
- `subject_token`, `subject_token_type` e `resource`. Ele não emite refresh token.
376
-
377
- ### Erros OAuth
378
-
379
- Os endpoints OAuth respondem com o objeto plano da RFC 6749, não com o envelope da API. O SDK
380
- levanta `Assinafy::OAuthError` (subclasse de `Assinafy::ApiError`):
381
-
382
- ```ruby
383
- begin
384
- client.oauth.exchange_code(...)
385
- rescue Assinafy::OAuthError => e
386
- e.error # => "invalid_grant"
387
- e.error_description # => "The authorization code is invalid or has expired."
388
- e.status_code # => 400
389
- end
390
- ```
391
-
392
- Num `403` de qualquer recurso, `e.context[:www_authenticate]` traz o desafio que **nomeia o escopo
393
- faltante**: peça ao usuário para conectar de novo incluindo esse escopo, sem repetir a chamada. Um
394
- `403` sem esse desafio indica outra workspace, o papel do usuário ou uma área que tokens OAuth não
395
- alcançam.
396
-
397
- ---
398
-
399
- ## 4. Enviar o documento e esperar os metadados
400
-
401
- ### Upload
402
-
403
- ```ruby
404
- documento = client.documents.upload('./contrato-acme.pdf')
405
- documento['id'] # => "document-id"
406
- documento['name'] # => "contrato-acme.pdf"
407
- documento['status'] # => "uploaded"
408
- ```
409
-
410
- Aceita um caminho, um Hash com `:file_path`, ou `:buffer` + `:file_name` para bytes em memória:
411
-
412
- ```ruby
413
- client.documents.upload(buffer: pdf_bytes, file_name: 'contrato-acme.pdf')
414
- ```
415
-
416
- O documento recebe o nome do arquivo enviado; para mudar, use
417
- `client.documents.rename(documento['id'], 'Contrato Acme')`. Somente PDF, no máximo 25 MB: o SDK
418
- confere a extensão, o tamanho e o cabeçalho `%PDF-` antes de enviar.
419
-
420
- ### Esperar o processamento
421
-
422
- A Assinafy extrai páginas e metadados de forma assíncrona. Só é possível abrir um assignment
423
- depois disso:
424
-
425
- ```ruby
426
- documento = client.documents.wait_until_ready(
427
- documento['id'],
428
- max_wait_seconds: 30,
429
- poll_interval_seconds: 2
430
- )
431
- documento['status'] # => "metadata_ready"
432
- documento['pages'] # => [{ 'id' => 'page-id', 'number' => 1, 'height' => 1651, 'width' => 1275 }]
433
- ```
434
-
435
- Erros de rede durante a espera são tolerados; um status terminal (`failed`, `expired`,
436
- `rejected_by_*`) ou o fim do prazo interrompe com `Assinafy::Error`.
437
-
438
- ### Alternativa: gerar a partir de um template
439
-
440
- Um template é um PDF reutilizável com papéis e campos definidos. Configure os papéis e campos no
441
- aplicativo Assinafy: o upload cria só um papel `Editor`, e a geração exige pelo menos um papel
442
- `Signer`.
443
-
444
- ```ruby
445
- template = client.templates.create('./contrato-modelo.pdf', name: 'Contrato padrão') # até 25 MB
446
- client.templates.list
447
- client.templates.get(template['id'])
448
- client.templates.update(template['id'], name: 'Contrato padrão v2')
449
- client.templates.download_page(template['id'], 'page-id') # bytes da imagem da página
450
- ```
451
-
452
- Para gerar, informe uma entrada por papel, cada uma com `role_id` (de `template['roles']`) e o ID de
453
- um signatário já existente e diferente para cada papel. A geração cria o documento e o assignment
454
- juntos:
455
-
456
- ```ruby
457
- papel = template.fetch('roles').find { |r| r['assignment_type'] == 'Signer' }
458
- papeis = [{ role_id: papel.fetch('id'), id: signatario['id'],
459
- verification_method: 'Email', notification_methods: ['Email'] }]
460
-
461
- client.documents.estimate_cost_from_template(template['id'], papeis)
462
- documento = client.documents.create_from_template(
463
- template['id'], papeis,
464
- name: 'contrato-acme.pdf', message: 'Por favor, assine.', expires_at: '2099-12-31T23:59:00-03:00'
465
- )
466
- ```
467
-
468
- ### Campos
469
-
470
- Definições de campo reutilizáveis, usadas em assignments `collect` e templates:
471
-
472
- ```ruby
473
- campo = client.fields.create(name: 'CPF', type: 'text', regex: '/^\d{11}$/')
474
- client.fields.types # tipos de campo disponíveis
475
- client.fields.list
476
- client.fields.validate(campo['id'], '12345678901')
477
- client.fields.validate_multiple([{ field_id: campo['id'], value: '12345678901' }])
478
- ```
479
-
480
- ---
481
-
482
- ## 5. Signatários
483
-
484
- Signatários pertencem à conta e podem ser reaproveitados entre documentos:
485
-
486
- ```ruby
487
- signatario = client.signers.find_by_email('ana@example.com') || # paginação percorrida pelo SDK
488
- client.signers.create(full_name: 'Ana Silva', email: 'ana@example.com')
489
- signatario['id'] # => "signer-id"
490
- ```
491
-
492
- Para notificar por WhatsApp, informe `whatsapp_phone_number` (ou o alias `phone:`) em E.164. Para
493
- **certificado digital**, informe o CPF ou CNPJ em `government_id` já na criação:
494
-
495
- ```ruby
496
- certificado = client.signers.create(
497
- full_name: 'Ana Silva',
498
- email: 'ana@example.com',
499
- government_id: ENV.fetch('ASSINAFY_SIGNER_GOVERNMENT_ID') # CPF/CNPJ real autorizado
500
- )
501
-
502
- # Ou num signatário existente:
503
- client.signers.update(signatario['id'], government_id: ENV.fetch('ASSINAFY_SIGNER_GOVERNMENT_ID'))
504
- ```
505
-
506
- `client.signers.validate_create!(payload)` valida e normaliza o corpo sem fazer requisição.
507
-
508
- ---
509
-
510
- ## 6. Métodos de verificação e notificação
511
-
512
- Cada signatário do assignment tem um **método de verificação** (como prova a identidade antes de
513
- assinar) e um **método de notificação** (como recebe o convite). Verificação e notificação são
514
- **acopladas**: envie um, os dois, ou nenhum — o lado que faltar é inferido do outro. Sem nenhum dos
515
- dois, ambos assumem `Email`.
516
-
517
- | Verificação | Como funciona | Notificação permitida | Custo por signatário |
518
- | --- | --- | --- | --- |
519
- | `Email` *(padrão)* | código de uso único (OTP) por e-mail, exigido antes de assinar | `Email` | 0 crédito |
520
- | `Whatsapp` | código de uso único (OTP) por WhatsApp; exige `whatsapp_phone_number` e plano pago | `Whatsapp` | 0,45 crédito (a notificação por WhatsApp) |
521
- | `DigitalCertificate` | o signatário assina com o **próprio certificado ICP-Brasil (A1/A3)**, gerando uma assinatura **PAdES qualificada** | `Email` **ou** `Whatsapp` | 0,5 crédito + a notificação (0 ou 0,45) |
522
- | `IdDocumentFaceMatchBasic` | documento de identidade, foto segurando o documento e prova de vida; exige o recurso na conta e CPF válido em `government_id` | `Email` **ou** `Whatsapp` | tarifa por signatário + notificação; consulte a estimativa |
523
-
524
- - Informe **exatamente um** canal em `notification_methods` (um array de um elemento).
525
- - O custo do certificado digital aparece na estimativa com o código `SignatureDigitalCertificate`.
526
- Reenviar uma notificação cobra a notificação de novo.
527
- - Os valores aceitos estão em `Assinafy::Resources::AssignmentResource::VERIFICATION_METHODS` e
528
- `::NOTIFICATION_METHODS`. O SDK valida enum e combinação localmente: um valor fora do enum, ou um
529
- par inválido, levanta `ValidationError` **antes** de a requisição sair.
530
-
531
- ### Certificado digital ICP-Brasil (A1/A3)
532
-
533
- Exige o recurso **Certificado Digital** na conta (planos Standard e Pro), CPF ou CNPJ em
534
- `government_id` do signatário, assignment com `method: 'virtual'` e que cada signatário por certificado esteja
535
- **sozinho no seu passo**. Um assignment `collect`, inclusive quando inferido de um template com campos de
536
- signatário posicionados, não aceita certificado digital. A criação e a estimativa diretas levantam
537
- `ValidationError` com o código estável `digital_certificate_requires_virtual`. Um CPF exige o certificado daquela
538
- pessoa (e-CPF, ou e-CNPJ que a nomeie como representante legal); um CNPJ exige um e-CNPJ da empresa, de qualquer
539
- um de seus representantes.
540
-
541
- ---
542
-
543
- ## 7. Estimar o custo
544
-
545
- Antes de enviar — principalmente com WhatsApp ou certificado digital:
546
-
547
- ```ruby
548
- estimativa = client.assignments.estimate_cost(
549
- documento['id'],
550
- signers: [
551
- { verification_method: 'Email' },
552
- { verification_method: 'DigitalCertificate', notification_methods: ['Whatsapp'] }
553
- ]
554
- )
555
- estimativa['total_credits'] # => 0.95
556
- estimativa['breakdown'] # itens com code, quantity, unit_cost, cost
557
- estimativa['has_sufficient_resources'] # => true
558
- estimativa['blocking_reason'] # => nil, ou "InsufficientCredits", ...
559
- ```
560
-
561
- Na estimativa, os signatários podem ser descritos só pelo método, sem `id`. Para um template, use
562
- `client.documents.estimate_cost_from_template`; para um reenvio,
563
- `client.assignments.estimate_resend_cost`.
564
-
565
- ---
566
-
567
- ## 8. Abrir o assignment
568
-
569
- Um assignment é o convite para assinar um documento. Todo assignment exige ao menos um signatário.
570
-
571
- **`virtual`** — sem campos posicionados; o signatário aceita o documento inteiro.
572
-
573
- ```ruby
574
- assignment = client.assignments.create(
575
- documento['id'],
576
- method: 'virtual',
577
- signers: [
578
- { id: signatario['id'], verification_method: 'Email', notification_methods: ['Email'], step: 1 },
579
- { id: certificado['id'], verification_method: 'DigitalCertificate', notification_methods: ['Email'], step: 2 }
580
- ],
581
- message: 'Por favor, assine o contrato em anexo.',
582
- expires_at: '2099-12-31T23:59:00-03:00',
583
- copy_receivers: [] # IDs de signatários que só recebem cópia
584
- )
585
-
586
- assignment['signing_urls']
587
- # => [{ 'signer_id' => 'signer-id', 'url' => 'https://.../sign/...' }, ...]
588
- ```
589
-
590
- **`collect`** — campos posicionados página a página, além da lista de signatários:
591
-
592
- ```ruby
593
- client.assignments.create(
594
- documento['id'],
595
- method: 'collect',
596
- signers: [{ id: signatario['id'] }],
597
- entries: [{
598
- page_id: documento['pages'].first['id'],
599
- fields: [{
600
- signer_id: signatario['id'],
601
- field_id: campo['id'],
602
- display_settings: { left: 100, top: 100, width: 240, height: 48, fontSize: 16 }
603
- }]
604
- }]
605
- )
606
- ```
607
-
608
- O SDK aceita `signers: ['id1', 'id2']` (IDs puros), `signers: [{ id: ... }]` (descritores completos)
609
- e o formato legado `signer_ids:`. Tudo é normalizado para o corpo que a API espera.
610
-
611
- ### Passos e prazos
612
-
613
- - `step` define a ordem: todos do passo 1 assinam antes do passo 2; passos iguais assinam em
614
- paralelo. Se informado para um signatário, informe para todos, em uma sequência contínua iniciada
615
- em 1. Omita para assinatura simultânea.
616
- - Cada signatário é notificado quando seu passo é ativado: o passo 1 na criação, os seguintes
617
- quando todos do passo anterior concluírem.
618
- - Um signatário por certificado digital fica sozinho no seu passo.
619
- - `expires_at` deve ser ISO 8601 com fuso horário e estar pelo menos uma hora no futuro.
620
-
621
- Gerenciar depois de criado:
622
-
623
- ```ruby
624
- client.assignments.estimate_resend_cost(documento['id'], assignment['id'], signatario['id'])
625
- client.assignments.resend_notification(documento['id'], assignment['id'], signatario['id'])
626
- client.assignments.reset_expiration(documento['id'], assignment['id'], '2099-01-31T23:59:00-03:00')
627
- client.assignments.whatsapp_notifications(documento['id'], assignment['id'])
628
- client.assignments.list # assignments da conta
629
- ```
630
-
631
- ### Atalho: tudo em uma chamada
632
-
633
- ```ruby
634
- resultado = client.upload_and_request_signatures(
635
- source: './contrato-acme.pdf',
636
- signers: [{ full_name: 'Ana Silva', email: 'ana@example.com' }],
637
- message: 'Por favor, assine o contrato em anexo.'
638
- )
639
-
640
- resultado[:document]['id'] # => "document-id"
641
- resultado[:assignment]['id'] # => "assignment-id"
642
- resultado[:signer_ids] # => ["signer-id"]
65
+ oauth_client = Assinafy::Client.new(token: tokens.fetch('access_token'), account_id: 'workspace-id')
643
66
  ```
644
67
 
645
- O helper faz upload, espera o processamento, cria os signatários (incluindo `government_id`, quando
646
- informado) e abre um assignment `virtual`. Ele valida o payload inteiro **antes** de enviar
647
- qualquer coisa, então um `expires_at` malformado não deixa um documento órfão.
68
+ Persista workspace, escopos, expiração e tokens criptografados. `offline_access` solicita o refresh token, mas não
69
+ aparece no `scope` devolvido. Renove sob um lock por conexão e salve atomicamente o novo access token e o novo
70
+ refresh token; cada refresh aposenta o anterior. Nunca repita token/revoke por middleware. Ao desconectar, revogue
71
+ o refresh token mais recente. `client.oauth.userinfo`, `protected_resource_metadata` e
72
+ `authorization_server_metadata` completam o fluxo.
648
73
 
649
- > **Não é transacional.** Se uma chamada posterior falhar, o documento enviado e os signatários
650
- > já criados continuam existindo. Em caso de erro, `e.context[:document]` e
651
- > `e.context[:signer_ids]` trazem o que foi criado, para você limpar.
74
+ ## Fluxo completo de assinatura
652
75
 
653
- ---
654
-
655
- ## 9. A experiência do signatário
656
-
657
- O signatário recebe o link de acesso pelo canal de notificação. As chamadas abaixo usam o **código
658
- de acesso do signatário** (enviado como parâmetro de query `signer-access-code`), não suas
659
- credenciais de workspace — o SDK remove `X-Api-Key`/`Authorization` delas. Um cliente sem
660
- credenciais basta:
76
+ 1. Faça upload e espere os metadados do PDF.
77
+ 2. Crie ou reutilize os signatários.
78
+ 3. Estime o custo.
79
+ 4. Crie um assignment `virtual` ou `collect`.
80
+ 5. O signatário aceita os termos, confirma sua identidade e assina ou recusa.
81
+ 6. Acompanhe atividades/webhooks, baixe o artefato e verifique seu hash.
82
+ 7. Opcionalmente registre termos contratuais, aditivos, renovação ou encerramento.
83
+ 8. Exclua somente os recursos que sua aplicação possui, depois dos dependentes.
661
84
 
662
85
  ```ruby
663
- signatario_client = Assinafy::Client.new
664
- codigo = 'codigo-de-acesso-do-link-de-assinatura'
665
-
666
- # 1. Carregar o documento e aceitar os termos
667
- dados = signatario_client.signers.self_data(signer_access_code: codigo)
668
- signatario_client.signers.accept_terms(signer_access_code: codigo) unless dados['has_accepted_terms']
669
- doc = signatario_client.assignments.signer_document(signer_access_code: codigo, has_accepted_terms: true)
670
-
671
- # 2. Confirmar a identidade
672
- signatario_client.signers.confirm_data(
673
- doc['id'], { full_name: 'Ana Silva', government_id: '00000000000' },
674
- signer_access_code: codigo
675
- )
86
+ document = client.documents.upload(file_path: '/tmp/contract.pdf')
87
+ document = client.documents.wait_until_ready(document.fetch('id'))
676
88
 
677
- # 3. Verificar o código de uso único (OTP) recebido por e-mail ou WhatsApp
678
- signatario_client.signers.verify_email(verification_code: '123456', signer_access_code: codigo)
89
+ signer = client.signers.find_by_email('signer@example.com') ||
90
+ client.signers.create(full_name: 'Example Signer', email: 'signer@example.com')
679
91
 
680
- # 4. Enviar a imagem da assinatura (PNG)
681
- signatario_client.signers.upload_signature(
682
- File.binread('assinatura.png'), signer_access_code: codigo, type: 'signature'
683
- )
684
- ```
685
-
686
- `verify_email` envia o código de qualquer canal (`POST /verify`). Em seguida, assine.
687
-
688
- Assignment **virtual**:
689
-
690
- ```ruby
691
- signatario_client.signer_documents.sign_multiple([doc['id']], signer_access_code: codigo)
692
- ```
693
-
694
- Assignment **collect** — envie cada item posicionado:
695
-
696
- ```ruby
697
- itens = doc.fetch('assignment').fetch('items').map do |item|
698
- {
699
- item_id: item.fetch('id'),
700
- field_id: item.dig('field', 'id'),
701
- page_id: item.dig('page', 'id'),
702
- value: 'Aceito'
703
- }
704
- end
92
+ request = {
93
+ method: 'virtual',
94
+ signers: [{
95
+ signer_id: signer.fetch('id'),
96
+ verification_method: 'Email',
97
+ notification_methods: ['Email']
98
+ }],
99
+ expires_at: (Time.now + 86_400).iso8601
100
+ }
705
101
 
706
- signatario_client.assignments.sign(doc['id'], doc.dig('assignment', 'id'), itens, signer_access_code: codigo)
102
+ cost = client.assignments.estimate_cost(document.fetch('id'), request)
103
+ assignment = client.assignments.create(document.fetch('id'), request)
707
104
  ```
708
105
 
709
- O SDK converte as chaves `item_id`/`field_id`/`page_id` para o `itemId`/`fieldId`/`pageId` que a API
710
- espera.
106
+ `Client#upload_and_request_signatures` executa upload, signatários e assignment numa chamada e valida todo o
107
+ pedido antes da rede. Templates configurados usam `client.documents.create_from_template`.
711
108
 
712
- Recusar:
109
+ ### Verificação e notificação
713
110
 
714
- ```ruby
715
- signatario_client.assignments.decline(
716
- doc['id'], doc.dig('assignment', 'id'),
717
- decline_reason: 'Valores divergentes da proposta',
718
- signer_access_code: codigo
719
- )
720
- # Vários documentos de uma vez:
721
- signatario_client.signer_documents.decline_multiple([doc['id']], decline_reason: 'Não', signer_access_code: codigo)
722
- ```
723
-
724
- ### Signatários com verificação biométrica
725
-
726
- Na criação do assignment (virtual ou collect), ou em `create_from_template`, envie
727
- `verification_method: 'IdDocumentFaceMatchBasic'` e exatamente um canal de notificação.
728
- Cadastre o CPF válido do signatário em `government_id` antes de criar o assignment. Use
729
- `estimate_cost` ou `estimate_cost_from_template` para obter o custo antes de enviar.
730
-
731
- O fluxo de OTP acima se aplica a `Email` e `Whatsapp`. Para biometria, consulte o estado com o
732
- código de acesso do signatário e abra uma sessão somente após obter o consentimento explícito:
733
-
734
- ```ruby
735
- estado = signatario_client.signers.identity_verification(signer_access_code: codigo)
736
- # => { 'can_start' => true, 'status' => nil, 'url' => nil, 'expires_at' => nil, 'completed_at' => nil }
737
-
738
- # Após o consentimento explícito do signatário para o processamento biométrico:
739
- sessao = signatario_client.signers.start_identity_verification(consent: true, signer_access_code: codigo)
740
- # => { 'id' => 'verification-id', 'url' => 'https://verify.assinafy.com.br/c/session#t=token-placeholder',
741
- # 'expires_at' => '2026-10-16T13:00:00Z' }
742
- ```
743
-
744
- Abra `sessao['url']` sem alterar a URL nem o fragmento `#t=`; ela concede acesso à captura e não
745
- deve aparecer em logs ou armazenamento do navegador. Se já houver uma sessão `Pending`, use a
746
- URL retornada por `identity_verification`. `can_start` só é verdadeiro antes da primeira sessão
747
- ou após `Expired`; reiniciar uma sessão expirada não cobra novamente. `Declined` e `Abandoned`
748
- exigem um novo documento e assignment.
749
-
750
- Em `InReview`, o signatário continua visualizando, confirmando dados e assinando, mas a certificação
751
- aguarda a revisão da conta. Depois de todas as assinaturas, o documento pode ficar em
752
- `pending_identity_review`. Com as credenciais do workspace, o revisor consulta e decide:
753
-
754
- ```ruby
755
- revisao = client.assignments.identity_review(doc['id'], assignment['id'], signatario['id'])
756
- # => SignerIdentityReview: status, reason_codes, evidence_available, expected, document,
757
- # matches, checks, images, ruleset_version, evidence_sha256 e dados da decisão
758
- imagem = client.assignments.identity_review_image(doc['id'], assignment['id'], signatario['id'], 'face')
759
- # => bytes JPEG, PNG ou PDF, conforme o nome retornado em revisao['images']; não armazenar em cache
760
-
761
- # Após o revisor conferir as evidências:
762
- client.assignments.decide_identity_review(
763
- doc['id'], assignment['id'], signatario['id'], decision: 'Approved', note: 'Identidade conferida'
764
- )
765
- ```
766
-
767
- `decision: 'Rejected'` cancela o documento e notifica signatários e destinatários de cópia.
768
- A nota opcional aceita até 2000 caracteres, fica na atividade e vai ao provedor; não é mostrada
769
- ao signatário. As imagens só ficam disponíveis durante `InReview`. Acompanhe também os eventos
770
- `signer_identity_review_required`, `signer_identity_review_approved`, `signer_identity_review_rejected`,
771
- `signer_identity_verified` e `signer_identity_verification_failed`.
772
-
773
- ### Signatários com certificado A1/A3
111
+ | `verification_method` | Notificação | Regras |
112
+ | --- | --- | --- |
113
+ | `Email` | `Email` | código por e-mail |
114
+ | `Whatsapp` | `Whatsapp` | código por WhatsApp |
115
+ | `IdDocumentFaceMatchBasic` | `Email` ou `Whatsapp` | documento + selfie; requer CPF válido e recurso na conta |
116
+ | `DigitalCertificate` | `Email` ou `Whatsapp` | A1/A3 no Web PKI hospedado; somente assignment `virtual` |
774
117
 
775
- O signatário por certificado digital conclui a assinatura no fluxo hospedado da Assinafy, aberto
776
- pelo link de `assignment['signing_urls']`: ele aceita os termos, confirma os dados e assina com o
777
- certificado A1 ou A3 pela extensão de navegador Web PKI. O endpoint comum de assinatura rejeita
778
- signatários por certificado, e o SDK não envolve o handshake Web PKI
779
- (`/signers/certificate/start`, `/signers/certificate/complete`), cujos esquemas não fazem parte do
780
- contrato OpenAPI. Concluído o fluxo, o artefato `pades` traz a assinatura PAdES qualificada.
118
+ Use exatamente uma notificação por signatário. Etapas são opcionais, mas, quando usadas, devem ser positivas,
119
+ contíguas a partir de 1 e atribuídas a todos; valores iguais são paralelos. Um signatário de certificado fica
120
+ sozinho na etapa. `expires_at` deve ter fuso e ficar pelo menos uma hora no futuro.
781
121
 
782
- ---
122
+ Na biometria, `signers.start_identity_verification(consent: true, signer_access_code:)` devolve uma URL bearer:
123
+ preserve o fragmento e não a armazene em logs ou browser storage. O revisor usa `assignments.identity_review`,
124
+ `identity_review_image` e `decide_identity_review`. Evidências existem apenas durante `InReview` e não devem ser
125
+ cacheadas. Uma rejeição cancela o documento. Certificados A1/A3 são concluídos no fluxo hospedado; o SDK não
126
+ inventa os endpoints de handshake ainda não publicados.
783
127
 
784
- ## 10. Webhooks: endpoints e verificação de assinatura
128
+ Operações do signatário usam `signer_access_code:` sem chave/token da workspace. O fluxo inclui termos,
129
+ confirmação de dados, OTP, identidade, imagem de assinatura, `assignments.sign`/`decline` e operações em lote em
130
+ `signer_documents`.
785
131
 
786
- Para acompanhar sem polling, registre um **endpoint de webhook**. Uma conta pode ter 1 endpoint, ou
787
- até 3 em planos pagos; cada um tem sua própria URL (distinta das demais), lista de eventos e
788
- configuração de assinatura. Todo endpoint ativo inscrito num evento o recebe.
132
+ ## Webhooks e artefatos
789
133
 
790
- ### Gerenciar endpoints
134
+ Crie o endpoint com `signing_enabled: true`, guarde o segredo `whsec_...` retornado uma vez e verifique o corpo
135
+ bruto antes de interpretar JSON:
791
136
 
792
137
  ```ruby
793
- client.webhooks.list_event_types # => [{ 'id' => 'document_ready', 'description' => '...' }, ...]
794
-
795
138
  endpoint = client.webhooks.create_endpoint(
796
- url: 'https://app.example.com/webhooks/assinafy',
797
- email: 'ops@example.com', # avisos de falha de entrega
798
- events: %w[document_ready signer_signed_document signer_rejected_document],
799
- name: 'ERP',
139
+ url: 'https://example.com/webhooks/assinafy',
140
+ email: 'ops@example.com',
141
+ events: ['document_certificated'],
800
142
  signing_enabled: true
801
143
  )
802
- endpoint['id'] # => "webhook-endpoint-id"
803
144
 
804
- client.webhooks.list_endpoints # do mais antigo ao mais novo
805
- client.webhooks.list_endpoints(url: 'https://app.example.com/webhooks/assinafy') # URL exata
806
- client.webhooks.get_endpoint('webhook-endpoint-id')
807
- client.webhooks.update_endpoint('webhook-endpoint-id', is_active: false) # só os campos enviados
808
- client.webhooks.delete_endpoint('webhook-endpoint-id') # => nil, libera a vaga
809
- client.webhooks.delete_endpoint_by_url('https://app.example.com/webhooks/assinafy') # => nil
810
- ```
811
-
812
- - `create_endpoint` exige `url`, `email` e `events`; `name`, `is_active` (padrão `true`) e
813
- `signing_enabled` (padrão `false`) são opcionais. Chaves desconhecidas levantam
814
- `ValidationError` localmente.
815
- - Criar além do limite do plano responde `403`; uma URL já usada por outro endpoint responde `400`.
816
- - Em `update_endpoint`, `signing_enabled: true` gera um segredo se o endpoint não tiver um (e mantém
817
- o atual se tiver); `signing_enabled: false` descarta o segredo.
818
-
819
- ### Segredo de assinatura
820
-
821
- Com `signing_enabled: true`, `create_endpoint` devolve o segredo uma única vez em
822
- `endpoint['secret']`. Guarde esse valor na criação, inclusive em aplicativos OAuth. Se o segredo
823
- de um aplicativo OAuth for perdido, exclua e recrie o endpoint.
824
-
825
- ```ruby
826
- segredo = client.webhooks.endpoint_secret('webhook-endpoint-id')
827
- segredo['secret'] # => "whsec_..."
828
-
829
- novo = client.webhooks.rotate_endpoint_secret('webhook-endpoint-id')
830
- ```
831
-
832
- A rotação vale imediatamente: entregas posteriores são assinadas só com o segredo novo, então
833
- atualize o receptor na mesma hora. Ler e rotacionar o segredo **não** está disponível para
834
- aplicativos OAuth — use uma chave de API ou um token de sessão de usuário. Com assinatura
835
- desativada, ambos respondem `400`.
836
-
837
- ### O contrato de entrega
145
+ verifier = Assinafy::Support::WebhookVerifier.new(endpoint.fetch('secret'))
146
+ raise 'invalid webhook' unless verifier.verify_delivery(raw_body, request.headers)
838
147
 
839
- | Propriedade | Valor |
840
- | --- | --- |
841
- | Requisição | `POST`, `Content-Type: application/json` |
842
- | `webhook-id` | ID da mensagem, igual em todas as tentativas do mesmo evento para o mesmo endpoint — use para deduplicar |
843
- | `webhook-timestamp` | horário Unix (segundos) da tentativa |
844
- | `webhook-signature` | só com assinatura ativa: entradas `v1,<base64>` separadas por espaço |
845
- | Sucesso | qualquer resposta `2xx` |
846
- | Tentativas | até 2 por evento, com 3 segundos de intervalo |
847
- | Circuit breaker | depois de 10 eventos consecutivos com falha, a entrega pausa e só uma amostra de eventos é testada até um dar certo |
848
-
849
- Responda `2xx` rapidamente e processe em segundo plano. Para forçar uma nova entrega, use
850
- `retry_dispatch`:
851
-
852
- ```ruby
853
- client.webhooks.list_dispatches(endpoint_id: 'webhook-endpoint-id', delivered: false, per_page: 20)
854
- client.webhooks.retry_dispatch('dispatch-id')
148
+ event = verifier.extract_event(raw_body)
855
149
  ```
856
150
 
857
- O corpo segue o envelope `{ id, event, message, payload, origin, created_at, subject, object,
858
- account_id }`. `subject` é quem agiu e `object` é a entidade afetada, cada um com uma propriedade
859
- `type` (`User`, `Signer`, `Account`, `Document` ou `Template`). Os horários do corpo são Unix em
860
- segundos.
861
-
862
- ### Verificar a assinatura
863
-
864
- As assinaturas seguem a especificação [Standard Webhooks](https://www.standardwebhooks.com): um
865
- HMAC-SHA256 sobre `"{webhook-id}.{webhook-timestamp}.{corpo bruto}"`, com a chave obtida
866
- decodificando em base64 a parte do segredo depois de `whsec_`. `verify_delivery` confere essa
867
- assinatura em tempo constante e rejeita horários a mais de 5 minutos do relógio local:
151
+ Deduplicate por `webhook-id`. O timestamp tem tolerância padrão de 300 segundos. Aplicações OAuth precisam guardar
152
+ o segredo da criação: leitura e rotação exigem chave de API ou sessão de usuário. Depois da certificação, use
153
+ `documents.download`, `thumbnail`, `download_page`, `activities` e `verify` conforme o artefato.
868
154
 
869
- ```ruby
870
- verificador = Assinafy::Support::WebhookVerifier.new(ENV.fetch('ASSINAFY_WEBHOOK_SECRET'))
871
- # ou client.webhook_verifier, que usa o webhook_secret do cliente
872
-
873
- verificador.verify_delivery(corpo_bruto, headers) # => true / false
874
- verificador.verify_delivery(corpo_bruto, headers, tolerance: 120) # janela em segundos
875
- ```
155
+ ## Contratos, erros e recursos
876
156
 
877
- `headers` pode ser o `request.headers` do Rails, o `env` do Rack (`HTTP_WEBHOOK_ID`) ou um Hash
878
- simples. O método devolve `false` — nunca levanta — para segredo ausente ou malformado, cabeçalho
879
- faltando, assinatura errada ou horário fora da janela. Sempre passe o corpo **bruto**, exatamente
880
- como recebido, nunca o JSON reserializado. Com mais de um endpoint, cada um tem seu segredo.
157
+ Planos com contract tracking usam `client.contracts` para termos, família, aditivo, renovação e encerramento.
158
+ Envie o `updated_at` lido como precondição de escrita; conflito concorrente responde `409`. Uma renovação repetível
159
+ usa o `term` atual ou `end_date` posterior.
881
160
 
882
- Receptor Rails:
883
-
884
- ```ruby
885
- class AssinafyWebhooksController < ActionController::API
886
- VERIFICADOR = Assinafy::Support::WebhookVerifier.new(ENV.fetch('ASSINAFY_WEBHOOK_SECRET'))
161
+ Falhas da API levantam `Assinafy::ApiError`; OAuth usa `OAuthError < ApiError`; entrada inválida usa
162
+ `ValidationError`; transporte/TLS usa `NetworkError`. `ApiError#rate_limit` expõe limite/restante/reset e
163
+ `#retry_after` o atraso de `429`; o SDK nunca repete automaticamente. Listagens paginadas devolvem
164
+ `{ data:, meta: }`, listas simples devolvem `Array`, binários devolvem `String` e respostas vazias devolvem `nil`.
887
165
 
888
- def create
889
- corpo = request.raw_post
890
- return head(:unauthorized) unless VERIFICADOR.verify_delivery(corpo, request.headers)
166
+ Recursos: `accounts`, `auth`, `oauth`, `users`, `documents`, `contracts`, `signers`, `signer_documents`,
167
+ `assignments`, `templates`, `tags`, `fields`, `webhooks` e `webhook_verifier`. As assinaturas RBS publicadas ficam em
168
+ [`sig/assinafy.rbs`](sig/assinafy.rbs).
891
169
 
892
- webhook_id = request.headers['webhook-id']
893
- return head(:ok) if EventoAssinafy.exists?(webhook_id: webhook_id) # já processado
894
-
895
- evento = VERIFICADOR.extract_event(corpo)
896
- EventoAssinafy.create!(webhook_id: webhook_id, tipo: VERIFICADOR.event_type(evento), corpo: corpo)
897
- ProcessarEventoAssinafyJob.perform_later(webhook_id)
898
- head :ok
899
- end
900
- end
901
- ```
902
-
903
- Receptor Rack (Sinatra):
904
-
905
- ```ruby
906
- post '/webhooks/assinafy' do
907
- corpo = request.body.read
908
- halt 401 unless VERIFICADOR.verify_delivery(corpo, request.env)
909
-
910
- evento = VERIFICADOR.extract_event(corpo)
911
- case VERIFICADOR.event_type(evento)
912
- when 'signer_signed_document' then processar(VERIFICADOR.event_object(evento)) # o Document
913
- when 'document_ready' then baixar_assinado(VERIFICADOR.event_object(evento)['id'])
914
- end
915
-
916
- 200
917
- end
918
- ```
919
-
920
- `extract_event` devolve `nil` para JSON inválido; `event_type`, `event_payload`, `event_object` e
921
- `event_subject` leem os campos do envelope.
922
-
923
- O método `verify(corpo, assinatura_hex)` continua disponível apenas para receptores cujo **próprio
924
- gateway** assina o corpo com um HMAC-SHA256 em hexadecimal e um segredo compartilhado.
925
-
926
- ### Assinatura legada
927
-
928
- `client.webhooks.register`, `#get` e `#inactivate` (`/webhooks/subscriptions`) continuam funcionando e
929
- agem sobre o endpoint **mais antigo** da conta. `register` aceita só `url`, `email`, `events` e
930
- `is_active`; outras chaves levantam `ValidationError`. Prefira as operações de endpoint.
931
-
932
- ---
933
-
934
- ## 11. Andamento, download e verificação
935
-
936
- ```ruby
937
- client.documents.signing_progress(documento['id'])
938
- # => { signed: 1, total: 3, pending: 2, percentage: 33.33 }
939
-
940
- client.documents.fully_signed?(documento['id']) # => false
941
- client.documents.activities(documento['id']) # trilha completa de eventos, com origin (ip, user-agent)
942
- ```
943
-
944
- | Artefato | Conteúdo |
945
- | --- | --- |
946
- | `original` | O PDF enviado, como recebido |
947
- | `certificated` | O documento assinado, com a certificação da plataforma |
948
- | `certificate-page` | Apenas a página de certificação |
949
- | `pades` | Assinaturas ICP-Brasil dos signatários + caixa de certificação — só em documentos com signatários por certificado digital |
950
- | `bundle` | Zip com `original`, `certificated` e `certificate-page`, mais o `pades` quando houver |
951
-
952
- ```ruby
953
- File.binwrite('assinado.pdf', client.documents.download(documento['id'], 'certificated'))
954
- File.binwrite('pades.pdf', client.documents.download(documento['id'], 'pades'))
955
- File.binwrite('pacote.zip', client.documents.download(documento['id'], 'bundle'))
956
- ```
957
-
958
- A verificação pública confere um documento assinado pelo hash da assinatura, sem autenticação:
959
-
960
- ```ruby
961
- verificacao = Assinafy::Client.new.documents.verify('hash-da-assinatura')
962
- verificacao['is_valid'] # => true
963
- verificacao['agreement_code'] # código impresso no certificado do documento
964
- ```
965
-
966
- Ela devolve o resultado da Assinafy; não valida independentemente a assinatura do PDF nem a
967
- cadeia de certificação.
968
-
969
- ### Acompanhar o contrato
970
-
971
- Quando o plano da conta inclui acompanhamento de contratos, registre os termos no documento e
972
- use o `updated_at` devolvido pela API como precondição para evitar sobrescrever uma alteração
973
- concorrente:
974
-
975
- ```ruby
976
- contrato = client.contracts.update(
977
- documento['id'],
978
- status: 'active',
979
- counterparty_name: 'Empresa Exemplo Ltda',
980
- value: 120_000,
981
- currency: 'BRL',
982
- start_date: '2026-11-01',
983
- end_date: '2027-10-31',
984
- auto_renew: true,
985
- renewal_term_months: 12,
986
- notice_period_days: 30
987
- )
988
-
989
- client.contracts.get(documento['id'])
990
- client.contracts.list(status: ['active'], ending_within_days: 30)
991
- client.contracts.related(documento['id'])
992
- client.contracts.renew(documento['id'], term: contrato['term'], updated_at: contrato['updated_at'])
993
- client.contracts.terminate(documento['id'], reason: 'Encerrado pelas partes')
994
- ```
995
-
996
- `update` é parcial e preserva `nil` como JSON `null` para limpar campos. Um documento de aditivo
997
- pode ser ligado com `parent_document_id` e `relation_type`. As leituras usam o escopo OAuth
998
- `documents:read`; alterações usam `documents:write` e exigem que o plano tenha acompanhamento de
999
- contratos. `get` e `related` devolvem `nil` quando o documento não tem termos acompanhados.
1000
-
1001
- ---
1002
-
1003
- ## 12. Organizar e limpar
1004
-
1005
- ```ruby
1006
- tag = client.tags.create(name: 'Contratos 2026', color: '2072b9')
1007
- client.documents.append_tags(documento['id'], [tag['id']])
1008
- client.documents.replace_tags(documento['id'], [tag['id']])
1009
- client.documents.detach_tag(documento['id'], tag['id'])
1010
-
1011
- # Documentos que carregam TODAS as tags informadas (o SDK junta o Array com vírgulas):
1012
- client.documents.list(tags: [tag['id'], 'outra-tag-id'])
1013
-
1014
- client.documents.rename(documento['id'], 'Contrato Acme — assinado')
1015
- ```
1016
-
1017
- Apague só o que a sua aplicação criou, e só depois de concluir o que depende do recurso. Alguns
1018
- respondem `409` enquanto ainda estão em processamento ou referenciados. Mantenha signatários e
1019
- templates compartilhados.
1020
-
1021
- ```ruby
1022
- client.documents.delete(documento['id'])
1023
- client.signers.delete(signatario['id'])
1024
- client.webhooks.delete_endpoint('webhook-endpoint-id')
1025
- client.tags.delete(tag['id'], force: true) # force desvincula de documentos e templates
1026
- ```
1027
-
1028
- ---
1029
-
1030
- ## 13. Erros
1031
-
1032
- ```ruby
1033
- begin
1034
- client.documents.details(documento_id)
1035
- rescue Assinafy::ValidationError => e
1036
- # entrada inválida — detectada ANTES de qualquer requisição
1037
- warn e.errors.inspect
1038
- rescue Assinafy::OAuthError => e
1039
- # falha em endpoint OAuth (subclasse de ApiError)
1040
- warn "#{e.error}: #{e.error_description}"
1041
- rescue Assinafy::ApiError => e
1042
- # a API respondeu com erro
1043
- warn "Assinafy respondeu #{e.status_code}: #{e.message}"
1044
- warn e.response_data.inspect
1045
- rescue Assinafy::NetworkError => e
1046
- # conexão, timeout ou TLS
1047
- warn "Falha de rede: #{e.message}"
1048
- rescue Assinafy::Error => e
1049
- # qualquer outra falha do SDK
1050
- warn e.context.inspect
1051
- end
1052
- ```
1053
-
1054
- Toda exceção do SDK deriva de `Assinafy::Error` e carrega um `#context` com detalhes para
1055
- depuração. `ApiError` trata as duas formas de corpo de erro da API — o erro de framework
1056
- (`{"name":..., "code":..., "status":...}`) e o envelope de aplicação
1057
- (`{"status":..., "data":null, "message":...}`) — inclusive quando um `200` traz um `status`
1058
- interno de falha. Num `403`, `e.context[:www_authenticate]` traz o desafio OAuth, quando houver. Num `429`,
1059
- `e.retry_after` traz os segundos de `Retry-After` e `e.rate_limit` traz `{ limit:, remaining:, reset: }` dos
1060
- cabeçalhos `X-Rate-Limit-*`.
1061
-
1062
- ---
1063
-
1064
- ## 14. Paginação
1065
-
1066
- Os métodos `*.list*` devolvem `{ data: [...], meta: { ... } }` quando a API envia cabeçalhos de
1067
- paginação:
1068
-
1069
- ```ruby
1070
- pagina = client.documents.list(page: 1, per_page: 50)
1071
- pagina[:data] # => [Documento, ...]
1072
- pagina[:meta] # => { current_page: 1, per_page: 50, total: 128, last_page: 3 }
1073
- ```
1074
-
1075
- O `per_page:` em estilo Ruby é convertido para o parâmetro `per-page` documentado. Valores acima
1076
- do máximo são limitados pelo servidor. Endpoints que não paginam (por exemplo `accounts.list`)
1077
- devolvem só `{ data: [...] }`, sem `meta`.
1078
-
1079
- ```ruby
1080
- def cada_documento(client)
1081
- return to_enum(:cada_documento, client) unless block_given?
1082
-
1083
- pagina = 1
1084
- loop do
1085
- resultado = client.documents.list(page: pagina, per_page: 50)
1086
- resultado[:data].each { |documento| yield documento }
1087
-
1088
- meta = resultado[:meta]
1089
- break unless meta && meta[:last_page] && pagina < meta[:last_page]
1090
-
1091
- pagina += 1
1092
- end
1093
- end
1094
- ```
1095
-
1096
- ---
1097
-
1098
- ## 15. Recursos do cliente
1099
-
1100
- | Acessor | Cobre |
1101
- | --- | --- |
1102
- | `client.auth` | login, autenticação em dois fatores, login social, senha, chaves de API |
1103
- | `client.oauth` | OAuth 2.1: token, refresh, revoke, userinfo, descoberta |
1104
- | `client.accounts` | workspaces, tema, KPIs, logo |
1105
- | `client.users` | perfil próprio, KPIs, preferências de notificação |
1106
- | `client.documents` | upload, listagem, download, ciclo de vida, tags, verificação |
1107
- | `client.contracts` | termos, aditivos, renovações e encerramentos de contratos |
1108
- | `client.signers` | CRUD de signatários e todo o autoatendimento do signatário |
1109
- | `client.signer_documents` | documentos do signatário, assinar/recusar em lote |
1110
- | `client.assignments` | pedidos de assinatura, custos, reenvios, assinatura, recusa |
1111
- | `client.templates` | ciclo de vida de templates |
1112
- | `client.tags` | CRUD de tags |
1113
- | `client.fields` | definições de campo e validação |
1114
- | `client.webhooks` | endpoints, segredos de assinatura, entregas, reenvio |
1115
- | `client.webhook_verifier` | verificação de assinatura das entregas recebidas |
1116
-
1117
- KPIs: `client.users.stats` e `client.accounts.stats` aceitam `granularity:` (`'monthly'` ou
1118
- `'daily'`) e `month:` (`'AAAA-MM'`), validados localmente:
1119
-
1120
- ```ruby
1121
- client.accounts.stats(granularity: 'daily', month: '2026-06')
1122
- ```
1123
-
1124
- `client.faraday_connection` expõe a conexão Faraday para inspeção em testes. Nunca adicione
1125
- middleware que repita requisições: pedidos de token não podem ser reenviados.
1126
-
1127
- ---
1128
-
1129
- ## 16. Assinaturas RBS
1130
-
1131
- `sig/assinafy.rbs` acompanha a gem, então consumidores que usam RBS têm as assinaturas
1132
- publicadas. [`spec/rbs_signature_spec.rb`](spec/rbs_signature_spec.rb) garante que todo método
1133
- público de recurso, do `Client` e de `Assinafy::OAuth` tenha uma assinatura declarada.
1134
-
1135
- ---
1136
-
1137
- ## Documentação
1138
-
1139
- - **[README.en.md](README.en.md)** — referência completa por recurso, em inglês
1140
- - [docs/API_REFERENCE.md](docs/API_REFERENCE.md) — referência por operação
1141
- - [CHANGELOG.md](CHANGELOG.md) — histórico de versões
1142
- - [Documentação da API](https://api.assinafy.com.br/v1/docs)
1143
-
1144
- ## Desenvolvimento e testes
170
+ ## Desenvolvimento
1145
171
 
1146
172
  ```bash
1147
- bundle exec rake spec
1148
- bundle exec rubocop
173
+ bundle exec rspec
174
+ bundle exec rubocop --cache false
1149
175
  bundle exec bundler-audit check --update
1150
176
  rbs -I sig validate
1151
177
  ruby scripts/check_api_contract.rb
1152
178
  ```
1153
179
 
1154
- A [suíte sandbox](spec/integration/live_sandbox_spec.rb) fica fora da execução padrão.
1155
- Veja os [pré-requisitos e variáveis](README.en.md#live-integration-tests), incluindo um template
1156
- com papel `Signer` para testar a geração. Os testes enviam e-mails e removem os recursos criados.
1157
-
1158
- ## Licença
1159
-
1160
- Distribuído sob a licença [MIT](LICENSE).
180
+ Licença: [MIT](LICENSE).