assinafy 2.0.1 → 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,1123 +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)
98
- )
99
- ```
100
-
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
- ---
113
-
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
- ```
135
-
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:
139
-
140
- ```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')
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
151
29
  )
152
30
  ```
153
31
 
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.
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.
158
35
 
159
- ### Gerenciar a autenticação em dois fatores
36
+ ## OAuth 2.1 para marketplace
160
37
 
161
- Estas chamadas agem sobre o usuário autenticado:
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.
162
40
 
163
41
  ```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
42
+ verifier = Assinafy::OAuth.generate_code_verifier
43
+ state = Assinafy::OAuth.generate_state
170
44
 
171
- usuario.auth.mfa_methods
172
- # => { 'methods' => [{ 'id' => 'mfa-method-id', 'type' => 'Totp', 'label' => 'Meu celular', ... }],
173
- # 'recovery_codes_remaining' => 10 }
174
-
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
- )
65
+ oauth_client = Assinafy::Client.new(token: tokens.fetch('access_token'), account_id: 'workspace-id')
606
66
  ```
607
67
 
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
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.
612
73
 
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.
74
+ ## Fluxo completo de assinatura
620
75
 
621
- Gerenciar depois de criado:
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.
622
84
 
623
85
  ```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
- ```
86
+ document = client.documents.upload(file_path: '/tmp/contract.pdf')
87
+ document = client.documents.wait_until_ready(document.fetch('id'))
630
88
 
631
- ### Atalho: tudo em uma chamada
89
+ signer = client.signers.find_by_email('signer@example.com') ||
90
+ client.signers.create(full_name: 'Example Signer', email: 'signer@example.com')
632
91
 
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"]
643
- ```
644
-
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.
648
-
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.
652
-
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:
661
-
662
- ```ruby
663
- signatario_client = Assinafy::Client.new
664
- codigo = 'codigo-de-acesso-do-link-de-assinatura'
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
+ }
665
101
 
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
- )
676
-
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)
679
-
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
- )
102
+ cost = client.assignments.estimate_cost(document.fetch('id'), request)
103
+ assignment = client.assignments.create(document.fetch('id'), request)
684
104
  ```
685
105
 
686
- `verify_email` envia o código de qualquer canal (`POST /verify`). Em seguida, assine.
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`.
687
108
 
688
- Assignment **virtual**:
109
+ ### Verificação e notificação
689
110
 
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
705
-
706
- signatario_client.assignments.sign(doc['id'], doc.dig('assignment', 'id'), itens, signer_access_code: codigo)
707
- ```
708
-
709
- O SDK converte as chaves `item_id`/`field_id`/`page_id` para o `itemId`/`fieldId`/`pageId` que a API
710
- espera.
711
-
712
- Recusar:
713
-
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
-
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
144
 
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.
145
+ verifier = Assinafy::Support::WebhookVerifier.new(endpoint.fetch('secret'))
146
+ raise 'invalid webhook' unless verifier.verify_delivery(raw_body, request.headers)
824
147
 
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')
148
+ event = verifier.extract_event(raw_body)
830
149
  ```
831
150
 
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
838
-
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`:
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.
851
154
 
852
- ```ruby
853
- client.webhooks.list_dispatches(endpoint_id: 'webhook-endpoint-id', delivered: false, per_page: 20)
854
- client.webhooks.retry_dispatch('dispatch-id')
855
- ```
155
+ ## Contratos, erros e recursos
856
156
 
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.
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.
861
160
 
862
- ### Verificar a assinatura
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`.
863
165
 
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:
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).
868
169
 
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
- ```
876
-
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.
881
-
882
- Receptor Rails:
883
-
884
- ```ruby
885
- class AssinafyWebhooksController < ActionController::API
886
- VERIFICADOR = Assinafy::Support::WebhookVerifier.new(ENV.fetch('ASSINAFY_WEBHOOK_SECRET'))
887
-
888
- def create
889
- corpo = request.raw_post
890
- return head(:unauthorized) unless VERIFICADOR.verify_delivery(corpo, request.headers)
891
-
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
- ---
970
-
971
- ## 12. Organizar e limpar
972
-
973
- ```ruby
974
- tag = client.tags.create(name: 'Contratos 2026', color: '2072b9')
975
- client.documents.append_tags(documento['id'], [tag['id']])
976
- client.documents.replace_tags(documento['id'], [tag['id']])
977
- client.documents.detach_tag(documento['id'], tag['id'])
978
-
979
- # Documentos que carregam TODAS as tags informadas (o SDK junta o Array com vírgulas):
980
- client.documents.list(tags: [tag['id'], 'outra-tag-id'])
981
-
982
- client.documents.rename(documento['id'], 'Contrato Acme — assinado')
983
- ```
984
-
985
- Apague só o que a sua aplicação criou, e só depois de concluir o que depende do recurso. Alguns
986
- respondem `409` enquanto ainda estão em processamento ou referenciados. Mantenha signatários e
987
- templates compartilhados.
988
-
989
- ```ruby
990
- client.documents.delete(documento['id'])
991
- client.signers.delete(signatario['id'])
992
- client.webhooks.delete_endpoint('webhook-endpoint-id')
993
- client.tags.delete(tag['id'], force: true) # force desvincula de documentos e templates
994
- ```
995
-
996
- ---
997
-
998
- ## 13. Erros
999
-
1000
- ```ruby
1001
- begin
1002
- client.documents.details(documento_id)
1003
- rescue Assinafy::ValidationError => e
1004
- # entrada inválida — detectada ANTES de qualquer requisição
1005
- warn e.errors.inspect
1006
- rescue Assinafy::OAuthError => e
1007
- # falha em endpoint OAuth (subclasse de ApiError)
1008
- warn "#{e.error}: #{e.error_description}"
1009
- rescue Assinafy::ApiError => e
1010
- # a API respondeu com erro
1011
- warn "Assinafy respondeu #{e.status_code}: #{e.message}"
1012
- warn e.response_data.inspect
1013
- rescue Assinafy::NetworkError => e
1014
- # conexão, timeout ou TLS
1015
- warn "Falha de rede: #{e.message}"
1016
- rescue Assinafy::Error => e
1017
- # qualquer outra falha do SDK
1018
- warn e.context.inspect
1019
- end
1020
- ```
1021
-
1022
- Toda exceção do SDK deriva de `Assinafy::Error` e carrega um `#context` com detalhes para
1023
- depuração. `ApiError` trata as duas formas de corpo de erro da API — o erro de framework
1024
- (`{"name":..., "code":..., "status":...}`) e o envelope de aplicação
1025
- (`{"status":..., "data":null, "message":...}`) — inclusive quando um `200` traz um `status`
1026
- interno de falha. Num `403`, `e.context[:www_authenticate]` traz o desafio OAuth, quando houver. Num `429`,
1027
- `e.retry_after` traz os segundos de `Retry-After` e `e.rate_limit` traz `{ limit:, remaining:, reset: }` dos
1028
- cabeçalhos `X-Rate-Limit-*`.
1029
-
1030
- ---
1031
-
1032
- ## 14. Paginação
1033
-
1034
- Os métodos `*.list*` devolvem `{ data: [...], meta: { ... } }` quando a API envia cabeçalhos de
1035
- paginação:
1036
-
1037
- ```ruby
1038
- pagina = client.documents.list(page: 1, per_page: 50)
1039
- pagina[:data] # => [Documento, ...]
1040
- pagina[:meta] # => { current_page: 1, per_page: 50, total: 128, last_page: 3 }
1041
- ```
1042
-
1043
- O `per_page:` em estilo Ruby é convertido para o parâmetro `per-page` documentado. Valores acima
1044
- do máximo são limitados pelo servidor. Endpoints que não paginam (por exemplo `accounts.list`)
1045
- devolvem só `{ data: [...] }`, sem `meta`.
1046
-
1047
- ```ruby
1048
- def cada_documento(client)
1049
- return to_enum(:cada_documento, client) unless block_given?
1050
-
1051
- pagina = 1
1052
- loop do
1053
- resultado = client.documents.list(page: pagina, per_page: 50)
1054
- resultado[:data].each { |documento| yield documento }
1055
-
1056
- meta = resultado[:meta]
1057
- break unless meta && meta[:last_page] && pagina < meta[:last_page]
1058
-
1059
- pagina += 1
1060
- end
1061
- end
1062
- ```
1063
-
1064
- ---
1065
-
1066
- ## 15. Recursos do cliente
1067
-
1068
- | Acessor | Cobre |
1069
- | --- | --- |
1070
- | `client.auth` | login, autenticação em dois fatores, login social, senha, chaves de API |
1071
- | `client.oauth` | OAuth 2.1: token, refresh, revoke, userinfo, descoberta |
1072
- | `client.accounts` | workspaces, tema, KPIs, logo |
1073
- | `client.users` | perfil próprio, KPIs, preferências de notificação |
1074
- | `client.documents` | upload, listagem, download, ciclo de vida, tags, verificação |
1075
- | `client.signers` | CRUD de signatários e todo o autoatendimento do signatário |
1076
- | `client.signer_documents` | documentos do signatário, assinar/recusar em lote |
1077
- | `client.assignments` | pedidos de assinatura, custos, reenvios, assinatura, recusa |
1078
- | `client.templates` | ciclo de vida de templates |
1079
- | `client.tags` | CRUD de tags |
1080
- | `client.fields` | definições de campo e validação |
1081
- | `client.webhooks` | endpoints, segredos de assinatura, entregas, reenvio |
1082
- | `client.webhook_verifier` | verificação de assinatura das entregas recebidas |
1083
-
1084
- KPIs: `client.users.stats` e `client.accounts.stats` aceitam `granularity:` (`'monthly'` ou
1085
- `'daily'`) e `month:` (`'AAAA-MM'`), validados localmente:
1086
-
1087
- ```ruby
1088
- client.accounts.stats(granularity: 'daily', month: '2026-06')
1089
- ```
1090
-
1091
- `client.faraday_connection` expõe a conexão Faraday para inspeção em testes. Nunca adicione
1092
- middleware que repita requisições: pedidos de token não podem ser reenviados.
1093
-
1094
- ---
1095
-
1096
- ## 16. Assinaturas RBS
1097
-
1098
- `sig/assinafy.rbs` acompanha a gem, então consumidores que usam RBS têm as assinaturas
1099
- publicadas. [`spec/rbs_signature_spec.rb`](spec/rbs_signature_spec.rb) garante que todo método
1100
- público de recurso, do `Client` e de `Assinafy::OAuth` tenha uma assinatura declarada.
1101
-
1102
- ---
1103
-
1104
- ## Documentação
1105
-
1106
- - **[README.en.md](README.en.md)** — referência completa por recurso, em inglês
1107
- - [docs/API_REFERENCE.md](docs/API_REFERENCE.md) — referência por operação
1108
- - [CHANGELOG.md](CHANGELOG.md) — histórico de versões
1109
- - [Documentação da API](https://api.assinafy.com.br/v1/docs)
1110
-
1111
- ## Desenvolvimento e testes
170
+ ## Desenvolvimento
1112
171
 
1113
172
  ```bash
1114
- bundle exec rake spec
1115
- bundle exec rubocop
173
+ bundle exec rspec
174
+ bundle exec rubocop --cache false
1116
175
  bundle exec bundler-audit check --update
1117
176
  rbs -I sig validate
1118
177
  ruby scripts/check_api_contract.rb
1119
178
  ```
1120
179
 
1121
- A [suíte sandbox](spec/integration/live_sandbox_spec.rb) fica fora da execução padrão.
1122
- Veja os [pré-requisitos e variáveis](README.en.md#live-integration-tests), incluindo um template
1123
- com papel `Signer` para testar a geração. Os testes enviam e-mails e removem os recursos criados.
1124
-
1125
- ## Licença
1126
-
1127
- Distribuído sob a licença [MIT](LICENSE).
180
+ Licença: [MIT](LICENSE).