@assinafy/sdk 2.1.2 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md ADDED
@@ -0,0 +1,1373 @@
1
+ # @assinafy/sdk
2
+
3
+ *[Leia em português](README.md) · English*
4
+
5
+ TypeScript SDK for the [Assinafy API](https://api.assinafy.com.br/v1/docs) — a Brazilian digital signature platform.
6
+
7
+ Covers all 93 operations in the current official OpenAPI document: accounts,
8
+ authentication, OAuth 2.1 applications, users, documents, assignments, signers,
9
+ signer-side flows, templates, tags, fields, webhooks, branding, statistics, and
10
+ the high-level `uploadAndRequestSignatures` workflow. Five additional
11
+ template-management routes used by existing integrations and two legacy browser
12
+ URL helpers are retained for compatibility.
13
+
14
+ See [API coverage](docs/API_COVERAGE.md) for the operation map and
15
+ [compatibility notes](docs/COMPATIBILITY.md) for deployment-specific request
16
+ and response variants.
17
+
18
+ ## Contents
19
+
20
+ This document runs from setup to a complete signature workflow, then to
21
+ per-resource detail. Read it in order the first time; use it as a reference
22
+ afterwards.
23
+
24
+ **Getting set up** — [Requirements](#requirements) ·
25
+ [Installation](#installation) · [Quick start](#quick-start) ·
26
+ [Authentication](#authentication) ·
27
+ [OAuth applications](#oauth-applications) · [Configuration](#configuration)
28
+ ([rate limiting](#rate-limiting), [factories](#factories)) ·
29
+ [Endpoint coverage](#endpoint-coverage)
30
+
31
+ **The end-to-end flow** — [Document lifecycle](#document-lifecycle):
32
+ [upload](#1-upload-the-pdf) → [signers](#2-create-or-reuse-the-email-signers) →
33
+ [price and request signatures](#3-price-then-request-signatures) →
34
+ [the signer's side](#4-complete-the-email-signer-flow) →
35
+ [completion and artifacts](#5-observe-completion-and-download-artifacts)
36
+
37
+ **Per-resource detail** — [Resource reference](#resource-reference):
38
+ [documents](#documents) · [signers](#signers) · [assignments](#assignments) ·
39
+ [paid signing branches](#paid-signing-branches) · [templates](#templates) ·
40
+ [tags](#tags) · [workspaces](#workspaces) ·
41
+ [field definitions](#field-definitions) ·
42
+ [auth and API keys](#authentication--api-key-management) ·
43
+ [the current user](#authenticated-user) · [webhooks](#webhooks)
44
+ ([verification](#webhook-verification)) ·
45
+ [signer-side endpoints](#signer-side-endpoints)
46
+
47
+ **Everything else** — [High-level helper](#high-level-helper) ·
48
+ [Errors](#errors) · [Development](#development) · [License](#license)
49
+
50
+ ## Requirements
51
+
52
+ - Node.js 22+ for the built-in `FormData` / `Blob` APIs used by uploads. Packed
53
+ CJS and ESM imports are tested on 22 (maintenance LTS), 24 (active LTS), and
54
+ 26 (Current); Node 20 reached end-of-life in April 2026 and is unsupported.
55
+ - or Bun 1.4.0 (the version pinned for development and CI)
56
+
57
+ ## Installation
58
+
59
+ ```bash
60
+ npm install @assinafy/sdk
61
+ # or
62
+ bun add @assinafy/sdk
63
+ ```
64
+
65
+ The package is published to both [npmjs.com](https://www.npmjs.com/package/@assinafy/sdk) and [GitHub Packages](https://github.com/assinafy/typescript-sdk/packages). To install from GitHub Packages, add to your `.npmrc`:
66
+
67
+ ```
68
+ @assinafy:registry=https://npm.pkg.github.com
69
+ //npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}
70
+ ```
71
+
72
+ ## Quick start
73
+
74
+ ```ts
75
+ import { AssinafyClient } from '@assinafy/sdk';
76
+
77
+ const baseUrl = process.env.ASSINAFY_BASE_URL ?? 'https://api.assinafy.com.br/v1';
78
+ const client = new AssinafyClient({
79
+ apiKey: process.env.ASSINAFY_API_KEY!,
80
+ accountId: process.env.ASSINAFY_ACCOUNT_ID!,
81
+ baseUrl,
82
+ });
83
+
84
+ const result = await client.uploadAndRequestSignatures({
85
+ source: { filePath: './contract.pdf' },
86
+ signers: [
87
+ { name: 'John Doe', email: 'john@example.com' },
88
+ { name: 'Jane Smith', email: 'jane@example.com' },
89
+ ],
90
+ message: 'Please sign this contract',
91
+ });
92
+
93
+ console.log('Document ID:', result.document.id);
94
+ console.log('Assignment ID:', result.assignment.id);
95
+ ```
96
+
97
+ This path uses email verification and notification for every signer. WhatsApp
98
+ and ICP-Brasil certificate signing have separate prerequisites and costs; see
99
+ [Paid signing branches](#paid-signing-branches) before enabling either one.
100
+
101
+ ## Authentication
102
+
103
+ The API supports three credentials. Which one you need depends on *whose*
104
+ workspace you are acting in.
105
+
106
+ | Credential | Acts on | Choose it when |
107
+ | --- | --- | --- |
108
+ | `apiKey` (`X-Api-Key`) | **Your own** workspace | You automate your own account. Recommended for back-end services. |
109
+ | `token` (`Authorization: Bearer`) | The logged-in user | You bootstrapped a session with `auth.login()`. |
110
+ | OAuth access token (`Authorization: Bearer`) | **Someone else's** workspace, with their permission | You build an app other people connect. See [OAuth applications](#oauth-applications). |
111
+
112
+ Prefer `apiKey` for a server-to-server integration — it maps to the `X-Api-Key`
113
+ header recommended by Assinafy for backend services.
114
+
115
+ ```ts
116
+ // Preferred: X-Api-Key header
117
+ new AssinafyClient({ apiKey: 'k_xxx', accountId: 'acc_xxx' });
118
+
119
+ // Access token: Authorization: Bearer <token>
120
+ new AssinafyClient({ token: 'jwt_xxx', accountId: 'acc_xxx' });
121
+ ```
122
+
123
+ Credentials are optional at construction time. A credentialless client uses a
124
+ separate, auth-free transport for public authentication and signer-access-code
125
+ operations, so an API key or Bearer token is never attached accidentally:
126
+
127
+ ```ts
128
+ const publicClient = new AssinafyClient({
129
+ baseUrl: 'https://sandbox.assinafy.com.br/v1',
130
+ });
131
+
132
+ await publicClient.auth.login('me@example.com', 'password');
133
+ await publicClient.documents.getPublic(documentId);
134
+ await publicClient.signerDocuments.self(signerAccessCode);
135
+ ```
136
+
137
+ Protected methods still require `apiKey` or `token`; the API returns its normal
138
+ `401` response if one is called without credentials.
139
+
140
+ Every SDK transport, including public and signer-access-code requests, sends
141
+ `User-Agent: Assinafy-Typescript-SDK/v<VERSION>`, where `<VERSION>` is the
142
+ installed package version. The exact value is also exported as
143
+ `SDK_USER_AGENT` for custom transport checks and observability rules.
144
+
145
+ ## OAuth applications
146
+
147
+ Use OAuth when your product is connected by *its users* to *their* Assinafy
148
+ workspaces, so you never hold their password or API key. Automating your own
149
+ workspace needs none of this — keep using an API key.
150
+
151
+ Register the application in the Assinafy app under **Settings → OAuth
152
+ applications → New application**. You choose its redirect URIs (exact-match
153
+ `https://`, no fragment), the maximum permissions it may ever request, and
154
+ whether it is **confidential** (runs on your server, gets a `client_secret`) or
155
+ **public** (runs on the user's device, PKCE only). Applications cannot be
156
+ created through the API.
157
+
158
+ The flow spans two hosts on purpose: the consent page lives on the
159
+ authorization server (`https://auth.assinafy.com.br`) while the token,
160
+ revocation and userinfo endpoints live on this API. Read both from discovery
161
+ rather than hardcoding them.
162
+
163
+ ```ts
164
+ import { AssinafyClient, OAuthError } from '@assinafy/sdk';
165
+
166
+ const client = new AssinafyClient(); // no credentials needed
167
+
168
+ // 1 — before redirecting the user. Store the whole request in their session:
169
+ // `state` and `issuer` prove the callback is yours, `codeVerifier`
170
+ // completes PKCE, `nonce` validates the id_token.
171
+ const request = await client.oauth.createAuthorizationUrl({
172
+ clientId: process.env.ASSINAFY_CLIENT_ID!,
173
+ redirectUri: 'https://myapp.com/oauth/callback',
174
+ scopes: ['documents:read', 'documents:write', 'offline_access'],
175
+ });
176
+ session.oauth = request;
177
+ response.redirect(request.url); // full page navigation
178
+
179
+ // 2 — on https://myapp.com/oauth/callback
180
+ const { code } = client.oauth.readAuthorizationCallback(query, session.oauth);
181
+ const tokens = await client.oauth.exchangeCode({
182
+ code,
183
+ codeVerifier: session.oauth.codeVerifier,
184
+ redirectUri: 'https://myapp.com/oauth/callback',
185
+ clientId: process.env.ASSINAFY_CLIENT_ID!,
186
+ clientSecret: process.env.ASSINAFY_CLIENT_SECRET, // confidential apps only
187
+ });
188
+ // → { access_token, token_type: 'Bearer', expires_in: 3600,
189
+ // scope: 'documents:read documents:write',
190
+ // refresh_token?, id_token? }
191
+
192
+ // 3 — a token covers exactly ONE workspace: the one the user picked.
193
+ const connected = new AssinafyClient({ token: tokens.access_token });
194
+ const { data } = await connected.workspaces.list();
195
+ const accountId = data[0]?.id; // store it with the tokens
196
+
197
+ // 4 — renew before the hour is up (needs `offline_access`)
198
+ const renewed = await client.oauth.refreshToken({
199
+ refreshToken: connection.refreshToken,
200
+ clientId: process.env.ASSINAFY_CLIENT_ID!,
201
+ clientSecret: process.env.ASSINAFY_CLIENT_SECRET,
202
+ });
203
+ await connection.save({ refreshToken: renewed.refresh_token }); // BEFORE using it
204
+
205
+ // 5 — when the user disconnects
206
+ await client.oauth.revokeToken({
207
+ token: connection.refreshToken,
208
+ tokenTypeHint: 'refresh_token',
209
+ clientId: process.env.ASSINAFY_CLIENT_ID!,
210
+ clientSecret: process.env.ASSINAFY_CLIENT_SECRET,
211
+ });
212
+ ```
213
+
214
+ Discovery and identity, when you need them:
215
+
216
+ ```ts
217
+ await client.oauth.getProtectedResourceMetadata(); // RFC 9728, at the host root
218
+ await client.oauth.getAuthorizationServerMetadata(); // RFC 8414, on auth.assinafy.com.br
219
+ await client.oauth.getUserInfo(tokens.access_token); // OIDC claims; needs `openid`
220
+ ```
221
+
222
+ ### Scopes
223
+
224
+ | Scope | Lets your app |
225
+ | --- | --- |
226
+ | `documents:read` | Read documents, their signers, assignments and activity |
227
+ | `documents:write` | Create documents and send them for signature |
228
+ | `templates:read` | Read templates |
229
+ | `templates:write` | Create and change templates |
230
+ | `account:read` | Read the workspace's profile, theme and logo |
231
+ | `openid` | Receive an `id_token` identifying the user |
232
+ | `profile` | Read the user's name |
233
+ | `email` | Read the user's email and whether it is verified |
234
+ | `offline_access` | Receive a refresh token |
235
+
236
+ Request the minimum: the user approves all of them or none. Read the `scope`
237
+ returned by the token endpoint instead of assuming the request was honoured in
238
+ full — `offline_access` never appears there, because it is a request-time
239
+ signal rather than a permission. Billing, workspace membership, credentials and
240
+ administration are never reachable with an OAuth token, whatever its scopes.
241
+
242
+ ### What the SDK enforces for you
243
+
244
+ - A fresh RFC 7636 verifier (S256) and `state` per attempt.
245
+ - `state` compared in constant time, and `iss` checked (RFC 9207), before the
246
+ response is trusted at all.
247
+ - The RFC 8414 document's own `issuer` matched against the URL it came from.
248
+ - `redirect_uri` required to be absolute `https://` with no fragment.
249
+ - The RFC 8707 `resource` indicator defaulted to the configured API origin, and
250
+ kept identical between the authorize and token legs.
251
+
252
+ ### Handling failure
253
+
254
+ ```ts
255
+ try {
256
+ await connected.documents.upload({ filePath: './contract.pdf' });
257
+ } catch (error) {
258
+ if (error instanceof ApiError && error.challenge?.error === 'insufficient_scope') {
259
+ // Reconnect asking for error.challenge.scope, e.g. 'documents:write'.
260
+ }
261
+ }
262
+ ```
263
+
264
+ | Situation | What you see | What to do |
265
+ | --- | --- | --- |
266
+ | The user declined | `OAuthError` with `error: 'access_denied'` from `readAuthorizationCallback` | Nothing; tell the user |
267
+ | Code spent, expired (60 s) or mismatched | `OAuthError` `invalid_grant` | Restart the authorization flow |
268
+ | Refresh token replayed or expired | `OAuthError` `invalid_grant` | The whole connection ended; ask the user to reconnect |
269
+ | Wrong `client_id` / secret, app disabled | `OAuthError` `invalid_client` | Fix configuration; retrying will not help |
270
+ | Token expired or revoked | `ApiError` `401` | Refresh; if that fails, reconnect |
271
+ | Missing permission | `ApiError` `403` with `challenge.error === 'insufficient_scope'` | Reconnect requesting `challenge.scope` |
272
+ | `403` without a challenge | `ApiError` `403` | Another workspace, or a surface OAuth cannot reach |
273
+
274
+ Refresh tokens **rotate**: every refresh returns a new one and retires the old
275
+ one, and replaying a retired token ends the entire connection. Persist the new
276
+ value before using the response, treat a timeout as "it may have worked" and
277
+ re-read your stored token instead of retrying blindly, and never refresh one
278
+ connection twice concurrently. A connection lasts 30 days from approval however
279
+ often it is refreshed, and the authorize/token endpoints accept 50 requests per
280
+ minute per IP.
281
+
282
+ AI assistants such as Claude, Claude Code and ChatGPT connect to Assinafy
283
+ through their own connector settings; your users do not need you to register
284
+ anything for them.
285
+
286
+ ## Configuration
287
+
288
+ | Option | Type | Default | Description |
289
+ | --------------- | -------- | --------------------------------------- | --------------------------------------------- |
290
+ | `apiKey` | string | — | Preferred credential (sent as `X-Api-Key`). |
291
+ | `token` | string | — | Access token (sent as `Authorization: Bearer`). |
292
+ | `accountId` | string | — | Default workspace/account ID. |
293
+ | `baseUrl` | string | `https://api.assinafy.com.br/v1` | Absolute API base without credentials, query, or fragment. Must be `https` unless the host is loopback. |
294
+ | `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). |
295
+ | `timeout` | number | `30000` | Request timeout in milliseconds. |
296
+ | `maxRetries` | number | `2` | Auto-retries eligible HTTP 429 responses, honoring `Retry-After`. `0` disables. |
297
+ | `logger` | `Logger` | no-op | Optional `{debug,info,warn,error}` logger. |
298
+
299
+ ### Rate limiting
300
+
301
+ On an HTTP `429`, the client automatically retries up to `maxRetries` times,
302
+ waiting for the server-provided `Retry-After` (or `X-Rate-Limit-Reset`) delay
303
+ before each attempt. Automatic replay is limited to read-safe `GET`, `HEAD`,
304
+ `OPTIONS`, and `DELETE` requests. `GET /sign` is excluded because it records
305
+ that the signer viewed the assignment. Writes are not replayed by default.
306
+ A non-empty `Idempotency-Key` opts a custom request into SDK replay, but it is
307
+ not part of the current Assinafy OpenAPI contract: confirm that the target
308
+ route deduplicates that key server-side first. No other HTTP status is retried.
309
+
310
+ ### Factories
311
+
312
+ ```ts
313
+ // Positional factory
314
+ const client = AssinafyClient.create('api-key', 'account-id');
315
+
316
+ // From a plain object (accepts snake_case or camelCase keys)
317
+ const client = AssinafyClient.fromConfig({
318
+ api_key: process.env.ASSINAFY_API_KEY!,
319
+ account_id: process.env.ASSINAFY_ACCOUNT_ID!,
320
+ });
321
+ ```
322
+
323
+ ## Endpoint coverage
324
+
325
+ All 93 operations documented at https://api.assinafy.com.br/v1/docs are
326
+ covered. The table below is the resource-level summary; the detailed operation
327
+ ledger is in [docs/API_COVERAGE.md](docs/API_COVERAGE.md).
328
+
329
+ | Resource | Endpoints |
330
+ | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
331
+ | `client.documents` | list, **search**, upload, details, get, **rename**, activities, waitUntilReady, download, thumbnail, downloadPage, statuses, delete, verify, createFromTemplate, estimateCostFromTemplate, getPublic, sendToken, listTags, replaceTags, addTags, detachTag, isFullySigned, getSigningProgress |
332
+ | `client.signers` | create, get, list, update, delete, findByEmail |
333
+ | `client.assignments` | **list**, create, estimateCost, resetExpiration, resendNotification, estimateResendCost, listWhatsAppNotifications |
334
+ | `client.templates` | **create**, list, get, **update**, **delete**, downloadPage |
335
+ | `client.tags` | list, create, update, delete |
336
+ | `client.workspaces` | create, list, get, update, delete, getTheme, downloadLogo, uploadLogo, deleteLogo, getStats |
337
+ | `client.webhooks` | register, get, inactivate, listEventTypes, listDispatches, retryDispatch |
338
+ | `client.fields` | create, list, get, update, delete, validate, validateMultiple, listTypes |
339
+ | `client.oauth` | **getProtectedResourceMetadata**, **getAuthorizationServerMetadata**, **createAuthorizationUrl**, **readAuthorizationCallback**, **exchangeCode**, **refreshToken**, **revokeToken**, **getUserInfo** |
340
+ | `client.auth` | getSocialLoginUrl, getSocialLoginCallbackUrl, login, socialLogin, linkSocialLogin, createApiKey, getApiKey, deleteApiKey, changePassword, requestPasswordReset, resetPassword |
341
+ | `client.users` | getCurrent, getStats, getNotificationPreferences, updateNotificationPreferences |
342
+ | `client.signerDocuments` | getCurrent, list, **search**, download, signMultiple, declineMultiple, self, acceptTerms, verifyEmail, confirmData, uploadSignature, downloadSignature, getAssignment, sign, decline |
343
+ | `client.webhookVerifier` | verify, extractEvent, getEventType, getEventData |
344
+
345
+ Every HTTP wrapper has TypeScript-checked request/response shapes and
346
+ method-level JSDoc covering the wire payload, return shape, validation,
347
+ relevant API errors, and a copyable example. Reusable and OpenAPI schema-level
348
+ payloads are exported as named types; small method-local option bags remain
349
+ inline in the generated declarations. Editors expose the reference on hover,
350
+ and declaration files ship with the package. The coverage ledger links those
351
+ typed methods back to each upstream operation without duplicating the schema.
352
+
353
+ ## Document lifecycle
354
+
355
+ The normal integration has an account-owner phase, a signer phase, and a final
356
+ artifact phase. The example below keeps every signer on email and uses a
357
+ `virtual` assignment, so no page coordinates or paid notification channel are
358
+ required.
359
+
360
+ ### 1. Upload the PDF
361
+
362
+ ```ts
363
+ const uploaded = await client.documents.upload({ filePath: './contract.pdf' });
364
+ ```
365
+
366
+ The official multipart body contains the `file` part. The SDK also supports a
367
+ display-name override (used as that file part's filename) and an optional JSON
368
+ `metadata` part for deployments that accept it. A successful response is
369
+ `IDocumentUploadResponse`:
370
+
371
+ ```ts
372
+ {
373
+ resource?: string;
374
+ id: string;
375
+ account_id: string;
376
+ template_id: string | null;
377
+ name: string;
378
+ status: DocumentStatus;
379
+ assignment?: IAssignment | null;
380
+ artifacts: {
381
+ original: string;
382
+ certificated?: string;
383
+ 'certificate-page'?: string;
384
+ pades?: string;
385
+ bundle?: string;
386
+ thumbnail?: string;
387
+ };
388
+ signing_url?: string;
389
+ pages: Array<{ id: string; number: number; height: number; width: number; download_url: string }>;
390
+ tags?: Array<{ id: string; name: string; color?: string | null }>;
391
+ created_at: string;
392
+ updated_at: string;
393
+ is_closed: boolean;
394
+ decline_reason: string | null;
395
+ declined_by: ISigner | null;
396
+ }
397
+ ```
398
+
399
+ `DocumentStatus` covers `uploading`, `uploaded`, `metadata_processing`,
400
+ `metadata_ready`, `pending_signature`, `expired`, `certificating`,
401
+ `certificated`, `rejected_by_signer`, `rejected_by_user`, and `failed`.
402
+
403
+ Uploads must be PDFs, at most 25 MB and at most 2,000 pages. The SDK checks the
404
+ extension, size, and `%PDF-` header before sending. A new upload can have an
405
+ empty `pages` array until metadata processing finishes. Wait before creating a
406
+ `collect` assignment because its fields refer to rendered page IDs; a
407
+ `virtual` assignment may be created immediately.
408
+
409
+ ```ts
410
+ const prepared = await client.documents.waitUntilReady(uploaded.id, {
411
+ maxWaitMs: 30_000,
412
+ pollIntervalMs: 2_000,
413
+ });
414
+ ```
415
+
416
+ ### 2. Create or reuse the email signers
417
+
418
+ ```ts
419
+ const signerA = await client.signers.create({
420
+ full_name: 'John Doe',
421
+ email: 'john@example.com',
422
+ });
423
+ const signerB = await client.signers.create({
424
+ full_name: 'Jane Smith',
425
+ email: 'jane@example.com',
426
+ });
427
+ ```
428
+
429
+ The wire body is `{ full_name, email }`. Each response is an `ISigner`:
430
+
431
+ ```ts
432
+ {
433
+ resource?: string;
434
+ id: string;
435
+ full_name: string;
436
+ email: string | null;
437
+ whatsapp_phone_number?: string | null;
438
+ cpf?: string | null; // compatibility type; not echoed by the API
439
+ has_accepted_terms?: boolean;
440
+ has_signature?: boolean; // signer-self response only
441
+ has_initial?: boolean; // signer-self response only
442
+ is_signature_reusable?: boolean; // signer-self response only
443
+ metadata?: Record<string, unknown>;
444
+ }
445
+ ```
446
+
447
+ When an email is present, `signers.create()` first looks up that email in the
448
+ workspace and reuses the matching signer; a name-only or phone-only request
449
+ always creates a new signer.
450
+
451
+ ### 3. Price, then request signatures
452
+
453
+ Cost estimation takes channel descriptors, not signer IDs:
454
+
455
+ ```ts
456
+ const estimate = await client.assignments.estimateCost(uploaded.id, {
457
+ method: 'virtual',
458
+ signers: [{}, {}], // `{}` selects Email for each signer
459
+ });
460
+
461
+ if (!estimate.has_sufficient_resources) {
462
+ throw new Error(estimate.blocking_reason ?? estimate.message ?? 'Insufficient resources');
463
+ }
464
+ ```
465
+
466
+ The response is `ICostEstimate`:
467
+
468
+ ```ts
469
+ {
470
+ documents: number;
471
+ credits: number;
472
+ needs_extra_document: boolean;
473
+ extra_document_cost: number;
474
+ total_credits: number;
475
+ breakdown: Array<{ code: string; name: string; cost: number; quantity?: number; unit_cost?: number }>;
476
+ document_balance: number;
477
+ credit_balance: number;
478
+ has_sufficient_resources: boolean;
479
+ blocking_reason: 'PendingPayment' | 'InsufficientDocuments' | 'InsufficientCredits' | null;
480
+ message: string | null;
481
+ }
482
+ ```
483
+
484
+ Create the email assignment only after accepting that estimate:
485
+
486
+ ```ts
487
+ const assignment = await client.assignments.create(uploaded.id, {
488
+ method: 'virtual',
489
+ signers: [
490
+ { id: signerA.id, verification_method: 'Email', notification_methods: ['Email'] },
491
+ { id: signerB.id, verification_method: 'Email', notification_methods: ['Email'] },
492
+ ],
493
+ message: 'Please review and sign',
494
+ expires_at: '2027-12-31T23:59:00Z',
495
+ });
496
+ ```
497
+
498
+ The request returns an `IAssignment`:
499
+
500
+ ```ts
501
+ {
502
+ resource?: string;
503
+ id: string;
504
+ sender_email?: string;
505
+ method: 'virtual' | 'collect';
506
+ expires_at?: string | null;
507
+ expiration?: string;
508
+ message?: string | null;
509
+ signers: IAssignmentSigner[];
510
+ copy_receivers?: Array<Record<string, unknown>>;
511
+ items?: IAssignmentItem[];
512
+ summary?: {
513
+ signer_count: number;
514
+ completed_count: number;
515
+ signers: Array<ISigner & { completed?: boolean }>;
516
+ };
517
+ signing_urls?: Array<{ signer_id: string; url: string }>;
518
+ }
519
+ ```
520
+
521
+ The URLs and delivered messages contain signer credentials; treat them as
522
+ secrets.
523
+
524
+ ### 4. Complete the email signer flow
525
+
526
+ Assinafy sends each signer a link containing their access code and sends the
527
+ one-time verification code through the selected channel. Neither value is
528
+ returned as a standalone owner-side API field. A custom signer portal must
529
+ obtain both values from the signer-delivery flow; do not manufacture them or
530
+ log them.
531
+
532
+ ```ts
533
+ // Signer-side client: no account API credential is needed or sent.
534
+ const signerClient = new AssinafyClient({
535
+ baseUrl,
536
+ });
537
+
538
+ const self = await signerClient.signerDocuments.self(accessCode); // ISignerSelf
539
+
540
+ // Query: signer-access-code=<accessCode>
541
+ // Body: { 'verification-code': '<six-digit code>' }
542
+ await signerClient.signerDocuments.verifyEmail({
543
+ signerAccessCode: accessCode,
544
+ verificationCode,
545
+ }); // Promise<void>
546
+
547
+ const confirmed = await signerClient.signerDocuments.confirmData(
548
+ uploaded.id,
549
+ accessCode,
550
+ { full_name: self.full_name, email: self.email ?? undefined },
551
+ ); // ISigner
552
+
553
+ const signable = await signerClient.signerDocuments.getAssignment(accessCode, true);
554
+ // `getAssignment` returns IDocumentDetailsResponse and records that the signer
555
+ // viewed the assignment. Do not issue it merely as a health check.
556
+
557
+ await signerClient.signerDocuments.signMultiple([signable.id], accessCode);
558
+ // Wire body: { document_ids: [signable.id] }; acknowledgement has no data.
559
+ ```
560
+
561
+ Repeat this phase separately for each signer with that signer's own access code
562
+ and one-time code. The two signers in this example share the default step and
563
+ can sign in parallel.
564
+
565
+ `signMultiple` is only for `virtual` assignments. For `collect`, read
566
+ `signable.assignment.items`, then call `sign(documentId, assignmentId,
567
+ accessCode, entries)` with a non-empty array of
568
+ `{ itemId, fieldId, pageId, value }`. A virtual signer must confirm their data
569
+ before signing. A `DigitalCertificate` signer cannot call `sign`; that branch
570
+ uses Assinafy's certificate-start and certificate-complete flow, which is not
571
+ part of this SDK's current 89-operation surface.
572
+
573
+ ### 5. Observe completion and download artifacts
574
+
575
+ Subscribe to `document_ready` for event-driven completion, or fetch
576
+ `documents.details(documentId)` until `status === 'certificated'`. Webhook
577
+ deliveries can repeat, so use their numeric `id` as an
578
+ idempotency key. Once complete:
579
+
580
+ ```ts
581
+ const finalDocument = await client.documents.details(uploaded.id);
582
+ const signedPdf = await client.documents.download(uploaded.id, 'certificated');
583
+ const certificatePage = await client.documents.download(uploaded.id, 'certificate-page');
584
+ const bundleZip = await client.documents.download(uploaded.id, 'bundle');
585
+
586
+ // Validate an Assinafy signature hash when your workflow has extracted it.
587
+ const validation = await client.documents.verify(documentSignatureHash);
588
+ ```
589
+
590
+ `original`, `certificated`, and `certificate-page` are PDFs. `bundle` is a ZIP
591
+ containing those three artifacts and also `pades` when the document had an
592
+ ICP-Brasil certificate signer. The `pades` PDF exists only for documents that
593
+ had certificate signers. An artifact can return `404` before generation has
594
+ finished. `decline_reason` is included in document details only when the access
595
+ token belongs to the document creator.
596
+
597
+ ## Resource reference
598
+
599
+ Most account-scoped methods accept an optional `accountId` that overrides the
600
+ client default. Workspace `get`, `update`, `delete`, branding, and statistics
601
+ methods always require an explicit account ID.
602
+
603
+ ### Documents
604
+
605
+ ```ts
606
+ // Upload from a file path (recommended)
607
+ const doc = await client.documents.upload(
608
+ { filePath: './contract.pdf' },
609
+ { name: 'Service agreement', metadata: { type: 'service' } },
610
+ );
611
+ // `name` and `metadata` are compatibility multipart parts outside the published
612
+ // file-only request schema.
613
+ // `name` is optional and defaults to the file's own name. The API derives the
614
+ // display name from the uploaded filename and appends `.pdf` when absent, so
615
+ // the document above is stored as 'Service agreement.pdf'. Accents are
616
+ // transliterated by the API ('Contrato de Serviço' → 'Contrato de Servico.pdf').
617
+ // → {
618
+ // resource: 'document', id: '1031…', account_id: '102d…', template_id: null,
619
+ // name: 'Service agreement.pdf', status: 'uploaded',
620
+ // artifacts: { original: 'https://…/download/original' },
621
+ // signing_url: 'https://app…/sign/1031…',
622
+ // pages: [], // populated once status reaches `metadata_ready`
623
+ // tags: [], is_closed: false, created_at: '2026-…', updated_at: '2026-…'
624
+ // }
625
+
626
+ // …or from a Buffer already in memory
627
+ await client.documents.upload({ buffer, fileName: 'contract.pdf' });
628
+
629
+ // List → { data: IDocumentListItem[], meta?: { current_page, per_page, total, last_page } }
630
+ const { data, meta } = await client.documents.list({ page: 1, 'per-page': 20, sort: 'updated_at' });
631
+
632
+ // Search is the lightweight alternative to list: same item shape, but the API
633
+ // skips the expanded `assignment`/`pages`. Prefer it for name lookups.
634
+ const hits = await client.documents.search({ search: 'agreement', status: 'pending_signature', 'per-page': 20 });
635
+
636
+ await client.documents.details(doc.id);
637
+ await client.documents.activities(doc.id);
638
+ await client.documents.waitUntilReady(doc.id, { maxWaitMs: 30_000 });
639
+
640
+ // Rename. The API rejects this with 400 while the document is still in
641
+ // `metadata_processing`, so await waitUntilReady() first on a fresh upload.
642
+ // (Passing `name` to upload() avoids both the round-trip and the race.)
643
+ await client.documents.rename(doc.id, 'Signed service agreement.pdf');
644
+
645
+ await client.documents.download(doc.id, 'certificated'); // signed PDF
646
+ await client.documents.download(doc.id, 'certificate-page');
647
+ await client.documents.download(doc.id, 'bundle'); // ZIP
648
+ // `pades` exists only when at least one signer used DigitalCertificate.
649
+ await client.documents.download(doc.id, 'pades');
650
+ await client.documents.thumbnail(doc.id);
651
+ await client.documents.downloadPage(doc.id, pageId);
652
+
653
+ await client.documents.statuses(); // list every status code + deletable flag
654
+ await client.documents.isFullySigned(doc.id);
655
+ await client.documents.getSigningProgress(doc.id);
656
+ await client.documents.delete(doc.id);
657
+
658
+ // Verify a signed document by its Assinafy signature hash
659
+ await client.documents.verify('FE32EDDADE7CBDDCBB934E7402047450B0E59C02');
660
+
661
+ // Public endpoints (no auth)
662
+ await client.documents.getPublic(doc.id);
663
+ // Official request body: { email: 'jane@example.com' }
664
+ await client.documents.sendToken(doc.id, 'jane@example.com');
665
+
666
+ // Explicit compatibility overload for older deployments:
667
+ // { recipient: '+5548999990000', channel: 'whatsapp' }
668
+ await client.documents.sendToken(doc.id, '+5548999990000', 'whatsapp');
669
+
670
+ // The current OpenAPI contract requires existing tag IDs.
671
+ const contractsTag = await client.tags.create({ name: 'Contracts' });
672
+ const quarterTag = await client.tags.create({ name: '2026-Q1' });
673
+ const urgentTag = await client.tags.create({ name: 'Urgent' });
674
+ await client.documents.listTags(doc.id);
675
+ await client.documents.replaceTags(doc.id, [contractsTag.id, quarterTag.id]); // [] detaches all
676
+ await client.documents.addTags(doc.id, [urgentTag.id]); // append
677
+ await client.documents.detachTag(doc.id, urgentTag.id); // → { detached: true }
678
+ ```
679
+
680
+ Uploads are validated locally: only `.pdf` files up to 25 MB whose bytes begin
681
+ with the PDF magic header (`%PDF-`) are accepted. The API also limits documents
682
+ to 2,000 pages.
683
+
684
+ Page and artifact URLs embedded in JSON responses still require the same
685
+ account authentication as their download operations. Prefer
686
+ `documents.downloadPage()` and `documents.download()` so the SDK applies the
687
+ credential and returns a `Buffer`. `bundle` contains `original`, `certificated`,
688
+ and `certificate-page`, plus `pades` when available.
689
+
690
+ List endpoints return `{ data, meta }`, where `meta` is populated from the
691
+ `X-Pagination-*` response headers. Two API behaviours are worth knowing because
692
+ both are silent rather than errors:
693
+
694
+ - Only the hyphenated `per-page` is read. `per_page` is accepted and ignored,
695
+ falling back to 20 rows — so the SDK rewrites `per_page` to `per-page` on
696
+ every list method, and an explicit `per-page` wins when both are given.
697
+ - `per-page` is clamped to **50**. Asking for 100 returns 50 rows with a `200`.
698
+ The exported `MAX_LIST_PAGE_SIZE` constant is that ceiling; page through with
699
+ `page` rather than requesting a larger page, and trust `meta.per_page` over
700
+ the value you asked for.
701
+
702
+ ### Signers
703
+
704
+ ```ts
705
+ await client.signers.create({
706
+ full_name: 'John Doe',
707
+ email: 'john@example.com',
708
+ cpf: '123.456.789-00', // legacy compatibility input; non-digits are stripped
709
+ });
710
+ // → { id: '19e6…', full_name: 'John Doe', email: 'john@example.com',
711
+ // whatsapp_phone_number: null, has_accepted_terms: false }
712
+ // (note: `cpf` is accepted on input but never echoed back by the API)
713
+
714
+ // Both contacts are optional. A name-only signer cannot be notified until a
715
+ // contact is added.
716
+ await client.signers.create({ full_name: 'Contact Pending' });
717
+
718
+ await client.signers.create({
719
+ full_name: 'Jane Doe',
720
+ email: 'jane@example.com',
721
+ });
722
+
723
+ await client.signers.get(signerId);
724
+ await client.signers.list({ page: 1, 'per-page': 50, search: 'john' });
725
+ await client.signers.update(signerId, {
726
+ full_name: 'Johnny Doe',
727
+ government_id: '390.533.447-05', // official update field; sent as digits
728
+ });
729
+ await client.signers.delete(signerId);
730
+
731
+ const existing = await client.signers.findByEmail('john@example.com');
732
+ ```
733
+
734
+ When an `email` is supplied, `signers.create()` is idempotent by email: it
735
+ reuses an existing signer when the same email is already present in the
736
+ workspace. Signers without email are always created fresh. See
737
+ [Paid signing branches](#paid-signing-branches) for phone-only signers.
738
+
739
+ ### Assignments
740
+
741
+ ```ts
742
+ // List every assignment in the workspace.
743
+ // → { data: IAssignment[], meta?: { current_page, per_page, total, last_page } }
744
+ const { data, meta } = await client.assignments.list({ page: 1, 'per-page': 20 });
745
+
746
+ // Signers may be ids or objects — the SDK normalises to the API shape.
747
+ await client.assignments.create(documentId, {
748
+ method: 'virtual',
749
+ signers: ['signer-1', 'signer-2'],
750
+ message: 'Please review and sign',
751
+ expires_at: '2027-12-31T23:59:00Z',
752
+ copy_receivers: ['copy-recipient-signer-id'],
753
+ });
754
+
755
+ // Sequential signing: `step` controls signing order (parallel within a step).
756
+ await client.assignments.create(documentId, {
757
+ method: 'virtual',
758
+ signers: [
759
+ { id: 'signer-1', step: 1 },
760
+ { id: 'signer-2', step: 2 }, // notified only after step 1 finishes
761
+ ],
762
+ });
763
+
764
+ // Collect fields use 150-DPI page-image pixels measured from the upper-left.
765
+ await client.assignments.create(documentId, {
766
+ method: 'collect',
767
+ signers: [{ id: signerId }],
768
+ entries: [{
769
+ page_id: pageId,
770
+ fields: [{
771
+ signer_id: signerId,
772
+ field_id: fieldId,
773
+ display_settings: {
774
+ left: 69, top: 282, width: 421, height: 45.86, fontSize: 22,
775
+ fontFamily: 'Arial', backgroundColor: '#D5EBFF',
776
+ },
777
+ }],
778
+ }],
779
+ });
780
+
781
+ // Estimate cost (the endpoint prices channel descriptors, not signer IDs) → ICostEstimate
782
+ await client.assignments.estimateCost(documentId, { signers: [{}] }); // default Email
783
+ // → {
784
+ // documents: 1, credits: 0, needs_extra_document: false, extra_document_cost: 0,
785
+ // total_credits: 0, breakdown: [], document_balance: 67, credit_balance: 0,
786
+ // has_sufficient_resources: true, blocking_reason: null, message: null
787
+ // }
788
+
789
+ await client.assignments.resetExpiration(documentId, assignmentId, '2027-06-30T00:00:00Z');
790
+ // Compatibility only: the published request requires a date-time string.
791
+ // Confirm target support before using `null` to clear an expiration.
792
+ await client.assignments.resetExpiration(documentId, assignmentId, null);
793
+
794
+ await client.assignments.resendNotification(documentId, assignmentId, signerId);
795
+ // → { is_sent: true, document_id: '…', signer_id: '…' }
796
+
797
+ const resendCost = await client.assignments.estimateResendCost(documentId, assignmentId, signerId);
798
+ // Official response: ICostEstimate. Older deployments can return the compact
799
+ // IResendCostEstimate branch with `total` and `has_sufficient_credits`; narrow
800
+ // with `'total_credits' in resendCost` before reading branch-specific fields.
801
+ ```
802
+
803
+ The `create` response is an `IAssignment`: `{ id, method, signers: [...],
804
+ items: [{ display_settings, ... }], signing_urls: [{ signer_id, url }], … }`.
805
+
806
+ 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.
807
+
808
+ **Cancelling a signature request.** Assinafy has no workspace-side "cancel" endpoint. To stop a pending request either delete the document (when its status is deletable) or have the signer decline:
809
+
810
+ ```ts
811
+ await client.documents.delete(documentId); // workspace-side
812
+ await client.signerDocuments.decline(documentId, assignmentId, accessCode, 'No longer needed'); // signer-side
813
+ ```
814
+
815
+ ### Paid signing branches
816
+
817
+ Keep the email flow as the default. Enable either branch below only after the
818
+ workspace has the required plan or feature and the returned cost estimate is
819
+ acceptable.
820
+
821
+ #### WhatsApp verification and notification
822
+
823
+ WhatsApp is available only on paid subscriptions and costs 0.45 credit per
824
+ notification. Create a phone-only signer or add a phone to an existing signer,
825
+ then request the `Whatsapp` channel explicitly:
826
+
827
+ ```ts
828
+ const phoneSigner = await client.signers.create({
829
+ full_name: 'Mobile Signer',
830
+ whatsapp_phone_number: '+5511999990000',
831
+ });
832
+
833
+ const whatsappCost = await client.assignments.estimateCost(documentId, {
834
+ method: 'virtual',
835
+ signers: [{ verification_method: 'Whatsapp', notification_methods: ['Whatsapp'] }],
836
+ });
837
+
838
+ const whatsappAssignment = await client.assignments.create(documentId, {
839
+ method: 'virtual',
840
+ signers: [{
841
+ id: phoneSigner.id,
842
+ verification_method: 'Whatsapp',
843
+ notification_methods: ['Whatsapp'],
844
+ }],
845
+ });
846
+
847
+ const notices = await client.assignments.listWhatsAppNotifications(
848
+ documentId,
849
+ whatsappAssignment.id,
850
+ );
851
+ // IWhatsAppNotification[]:
852
+ // [{ sent_at, header, body, buttons: [{ text, url? }], phone_number, signer_id }]
853
+ ```
854
+
855
+ The high-level helper selects this paid branch for a signer that has a phone
856
+ number but no email. Button URLs can contain signer credentials; do not log or
857
+ forward them outside the signing flow.
858
+
859
+ #### ICP-Brasil digital certificate
860
+
861
+ `DigitalCertificate` requires the account feature, a CPF or CNPJ in the
862
+ signer's `government_id`, and exactly one certificate signer in that signing
863
+ step. It costs two credits per certificate signer in addition to the selected
864
+ notification cost.
865
+
866
+ ```ts
867
+ const certificateSigner = await client.signers.update(signerId, {
868
+ government_id: '390.533.447-05',
869
+ });
870
+
871
+ const certificateCost = await client.assignments.estimateCost(documentId, {
872
+ method: 'virtual',
873
+ signers: [{ verification_method: 'DigitalCertificate', notification_methods: ['Email'] }],
874
+ });
875
+
876
+ await client.assignments.create(documentId, {
877
+ method: 'virtual',
878
+ signers: [{
879
+ id: certificateSigner.id,
880
+ step: 1,
881
+ verification_method: 'DigitalCertificate',
882
+ notification_methods: ['Email'],
883
+ }],
884
+ });
885
+ ```
886
+
887
+ Before opening the assignment, the signer must confirm identity data and accept
888
+ terms with `confirmData(..., { has_accepted_terms: true })` or `acceptTerms()`.
889
+ The regular `sign()` endpoint rejects certificate signers; they complete the
890
+ ICP-Brasil flow through Assinafy's browser integration. After completion,
891
+ `documents.download(documentId, 'pades')` returns the qualified PAdES artifact.
892
+
893
+ ### Templates
894
+
895
+ `templates.list()` is part of the current OpenAPI document. Existing
896
+ integrations can also use five template-management routes—`create`, `get`,
897
+ `update`, `delete`, and `downloadPage`—that are absent from that document. See
898
+ [compatibility notes](docs/COMPATIBILITY.md#template-management-extensions).
899
+ Template status casing can vary by deployment; normalize with
900
+ `template.status.toLowerCase()` when branching on it.
901
+
902
+ ```ts
903
+ // Create a template by uploading a PDF (multipart). The template starts in
904
+ // an uploaded state and becomes ready once its pages are processed.
905
+ const created = await client.templates.create(
906
+ { filePath: './nda.pdf' }, // or { buffer, fileName: 'nda.pdf' }
907
+ { name: 'NDA template' },
908
+ );
909
+ // →
910
+ // {
911
+ // resource: 'template', id: '1032...', name: 'nda.pdf',
912
+ // document_name: 'nda.pdf', message: null, status: 'Uploaded',
913
+ // roles: [{ id: '1032...', name: 'TemplateEditor', assignment_type: 'Editor' }],
914
+ // pages: [], tags: [], created_at: '2026-…', updated_at: '2026-…'
915
+ // }
916
+
917
+ const { data, meta } = await client.templates.list({ search: 'NDA', 'per-page': 20 });
918
+ const template = await client.templates.get(created.id); // includes pages[] + default_document_tags
919
+ await client.templates.update(created.id, { name: 'NDA v2', message: 'Please sign' });
920
+ const firstPage = template.pages?.[0];
921
+ if (firstPage) await client.templates.downloadPage(created.id, firstPage.id); // → Buffer (JPEG)
922
+ await client.templates.delete(created.id);
923
+
924
+ // Create a document from an existing, configured template. Fresh uploads have
925
+ // only an Editor role; add signer roles in Assinafy's editor first.
926
+ const configured = await client.templates.get(templateId);
927
+ const signerRole = configured.roles?.find(
928
+ (role) => typeof role.assignment_type === 'string'
929
+ && role.assignment_type.toLowerCase() !== 'editor',
930
+ );
931
+ if (!signerRole) throw new Error('Template has no signer role');
932
+ await client.documents.createFromTemplate(
933
+ templateId,
934
+ [{ role_id: signerRole.id, id: signerId, verification_method: 'Email', notification_methods: ['Email'] }],
935
+ { name: 'NDA - John Doe', message: 'Please sign at your earliest convenience.' },
936
+ );
937
+
938
+ // Estimate the cost before creating → ICostEstimate
939
+ await client.documents.estimateCostFromTemplate(templateId, [
940
+ { role_id: 'role_id', verification_method: 'Email', notification_methods: ['Email'] },
941
+ ]);
942
+ // → { documents: 1, total_credits: 0, document_balance: 67, credit_balance: 0,
943
+ // has_sufficient_resources: true, blocking_reason: null, breakdown: [], … }
944
+ ```
945
+
946
+ Template signer descriptors also accept
947
+ `verification_method: 'DigitalCertificate'` with the same prerequisites under
948
+ [ICP-Brasil digital certificate](#icp-brasil-digital-certificate).
949
+
950
+ Template creation only uploads the PDF and provisions the default editor role —
951
+ configure roles/fields in the Assinafy editor (or the web UI) afterwards.
952
+ The `download_url` values in template page objects are protected URLs; prefer
953
+ `templates.downloadPage()` so the API credential is attached.
954
+
955
+ ### Tags
956
+
957
+ Workspace-scoped labels that can be attached to documents and templates. Tag names are unique per workspace (case-insensitive).
958
+
959
+ ```ts
960
+ await client.tags.list({ search: 'contract' }); // ITag[]
961
+ const tag = await client.tags.create({ name: 'Contracts', color: 'ff8800' });
962
+ await client.tags.update(tag.id, { name: 'Sales Contracts' });
963
+ await client.tags.update(tag.id, { color: null }); // clear the color
964
+ await client.tags.delete(tag.id); // 409 if still attached
965
+ await client.tags.delete(tag.id, { force: true }); // detach everywhere, then delete
966
+ ```
967
+
968
+ Attach/detach tags on a specific document via `client.documents.listTags / replaceTags / addTags / detachTag` (see [Documents](#documents)).
969
+
970
+ ### Workspaces
971
+
972
+ The official create/update request schemas define `name` and
973
+ `notification_sender_type`. The sandbox also accepts the color fields shown
974
+ below; they are retained as a documented compatibility extension.
975
+
976
+ ```ts
977
+ // Colours are 6-char hex WITHOUT a leading '#' (unlike tags, which strip it).
978
+ // '#ff0066' is rejected — the account endpoints want exactly 6 characters.
979
+ await client.workspaces.create({
980
+ name: 'My Workspace',
981
+ notification_sender_type: 'Account',
982
+ primary_color: 'ff0066',
983
+ secondary_color: '0066ff',
984
+ });
985
+ // → { id, name, primary_color: 'ff0066', secondary_color: '0066ff', created_at }
986
+ await client.workspaces.list();
987
+ await client.workspaces.get(accountId);
988
+ await client.workspaces.update(accountId, {
989
+ name: 'Renamed',
990
+ notification_sender_type: 'User',
991
+ primary_color: '112233',
992
+ });
993
+
994
+ // Branding
995
+ const theme = await client.workspaces.getTheme(accountId);
996
+ const logo = await client.workspaces.downloadLogo(accountId); // Buffer
997
+ await client.workspaces.uploadLogo(accountId, { filePath: './logo.png' });
998
+ await client.workspaces.uploadLogo(accountId, {
999
+ buffer: logoBuffer,
1000
+ fileName: 'logo.png',
1001
+ contentType: 'image/png',
1002
+ });
1003
+ await client.workspaces.deleteLogo(accountId);
1004
+
1005
+ // Latest 12 months by default; daily statistics require a YYYY-MM month.
1006
+ await client.workspaces.getStats(accountId);
1007
+ await client.workspaces.getStats(accountId, {
1008
+ granularity: 'daily',
1009
+ month: '2026-06',
1010
+ });
1011
+
1012
+ await client.workspaces.delete(accountId);
1013
+ // `force` cancels an active paid subscription as part of account deletion. It
1014
+ // is not a general bypass for unrelated deletion restrictions.
1015
+ await client.workspaces.delete(restrictedAccountId, { force: true });
1016
+ ```
1017
+
1018
+ ### Field definitions
1019
+
1020
+ Custom field types used by `collect`-method assignments.
1021
+
1022
+ ```ts
1023
+ await client.fields.create({ type: 'text', name: 'Contract Number' });
1024
+ await client.fields.list({ include_inactive: true, include_standard: true });
1025
+ await client.fields.get(fieldId);
1026
+ await client.fields.update(fieldId, { name: 'Updated Name' });
1027
+ await client.fields.delete(fieldId);
1028
+
1029
+ // Validate a single value (signer-access-code only required for signer-side calls)
1030
+ await client.fields.validate(fieldId, '400.676.228-36', { signerAccessCode });
1031
+
1032
+ // Validate multiple values at once
1033
+ await client.fields.validateMultiple(
1034
+ [
1035
+ { field_id: 'f1', value: '1111111111111' },
1036
+ { field_id: 'f2', value: 'value@example.com' },
1037
+ ],
1038
+ { signerAccessCode },
1039
+ );
1040
+
1041
+ // Catalog of every field type the platform recognises
1042
+ await client.fields.listTypes();
1043
+ ```
1044
+
1045
+ ### Authentication / API key management
1046
+
1047
+ 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.
1048
+
1049
+ ```ts
1050
+ // Browser OAuth: redirect the user to this URL. The callback helper returns the
1051
+ // Assinafy callback URL for provider configuration; neither follows a redirect.
1052
+ const oauthStart = client.auth.getSocialLoginUrl('google');
1053
+ const oauthCallback = client.auth.getSocialLoginCallbackUrl();
1054
+
1055
+ const { access_token, user, accounts } = await client.auth.login('me@example.com', 'pw');
1056
+ await client.auth.socialLogin({ provider: 'google', token: 'google-id-token', has_accepted_terms: true });
1057
+ await client.auth.linkSocialLogin({ provider: 'google', token: 'google-id-token' });
1058
+
1059
+ // Personal API key
1060
+ await client.auth.createApiKey('current-password');
1061
+ await client.auth.getApiKey(); // → { api_key: '****...nBNr' } or null
1062
+ await client.auth.deleteApiKey();
1063
+
1064
+ // Password lifecycle
1065
+ await client.auth.changePassword({ email, password: 'current', new_password: 'next' });
1066
+ await client.auth.requestPasswordReset('me@example.com');
1067
+ await client.auth.resetPassword({ email, token: 'tk', new_password: 'next' });
1068
+ ```
1069
+
1070
+ ### Authenticated user
1071
+
1072
+ ```ts
1073
+ const user = await client.users.getCurrent();
1074
+ // → { id, name, email, telephone, government_id, is_email_verified,
1075
+ // has_accepted_terms, created_at, to_be_deleted_at }
1076
+
1077
+ // Cross-account document funnel, latest 12 monthly periods by default.
1078
+ const monthly = await client.users.getStats();
1079
+ const daily = await client.users.getStats({
1080
+ granularity: 'daily',
1081
+ month: '2026-06',
1082
+ });
1083
+ // Each row includes period, upload/send/certification totals, notification
1084
+ // counts for email/WhatsApp/bypass, verification counts for
1085
+ // email/WhatsApp/bypass/digital-certificate, viewed, and completed counts.
1086
+
1087
+ const preferences = await client.users.getNotificationPreferences();
1088
+ await client.users.updateNotificationPreferences({
1089
+ SignerDeclined: false,
1090
+ DocumentExpired: false,
1091
+ });
1092
+ // Updates merge: omitted keys keep their current value. Both methods return
1093
+ // the complete nine-key notification preference map.
1094
+ ```
1095
+
1096
+ ### Webhooks
1097
+
1098
+ ```ts
1099
+ await client.webhooks.register({
1100
+ url: 'https://example.com/webhooks/assinafy',
1101
+ email: 'admin@example.com',
1102
+ is_active: true,
1103
+ // events defaults to the current SDK default set below
1104
+ events: [
1105
+ 'document_ready',
1106
+ 'document_prepared',
1107
+ 'signer_signed_document',
1108
+ 'signer_rejected_document',
1109
+ 'document_processing_failed',
1110
+ ],
1111
+ });
1112
+
1113
+ await client.webhooks.get(); // IWebhookSubscription | null
1114
+ await client.webhooks.inactivate(); // stop deliveries (no delete route exists)
1115
+ await client.webhooks.listEventTypes();
1116
+ const history = await client.webhooks.listDispatches({
1117
+ delivered: false,
1118
+ page: 1,
1119
+ 'per-page': 20,
1120
+ }); // { data: IWebhookDispatch[], meta?: PaginationMeta }
1121
+ const retried = await client.webhooks.retryDispatch(dispatchId); // IWebhookDispatch
1122
+ ```
1123
+
1124
+ `register` sends `{ events, is_active, url, email }` and returns
1125
+ `{ events, is_active, url, email, updated_at? }`. Assinafy delivers each event
1126
+ as an HTTP `POST` with `Content-Type: application/json` and `Connection: close`.
1127
+ Any `2xx` is success. There are at most two automatic attempts, separated by
1128
+ three seconds. After ten consecutive failed events, ordinary delivery pauses
1129
+ and about 5% of later events are attempted until one succeeds; use
1130
+ `retryDispatch()` for an immediate manual redelivery. The dispatch history
1131
+ retains only the first 2,000 characters of the receiver's response body.
1132
+
1133
+ Each history or retry result is an `IWebhookDispatch`:
1134
+
1135
+ ```ts
1136
+ {
1137
+ resource?: string;
1138
+ id: string;
1139
+ event: string;
1140
+ activity_id: number;
1141
+ endpoint: string | null;
1142
+ payload: IWebhookPayload | Record<string, unknown> | null;
1143
+ delivered: boolean;
1144
+ http_status: number | null;
1145
+ response_body: string | null;
1146
+ error: string | null;
1147
+ created_at: string;
1148
+ updated_at?: string;
1149
+ }
1150
+ ```
1151
+
1152
+ Every delivery body uses this envelope:
1153
+
1154
+ ```ts
1155
+ {
1156
+ id: number; // use for idempotent processing
1157
+ event: string;
1158
+ message: string | null;
1159
+ payload: Record<string, unknown> | null;
1160
+ origin: { ip?: string; 'user-agent'?: string } | null;
1161
+ created_at: number; // Unix seconds
1162
+ subject: { type: 'User' | 'Signer' | 'Account' | 'Document' | 'Template'; [key: string]: unknown };
1163
+ object: { type: 'User' | 'Signer' | 'Account' | 'Document' | 'Template'; [key: string]: unknown };
1164
+ account_id: string;
1165
+ }
1166
+ ```
1167
+
1168
+ Event-specific values are:
1169
+
1170
+ | `event` | `subject.type` | `object.type` | `payload` keys |
1171
+ | --- | --- | --- | --- |
1172
+ | `document_uploaded` | `User` | `Document` | — |
1173
+ | `document_metadata_ready` | `User` | `Document` | — |
1174
+ | `document_prepared` | `User` | `Document` | — |
1175
+ | `assignment_created` | `User` | `Document` | `user_name`, `user_email`, `user_telephone` |
1176
+ | `document_ready` | `Account` | `Document` | — |
1177
+ | `document_processing_failed` | `Account` | `Document` | `error_message` |
1178
+ | `signature_requested` | `User` | `Document` | `signer_email`, `signer_full_name`, or `signer_whatsapp_phone_number`, according to channel |
1179
+ | `signer_created` | `User` | `Signer` | `signer_full_name` |
1180
+ | `signer_email_verified` | `Signer` | `Document` | `signer_email` |
1181
+ | `signer_whatsapp_verified` | `Signer` | `Document` | `signer_whatsapp_phone_number` |
1182
+ | `signer_data_confirmed` | `Signer` | `Document` | `signer_email` |
1183
+ | `signer_viewed_document` | `Signer` | `Document` | `signer_full_name` |
1184
+ | `signer_signed_document` | `Signer` | `Document` | `signer_full_name` |
1185
+ | `signer_rejected_document` | `Signer` | `Document` | `signer_full_name` |
1186
+ | `user_rejected_document` | `User` | `Document` | `user_name` |
1187
+ | `template_created` | `User` | `Template` | — |
1188
+ | `template_processed` | `User` | `Template` | — |
1189
+ | `template_processing_failed` | `Account` | `Template` | `error_message` |
1190
+
1191
+ `payload`, `subject`, and `object` are event-dependent. Accept unknown fields
1192
+ for forward compatibility and acknowledge only after durable, idempotent
1193
+ processing. Non-`2xx` responses, timeouts, and connection failures all count as
1194
+ failed deliveries. `assignment_created` and `document_metadata_ready` have no
1195
+ guaranteed ordering. For account entities, Assinafy removes the `integration`
1196
+ property before delivery.
1197
+
1198
+ ### Webhook verification
1199
+
1200
+ `WebhookVerifier` is an opt-in HMAC-SHA256 utility for integrations whose
1201
+ Assinafy environment provides a shared secret and signature header. The
1202
+ current official OpenAPI document does **not** define a webhook signature
1203
+ scheme or header name. Confirm the delivery contract for your environment
1204
+ before enabling this check; do not reject production callbacks based on an
1205
+ assumed header. The example below uses an application-configured header name.
1206
+
1207
+ ```ts
1208
+ import express from 'express';
1209
+
1210
+ const webhookSecret = process.env.ASSINAFY_WEBHOOK_SECRET;
1211
+ const signatureHeader = process.env.ASSINAFY_SIGNATURE_HEADER;
1212
+ if (!webhookSecret || !signatureHeader) {
1213
+ throw new Error('This deployment has no confirmed webhook-signature contract');
1214
+ }
1215
+
1216
+ const webhookClient = new AssinafyClient({ webhookSecret });
1217
+
1218
+ app.post('/webhooks/assinafy', express.raw({ type: 'application/json' }), (req, res) => {
1219
+ const signature = req.header(signatureHeader) ?? '';
1220
+ const rawBody = req.body as Buffer;
1221
+
1222
+ if (!webhookClient.webhookVerifier.verify(rawBody, signature)) {
1223
+ return res.status(401).send('Invalid signature');
1224
+ }
1225
+
1226
+ const event = webhookClient.webhookVerifier.extractEvent(rawBody);
1227
+ const type = webhookClient.webhookVerifier.getEventType(event);
1228
+ const data = webhookClient.webhookVerifier.getEventData(event);
1229
+
1230
+ switch (type) {
1231
+ case 'document_ready': break;
1232
+ case 'signer_signed_document': break;
1233
+ case 'signer_rejected_document': break;
1234
+ case 'document_processing_failed':break;
1235
+ }
1236
+ res.sendStatus(200);
1237
+ });
1238
+ ```
1239
+
1240
+ ### Signer-side endpoints
1241
+
1242
+ For building custom signer portals. Most calls require the `signer-access-code`
1243
+ URL parameter that Assinafy emails/whatsapps to the signer. Artifact download is
1244
+ the documented public exception; its optional fourth access-code argument exists
1245
+ only for compatibility with deployments that still expect the legacy query.
1246
+
1247
+ ```ts
1248
+ await client.signerDocuments.self(accessCode);
1249
+ await client.signerDocuments.verifyEmail({ signerAccessCode: accessCode, verificationCode: '123456' });
1250
+
1251
+ await client.signerDocuments.getCurrent(signerId, accessCode);
1252
+ const { data } = await client.signerDocuments.list(signerId, accessCode, { 'per-page': 20 });
1253
+ // Signer-side counterpart of documents.search(), authorised by the access code.
1254
+ const found = await client.signerDocuments.search(signerId, accessCode, 'invoice');
1255
+ await client.signerDocuments.download(signerId, documentId, 'original');
1256
+ // Available only after an ICP-Brasil certificate signer completes signing.
1257
+ await client.signerDocuments.download(signerId, documentId, 'pades');
1258
+
1259
+ await client.signerDocuments.confirmData(documentId, accessCode, {
1260
+ email: 'me@example.com',
1261
+ full_name: 'Example Signer',
1262
+ government_id: '123.456.789-00',
1263
+ has_accepted_terms: true,
1264
+ });
1265
+ // Alternatively, accept terms separately before getAssignment():
1266
+ // await client.signerDocuments.acceptTerms(accessCode);
1267
+
1268
+ // Signature image management ({ reuse: true } persists it for future documents)
1269
+ await client.signerDocuments.uploadSignature(accessCode, pngBuffer, { imageType: 'signature', reuse: true });
1270
+ await client.signerDocuments.downloadSignature(accessCode, 'signature');
1271
+
1272
+ // Sign / decline
1273
+ const signable = await client.signerDocuments.getAssignment(accessCode);
1274
+ // `sign()` is for collect assignments and requires every placed field value.
1275
+ await client.signerDocuments.sign(documentId, assignmentId, accessCode, [
1276
+ { itemId, fieldId, pageId, value: 'Signed by John' },
1277
+ ]);
1278
+ await client.signerDocuments.decline(documentId, assignmentId, accessCode, 'Not authorized');
1279
+
1280
+ // `signMultiple()` is for virtual assignments only.
1281
+ await client.signerDocuments.signMultiple(['doc-1', 'doc-2'], accessCode);
1282
+ await client.signerDocuments.declineMultiple(['doc-1'], 'Unfavorable terms', accessCode);
1283
+ ```
1284
+
1285
+ `sign()` also requires virtual signers to have confirmed their data first, but
1286
+ virtual assignments should normally use `signMultiple()`. Certificate signers
1287
+ cannot use `sign()`; see [Paid signing branches](#paid-signing-branches).
1288
+
1289
+ ## High-level helper
1290
+
1291
+ Uploads a PDF, reuses or creates signers by email, creates a virtual assignment
1292
+ immediately, and optionally waits for processing before returning.
1293
+
1294
+ ```ts
1295
+ const result = await client.uploadAndRequestSignatures({
1296
+ source: { filePath: './contract.pdf' },
1297
+ signers: [
1298
+ { name: 'John', email: 'john@example.com' },
1299
+ { name: 'Jane', email: 'jane@example.com' },
1300
+ ],
1301
+ message: 'Please sign',
1302
+ metadata: { year: 2026 }, // compatibility upload part; omit for file-only wire format
1303
+ waitForReady: true,
1304
+ waitOptions: { maxWaitMs: 30_000, pollIntervalMs: 1_000 },
1305
+ expiresAt: '2027-12-31T00:00:00Z',
1306
+ copyReceivers: ['existing-copy-recipient-signer-id'],
1307
+ });
1308
+
1309
+ result.document; // fully-processed IDocumentDetailsResponse (waitForReady: true, the default);
1310
+ // the raw IDocumentUploadResponse when waitForReady: false
1311
+ result.assignment; // IAssignment
1312
+ result.signer_ids; // string[]
1313
+ ```
1314
+
1315
+ `waitForReady: false` skips post-assignment polling and returns the initial
1316
+ upload response. With the default `true`, the helper creates the assignment first and
1317
+ then waits for the current document details. Both production and sandbox allow
1318
+ virtual assignments in `uploaded` and `metadata_processing` and promote them
1319
+ automatically; only `collect` assignments require rendered pages.
1320
+ Every signer above uses the default email channel. A phone-only signer selects
1321
+ the paid WhatsApp branch described earlier. `copyReceivers` accepts existing
1322
+ signer IDs, not email addresses; check the returned assignment before treating
1323
+ a copy receiver as registered.
1324
+
1325
+ The helper is not transactional. A post-assignment polling error includes the
1326
+ created `documentId`, `assignmentId`, and `signerIds` in its `context` (and in
1327
+ `ValidationError.errors` for timeouts); inspect those IDs before deciding
1328
+ whether to retry the workflow.
1329
+
1330
+ ## Errors
1331
+
1332
+ HTTP methods reject with an `AssinafyError` subclass. Synchronous helpers such
1333
+ as `getSocialLoginUrl()` can throw `ValidationError` before any request.
1334
+
1335
+ ```ts
1336
+ import { ApiError, OAuthError, ValidationError, NetworkError, AssinafyError } from '@assinafy/sdk';
1337
+
1338
+ try {
1339
+ await client.documents.upload({ filePath: './x.pdf' });
1340
+ } catch (err) {
1341
+ if (err instanceof ValidationError) {
1342
+ console.error('Validation failed:', err.errors);
1343
+ } else if (err instanceof OAuthError) {
1344
+ // OAuthError extends ApiError; branch on the RFC 6749 code, not the message.
1345
+ console.error('OAuth error:', err.error, err.errorDescription);
1346
+ } else if (err instanceof ApiError) {
1347
+ console.error(`API error ${err.statusCode}:`, err.responseData);
1348
+ // `err.challenge` carries the parsed WWW-Authenticate header when the API
1349
+ // sent one — on a 403 it names the OAuth scope that is missing.
1350
+ } else if (err instanceof NetworkError) {
1351
+ console.error('Network error:', err.message);
1352
+ } else if (err instanceof AssinafyError) {
1353
+ console.error('SDK error:', err.message, err.context);
1354
+ }
1355
+ }
1356
+ ```
1357
+
1358
+ ## Development
1359
+
1360
+ ```bash
1361
+ bun install --frozen-lockfile
1362
+ bun run typecheck # source, script, and test type checks
1363
+ bun run lint
1364
+ bun test # bun:test suites
1365
+ bun run test:coverage
1366
+ bun run build # tsup → dist/ (CJS + ESM + .d.ts)
1367
+ bun run lint:pkg # publint + arethetypeswrong
1368
+ bun run verify # complete local release gate
1369
+ ```
1370
+
1371
+ ## License
1372
+
1373
+ MIT