Leia em português · English
Java client SDK for the Assinafy API, the Brazilian digital signature platform.
Covers all 106 operations in the official API contract: accounts, users, authentication, OAuth, documents, signers, assignments, fields, templates, tags, webhooks, and the signer-facing signing flows.
This artifact uses
new AssinafyClientOptions().setApiKey(...). Thewebformsname is historical; the SDK serves the Assinafy API. Assinafy recommendscom.assinafy:assinafy-sdkfor new integrations.
This README follows one document from installation to a signed, downloaded PDF. Each section is the next step of that journey, so reading top to bottom gives you the whole integration; jumping to a heading gives you one stage of it. The complete API reference is the per-operation lookup table, worked examples hold longer runnable programs, and docs/INSTALLATION.md covers build-tool setup in depth.
- Java 25+ (the SDK is compiled and verified on the current Java 25 LTS)
- Maven 3.9.16 or newer 3.x (the wrapper pins 3.9.16); for Gradle, use a release that supports JDK 25
- TLS 1.2 or later: the client refuses TLS 1.0 and 1.1
Maven
<dependency>
<groupId>com.assinafy</groupId>
<artifactId>webforms-java-client-sdk</artifactId>
<version>2.7.0</version>
</dependency>Gradle
implementation 'com.assinafy:webforms-java-client-sdk:2.7.0'The artifact is published to GitHub Packages, so the repository must be declared once in your build. See docs/INSTALLATION.md for that and for the sources/Javadoc jars.
One AssinafyClient per credential and account. It is thread-safe, holds a shared connection pool, and is
meant to be created once and reused for the life of the application.
Keep credentials in environment variables or a deployment secret manager. The SDK does not read .env files,
so applications pass secret values explicitly. Never put credentials in source, logs, exception messages, Maven
properties, or browser code.
export ASSINAFY_API_KEY=your_api_key
export ASSINAFY_ACCOUNT_ID=your_account_idimport com.assinafy.sdk.AssinafyClient;
import com.assinafy.sdk.AssinafyClientOptions;
AssinafyClient client = new AssinafyClient(new AssinafyClientOptions()
.setApiKey(System.getenv("ASSINAFY_API_KEY"))
.setAccountId(System.getenv("ASSINAFY_ACCOUNT_ID"))
.setTimeoutMs(30_000)
.setMaxRetries(2)); // safe reads only; mutations are never replayed| Option | Type | Default | Description |
|---|---|---|---|
apiKey |
String | — | Preferred credential, sent as the X-Api-Key header |
token |
String | — | Bearer access token, used only when no API key is set |
accountId |
String | — | Default workspace for every account-scoped method |
baseUrl |
String | https://api.assinafy.com.br/v1 |
HTTPS API root; loopback HTTP is accepted only for local tests |
timeoutMs |
int | 30000 |
Connect, read, and write timeout; must be positive |
maxRetries |
int | 0 |
Extra attempts for safe reads on HTTP 429/503; mutating requests are never replayed |
Two shortcuts exist for the common cases:
// Positional factory with an optional customizer.
AssinafyClient configured = AssinafyClient.create("api-key", "account-id",
opts -> opts.setTimeoutMs(60_000));
// From string configuration (snake_case or camelCase keys).
AssinafyClient fromMap = AssinafyClient.fromConfig(Map.of(
"api_key", System.getenv("ASSINAFY_API_KEY"),
"account_id", System.getenv("ASSINAFY_ACCOUNT_ID")
));An API key is the right credential for a back-end integration. A bearer token works too, and a client with no credential at all is valid — it is what you use for the public endpoints (login, password reset, document verification, and the public document views).
// Bearer session: Authorization: Bearer <token>
new AssinafyClient(new AssinafyClientOptions().setToken("jwt_xxx").setAccountId("acc_xxx"));
// Unauthenticated: login and public signer flows only.
new AssinafyClient(new AssinafyClientOptions());Section 10 covers obtaining a token and managing API keys, and section 11 covers OAuth, which is how an application acts in other people's workspaces.
JSON responses are wrapped as { "status": <int>, "message": "<string>", "data": <payload> }. The SDK unwraps
data into the typed model, so your code never handles the envelope. It raises ApiException for a non-success
HTTP status or for status >= 400 inside the envelope — the API sometimes returns an error envelope under
HTTP 200, and the SDK treats that as the error it is.
Three response shapes follow from that:
- Typed models for ordinary JSON endpoints —
DocumentDetails,Assignment,Signer, and so on. PaginatedResult<T>for list endpoints.getData()is the array;getMeta()carriescurrentPage,perPage,total, andlastPage, read from theX-Pagination-*response headers. Query maps acceptper-page,per_page, orperPage; the SDK always sendsper-page.byte[]for binary downloads (logos, document artifacts, page images, thumbnails, signature images). These are not envelopes; when the server answers one with an error envelope instead, the SDK detects the JSON body and raisesApiExceptionrather than handing you the error as if it were a PDF.
The OAuth endpoints in section 11 are the one documented exception: RFC 6749, OpenID Connect, and RFC 8615 each
require a flat object, so they answer with the object rather than the envelope. The SDK reads it directly and
still raises ApiException on failure.
Retries are opt-in and deliberately narrow. With maxRetries above zero the client retries only GET, HEAD,
and OPTIONS that receive 429 or 503. It honors a numeric Retry-After or X-Rate-Limit-Reset, caps the wait
at 30 seconds, and preserves thread interruption. It never replays an upload, create, update, delete,
notification, or signature.
Everything below is account-scoped. Methods take an optional trailing accountId that overrides the client
default, so one client can serve several workspaces.
List<WorkspaceAccount> workspaces = client.accounts.list();
WorkspaceAccount workspace = client.accounts.get();
WorkspaceAccount created = client.accounts.create(new AccountPayload("Legal Operations")
.setNotificationSenderType("Account"));
client.accounts.update(new AccountPayload().setName("Legal"));
AccountTheme theme = client.accounts.getTheme();
byte[] logo = client.accounts.downloadLogo();
client.accounts.uploadLogo(pngBytes, "logo.png");
client.accounts.deleteLogo();
User me = client.users.getSelf();
NotificationPreferences preferences = client.users.getNotificationPreferences();
client.users.updateNotificationPreferences(
new NotificationPreferences().setDocumentCompleted(true).setSignerDeclined(true));
// Permanent; force=true also cancels an active paid subscription.
client.accounts.delete(false, created.getId());Tags organise documents, and field definitions describe the inputs a collect assignment can place on
a page. Both are workspace-level and are usually created once, before any document exists.
Tag contracts = client.tags.create(new CreateTagPayload("Contracts").setColor("ff8800"));
Tag renamed = client.tags.update(contracts.getId(),
new UpdateTagPayload().setName("Sales Contracts").clearColor());
PaginatedResult<Tag> tags = client.tags.list(Map.of("search", "contract"));
boolean deleted = client.tags.delete(renamed.getId(), true); // force detaches it from documents first
FieldDefinition reference = client.fields.create(
new CreateFieldPayload("text", "Reference").setRequired(true));
PaginatedResult<FieldDefinition> fields = client.fields.list(Map.of("include_standard", "true"));
client.fields.update(reference.getId(), new UpdateFieldPayload().setName("Internal Reference"));
List<FieldTypeInfo> fieldTypes = client.fields.listTypes();
client.fields.delete(reference.getId());
// Validate a value against a field's type/regex rules before submitting it.
FieldValidationResult check = client.fields.validate(reference.getId(), "ABC-123");
List<FieldValidationResult> checks = client.fields.validateMultiple(List.of(
new FieldValidationPayload(reference.getId(), "ABC-123")));A document starts either from an uploaded PDF or from a template. Uploads are multipart/form-data with one
file part, at most 25 MB and 2,000 pages.
// From a file or from bytes already in memory.
DocumentDetails doc = client.documents.upload(new File("contract.pdf"));
DocumentDetails fromBytes = client.documents.upload(pdfBytes, "contract.pdf");
// Rename is allowed only before an assignment exists.
DocumentDetails renamed = client.documents.rename(doc.getId(), "Signed contract.pdf");
// Full listing, and a lightweight search for typeahead (no expanded assignment/pages).
PaginatedResult<DocumentListItem> page = client.documents.list(Map.of("page", "1", "per_page", "20"));
PaginatedResult<DocumentListItem> hits = client.documents.search(Map.of("search", "invoice"));
// Tags on this document.
List<Tag> attached = client.documents.listTags(doc.getId());
client.documents.appendTags(doc.getId(), List.of(urgentTagId));
client.documents.replaceTags(doc.getId(), List.of(contractTagId, quarterTagId));
boolean detached = client.documents.detachTag(doc.getId(), tagId);Templates produce a document and its assignment in a single call. Provide one signer entry per template role; the signers must already exist in the account.
PaginatedResult<TemplateListItem> templates = client.templates.list(Map.of("search", "NDA"));
TemplateDetails template = client.templates.get(templateId);
CostEstimate templateCost = client.documents.estimateCostFromTemplate(templateId,
List.of(new TemplateSigner(template.getRoles().get(0).getId()).setVerificationMethod("Email")));
DocumentDetails generated = client.documents.createFromTemplate(
templateId,
List.of(new TemplateSigner(template.getRoles().get(0).getId(), signerId)
.setVerificationMethod("Email")
.setNotificationMethods(List.of("Email"))
.setStep(1)),
new CreateDocumentFromTemplateOptions().setTags(List.of("Generated")));An uploaded PDF is not immediately assignable: the API extracts page metadata first. waitUntilReady polls
GET /documents/{id} until the status reaches metadata_ready, pending_signature, or certificated,
raising ValidationException if the document fails, expires, is rejected, or the wait budget elapses.
DocumentDetails ready = client.documents.waitUntilReady(doc.getId()); // 30s budget, 2s interval
DocumentDetails patient = client.documents.waitUntilReady(doc.getId(), 120_000, 5_000);A virtual assignment can be created while metadata is still processing; a collect assignment cannot,
because its field placements reference specific page IDs.
Signers live at the account level and are reused across documents, so the same person is one record no matter how many contracts they sign.
// Strict create: always sends POST and reports a duplicate email as an ApiException.
Signer signer = client.signers.create(
new CreateSignerPayload("John Doe", "john@example.com")
.setWhatsappPhoneNumber("+5548999990000")
.setGovernmentId("52998224725")); // CPF, optional
// Explicit reuse policy: search by exact case-insensitive email, POST only when absent.
// It does not update an existing signer's fields.
Signer reusable = client.signers.findOrCreate(
new CreateSignerPayload("John Doe", "john@example.com"));
Signer existing = client.signers.findByEmail("john@example.com");
Signer fetched = client.signers.get(signer.getId());
PaginatedResult<Signer> list = client.signers.list(Map.of("search", "john"));
client.signers.update(signer.getId(), new UpdateSignerPayload()
.setFullName("Johnny Doe")
.setGovernmentId("39053344705"));
client.signers.delete(signer.getId());Pick create when the signer is genuinely new and a duplicate should be an error; pick findOrCreate when
"this person, whether or not we have met them before" is what you mean. Changing a signer's email or WhatsApp
number is refused while that channel is verified on an in-flight document, and rotates the access codes of any
unverified in-flight requests — resend after such a change.
Estimate first. The estimate endpoint takes the same payload shape as the create call, charges nothing, and tells you whether the account can fund the request.
CreateAssignmentPayload request = new CreateAssignmentPayload()
.setMethod("virtual")
.setSignerStrings(signer.getId())
.setMessage("Please review and sign")
.setExpiresAt("2030-12-31T23:59:00Z");
CostEstimate estimate = client.assignments.estimateCost(doc.getId(), request);
if (!Boolean.TRUE.equals(estimate.getHasSufficientResources())) {
throw new IllegalStateException("Assignment cannot be funded: " + estimate.getBlockingReason());
}
Assignment assignment = client.assignments.create(doc.getId(), request);blocking_reason is PendingPayment, InsufficientDocuments, or InsufficientCredits;
getBreakdown() itemises what drove the number.
virtual versus collect. A virtual assignment asks for a signature and nothing else. A collect
assignment additionally places input fields at coordinates on specific pages, so it needs a metadata_ready
document and one entry per page:
CreateAssignmentPayload collect = new CreateAssignmentPayload()
.setMethod("collect")
.setSignerStrings(signer.getId())
.setCollectEntries(List.of(new CollectAssignmentEntry(pageId, List.of(
new CollectFieldPlacement(signer.getId(), fieldId,
new DisplaySettings(100, 100, 240, 40, 12))))));Set per signer at assignment time through SignerRef.setVerificationMethod(...) and
setNotificationMethods(...). Verification and notification are coupled: send one, both, or neither, and
the missing side is inferred. With neither, both default to Email.
| Method | How it works | Cost per signer |
|---|---|---|
Email (default) |
A one-time code by email, required before signing | Free |
Whatsapp |
A one-time code over WhatsApp | Verification is free; the WhatsApp notification it requires costs 0.45 credits, on paid plans only |
DigitalCertificate |
The signer signs with their own ICP-Brasil certificate (A1 or A3) through the Web PKI browser extension, producing a qualified PAdES signature | 2 credits, plus the notification cost |
Allowed pairings — an invalid one answers HTTP 400:
| Verification | Accepted notifications |
|---|---|
Email |
Email |
Whatsapp |
Whatsapp |
DigitalCertificate |
Email or Whatsapp |
One notification channel per signer. No verification method is priced on its own: what is billed is the notification it travels with — plus, for a digital certificate, the signature itself. So two email-notified signers cost 0 credits and two WhatsApp-notified ones cost 0.9.
CreateAssignmentPayload overWhatsapp = new CreateAssignmentPayload()
.setMethod("virtual")
.setSigners(List.of(SignerRef.of(signer.getId())
.setVerificationMethod("Whatsapp")
.setNotificationMethods(List.of("Whatsapp"))
.setStep(1)));ICP-Brasil digital certificates (A1 and A3). These need the Digital Certificate feature on the account
(Standard and Pro plans), a CPF or CNPJ in the signer's government_id, and exactly one certificate signer
alone in that step. A CPF requires that person's own certificate (an e-CPF, or an e-CNPJ naming them as legal
representative); a CNPJ requires the company's e-CNPJ. A1 (a file) and A3 (a token or smartcard) both work —
they differ only in where the signer's key is stored, and both are reached through the same Web PKI extension.
The 2-credit charge appears in the estimate under SignatureDigitalCertificate.
The ordinary signing endpoint rejects certificate signers: their signature comes from a two-step handshake
with the Web PKI extension (POST /v1/signers/certificate/start, then .../complete). Those two routes are
browser certificate operations without published OpenAPI request/response schemas, so this
server-side SDK does not wrap them. Once the flow completes, download the qualified PDF with
client.documents.download(documentId, "pades").
Signing order. step sequences the signers: everyone sharing a step signs in parallel, and the next step
is notified only after the previous one completes. If you use it, every signer needs one, and the values must
be contiguous from 1.
Once an assignment exists you can list, re-notify, and re-schedule it:
PaginatedResult<Assignment> assignments = client.assignments.list(Map.of("page", "1", "per-page", "20"));
ResendResult resent = client.assignments.resendNotification(doc.getId(), assignment.getId(), signer.getId());
ResendCostEstimate resendCost = client.assignments.estimateResendCost(
doc.getId(), assignment.getId(), signer.getId());
client.assignments.resetExpiration(doc.getId(), assignment.getId(), "2027-06-30T00:00:00Z");
client.assignments.clearExpiration(doc.getId(), assignment.getId()); // sends expires_at: null
List<WhatsappNotification> whatsapp =
client.assignments.whatsappNotifications(doc.getId(), assignment.getId());For the plain virtual path, uploadAndRequestSignatures composes upload, optional readiness polling,
findOrCreate per signer, and assignment creation, returning the document, the assignment, and the signer IDs.
UploadAndRequestSignaturesResult result = client.uploadAndRequestSignatures(
new UploadAndRequestSignaturesOptions(new File("contract.pdf"), List.of(
new UploadAndRequestSignaturesSigner("John Doe", "john@example.com")))
.setMessage("Please review and sign"));The API has no transaction spanning those calls. If a later stage fails, the helper makes a best-effort attempt to delete the uploaded document and attaches any cleanup failure to the original exception as a suppressed exception. Account-scoped signers are never deleted automatically, because another workflow may already reference them.
These endpoints are authorised by a short-lived signer-access-code sent as a query parameter, not by the
account API key. They are normally called from a signer landing page rather than from your back end, and the
SDK exposes them so you can build that page or simulate the flow in tests.
// The document the signer was invited to sign. Returns HTTP 409 while it is still being prepared —
// surfaced as ApiException with getStatusCode() == 409; retry with backoff.
DocumentDetails signingView = client.signerSelf.getSign(signerAccessCode);
// Identity: profile, terms, one-time code, and confirmed personal data.
Signer self = client.signerSelf.getSelf(signerAccessCode);
client.signerSelf.acceptTerms(signerAccessCode);
client.signerSelf.verifyEmail("123456", signerAccessCode);
Signer confirmed = client.signerSelf.confirmSignerData(doc.getId(), signerAccessCode,
new ConfirmSignerDataPayload().setFullName("John Doe").setEmail("signer@example.com")
.setGovernmentId("15774136604"));
// Signature image. PNG is the published media type; the SDK also detects JPEG bytes.
// reuse=true lets the saved signature be reused across documents (sets is_signature_reusable).
client.signerSelf.uploadSignature(signerAccessCode, signatureBytes, "signature");
client.signerSelf.uploadSignature(signerAccessCode, signatureBytes, "signature", true);
byte[] saved = client.signerSelf.downloadSignature(signerAccessCode, "signature");A signer with a digital certificate must confirm their data and accept the terms before getSign will
return the document; otherwise it answers HTTP 400. A signer using a virtual assignment must confirm their data
before signing, or the sign call answers HTTP 400.
Signing itself, and declining, are mutually exclusive:
client.assignments.signEntries(doc.getId(), assignment.getId(), signerAccessCode, List.of(
new AssignmentSignEntry("item-1", "field-1", "page-1", "John Doe")));
// Alternative to the above, not a follow-up:
// client.assignments.decline(doc.getId(), assignment.getId(), signerAccessCode, "Clause 3 is unacceptable");A signer with several pending documents can work through them in bulk, and can browse and download their own copies:
DocumentDetails current = client.signerSelf.getCurrentDocument(signerId, signerAccessCode);
PaginatedResult<DocumentDetails> mine = client.signerSelf.listDocuments(
signerId, signerAccessCode, Map.of("page", "1", "per_page", "20"));
PaginatedResult<DocumentDetails> found =
client.signerSelf.searchDocuments(signerId, signerAccessCode, "invoice");
// The artifact route is public; an overload also sends the access code where an environment requires it.
byte[] signerCopy = client.signerSelf.downloadDocument(signerId, doc.getId(), "pades");
byte[] authorizedCopy = client.signerSelf.downloadDocument(
signerId, doc.getId(), "original", signerAccessCode);
client.signerSelf.signMultiple(signerAccessCode, List.of(doc1.getId(), doc2.getId()));
// Alternative to the above for the same documents, not a follow-up:
// client.signerSelf.declineMultiple(signerAccessCode, List.of(doc1.getId()), "Not interested");Two public endpoints support a signer landing page before any access code exists — an unauthenticated document view, and a request to re-send the one-time access token:
AssinafyClient publicClient = new AssinafyClient(new AssinafyClientOptions());
DocumentDetails publicInfo = publicClient.documents.getPublic(doc.getId());
// The document must be in pending_signature. channel is "email" or "whatsapp".
publicClient.documents.sendToken(doc.getId(), "signer@example.com", "email");Poll for state, or — better — subscribe to webhooks and fetch state when one arrives.
DocumentDetails currentState = client.documents.details(doc.getId());
SigningProgress progress = client.documents.getSigningProgress(doc.getId());
boolean done = client.documents.isFullySigned(doc.getId());
List<DocumentActivity> activity = client.documents.activities(doc.getId());
List<DocumentStatsRow> accountStats = client.accounts.stats(Map.of("granularity", "monthly"));
List<DocumentStatsRow> allAccountStats = client.users.stats(Map.of("granularity", "monthly"));An account registers one webhook endpoint, or up to three on paid plans. Each endpoint has its own URL, event
list, and signing setting, and every active endpoint subscribed to an event receives it. Creating an endpoint
past the plan's limit returns 403; reusing another endpoint's URL returns 400.
List<String> events = List.of("document_ready", "signer_signed_document");
WebhookEndpoint endpoint = client.webhooks.createEndpoint(new WebhookEndpointPayload()
.setUrl("https://example.com/webhooks/assinafy")
.setEmail("ops@example.com")
.setEvents(events)
.setName("ERP")
.setSigningEnabled(true)); // sign every delivery (Standard Webhooks)
List<WebhookEndpoint> endpoints = client.webhooks.listEndpoints(); // oldest first
client.webhooks.getEndpoint(endpoint.id());
client.webhooks.updateEndpoint(endpoint.id(), new WebhookEndpointPayload().setActive(false)); // only what changed
client.webhooks.deleteEndpoint(endpoint.id()); // frees the slot
List<WebhookEventTypeInfo> eventTypes = client.webhooks.listEventTypes();
PaginatedResult<WebhookDispatch> dispatches = client.webhooks.listDispatches(
new ListDispatchesParams().setEndpointId(endpoint.id()).setEvent("document_ready").setDelivered(false));
client.webhooks.retryDispatch(dispatchId);With signing_enabled, every delivery carries the webhook-id, webhook-timestamp, and webhook-signature
headers. The endpoint's secret (whsec_...) comes from getEndpointSecret, and only an API key or a user
session can read it — an OAuth application cannot. WebhookVerifier checks the signature in constant time,
rejects deliveries more than five minutes from the local clock (replays), and returns the typed body. Pass the
raw body exactly as received; re-serialized JSON does not verify.
WebhookVerifier verifier = new WebhookVerifier(client.webhooks.getEndpointSecret(endpoint.id()));
// In the HTTP receiver:
WebhookEvent event = verifier.verify(
request.getHeader("webhook-id"),
request.getHeader("webhook-timestamp"),
request.getHeader("webhook-signature"),
rawBody); // byte[] or String; throws ValidationException when it does not verify
if (alreadyProcessed(request.getHeader("webhook-id"))) return; // the same id repeats on the retry
handle(event.event(), event.object());
// Rotation takes effect immediately: later deliveries use only the new secret.
verifier = new WebhookVerifier(client.webhooks.rotateEndpointSecret(endpoint.id()));Answer 2xx quickly and process afterwards. Each event gets up to two attempts, three seconds apart; after ten
consecutive failed events the endpoint enters a circuit breaker until a delivery succeeds.
The older subscription operations (register, getSubscription, inactivate) keep working and act on the
account's oldest endpoint:
WebhookSubscription sub = client.webhooks.register(
new RegisterWebhookPayload("https://example.com/webhooks", "ops@example.com")
.setEvents(events).setActive(true));
client.webhooks.getSubscription();
client.webhooks.inactivate(); // stops deliveries and keeps the configurationOnce the document reaches certificated, its artifacts are available. original is the uploaded PDF,
certificated is the signed one, certificate-page is the signature evidence page, pades exists only when a
digital-certificate signer took part, and bundle is a zip of the rest.
byte[] signedPdf = client.documents.download(doc.getId()); // defaults to "certificated"
byte[] original = client.documents.download(doc.getId(), "original");
byte[] bundle = client.documents.download(doc.getId(), "bundle");
byte[] thumbnail = client.documents.thumbnail(doc.getId());
byte[] pageImage = client.documents.downloadPage(doc.getId(), pageId);
// Public, unauthenticated verification by the hash printed on a signed document.
DocumentVerification verification = client.documents.verify(signatureHash);
boolean valid = Boolean.TRUE.equals(verification.getIsValid());Delete only what you own and only when the status allows it — documents.statuses() reports which statuses
are deletable.
boolean deletable = client.documents.statuses().stream()
.anyMatch(status -> currentState.getStatus().equals(status.getCode())
&& Boolean.TRUE.equals(status.getDeletable()));
if (deletable) {
client.documents.delete(doc.getId());
}The auth resource covers the credential lifecycle itself. The password-reset routes are public; the rest need
a bearer token or an API key.
AuthenticationResult session = client.auth.login("user@example.com", "password");
if (session.getMfaToken() != null) {
// Two-factor user: login returns a single-use challenge valid for 5 minutes.
session = client.auth.verifyMfa(session.getMfaToken(), authenticatorCode); // or a recovery code
}
String accessToken = session.getAccessToken();
AuthenticationResult googleSession = client.auth.socialLogin(
new SocialLoginPayload("google", googleToken, true));
// Rotate keys through a bearer session so the client does not retain a key it just revoked.
AssinafyClient tokenClient = new AssinafyClient(new AssinafyClientOptions().setToken(accessToken));
ApiKeyResponse masked = tokenClient.auth.getApiKey(); // masked; the full key is never retrievable
ApiKeyResponse created = tokenClient.auth.createApiKey("password"); // replaces any previous key
tokenClient.auth.deleteApiKey();
tokenClient.auth.linkSocialLogin("google", googleToken);
tokenClient.auth.changePassword("user@example.com", "old-password", "new-password");
// Two-factor authentication (authenticator app, TOTP).
TotpEnrollment enrollment = tokenClient.auth.startTotpEnrollment("My phone");
// Render enrollment.provisioningUri() as a QR code; the secret is returned only by this call.
List<String> recoveryCodes = tokenClient.auth.confirmTotpEnrollment(
enrollment.id(), codeFromNewDevice, null, null); // store the codes: they are shown only once
MfaMethods methods = tokenClient.auth.listMfaMethods();
List<String> freshCodes = tokenClient.auth.regenerateRecoveryCodes("password", null);
boolean stillEnabled = tokenClient.auth.removeMfaMethod(methods.methods().get(0).id(), "password", null);
AssinafyClient publicClient = new AssinafyClient(new AssinafyClientOptions());
publicClient.auth.requestPasswordReset("user@example.com");
publicClient.auth.resetPassword("user@example.com", resetToken, "new-password");Everything above assumes the workspace is yours. OAuth is the other case: an application that other Assinafy customers connect to their workspace. They approve it once, and you receive tokens limited to the permissions they granted and to the one workspace they chose — never their password or API key, and they can switch it off at any time. If you are automating your own account, keep the API key and skip this section.
Register the application in the Assinafy app under Settings → OAuth applications. You receive a
client_id, plus a client_secret if it is confidential (runs on a server you control). PKCE is mandatory
for every application, confidential ones included.
1. Start a connection. Generate a verifier and a state per attempt and keep both in the user's session.
String verifier = OAuthResource.generateCodeVerifier();
String state = OAuthResource.generateState();
String authorizeUrl = client.oauth.authorizationUrl(
new OAuthAuthorizationRequest("your-client-id", "https://myapp.example/oauth/callback")
.setScopes(List.of("documents:read", "documents:write", "offline_access"))
.setState(state)
.setCodeVerifier(verifier));Send the browser there with a full page navigation. response_type=code and code_challenge_method=S256 are
fixed, the challenge is derived from the verifier, and resource defaults to the origin of the client's base
URL. The redirect URI must be HTTPS, carry no fragment, and match a registered value character for character —
…/callback and …/callback/ are different URIs. If the client ID or redirect URI is wrong, the user is not
sent back to you: the authorization server shows an error on its own page.
| Scope | Lets your app |
|---|---|
documents:read |
Read documents, their signers, assignments, and activity |
documents:write |
Create documents and send them for signature |
templates:read / templates:write |
Read, and create or change, templates |
account:read |
Read the workspace profile, theme, and logo |
webhooks:write |
Create, change, and remove the workspace webhook endpoints (the signing secret needs an API key) |
openid / profile / email |
Identify the user, and read their name and email |
offline_access |
Receive a refresh token, so the app keeps working while the user is away |
Request the minimum: the user approves everything or nothing, and each permission is another line they read. Billing, account lifecycle, credentials, and administration are never reachable with an OAuth token.
2. Handle the return. Check state against the stored value and iss against
https://auth.assinafy.com.br before anything else, including on an error= return (such as access_denied
when the user declines), which carries no code to exchange. Then exchange the code from your server — it is
single-use and expires 60 seconds after approval.
OAuthTokens tokens = client.oauth.exchangeAuthorizationCode(
"your-client-id", "your-client-secret", // null secret for a public application
code, "https://myapp.example/oauth/callback", storedVerifier);Read tokens.getScope() for what you actually received rather than assuming. getRefreshToken() is populated
only when offline_access was granted, and getIdToken() only when openid was; the SDK does not validate the
id_token, so check it with an OpenID Connect library before trusting it.
3. Call the API as the user. A token belongs to exactly one workspace, and the workspace list returns that one; store its ID beside the tokens.
AssinafyClient asUser = new AssinafyClient(
new AssinafyClientOptions().setToken(tokens.getAccessToken()));
String workspaceId = asUser.accounts.list().get(0).getId();Calling any other workspace answers 403, even one the same user belongs to. If a customer uses several, connect each separately and keep tokens per workspace.
4. Keep it alive. Access tokens last one hour. A refresh token is valid for 30 days, and every refresh returns a new one with a fresh 30 days; a connection only expires if your app goes 30 days without refreshing, after which the user has to reconnect.
OAuthTokens renewed = client.oauth.refreshToken("your-client-id", "your-client-secret", store.load());
store.save(renewed.getRefreshToken()); // before using anything else in the response
// A client keeps the token it was built with: call the API through one built with the renewed token.
asUser = new AssinafyClient(new AssinafyClientOptions().setToken(renewed.getAccessToken()));Every refresh issues a new refresh token and retires the old one. A reused refresh token cannot be told apart
from a stolen one being replayed, so it ends the whole connection. Refresh one at a time per connection, and
never re-send a refresh token after a failure that may have reached the server — a timeout, a dropped
connection, a 5xx — because the first attempt may already have retired it; the SDK itself never re-sends it.
Re-read your storage instead: continue only if it holds a different, newer token; if it still holds the one you
sent, the outcome is unknown, so ask the user to reconnect. Only a failure that provably happened before sending
is safe to retry: a NetworkException caused by an UnknownHostException (DNS), a ConnectException
(connection refused), or an SSLHandshakeException. refreshToken returns only when the response carries a new
refresh token; otherwise it throws ValidationException, and the user has to reconnect.
5. Handle the two OAuth failures. A missing permission answers 403 with a challenge naming it; the SDK surfaces both parts.
catch (ApiException e) {
if ("insufficient_scope".equals(e.getOAuthError())) {
reconnectRequesting(e.getRequiredScope()); // reconnect, not retry
} else if (e.getStatusCode() == 401) {
// Expired or revoked: refresh, and if that fails ask the user to connect again.
}
}A 403 without that code has another cause: a different workspace, the user's own role, or an area OAuth tokens can never reach.
6. Identify and disconnect.
OAuthUserInfo who = asUser.oauth.userInfo(); // needs openid; name needs profile, email needs email
// On disconnect, revoke rather than only forgetting the token, and revoke the refresh token saved most recently:
// every refresh retired the one before it, and every token outcome answers 200, a retired token included.
client.oauth.revoke("your-client-id", "your-client-secret", store.load(), "refresh_token");client.oauth.protectedResourceMetadata() reads the RFC 9728 document at the API host root, naming the
canonical resource identifier and the authorization server. Most OAuth libraries need only the issuer,
https://auth.assinafy.com.br, and read the rest from its own /.well-known/oauth-authorization-server
document, which the authorization server serves — not this API.
Before going live: a new verifier and state per attempt; state and iss checked; the secret only on your
server; the new refresh token saved before use; 401 handled; the workspace ID stored per connection; every
production redirect URI registered; only the permissions you need; tokens revoked on disconnect. A new
application is unverified and can connect to at most 25 workspaces until Assinafy reviews it.
Everything the SDK throws descends from AssinafyException, so one catch block can be the backstop while the
three subtypes let you separate "my input was wrong" from "the API said no" from "the network failed".
import com.assinafy.sdk.exceptions.*;
try {
client.documents.upload(new File("contract.pdf"));
} catch (ValidationException e) {
// Caught before any request was sent: missing IDs, bad email, oversized file, malformed payload.
System.err.println("Validation: " + e.getMessage() + " " + e.getErrors());
} catch (ApiException e) {
// The API rejected it. getResponseBody() keeps the complete error JSON.
System.err.println("API error " + e.getStatusCode() + ": " + e.getMessage());
Integer backoff = e.getRetryAfterSeconds(); // populated only for retryable 429/503
String oauthError = e.getOAuthError(); // RFC 6749 code on an OAuth failure; see section 11
} catch (NetworkException e) {
// Transport failure, or a response body that could not be parsed.
System.err.println("Network: " + e.getMessage());
} catch (AssinafyException e) {
System.err.println("SDK error: " + e.getMessage());
}The standard error body is { "status": integer, "message": string, "data": object|null }. Treat both 400 and
422 as validation failures. A blocked account deletion adds a restrictions array naming each blocker.
| Environment | Base URL |
|---|---|
| Production | https://api.assinafy.com.br/v1 (default) |
| Sandbox | https://sandbox.assinafy.com.br/v1 — set it with setBaseUrl(...); this artifact exposes no sandbox constant |
The sandbox trails production. A route the sandbox router answers 404 for while api.assinafy.com.br serves it
can indicate deployment lag. OAuth endpoints are available in the sandbox; production apps and
authorizations use production credentials and workspaces.
# Run tests in Docker (recommended)
docker compose run --rm test
# Or run the complete local verification with the Maven Wrapper (requires JDK 25+)
./mvnw verify
# The release gate: Javadoc doclint plus the sources and javadoc jars. CI runs this as a separate step,
# because doclint warnings fail here and not under `verify`.
./mvnw -DskipTests -Prelease package
# Live smoke tests against the sandbox (skipped unless credentials are set; defaults to the sandbox base URL)
ASSINAFY_API_KEY=... ASSINAFY_ACCOUNT_ID=... ./mvnw test -Dtest=LiveSmokeTest
# Explicit opt-in for the tests that dispatch real signing-request email
ASSINAFY_API_KEY=... ASSINAFY_ACCOUNT_ID=... ASSINAFY_LIVE_EMAILS=true \
ASSINAFY_TEST_EMAIL=... ASSINAFY_SECOND_TEST_EMAIL=... ./mvnw test -Dtest=LiveSmokeTestLiveSmokeTest refuses non-sandbox base URLs. Keep live credentials in environment/CI secrets, never Maven
properties or source files.
The automated sandbox suite covers API-key workflows through assignment creation. Completing a signature also requires the short-lived signer access code and one-time verification code delivered out of band, plus an account with document/credit capacity. Use the signer self-service sequence from section 8 with a disposable assignment when validating that final step; CI does not fabricate or persist either credential.
The GitLab manual job uses protected, masked, sandbox-scoped variables; notification-producing cases require an explicit opt-in.
CI runs ./mvnw verify on the current JDK 25 LTS. GitLab is the source of truth and mirrors to GitHub, where
the equivalent Actions workflows run. Releases publish to GitHub Packages on a v* tag via the release
profile (-Prelease, which also builds -sources and -javadoc jars).
- docs/API_REFERENCE.md — the per-operation lookup table
- docs/EXAMPLES.md — longer runnable programs
- docs/INSTALLATION.md — build setup
- README.md — this same guide, in Portuguese
- API documentation
Distributed under the MIT license.