@assinafy/sdk 2.0.0 → 2.1.2

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.
package/README.md CHANGED
@@ -2,16 +2,23 @@
2
2
 
3
3
  TypeScript SDK for the [Assinafy API](https://api.assinafy.com.br/v1/docs) — a Brazilian digital signature platform.
4
4
 
5
- Covers the server-side surface of the API: documents, signers, assignments, templates, tags, workspaces, webhooks, field definitions, authentication, public/signer-side flows, and the high-level `uploadAndRequestSignatures` helper.
5
+ Covers all 89 operations in the current official OpenAPI document: accounts,
6
+ authentication, users, documents, assignments, signers, signer-side flows,
7
+ templates, tags, fields, webhooks, branding, statistics, and the high-level
8
+ `uploadAndRequestSignatures` workflow. Five additional template-management
9
+ routes used by existing integrations and two legacy browser URL helpers are
10
+ retained for compatibility.
6
11
 
7
- Deliberately not wrapped: the browser-redirect OAuth endpoints (`/auth/authenticate`, `/auth/link-social-login`, `/login-callback`), which a server-side SDK cannot meaningfully drive, and the account `theme`/`logo` branding routes.
12
+ See [API coverage](docs/API_COVERAGE.md) for the operation map and
13
+ [compatibility notes](docs/COMPATIBILITY.md) for deployment-specific request
14
+ and response variants.
8
15
 
9
16
  ## Requirements
10
17
 
11
- - Node.js 22+ for the built-in `FormData` / `Blob` APIs used by uploads. Tested
12
- on 22 (maintenance LTS) and 24 (active LTS); Node 20 reached end-of-life in
13
- April 2026 and is no longer supported.
14
- - or Bun 1.0+
18
+ - Node.js 22+ for the built-in `FormData` / `Blob` APIs used by uploads. Packed
19
+ CJS and ESM imports are tested on 22 (maintenance LTS), 24 (active LTS), and
20
+ 26 (Current); Node 20 reached end-of-life in April 2026 and is unsupported.
21
+ - or Bun 1.4.0 (the version pinned for development and CI)
15
22
 
16
23
  ## Installation
17
24
 
@@ -33,24 +40,30 @@ The package is published to both [npmjs.com](https://www.npmjs.com/package/@assi
33
40
  ```ts
34
41
  import { AssinafyClient } from '@assinafy/sdk';
35
42
 
43
+ const baseUrl = process.env.ASSINAFY_BASE_URL ?? 'https://api.assinafy.com.br/v1';
36
44
  const client = new AssinafyClient({
37
45
  apiKey: process.env.ASSINAFY_API_KEY!,
38
46
  accountId: process.env.ASSINAFY_ACCOUNT_ID!,
39
- webhookSecret: process.env.ASSINAFY_WEBHOOK_SECRET,
47
+ baseUrl,
40
48
  });
41
49
 
42
50
  const result = await client.uploadAndRequestSignatures({
43
51
  source: { filePath: './contract.pdf' },
44
52
  signers: [
45
- { name: 'John Doe', email: 'john@example.com' },
46
- { name: 'Jane Smith', email: 'jane@example.com', whatsapp_phone_number: '+5548999990000' },
53
+ { name: 'John Doe', email: 'john@example.com' },
54
+ { name: 'Jane Smith', email: 'jane@example.com' },
47
55
  ],
48
56
  message: 'Please sign this contract',
49
57
  });
50
58
 
51
59
  console.log('Document ID:', result.document.id);
60
+ console.log('Assignment ID:', result.assignment.id);
52
61
  ```
53
62
 
63
+ This path uses email verification and notification for every signer. WhatsApp
64
+ and ICP-Brasil certificate signing have separate prerequisites and costs; see
65
+ [Paid signing branches](#paid-signing-branches) before enabling either one.
66
+
54
67
  ## Authentication
55
68
 
56
69
  The API supports two authentication methods. Prefer `apiKey` — it maps to the `X-Api-Key` header recommended by Assinafy for backend services.
@@ -59,35 +72,61 @@ The API supports two authentication methods. Prefer `apiKey` — it maps to the
59
72
  // Preferred: X-Api-Key header
60
73
  new AssinafyClient({ apiKey: 'k_xxx', accountId: 'acc_xxx' });
61
74
 
62
- // Legacy: Authorization: Bearer <token>
75
+ // Access token: Authorization: Bearer <token>
63
76
  new AssinafyClient({ token: 'jwt_xxx', accountId: 'acc_xxx' });
64
77
  ```
65
78
 
79
+ Credentials are optional at construction time. A credentialless client uses a
80
+ separate, auth-free transport for public authentication and signer-access-code
81
+ operations, so an API key or Bearer token is never attached accidentally:
82
+
83
+ ```ts
84
+ const publicClient = new AssinafyClient({
85
+ baseUrl: 'https://sandbox.assinafy.com.br/v1',
86
+ });
87
+
88
+ await publicClient.auth.login('me@example.com', 'password');
89
+ await publicClient.documents.getPublic(documentId);
90
+ await publicClient.signerDocuments.self(signerAccessCode);
91
+ ```
92
+
93
+ Protected methods still require `apiKey` or `token`; the API returns its normal
94
+ `401` response if one is called without credentials.
95
+
96
+ Every SDK transport, including public and signer-access-code requests, sends
97
+ `User-Agent: Assinafy-Typescript-SDK/v<VERSION>`, where `<VERSION>` is the
98
+ installed package version. The exact value is also exported as
99
+ `SDK_USER_AGENT` for custom transport checks and observability rules.
100
+
66
101
  ## Configuration
67
102
 
68
103
  | Option | Type | Default | Description |
69
104
  | --------------- | -------- | --------------------------------------- | --------------------------------------------- |
70
105
  | `apiKey` | string | — | Preferred credential (sent as `X-Api-Key`). |
71
- | `token` | string | — | Legacy access token (sent as `Bearer`). |
106
+ | `token` | string | — | Access token (sent as `Authorization: Bearer`). |
72
107
  | `accountId` | string | — | Default workspace/account ID. |
73
- | `baseUrl` | string | `https://api.assinafy.com.br/v1` | Override base URL (e.g. the sandbox). |
74
- | `webhookSecret` | string | — | Shared secret used by `WebhookVerifier`. |
108
+ | `baseUrl` | string | `https://api.assinafy.com.br/v1` | Absolute HTTP(S) API base without credentials, query, or fragment. |
109
+ | `webhookSecret` | string | — | Opt-in HMAC secret used by `WebhookVerifier`; see its [contract caveat](docs/COMPATIBILITY.md#webhook-signature-verification-is-not-in-the-openapi-contract). |
75
110
  | `timeout` | number | `30000` | Request timeout in milliseconds. |
76
- | `maxRetries` | number | `2` | Auto-retries on HTTP 429, honoring `Retry-After`. `0` disables. |
111
+ | `maxRetries` | number | `2` | Auto-retries eligible HTTP 429 responses, honoring `Retry-After`. `0` disables. |
77
112
  | `logger` | `Logger` | no-op | Optional `{debug,info,warn,error}` logger. |
78
113
 
79
114
  ### Rate limiting
80
115
 
81
- The API allows ~120 requests/minute and returns `X-Rate-Limit-*` headers. On an
82
- HTTP `429`, the client automatically retries up to `maxRetries` times, waiting
83
- for the server-provided `Retry-After` (or `X-Rate-Limit-Reset`) delay before
84
- each attempt. Only `429` is retried, so non-idempotent calls are safe.
116
+ On an HTTP `429`, the client automatically retries up to `maxRetries` times,
117
+ waiting for the server-provided `Retry-After` (or `X-Rate-Limit-Reset`) delay
118
+ before each attempt. Automatic replay is limited to read-safe `GET`, `HEAD`,
119
+ `OPTIONS`, and `DELETE` requests. `GET /sign` is excluded because it records
120
+ that the signer viewed the assignment. Writes are not replayed by default.
121
+ A non-empty `Idempotency-Key` opts a custom request into SDK replay, but it is
122
+ not part of the current Assinafy OpenAPI contract: confirm that the target
123
+ route deduplicates that key server-side first. No other HTTP status is retried.
85
124
 
86
125
  ### Factories
87
126
 
88
127
  ```ts
89
128
  // Positional factory
90
- const client = AssinafyClient.create('api-key', 'account-id', { webhookSecret: 'shhh' });
129
+ const client = AssinafyClient.create('api-key', 'account-id');
91
130
 
92
131
  // From a plain object (accepts snake_case or camelCase keys)
93
132
  const client = AssinafyClient.fromConfig({
@@ -98,7 +137,9 @@ const client = AssinafyClient.fromConfig({
98
137
 
99
138
  ## Endpoint coverage
100
139
 
101
- Every public endpoint documented in https://api.assinafy.com.br/v1/docs is covered. The table below maps each resource to its API surface.
140
+ All 89 operations documented at https://api.assinafy.com.br/v1/docs are
141
+ covered. The table below is the resource-level summary; the detailed operation
142
+ ledger is in [docs/API_COVERAGE.md](docs/API_COVERAGE.md).
102
143
 
103
144
  | Resource | Endpoints |
104
145
  | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -107,16 +148,271 @@ Every public endpoint documented in https://api.assinafy.com.br/v1/docs is cover
107
148
  | `client.assignments` | **list**, create, estimateCost, resetExpiration, resendNotification, estimateResendCost, listWhatsAppNotifications |
108
149
  | `client.templates` | **create**, list, get, **update**, **delete**, downloadPage |
109
150
  | `client.tags` | list, create, update, delete |
110
- | `client.workspaces` | create, list, get, update, delete |
151
+ | `client.workspaces` | create, list, get, update, delete, getTheme, downloadLogo, uploadLogo, deleteLogo, getStats |
111
152
  | `client.webhooks` | register, get, inactivate, listEventTypes, listDispatches, retryDispatch |
112
153
  | `client.fields` | create, list, get, update, delete, validate, validateMultiple, listTypes |
113
- | `client.auth` | login, socialLogin, createApiKey, getApiKey, deleteApiKey, changePassword, requestPasswordReset, resetPassword |
154
+ | `client.auth` | getSocialLoginUrl, getSocialLoginCallbackUrl, login, socialLogin, linkSocialLogin, createApiKey, getApiKey, deleteApiKey, changePassword, requestPasswordReset, resetPassword |
155
+ | `client.users` | getCurrent, getStats, getNotificationPreferences, updateNotificationPreferences |
114
156
  | `client.signerDocuments` | getCurrent, list, **search**, download, signMultiple, declineMultiple, self, acceptTerms, verifyEmail, confirmData, uploadSignature, downloadSignature, getAssignment, sign, decline |
115
157
  | `client.webhookVerifier` | verify, extractEvent, getEventType, getEventData |
116
158
 
117
- ## Resources
159
+ Every HTTP wrapper has TypeScript-checked request/response shapes and
160
+ method-level JSDoc covering the wire payload, return shape, validation,
161
+ relevant API errors, and a copyable example. Reusable and OpenAPI schema-level
162
+ payloads are exported as named types; small method-local option bags remain
163
+ inline in the generated declarations. Editors expose the reference on hover,
164
+ and declaration files ship with the package. The coverage ledger links those
165
+ typed methods back to each upstream operation without duplicating the schema.
166
+
167
+ ## Document lifecycle
168
+
169
+ The normal integration has an account-owner phase, a signer phase, and a final
170
+ artifact phase. The example below keeps every signer on email and uses a
171
+ `virtual` assignment, so no page coordinates or paid notification channel are
172
+ required.
173
+
174
+ ### 1. Upload the PDF
175
+
176
+ ```ts
177
+ const uploaded = await client.documents.upload({ filePath: './contract.pdf' });
178
+ ```
179
+
180
+ The official multipart body contains the `file` part. The SDK also supports a
181
+ display-name override (used as that file part's filename) and an optional JSON
182
+ `metadata` part for deployments that accept it. A successful response is
183
+ `IDocumentUploadResponse`:
184
+
185
+ ```ts
186
+ {
187
+ resource?: string;
188
+ id: string;
189
+ account_id: string;
190
+ template_id: string | null;
191
+ name: string;
192
+ status: DocumentStatus;
193
+ assignment?: IAssignment | null;
194
+ artifacts: {
195
+ original: string;
196
+ certificated?: string;
197
+ 'certificate-page'?: string;
198
+ pades?: string;
199
+ bundle?: string;
200
+ thumbnail?: string;
201
+ };
202
+ signing_url?: string;
203
+ pages: Array<{ id: string; number: number; height: number; width: number; download_url: string }>;
204
+ tags?: Array<{ id: string; name: string; color?: string | null }>;
205
+ created_at: string;
206
+ updated_at: string;
207
+ is_closed: boolean;
208
+ decline_reason: string | null;
209
+ declined_by: ISigner | null;
210
+ }
211
+ ```
212
+
213
+ `DocumentStatus` covers `uploading`, `uploaded`, `metadata_processing`,
214
+ `metadata_ready`, `pending_signature`, `expired`, `certificating`,
215
+ `certificated`, `rejected_by_signer`, `rejected_by_user`, and `failed`.
216
+
217
+ Uploads must be PDFs, at most 25 MB and at most 2,000 pages. The SDK checks the
218
+ extension, size, and `%PDF-` header before sending. A new upload can have an
219
+ empty `pages` array until metadata processing finishes. Wait before creating a
220
+ `collect` assignment because its fields refer to rendered page IDs; a
221
+ `virtual` assignment may be created immediately.
222
+
223
+ ```ts
224
+ const prepared = await client.documents.waitUntilReady(uploaded.id, {
225
+ maxWaitMs: 30_000,
226
+ pollIntervalMs: 2_000,
227
+ });
228
+ ```
229
+
230
+ ### 2. Create or reuse the email signers
231
+
232
+ ```ts
233
+ const signerA = await client.signers.create({
234
+ full_name: 'John Doe',
235
+ email: 'john@example.com',
236
+ });
237
+ const signerB = await client.signers.create({
238
+ full_name: 'Jane Smith',
239
+ email: 'jane@example.com',
240
+ });
241
+ ```
242
+
243
+ The wire body is `{ full_name, email }`. Each response is an `ISigner`:
244
+
245
+ ```ts
246
+ {
247
+ resource?: string;
248
+ id: string;
249
+ full_name: string;
250
+ email: string | null;
251
+ whatsapp_phone_number?: string | null;
252
+ cpf?: string | null; // compatibility type; not echoed by the API
253
+ has_accepted_terms?: boolean;
254
+ has_signature?: boolean; // signer-self response only
255
+ has_initial?: boolean; // signer-self response only
256
+ is_signature_reusable?: boolean; // signer-self response only
257
+ metadata?: Record<string, unknown>;
258
+ }
259
+ ```
260
+
261
+ When an email is present, `signers.create()` first looks up that email in the
262
+ workspace and reuses the matching signer; a name-only or phone-only request
263
+ always creates a new signer.
264
+
265
+ ### 3. Price, then request signatures
266
+
267
+ Cost estimation takes channel descriptors, not signer IDs:
268
+
269
+ ```ts
270
+ const estimate = await client.assignments.estimateCost(uploaded.id, {
271
+ method: 'virtual',
272
+ signers: [{}, {}], // `{}` selects Email for each signer
273
+ });
274
+
275
+ if (!estimate.has_sufficient_resources) {
276
+ throw new Error(estimate.blocking_reason ?? estimate.message ?? 'Insufficient resources');
277
+ }
278
+ ```
279
+
280
+ The response is `ICostEstimate`:
281
+
282
+ ```ts
283
+ {
284
+ documents: number;
285
+ credits: number;
286
+ needs_extra_document: boolean;
287
+ extra_document_cost: number;
288
+ total_credits: number;
289
+ breakdown: Array<{ code: string; name: string; cost: number; quantity?: number; unit_cost?: number }>;
290
+ document_balance: number;
291
+ credit_balance: number;
292
+ has_sufficient_resources: boolean;
293
+ blocking_reason: 'PendingPayment' | 'InsufficientDocuments' | 'InsufficientCredits' | null;
294
+ message: string | null;
295
+ }
296
+ ```
297
+
298
+ Create the email assignment only after accepting that estimate:
299
+
300
+ ```ts
301
+ const assignment = await client.assignments.create(uploaded.id, {
302
+ method: 'virtual',
303
+ signers: [
304
+ { id: signerA.id, verification_method: 'Email', notification_methods: ['Email'] },
305
+ { id: signerB.id, verification_method: 'Email', notification_methods: ['Email'] },
306
+ ],
307
+ message: 'Please review and sign',
308
+ expires_at: '2027-12-31T23:59:00Z',
309
+ });
310
+ ```
311
+
312
+ The request returns an `IAssignment`:
313
+
314
+ ```ts
315
+ {
316
+ resource?: string;
317
+ id: string;
318
+ sender_email?: string;
319
+ method: 'virtual' | 'collect';
320
+ expires_at?: string | null;
321
+ expiration?: string;
322
+ message?: string | null;
323
+ signers: IAssignmentSigner[];
324
+ copy_receivers?: Array<Record<string, unknown>>;
325
+ items?: IAssignmentItem[];
326
+ summary?: {
327
+ signer_count: number;
328
+ completed_count: number;
329
+ signers: Array<ISigner & { completed?: boolean }>;
330
+ };
331
+ signing_urls?: Array<{ signer_id: string; url: string }>;
332
+ }
333
+ ```
334
+
335
+ The URLs and delivered messages contain signer credentials; treat them as
336
+ secrets.
337
+
338
+ ### 4. Complete the email signer flow
339
+
340
+ Assinafy sends each signer a link containing their access code and sends the
341
+ one-time verification code through the selected channel. Neither value is
342
+ returned as a standalone owner-side API field. A custom signer portal must
343
+ obtain both values from the signer-delivery flow; do not manufacture them or
344
+ log them.
345
+
346
+ ```ts
347
+ // Signer-side client: no account API credential is needed or sent.
348
+ const signerClient = new AssinafyClient({
349
+ baseUrl,
350
+ });
351
+
352
+ const self = await signerClient.signerDocuments.self(accessCode); // ISignerSelf
353
+
354
+ // Query: signer-access-code=<accessCode>
355
+ // Body: { 'verification-code': '<six-digit code>' }
356
+ await signerClient.signerDocuments.verifyEmail({
357
+ signerAccessCode: accessCode,
358
+ verificationCode,
359
+ }); // Promise<void>
360
+
361
+ const confirmed = await signerClient.signerDocuments.confirmData(
362
+ uploaded.id,
363
+ accessCode,
364
+ { full_name: self.full_name, email: self.email ?? undefined },
365
+ ); // ISigner
366
+
367
+ const signable = await signerClient.signerDocuments.getAssignment(accessCode, true);
368
+ // `getAssignment` returns IDocumentDetailsResponse and records that the signer
369
+ // viewed the assignment. Do not issue it merely as a health check.
370
+
371
+ await signerClient.signerDocuments.signMultiple([signable.id], accessCode);
372
+ // Wire body: { document_ids: [signable.id] }; acknowledgement has no data.
373
+ ```
374
+
375
+ Repeat this phase separately for each signer with that signer's own access code
376
+ and one-time code. The two signers in this example share the default step and
377
+ can sign in parallel.
378
+
379
+ `signMultiple` is only for `virtual` assignments. For `collect`, read
380
+ `signable.assignment.items`, then call `sign(documentId, assignmentId,
381
+ accessCode, entries)` with a non-empty array of
382
+ `{ itemId, fieldId, pageId, value }`. A virtual signer must confirm their data
383
+ before signing. A `DigitalCertificate` signer cannot call `sign`; that branch
384
+ uses Assinafy's certificate-start and certificate-complete flow, which is not
385
+ part of this SDK's current 89-operation surface.
386
+
387
+ ### 5. Observe completion and download artifacts
388
+
389
+ Subscribe to `document_ready` for event-driven completion, or fetch
390
+ `documents.details(documentId)` until `status === 'certificated'`. Webhook
391
+ deliveries can repeat, so use their numeric `id` as an
392
+ idempotency key. Once complete:
393
+
394
+ ```ts
395
+ const finalDocument = await client.documents.details(uploaded.id);
396
+ const signedPdf = await client.documents.download(uploaded.id, 'certificated');
397
+ const certificatePage = await client.documents.download(uploaded.id, 'certificate-page');
398
+ const bundleZip = await client.documents.download(uploaded.id, 'bundle');
399
+
400
+ // Validate an Assinafy signature hash when your workflow has extracted it.
401
+ const validation = await client.documents.verify(documentSignatureHash);
402
+ ```
118
403
 
119
- Most account-scoped methods accept an optional `accountId` that overrides the client default. Workspace `get/update/delete` always require an explicit account ID.
404
+ `original`, `certificated`, and `certificate-page` are PDFs. `bundle` is a ZIP
405
+ containing those three artifacts and also `pades` when the document had an
406
+ ICP-Brasil certificate signer. The `pades` PDF exists only for documents that
407
+ had certificate signers. An artifact can return `404` before generation has
408
+ finished. `decline_reason` is included in document details only when the access
409
+ token belongs to the document creator.
410
+
411
+ ## Resource reference
412
+
413
+ Most account-scoped methods accept an optional `accountId` that overrides the
414
+ client default. Workspace `get`, `update`, `delete`, branding, and statistics
415
+ methods always require an explicit account ID.
120
416
 
121
417
  ### Documents
122
418
 
@@ -126,13 +422,15 @@ const doc = await client.documents.upload(
126
422
  { filePath: './contract.pdf' },
127
423
  { name: 'Service agreement', metadata: { type: 'service' } },
128
424
  );
425
+ // `name` and `metadata` are compatibility multipart parts outside the published
426
+ // file-only request schema.
129
427
  // `name` is optional and defaults to the file's own name. The API derives the
130
428
  // display name from the uploaded filename and appends `.pdf` when absent, so
131
429
  // the document above is stored as 'Service agreement.pdf'. Accents are
132
430
  // transliterated by the API ('Contrato de Serviço' → 'Contrato de Servico.pdf').
133
431
  // → {
134
432
  // resource: 'document', id: '1031…', account_id: '102d…', template_id: null,
135
- // name: 'contract.pdf', status: 'uploaded',
433
+ // name: 'Service agreement.pdf', status: 'uploaded',
136
434
  // artifacts: { original: 'https://…/download/original' },
137
435
  // signing_url: 'https://app…/sign/1031…',
138
436
  // pages: [], // populated once status reaches `metadata_ready`
@@ -143,7 +441,7 @@ const doc = await client.documents.upload(
143
441
  await client.documents.upload({ buffer, fileName: 'contract.pdf' });
144
442
 
145
443
  // List → { data: IDocumentListItem[], meta?: { current_page, per_page, total, last_page } }
146
- const { data, meta } = await client.documents.list({ page: 1, per_page: 20, sort: '-created_at' });
444
+ const { data, meta } = await client.documents.list({ page: 1, per_page: 20, sort: 'updated_at' });
147
445
 
148
446
  // Search is the lightweight alternative to list: same item shape, but the API
149
447
  // skips the expanded `assignment`/`pages`. Prefer it for name lookups.
@@ -158,7 +456,11 @@ await client.documents.waitUntilReady(doc.id, { maxWaitMs: 30_000 });
158
456
  // (Passing `name` to upload() avoids both the round-trip and the race.)
159
457
  await client.documents.rename(doc.id, 'Signed service agreement.pdf');
160
458
 
161
- await client.documents.download(doc.id, 'certificated'); // 'original' | 'certificated' | 'certificate-page' | 'bundle'
459
+ await client.documents.download(doc.id, 'certificated'); // signed PDF
460
+ await client.documents.download(doc.id, 'certificate-page');
461
+ await client.documents.download(doc.id, 'bundle'); // ZIP
462
+ // `pades` exists only when at least one signer used DigitalCertificate.
463
+ await client.documents.download(doc.id, 'pades');
162
464
  await client.documents.thumbnail(doc.id);
163
465
  await client.documents.downloadPage(doc.id, pageId);
164
466
 
@@ -167,21 +469,37 @@ await client.documents.isFullySigned(doc.id);
167
469
  await client.documents.getSigningProgress(doc.id);
168
470
  await client.documents.delete(doc.id);
169
471
 
170
- // Verify a signed document by its SHA-1 hash
472
+ // Verify a signed document by its Assinafy signature hash
171
473
  await client.documents.verify('FE32EDDADE7CBDDCBB934E7402047450B0E59C02');
172
474
 
173
475
  // Public endpoints (no auth)
174
476
  await client.documents.getPublic(doc.id);
175
- await client.documents.sendToken(doc.id, 'jane@example.com', 'email');
477
+ // Official request body: { email: 'jane@example.com' }
478
+ await client.documents.sendToken(doc.id, 'jane@example.com');
479
+
480
+ // Explicit compatibility overload for older deployments:
481
+ // { recipient: '+5548999990000', channel: 'whatsapp' }
482
+ await client.documents.sendToken(doc.id, '+5548999990000', 'whatsapp');
176
483
 
177
- // Tags attached to a document (by tag name; unknown names are auto-created)
484
+ // The current OpenAPI contract requires existing tag IDs.
485
+ const contractsTag = await client.tags.create({ name: 'Contracts' });
486
+ const quarterTag = await client.tags.create({ name: '2026-Q1' });
487
+ const urgentTag = await client.tags.create({ name: 'Urgent' });
178
488
  await client.documents.listTags(doc.id);
179
- await client.documents.replaceTags(doc.id, ['Contracts', '2026-Q1']); // [] detaches all
180
- await client.documents.addTags(doc.id, ['Urgent']); // append, idempotent
181
- await client.documents.detachTag(doc.id, tagId); // remove one
489
+ await client.documents.replaceTags(doc.id, [contractsTag.id, quarterTag.id]); // [] detaches all
490
+ await client.documents.addTags(doc.id, [urgentTag.id]); // append
491
+ await client.documents.detachTag(doc.id, urgentTag.id); // { detached: true }
182
492
  ```
183
493
 
184
- Uploads are validated locally: only `.pdf` files up to 25 MB are accepted (the API's current hard limit).
494
+ Uploads are validated locally: only `.pdf` files up to 25 MB whose bytes begin
495
+ with the PDF magic header (`%PDF-`) are accepted. The API also limits documents
496
+ to 2,000 pages.
497
+
498
+ Page and artifact URLs embedded in JSON responses still require the same
499
+ account authentication as their download operations. Prefer
500
+ `documents.downloadPage()` and `documents.download()` so the SDK applies the
501
+ credential and returns a `Buffer`. `bundle` contains `original`, `certificated`,
502
+ and `certificate-page`, plus `pades` when available.
185
503
 
186
504
  List endpoints return `{ data, meta }` where `meta` is populated from the `X-Pagination-*` headers returned by the API.
187
505
 
@@ -191,35 +509,36 @@ List endpoints return `{ data, meta }` where `meta` is populated from the `X-Pag
191
509
  await client.signers.create({
192
510
  full_name: 'John Doe',
193
511
  email: 'john@example.com',
194
- whatsapp_phone_number: '+5548999990000',
195
- cpf: '123.456.789-00', // optional Brazilian tax ID — non-digits are stripped automatically
512
+ cpf: '123.456.789-00', // legacy compatibility input; non-digits are stripped
196
513
  });
197
514
  // → { id: '19e6…', full_name: 'John Doe', email: 'john@example.com',
198
- // whatsapp_phone_number: '+5548999990000', has_accepted_terms: false }
515
+ // whatsapp_phone_number: null, has_accepted_terms: false }
199
516
  // (note: `cpf` is accepted on input but never echoed back by the API)
200
517
 
201
- // `email` is optional a WhatsApp-only signer is valid (at least one is required)
202
- await client.signers.create({
203
- full_name: 'WhatsApp Only',
204
- whatsapp_phone_number: '+5548999990000',
205
- });
518
+ // Both contacts are optional. A name-only signer cannot be notified until a
519
+ // contact is added.
520
+ await client.signers.create({ full_name: 'Contact Pending' });
206
521
 
207
- // PHP SDK compatibility aliases are also accepted
208
522
  await client.signers.create({
209
523
  full_name: 'Jane Doe',
210
524
  email: 'jane@example.com',
211
- phone: '+5548999991111', // alias for whatsapp_phone_number
212
525
  });
213
526
 
214
527
  await client.signers.get(signerId);
215
528
  await client.signers.list({ page: 1, per_page: 50, search: 'john' });
216
- await client.signers.update(signerId, { full_name: 'Johnny Doe' });
529
+ await client.signers.update(signerId, {
530
+ full_name: 'Johnny Doe',
531
+ government_id: '390.533.447-05', // official update field; sent as digits
532
+ });
217
533
  await client.signers.delete(signerId);
218
534
 
219
535
  const existing = await client.signers.findByEmail('john@example.com');
220
536
  ```
221
537
 
222
- When an `email` is supplied, `signers.create()` is idempotent by email, matching the PHP SDK behavior: it reuses an existing signer when the same email is already present in the workspace. WhatsApp-only signers (no email) are always created fresh.
538
+ When an `email` is supplied, `signers.create()` is idempotent by email: it
539
+ reuses an existing signer when the same email is already present in the
540
+ workspace. Signers without email are always created fresh. See
541
+ [Paid signing branches](#paid-signing-branches) for phone-only signers.
223
542
 
224
543
  ### Assignments
225
544
 
@@ -233,8 +552,8 @@ await client.assignments.create(documentId, {
233
552
  method: 'virtual',
234
553
  signers: ['signer-1', 'signer-2'],
235
554
  message: 'Please review and sign',
236
- expires_at: '2024-12-31T23:59:00Z',
237
- copy_receivers: ['observer-id'],
555
+ expires_at: '2027-12-31T23:59:00Z',
556
+ copy_receivers: ['copy-recipient-signer-id'],
238
557
  });
239
558
 
240
559
  // Sequential signing: `step` controls signing order (parallel within a step).
@@ -246,31 +565,47 @@ await client.assignments.create(documentId, {
246
565
  ],
247
566
  });
248
567
 
249
- // Estimate cost (signers may omit `id` when only the channel matters) → ICostEstimate
250
- await client.assignments.estimateCost(documentId, { signers: ['signer-1'] });
251
- await client.assignments.estimateCost(documentId, {
252
- signers: [{ verification_method: 'Whatsapp' }],
568
+ // Collect fields use 150-DPI page-image pixels measured from the upper-left.
569
+ await client.assignments.create(documentId, {
570
+ method: 'collect',
571
+ signers: [{ id: signerId }],
572
+ entries: [{
573
+ page_id: pageId,
574
+ fields: [{
575
+ signer_id: signerId,
576
+ field_id: fieldId,
577
+ display_settings: {
578
+ left: 69, top: 282, width: 421, height: 45.86, fontSize: 22,
579
+ fontFamily: 'Arial', backgroundColor: '#D5EBFF',
580
+ },
581
+ }],
582
+ }],
253
583
  });
584
+
585
+ // Estimate cost (the endpoint prices channel descriptors, not signer IDs) → ICostEstimate
586
+ await client.assignments.estimateCost(documentId, { signers: [{}] }); // default Email
254
587
  // → {
255
588
  // documents: 1, credits: 0, needs_extra_document: false, extra_document_cost: 0,
256
589
  // total_credits: 0, breakdown: [], document_balance: 67, credit_balance: 0,
257
590
  // has_sufficient_resources: true, blocking_reason: null, message: null
258
591
  // }
259
592
 
260
- await client.assignments.resetExpiration(documentId, assignmentId, '2025-06-30T00:00:00Z');
261
- await client.assignments.resetExpiration(documentId, assignmentId, null); // remove expiration
593
+ await client.assignments.resetExpiration(documentId, assignmentId, '2027-06-30T00:00:00Z');
594
+ // Compatibility only: the published request requires a date-time string.
595
+ // Confirm target support before using `null` to clear an expiration.
596
+ await client.assignments.resetExpiration(documentId, assignmentId, null);
262
597
 
263
598
  await client.assignments.resendNotification(documentId, assignmentId, signerId);
264
599
  // → { is_sent: true, document_id: '…', signer_id: '…' }
265
600
 
266
- await client.assignments.estimateResendCost(documentId, assignmentId, signerId);
267
- // { total: 0, breakdown: [{ code: 'NotificationEmailResend', name: '…', cost: 0 }],
268
- // credit_balance: 0, has_sufficient_credits: true }
269
-
270
- await client.assignments.listWhatsAppNotifications(documentId, assignmentId); // → IWhatsAppNotification[]
601
+ const resendCost = await client.assignments.estimateResendCost(documentId, assignmentId, signerId);
602
+ // Official response: ICostEstimate. Older deployments can return the compact
603
+ // IResendCostEstimate branch with `total` and `has_sufficient_credits`; narrow
604
+ // with `'total_credits' in resendCost` before reading branch-specific fields.
271
605
  ```
272
606
 
273
- The `create` response is an `IAssignment`: `{ id, method, signers: [...], items: [...], signing_urls: [{ signer_id, url }], … }`.
607
+ The `create` response is an `IAssignment`: `{ id, method, signers: [...],
608
+ items: [{ display_settings, ... }], signing_urls: [{ signer_id, url }], … }`.
274
609
 
275
610
  For backwards compatibility, the SDK also accepts legacy `signer_ids` and `signerIds` payloads and rewrites them to the current `signers: [{ id }]` format expected by the API.
276
611
 
@@ -281,11 +616,96 @@ await client.documents.delete(documentId); // w
281
616
  await client.signerDocuments.decline(documentId, assignmentId, accessCode, 'No longer needed'); // signer-side
282
617
  ```
283
618
 
619
+ ### Paid signing branches
620
+
621
+ Keep the email flow as the default. Enable either branch below only after the
622
+ workspace has the required plan or feature and the returned cost estimate is
623
+ acceptable.
624
+
625
+ #### WhatsApp verification and notification
626
+
627
+ WhatsApp is available only on paid subscriptions and costs 0.45 credit per
628
+ notification. Create a phone-only signer or add a phone to an existing signer,
629
+ then request the `Whatsapp` channel explicitly:
630
+
631
+ ```ts
632
+ const phoneSigner = await client.signers.create({
633
+ full_name: 'Mobile Signer',
634
+ whatsapp_phone_number: '+5511999990000',
635
+ });
636
+
637
+ const whatsappCost = await client.assignments.estimateCost(documentId, {
638
+ method: 'virtual',
639
+ signers: [{ verification_method: 'Whatsapp', notification_methods: ['Whatsapp'] }],
640
+ });
641
+
642
+ const whatsappAssignment = await client.assignments.create(documentId, {
643
+ method: 'virtual',
644
+ signers: [{
645
+ id: phoneSigner.id,
646
+ verification_method: 'Whatsapp',
647
+ notification_methods: ['Whatsapp'],
648
+ }],
649
+ });
650
+
651
+ const notices = await client.assignments.listWhatsAppNotifications(
652
+ documentId,
653
+ whatsappAssignment.id,
654
+ );
655
+ // IWhatsAppNotification[]:
656
+ // [{ sent_at, header, body, buttons: [{ text, url? }], phone_number, signer_id }]
657
+ ```
658
+
659
+ The high-level helper selects this paid branch for a signer that has a phone
660
+ number but no email. Button URLs can contain signer credentials; do not log or
661
+ forward them outside the signing flow.
662
+
663
+ #### ICP-Brasil digital certificate
664
+
665
+ `DigitalCertificate` requires the account feature, a CPF or CNPJ in the
666
+ signer's `government_id`, and exactly one certificate signer in that signing
667
+ step. It costs two credits per certificate signer in addition to the selected
668
+ notification cost.
669
+
670
+ ```ts
671
+ const certificateSigner = await client.signers.update(signerId, {
672
+ government_id: '390.533.447-05',
673
+ });
674
+
675
+ const certificateCost = await client.assignments.estimateCost(documentId, {
676
+ method: 'virtual',
677
+ signers: [{ verification_method: 'DigitalCertificate', notification_methods: ['Email'] }],
678
+ });
679
+
680
+ await client.assignments.create(documentId, {
681
+ method: 'virtual',
682
+ signers: [{
683
+ id: certificateSigner.id,
684
+ step: 1,
685
+ verification_method: 'DigitalCertificate',
686
+ notification_methods: ['Email'],
687
+ }],
688
+ });
689
+ ```
690
+
691
+ Before opening the assignment, the signer must confirm identity data and accept
692
+ terms with `confirmData(..., { has_accepted_terms: true })` or `acceptTerms()`.
693
+ The regular `sign()` endpoint rejects certificate signers; they complete the
694
+ ICP-Brasil flow through Assinafy's browser integration. After completion,
695
+ `documents.download(documentId, 'pades')` returns the qualified PAdES artifact.
696
+
284
697
  ### Templates
285
698
 
699
+ `templates.list()` is part of the current OpenAPI document. Existing
700
+ integrations can also use five template-management routes—`create`, `get`,
701
+ `update`, `delete`, and `downloadPage`—that are absent from that document. See
702
+ [compatibility notes](docs/COMPATIBILITY.md#template-management-extensions).
703
+ Template status casing can vary by deployment; normalize with
704
+ `template.status.toLowerCase()` when branching on it.
705
+
286
706
  ```ts
287
707
  // Create a template by uploading a PDF (multipart). The template starts in
288
- // `Uploaded` status and becomes `Ready` once its pages are processed.
708
+ // an uploaded state and becomes ready once its pages are processed.
289
709
  const created = await client.templates.create(
290
710
  { filePath: './nda.pdf' }, // or { buffer, fileName: 'nda.pdf' }
291
711
  { name: 'NDA template' },
@@ -301,24 +721,40 @@ const created = await client.templates.create(
301
721
  const { data, meta } = await client.templates.list({ search: 'NDA', per_page: 20 });
302
722
  const template = await client.templates.get(created.id); // includes pages[] + default_document_tags
303
723
  await client.templates.update(created.id, { name: 'NDA v2', message: 'Please sign' });
304
- await client.templates.downloadPage(created.id, template.pages![0].id); // → Buffer (JPEG)
724
+ const firstPage = template.pages?.[0];
725
+ if (firstPage) await client.templates.downloadPage(created.id, firstPage.id); // → Buffer (JPEG)
305
726
  await client.templates.delete(created.id);
306
727
 
307
- // Create a *document* from a template (each signer maps to a template role)
728
+ // Create a document from an existing, configured template. Fresh uploads have
729
+ // only an Editor role; add signer roles in Assinafy's editor first.
730
+ const configured = await client.templates.get(templateId);
731
+ const signerRole = configured.roles?.find(
732
+ (role) => typeof role.assignment_type === 'string'
733
+ && role.assignment_type.toLowerCase() !== 'editor',
734
+ );
735
+ if (!signerRole) throw new Error('Template has no signer role');
308
736
  await client.documents.createFromTemplate(
309
737
  templateId,
310
- [{ role_id: template.roles![0].id, id: signerId, verification_method: 'Email', notification_methods: ['Email'] }],
738
+ [{ role_id: signerRole.id, id: signerId, verification_method: 'Email', notification_methods: ['Email'] }],
311
739
  { name: 'NDA - John Doe', message: 'Please sign at your earliest convenience.' },
312
740
  );
313
741
 
314
742
  // Estimate the cost before creating → ICostEstimate
315
- await client.documents.estimateCostFromTemplate(templateId, [{ role_id: 'role_id', id: signerId }]);
743
+ await client.documents.estimateCostFromTemplate(templateId, [
744
+ { role_id: 'role_id', verification_method: 'Email', notification_methods: ['Email'] },
745
+ ]);
316
746
  // → { documents: 1, total_credits: 0, document_balance: 67, credit_balance: 0,
317
747
  // has_sufficient_resources: true, blocking_reason: null, breakdown: [], … }
318
748
  ```
319
749
 
750
+ Template signer descriptors also accept
751
+ `verification_method: 'DigitalCertificate'` with the same prerequisites under
752
+ [ICP-Brasil digital certificate](#icp-brasil-digital-certificate).
753
+
320
754
  Template creation only uploads the PDF and provisions the default editor role —
321
755
  configure roles/fields in the Assinafy editor (or the web UI) afterwards.
756
+ The `download_url` values in template page objects are protected URLs; prefer
757
+ `templates.downloadPage()` so the API credential is attached.
322
758
 
323
759
  ### Tags
324
760
 
@@ -337,12 +773,50 @@ Attach/detach tags on a specific document via `client.documents.listTags / repla
337
773
 
338
774
  ### Workspaces
339
775
 
776
+ The official create/update request schemas define `name` and
777
+ `notification_sender_type`. The sandbox also accepts the color fields shown
778
+ below; they are retained as a documented compatibility extension.
779
+
340
780
  ```ts
341
- await client.workspaces.create({ name: 'My Workspace', primary_color: '#ff0066' });
781
+ // Colours are 6-char hex WITHOUT a leading '#' (unlike tags, which strip it).
782
+ // '#ff0066' is rejected — the account endpoints want exactly 6 characters.
783
+ await client.workspaces.create({
784
+ name: 'My Workspace',
785
+ notification_sender_type: 'Account',
786
+ primary_color: 'ff0066',
787
+ secondary_color: '0066ff',
788
+ });
789
+ // → { id, name, primary_color: 'ff0066', secondary_color: '0066ff', created_at }
342
790
  await client.workspaces.list();
343
791
  await client.workspaces.get(accountId);
344
- await client.workspaces.update(accountId, { name: 'Renamed' });
792
+ await client.workspaces.update(accountId, {
793
+ name: 'Renamed',
794
+ notification_sender_type: 'User',
795
+ primary_color: '112233',
796
+ });
797
+
798
+ // Branding
799
+ const theme = await client.workspaces.getTheme(accountId);
800
+ const logo = await client.workspaces.downloadLogo(accountId); // Buffer
801
+ await client.workspaces.uploadLogo(accountId, { filePath: './logo.png' });
802
+ await client.workspaces.uploadLogo(accountId, {
803
+ buffer: logoBuffer,
804
+ fileName: 'logo.png',
805
+ contentType: 'image/png',
806
+ });
807
+ await client.workspaces.deleteLogo(accountId);
808
+
809
+ // Latest 12 months by default; daily statistics require a YYYY-MM month.
810
+ await client.workspaces.getStats(accountId);
811
+ await client.workspaces.getStats(accountId, {
812
+ granularity: 'daily',
813
+ month: '2026-06',
814
+ });
815
+
345
816
  await client.workspaces.delete(accountId);
817
+ // `force` cancels an active paid subscription as part of account deletion. It
818
+ // is not a general bypass for unrelated deletion restrictions.
819
+ await client.workspaces.delete(restrictedAccountId, { force: true });
346
820
  ```
347
821
 
348
822
  ### Field definitions
@@ -363,7 +837,7 @@ await client.fields.validate(fieldId, '400.676.228-36', { signerAccessCode });
363
837
  await client.fields.validateMultiple(
364
838
  [
365
839
  { field_id: 'f1', value: '1111111111111' },
366
- { field_id: 'f2', value: 'foo@bar.com' },
840
+ { field_id: 'f2', value: 'value@example.com' },
367
841
  ],
368
842
  { signerAccessCode },
369
843
  );
@@ -377,8 +851,14 @@ await client.fields.listTypes();
377
851
  Most server-side integrations should just use `X-Api-Key` directly. Use these endpoints when you need to bootstrap a session for a human user.
378
852
 
379
853
  ```ts
854
+ // Browser OAuth: redirect the user to this URL. The callback helper returns the
855
+ // Assinafy callback URL for provider configuration; neither follows a redirect.
856
+ const oauthStart = client.auth.getSocialLoginUrl('google');
857
+ const oauthCallback = client.auth.getSocialLoginCallbackUrl();
858
+
380
859
  const { access_token, user, accounts } = await client.auth.login('me@example.com', 'pw');
381
860
  await client.auth.socialLogin({ provider: 'google', token: 'google-id-token', has_accepted_terms: true });
861
+ await client.auth.linkSocialLogin({ provider: 'google', token: 'google-id-token' });
382
862
 
383
863
  // Personal API key
384
864
  await client.auth.createApiKey('current-password');
@@ -391,12 +871,39 @@ await client.auth.requestPasswordReset('me@example.com');
391
871
  await client.auth.resetPassword({ email, token: 'tk', new_password: 'next' });
392
872
  ```
393
873
 
874
+ ### Authenticated user
875
+
876
+ ```ts
877
+ const user = await client.users.getCurrent();
878
+ // → { id, name, email, telephone, government_id, is_email_verified,
879
+ // has_accepted_terms, created_at, to_be_deleted_at }
880
+
881
+ // Cross-account document funnel, latest 12 monthly periods by default.
882
+ const monthly = await client.users.getStats();
883
+ const daily = await client.users.getStats({
884
+ granularity: 'daily',
885
+ month: '2026-06',
886
+ });
887
+ // Each row includes period, upload/send/certification totals, notification
888
+ // counts for email/WhatsApp/bypass, verification counts for
889
+ // email/WhatsApp/bypass/digital-certificate, viewed, and completed counts.
890
+
891
+ const preferences = await client.users.getNotificationPreferences();
892
+ await client.users.updateNotificationPreferences({
893
+ SignerDeclined: false,
894
+ DocumentExpired: false,
895
+ });
896
+ // Updates merge: omitted keys keep their current value. Both methods return
897
+ // the complete nine-key notification preference map.
898
+ ```
899
+
394
900
  ### Webhooks
395
901
 
396
902
  ```ts
397
903
  await client.webhooks.register({
398
904
  url: 'https://example.com/webhooks/assinafy',
399
905
  email: 'admin@example.com',
906
+ is_active: true,
400
907
  // events defaults to the current SDK default set below
401
908
  events: [
402
909
  'document_ready',
@@ -407,31 +914,122 @@ await client.webhooks.register({
407
914
  ],
408
915
  });
409
916
 
410
- await client.webhooks.get(); // current subscription or null
917
+ await client.webhooks.get(); // IWebhookSubscription | null
411
918
  await client.webhooks.inactivate(); // stop deliveries (no delete route exists)
412
919
  await client.webhooks.listEventTypes();
413
- await client.webhooks.listDispatches({ delivered: false, page: 1, 'per-page': 20 });
414
- await client.webhooks.retryDispatch(dispatchId);
920
+ const history = await client.webhooks.listDispatches({
921
+ delivered: false,
922
+ page: 1,
923
+ 'per-page': 20,
924
+ }); // { data: IWebhookDispatch[], meta?: PaginationMeta }
925
+ const retried = await client.webhooks.retryDispatch(dispatchId); // IWebhookDispatch
415
926
  ```
416
927
 
928
+ `register` sends `{ events, is_active, url, email }` and returns
929
+ `{ events, is_active, url, email, updated_at? }`. Assinafy delivers each event
930
+ as an HTTP `POST` with `Content-Type: application/json` and `Connection: close`.
931
+ Any `2xx` is success. There are at most two automatic attempts, separated by
932
+ three seconds. After ten consecutive failed events, ordinary delivery pauses
933
+ and about 5% of later events are attempted until one succeeds; use
934
+ `retryDispatch()` for an immediate manual redelivery. The dispatch history
935
+ retains only the first 2,000 characters of the receiver's response body.
936
+
937
+ Each history or retry result is an `IWebhookDispatch`:
938
+
939
+ ```ts
940
+ {
941
+ resource?: string;
942
+ id: string;
943
+ event: string;
944
+ activity_id: number;
945
+ endpoint: string | null;
946
+ payload: IWebhookPayload | Record<string, unknown> | null;
947
+ delivered: boolean;
948
+ http_status: number | null;
949
+ response_body: string | null;
950
+ error: string | null;
951
+ created_at: string;
952
+ updated_at?: string;
953
+ }
954
+ ```
955
+
956
+ Every delivery body uses this envelope:
957
+
958
+ ```ts
959
+ {
960
+ id: number; // use for idempotent processing
961
+ event: string;
962
+ message: string | null;
963
+ payload: Record<string, unknown> | null;
964
+ origin: { ip?: string; 'user-agent'?: string } | null;
965
+ created_at: number; // Unix seconds
966
+ subject: { type: 'User' | 'Signer' | 'Account' | 'Document' | 'Template'; [key: string]: unknown };
967
+ object: { type: 'User' | 'Signer' | 'Account' | 'Document' | 'Template'; [key: string]: unknown };
968
+ account_id: string;
969
+ }
970
+ ```
971
+
972
+ Event-specific values are:
973
+
974
+ | `event` | `subject.type` | `object.type` | `payload` keys |
975
+ | --- | --- | --- | --- |
976
+ | `document_uploaded` | `User` | `Document` | — |
977
+ | `document_metadata_ready` | `User` | `Document` | — |
978
+ | `document_prepared` | `User` | `Document` | — |
979
+ | `assignment_created` | `User` | `Document` | `user_name`, `user_email`, `user_telephone` |
980
+ | `document_ready` | `Account` | `Document` | — |
981
+ | `document_processing_failed` | `Account` | `Document` | `error_message` |
982
+ | `signature_requested` | `User` | `Document` | `signer_email`, `signer_full_name`, or `signer_whatsapp_phone_number`, according to channel |
983
+ | `signer_created` | `User` | `Signer` | `signer_full_name` |
984
+ | `signer_email_verified` | `Signer` | `Document` | `signer_email` |
985
+ | `signer_whatsapp_verified` | `Signer` | `Document` | `signer_whatsapp_phone_number` |
986
+ | `signer_data_confirmed` | `Signer` | `Document` | `signer_email` |
987
+ | `signer_viewed_document` | `Signer` | `Document` | `signer_full_name` |
988
+ | `signer_signed_document` | `Signer` | `Document` | `signer_full_name` |
989
+ | `signer_rejected_document` | `Signer` | `Document` | `signer_full_name` |
990
+ | `user_rejected_document` | `User` | `Document` | `user_name` |
991
+ | `template_created` | `User` | `Template` | — |
992
+ | `template_processed` | `User` | `Template` | — |
993
+ | `template_processing_failed` | `Account` | `Template` | `error_message` |
994
+
995
+ `payload`, `subject`, and `object` are event-dependent. Accept unknown fields
996
+ for forward compatibility and acknowledge only after durable, idempotent
997
+ processing. Non-`2xx` responses, timeouts, and connection failures all count as
998
+ failed deliveries. `assignment_created` and `document_metadata_ready` have no
999
+ guaranteed ordering. For account entities, Assinafy removes the `integration`
1000
+ property before delivery.
1001
+
417
1002
  ### Webhook verification
418
1003
 
419
- Webhook payloads are signed with HMAC-SHA256 of the raw body using the workspace `webhookSecret`. Assinafy sends the hex digest in the `X-Assinafy-Signature` header.
1004
+ `WebhookVerifier` is an opt-in HMAC-SHA256 utility for integrations whose
1005
+ Assinafy environment provides a shared secret and signature header. The
1006
+ current official OpenAPI document does **not** define a webhook signature
1007
+ scheme or header name. Confirm the delivery contract for your environment
1008
+ before enabling this check; do not reject production callbacks based on an
1009
+ assumed header. The example below uses an application-configured header name.
420
1010
 
421
1011
  ```ts
422
1012
  import express from 'express';
423
1013
 
1014
+ const webhookSecret = process.env.ASSINAFY_WEBHOOK_SECRET;
1015
+ const signatureHeader = process.env.ASSINAFY_SIGNATURE_HEADER;
1016
+ if (!webhookSecret || !signatureHeader) {
1017
+ throw new Error('This deployment has no confirmed webhook-signature contract');
1018
+ }
1019
+
1020
+ const webhookClient = new AssinafyClient({ webhookSecret });
1021
+
424
1022
  app.post('/webhooks/assinafy', express.raw({ type: 'application/json' }), (req, res) => {
425
- const signature = req.header('x-assinafy-signature') ?? '';
1023
+ const signature = req.header(signatureHeader) ?? '';
426
1024
  const rawBody = req.body as Buffer;
427
1025
 
428
- if (!client.webhookVerifier.verify(rawBody, signature)) {
1026
+ if (!webhookClient.webhookVerifier.verify(rawBody, signature)) {
429
1027
  return res.status(401).send('Invalid signature');
430
1028
  }
431
1029
 
432
- const event = client.webhookVerifier.extractEvent(rawBody);
433
- const type = client.webhookVerifier.getEventType(event);
434
- const data = client.webhookVerifier.getEventData(event);
1030
+ const event = webhookClient.webhookVerifier.extractEvent(rawBody);
1031
+ const type = webhookClient.webhookVerifier.getEventType(event);
1032
+ const data = webhookClient.webhookVerifier.getEventData(event);
435
1033
 
436
1034
  switch (type) {
437
1035
  case 'document_ready': break;
@@ -445,66 +1043,98 @@ app.post('/webhooks/assinafy', express.raw({ type: 'application/json' }), (req,
445
1043
 
446
1044
  ### Signer-side endpoints
447
1045
 
448
- For building custom signer portals. Every call requires the `signer-access-code` URL parameter that Assinafy emails/whatsapps to the signer.
1046
+ For building custom signer portals. Most calls require the `signer-access-code`
1047
+ URL parameter that Assinafy emails/whatsapps to the signer. Artifact download is
1048
+ the documented public exception; its optional fourth access-code argument exists
1049
+ only for compatibility with deployments that still expect the legacy query.
449
1050
 
450
1051
  ```ts
451
1052
  await client.signerDocuments.self(accessCode);
452
- await client.signerDocuments.acceptTerms(accessCode);
453
1053
  await client.signerDocuments.verifyEmail({ signerAccessCode: accessCode, verificationCode: '123456' });
454
1054
 
455
1055
  await client.signerDocuments.getCurrent(signerId, accessCode);
456
- const { data } = await client.signerDocuments.list(signerId, accessCode, { search: 'invoice' });
1056
+ const { data } = await client.signerDocuments.list(signerId, accessCode, { per_page: 20 });
457
1057
  // Signer-side counterpart of documents.search(), authorised by the access code.
458
1058
  const found = await client.signerDocuments.search(signerId, accessCode, 'invoice');
459
- await client.signerDocuments.download(signerId, documentId, 'original', accessCode);
1059
+ await client.signerDocuments.download(signerId, documentId, 'original');
1060
+ // Available only after an ICP-Brasil certificate signer completes signing.
1061
+ await client.signerDocuments.download(signerId, documentId, 'pades');
460
1062
 
461
1063
  await client.signerDocuments.confirmData(documentId, accessCode, {
462
1064
  email: 'me@example.com',
463
- whatsapp_phone_number: '+5548999990000',
1065
+ full_name: 'Example Signer',
1066
+ government_id: '123.456.789-00',
464
1067
  has_accepted_terms: true,
465
1068
  });
1069
+ // Alternatively, accept terms separately before getAssignment():
1070
+ // await client.signerDocuments.acceptTerms(accessCode);
466
1071
 
467
- // Signature image management
468
- await client.signerDocuments.uploadSignature(accessCode, pngBuffer, { imageType: 'signature' });
1072
+ // Signature image management ({ reuse: true } persists it for future documents)
1073
+ await client.signerDocuments.uploadSignature(accessCode, pngBuffer, { imageType: 'signature', reuse: true });
469
1074
  await client.signerDocuments.downloadSignature(accessCode, 'signature');
470
1075
 
471
1076
  // Sign / decline
472
- const assignment = await client.signerDocuments.getAssignment(accessCode);
1077
+ const signable = await client.signerDocuments.getAssignment(accessCode);
1078
+ // `sign()` is for collect assignments and requires every placed field value.
473
1079
  await client.signerDocuments.sign(documentId, assignmentId, accessCode, [
474
1080
  { itemId, fieldId, pageId, value: 'Signed by John' },
475
1081
  ]);
476
1082
  await client.signerDocuments.decline(documentId, assignmentId, accessCode, 'Not authorized');
477
1083
 
478
- // Bulk operations
1084
+ // `signMultiple()` is for virtual assignments only.
479
1085
  await client.signerDocuments.signMultiple(['doc-1', 'doc-2'], accessCode);
480
1086
  await client.signerDocuments.declineMultiple(['doc-1'], 'Unfavorable terms', accessCode);
481
1087
  ```
482
1088
 
1089
+ `sign()` also requires virtual signers to have confirmed their data first, but
1090
+ virtual assignments should normally use `signMultiple()`. Certificate signers
1091
+ cannot use `sign()`; see [Paid signing branches](#paid-signing-branches).
1092
+
483
1093
  ## High-level helper
484
1094
 
485
- Uploads a PDF, waits for processing, reuses or creates signers by email, and kicks off a virtual assignment.
1095
+ Uploads a PDF, reuses or creates signers by email, creates a virtual assignment
1096
+ immediately, and optionally waits for processing before returning.
486
1097
 
487
1098
  ```ts
488
1099
  const result = await client.uploadAndRequestSignatures({
489
1100
  source: { filePath: './contract.pdf' },
490
1101
  signers: [
491
1102
  { name: 'John', email: 'john@example.com' },
492
- { name: 'Jane', email: 'jane@example.com', whatsapp_phone_number: '+5548999990000' },
1103
+ { name: 'Jane', email: 'jane@example.com' },
493
1104
  ],
494
1105
  message: 'Please sign',
495
- metadata: { year: 2026 },
1106
+ metadata: { year: 2026 }, // compatibility upload part; omit for file-only wire format
496
1107
  waitForReady: true,
497
- expiresAt: '2026-12-31T00:00:00Z',
1108
+ waitOptions: { maxWaitMs: 30_000, pollIntervalMs: 1_000 },
1109
+ expiresAt: '2027-12-31T00:00:00Z',
1110
+ copyReceivers: ['existing-copy-recipient-signer-id'],
498
1111
  });
499
1112
 
500
- result.document; // IDocumentUploadResponse
1113
+ result.document; // fully-processed IDocumentDetailsResponse (waitForReady: true, the default);
1114
+ // the raw IDocumentUploadResponse when waitForReady: false
501
1115
  result.assignment; // IAssignment
502
1116
  result.signer_ids; // string[]
503
1117
  ```
504
1118
 
1119
+ `waitForReady: false` skips post-assignment polling and returns the initial
1120
+ upload response. With the default `true`, the helper creates the assignment first and
1121
+ then waits for the current document details. Both production and sandbox allow
1122
+ virtual assignments in `uploaded` and `metadata_processing` and promote them
1123
+ automatically; only `collect` assignments require rendered pages.
1124
+ Every signer above uses the default email channel. A phone-only signer selects
1125
+ the paid WhatsApp branch described earlier. `copyReceivers` accepts existing
1126
+ signer IDs, not email addresses; check the returned assignment before treating
1127
+ a copy receiver as registered.
1128
+
1129
+ The helper is not transactional. A post-assignment polling error includes the
1130
+ created `documentId`, `assignmentId`, and `signerIds` in its `context` (and in
1131
+ `ValidationError.errors` for timeouts); inspect those IDs before deciding
1132
+ whether to retry the workflow.
1133
+
505
1134
  ## Errors
506
1135
 
507
- Every method rejects with an `AssinafyError` subclass.
1136
+ HTTP methods reject with an `AssinafyError` subclass. Synchronous helpers such
1137
+ as `getSocialLoginUrl()` can throw `ValidationError` before any request.
508
1138
 
509
1139
  ```ts
510
1140
  import { ApiError, ValidationError, NetworkError, AssinafyError } from '@assinafy/sdk';
@@ -524,27 +1154,17 @@ try {
524
1154
  }
525
1155
  ```
526
1156
 
527
- ## Live smoke test
528
-
529
- A real-network test script under [`scripts/live-smoke.ts`](scripts/live-smoke.ts) exercises the full API. Use it to sanity-check a workspace before shipping.
530
-
531
- ```bash
532
- ASSINAFY_API_KEY=… ASSINAFY_ACCOUNT_ID=… bun scripts/live-smoke.ts # read-only
533
- ASSINAFY_API_KEY=… ASSINAFY_ACCOUNT_ID=… bun scripts/live-smoke.ts --write # also creates+deletes a signer
534
- ASSINAFY_API_KEY=… ASSINAFY_ACCOUNT_ID=… bun scripts/live-smoke.ts --upload # also uploads a PDF + a template, then deletes both
535
- ```
536
-
537
- Set `ASSINAFY_BASE_URL=https://sandbox.assinafy.com.br/v1` to run it against the
538
- sandbox instead of production.
539
-
540
1157
  ## Development
541
1158
 
542
1159
  ```bash
543
- bun install # or npm install
544
- bun test # runs bun:test suites (Bun is required for tests)
545
- npm run typecheck # tsc --noEmit
546
- npm run lint
547
- npm run build # tsup → dist/ (CJS + ESM + .d.ts)
1160
+ bun install --frozen-lockfile
1161
+ bun run typecheck # source, script, and test type checks
1162
+ bun run lint
1163
+ bun test # bun:test suites
1164
+ bun run test:coverage
1165
+ bun run build # tsup → dist/ (CJS + ESM + .d.ts)
1166
+ bun run lint:pkg # publint + arethetypeswrong
1167
+ bun run verify # complete local release gate
548
1168
  ```
549
1169
 
550
1170
  ## License