Pular para o conteúdo principal

Lacuna Signer integration

Operator walkthrough for routing a profile through Lacuna Signer instead of a locally-held certificate. Local certificate signing (PFX / PKCS#11 / Windows store) and Lacuna Signer signing coexist per profile — different watched folders can use different signing methods in the same instance.

When to use this​

Pick Method = LacunaSigner for a profile when:

  • A human (not a certificate held by the server) must sign each document — e.g. counter-signed contracts, employment agreements, HR onboarding paperwork.
  • The signer's identity is the participant's, not the service's. Each dispatched document is owned by the participant on the Signer side.
  • The audit trail you want is the one Signer keeps (signer identity, signature evidence, refusal reasons, expiry).

Pick Method = Local (the default) when:

  • The signature is the service's — automated invoice signing with the company's signing cert, NFe runtime signing on a PKCS#11 token, batch counter-signing.
  • The cert lives on the host (PFX / HSM / Windows store) and there's no human in the loop.

Both can run side by side. A single instance can watch input/contracts/ (LacunaSigner) and input/nfe/ (Local PKCS#11) at the same time.

Pick Method = SignerFolder — the opposite direction — when a person creates documents in Lacuna Signer and names the organization's certificate holder as a participant, and that participant's signature should arrive without anyone touching a token. See Signing in a Lacuna Signer folder below.

Architecture summary​

input/ ─▶ Watcher ─▶ Queued ─▶ worker claims
│
profile.Method? ───┤
│
Local ───────────────────────────▶ sign in-slot ─▶ Verifying ─▶ Completed
│
LacunaSigner ─▶ upload + create-document ─▶ AwaitingSigner (concurrency slot RELEASED)
│
poll worker tick ────────┤
│
Pending → stays AwaitingSigner
Concluded → download bytes ─▶ Verifying ─▶ Completed
Refused/Expired/Canceled → Failed
timeout → Failed

Two cooperating workers instead of one:

  1. The pipeline worker claims Queued jobs and, for LacunaSigner profiles, only dispatches them to Signer (upload + create-document) and transitions them to AwaitingSigner. The pipeline slot is released immediately after dispatch — the job is now parked on the remote side and the worker is free to pick the next item up.
  2. A separate poll worker wakes every Signer:PollIntervalSeconds (default 30 s) and walks every AwaitingSigner row. For each row it checks the document status on the Signer API; concluded documents are downloaded and pushed through the same verify → encrypt → promote tail the Local path uses.

This split matters: holding a Pipeline:MaxConcurrency slot while a human takes days to sign would defeat the queue entirely.

The state machine, extended​

AwaitingSigner slots between Processing and Verifying for LacunaSigner profiles:

Queued ─▶ Processing ─┬─ Local sign ok ─────────────▶ Verifying ─▶ Completed
│ └▶ Failed
└─ dispatched to Signer ─▶ AwaitingSigner
│
concluded → download ───────┼──▶ Verifying ─▶ Completed
refused/expired/timeout ────┴──▶ Failed
operator cancel ───────────────▶ Canceled (best-effort remote cancel)

Local-only profiles never enter AwaitingSigner. LacunaSigner profiles never take the local Processing → Verifying direct path.

Configuration​

Signer:* — one tenant per host​

The Signer connection is global — one endpoint + one API key for the host, shared by every profile that uses Method = LacunaSigner or Method = SignerFolder. The two methods read different halves of the rest of the block:

KeyTypeDefaultEnv overrideUsed byRequired when
Signer:Endpointstring""Signer__Endpointboth methodsAny profile uses LacunaSigner or SignerFolder, or anything else under Signer: is set. Cloud default: https://signer.lacunasoftware.com.
Signer:ApiKeystring""Signer__ApiKeyboth methodsREQUIRED, SECRET, same condition. Format: application-id|secret.
Signer:PollIntervalSecondsint30Signer__PollIntervalSecondsLacunaSigneroptional — how often the poll worker walks the AwaitingSigner rows. 1–3600.
Signer:TimeoutHoursint168 (7 days)Signer__TimeoutHoursLacunaSigneroptional — how long a dispatched document may wait for its participant. 1–8760.
Signer:MaxConsecutiveApiFailuresint5Signer__MaxConsecutiveApiFailuresLacunaSigneroptional — the per-document poll budget below. 1–100.
Signer:FolderSweepIntervalSecondsint300 (5 minutes)Signer__FolderSweepIntervalSecondsSignerFolderoptional — how often every bound Signer folder is listed; the longest a document waits for this host to notice it. 60–86400.
Signer:WebhookSecretstring"" (notifications off)Signer__WebhookSecretSignerFolderoptional, SECRET — the Bearer token Signer presents on a notification. At least 32 characters. Setting it counts as setting the block, so Signer:Endpoint and Signer:ApiKey become required.

A host that needs only one method may leave the other method's keys at their defaults: nothing reads them. Every key, its bounds and its refusals, is also in Configuration.

The validator is self-gating on this section — omit Signer:* entirely and nothing here is enforced, which is what a pure Local deployment does. Write any part of it and the whole block is validated.

Changed in 2.1.0 — the Signer:* block is judged on its own

Up to 2.0.x the block was validated only when some profile selected Method = LacunaSigner. Profiles now live in the operational store and can be switched to Lacuna Signer from the dashboard with no restart, so the rule is stated the other way round:

  • A half-written Signer: block refuses the boot even if no profile uses it — an endpoint with no API key, say, left behind for later. The message names both keys and offers removing the section as the remedy.
  • Selecting Method = LacunaSigner is refused when the host has no Signer:* settings — at boot for a profile still being seeded from configuration, and on the profile page for one being saved.
  • Whether the host has Signer:* settings is what starts the remote-signer gateway and the poll worker, so a profile switched over to Lacuna Signer after boot starts dispatching without a restart. On a host with the whole block set and no profile using it, the poll worker runs and finds nothing to do each interval — remove the section if this host signs everything locally.
The API key and the webhook secret are secrets.

Set them as Signer__ApiKey and Signer__WebhookSecret in bulksigner.env (Linux) / a Machine env var (Windows) / .env (Docker). The literal values are scrubbed from logs.

A profile that selects Method = SignerFolder is refused on a host with no Signer:* settings exactly as a LacunaSigner one is, and the same settings start the Signer-folder sweep. A stored SignerFolder profile on a host that later lost the settings is degraded, never a boot refusal.

Choosing the method from the dashboard​

This is where you pick the method on a running deployment. Signing:Profiles[] is a one-time seed imported on the first boot (see Configuration), so the next section describes what a seed looks like rather than where you go to change one.

The Certificate panel on /profiles/{name} carries the method, and so does the form at /profiles/_new. It sits on that panel rather than the Behaviour one because the method decides whether the profile has a local key at all: pick Lacuna Signer and the certificate source and its coordinates are replaced by the three participant fields — name, email, identifier — which is the whole of where such a profile's signature comes from. Pick Lacuna Signer folder and the certificate stays, with a Signer folder picker above it — see Signing in a Lacuna Signer folder below.

The directions differ, and the form says which before you save. The rule underneath is one sentence: a change waits for a restart exactly when it leaves the profile needing a key no running instance has opened.

SwitchingWhen it takes effectWhat happens to the other block
Local or Signer folder → Lacuna SignerThe next job claimed. No restart — the gateway runs on every host that has Signer:* settings, not only for the profiles that existed at boot.The certificate coordinates are cleared, password included: a credential at rest for a key that lives at the service is one nothing will ever open. A Signer folder binding is let go.
Lacuna Signer → Local or Signer folderThe next restart, because a private key has to be opened and no save opens one. Until then the profile is reported as degraded on its own page, and jobs routed to it fail with profile.degraded.The participant is cleared.
Local ↔ Signer folder, same certificateThe next job claimed. No restart — both sign with the key this host already opened, and it carries over.Moving off the Signer folder lets the folder go.
Any save that also changes a certificate coordinateThe next restart, whatever the method — the certificate rule is unchanged.—

Refusals happen at the save, in your display language: a participant missing any of its three fields, an email with no @, and selecting Lacuna Signer on a host with no Signer:* settings — the one refusal whose remedy is a configuration change and a restart rather than a form field, which is why its wording names the keys.

The participant's identifier is not check-digit validated. Unlike an approver's CPF — which this product writes into its own audit records — this one is handed to the remote service, and the service is the authority on whether it knows the participant.

Signing:Profiles[].Method + Signer block​

Per-profile method selection as a seed, imported on the first boot against an empty profile table. The default is Method = Local, so pre-existing profiles need no change.

"Signing": {
"Profiles": [
{
"Name": "contracts",
"Format": "Pades",
"Method": "LacunaSigner",
"Verify": true,
"Encrypt": false,
"ValidateCertificate": false,
"Signer": {
"Name": "Jack Bauer",
"Email": "jack.bauer@example.com",
"Identifier": "75502846369"
}
}
]
}

Profile-level validation:

  • Method = LacunaSigner requires a non-empty Signer:{Name, Email, Identifier} block. The validator refuses partial blocks.
  • Method = LacunaSigner forbids a Certificate:* block (no local cert involved).
  • Method = LacunaSigner forbids ValidateCertificate = true (there's no local cert to validate).
  • Method = Local rules are unchanged: cert block required, Signer block ignored if present.
  • Method = LacunaSigner cannot be combined with an approval rule whose signer set is ProfileKeyAndApprovers — the remote signer would be handed an envelope of approver signatures rather than the payment file. See Approvals.

The same rules refuse a save on the profile page. The derived default profile (seeded when Signing:Profiles[] is omitted) is always Method = Local.

Operator flow​

  1. Operator drops a file into a folder watched by a LacunaSigner profile (or POST /api/files?profile=contracts).
  2. Watcher / endpoint enqueues the job; Status = Queued.
  3. The pipeline worker claims the next slot, transitions the job to Processing, then uploads and creates the document on Signer. On success the job transitions to AwaitingSigner with the remote document id recorded; the slot is released.
  4. Signer emails the participant; the participant signs through the Signer UI on their own time.
  5. The poll worker ticks every Signer:PollIntervalSeconds. On each tick it loads every AwaitingSigner row, oldest-first, and for each:
    • Pending → leaves the row alone.
    • Concluded → downloads the signed bytes, transitions to Verifying, runs the same verify → optionally-encrypt → promote tail, transitions to Completed.
    • Refused / Expired / Canceled → transitions to Failed with signer.document-rejected.
    • Local timeout (AwaitingSigner longer than Signer:TimeoutHours) → transitions to Failed with signer.timeout. The remote document is left as-is on the Signer side.

The dashboard surfaces AwaitingSigner as a distinct status (yellow chip, hourglass icon). The Job detail page shows the remote document id and the dispatch time, and an Awaiting signer stat tile appears when any LacunaSigner profile is configured.

Cancel semantics​

Operator cancel is widened to {Queued, AwaitingSigner} for LacunaSigner profiles. Processing and Verifying remain sacred.

When an operator cancels an AwaitingSigner job:

  1. The job transitions to Canceled locally — same handler, same audit trail.
  2. The handler then makes a best-effort remote-cancel call to Signer. Failures are logged at Warning but do not roll back the local cancel.
  3. If the remote cancel failed, the participant may still see the document in their Signer inbox. The local job is correctly Canceled regardless.

The Cancel button on the job page asks first: its confirmation dialog names the file and says what the cancel does from the job's current status — for a job waiting on Lacuna Signer, that its remote document is canceled best-effort. POST /api/jobs/{id}/cancel asks nothing.

Clear Jobs does not cancel remote documents

Clear Jobs deletes every job record whatever its status, AwaitingSigner included, but it makes no call to Lacuna Signer: a document already dispatched stays in the participant's inbox. Cancel those jobs first if the participant should not sign them. See Operations.

Best-effort cancel is a deliberate trade-off.

Rolling back the local cancel because a network round-trip failed would leave the operator in limbo and contradict the "cancel returns closure" behavior. The orphaned-remote-document case is rare and benign — the participant can ignore the email, or the operator can clean up in the Signer admin.

API failures and the per-job budget​

The Signer integration distinguishes two failure shapes:

  • Transient — a refused connection or a timeout, a 5xx, 408 or 429, or an answer that is not Signer's API (a proxy's error page). The poll worker increments a per-document failure counter and continues to the next row. The counter resets on the first successful call. Once Signer:MaxConsecutiveApiFailures is exceeded for a single document, that job is failed with code = signer.unreachable. Other rows are unaffected.
  • Permanent — a 4xx that won't be fixed by retrying (invalid API key, unknown document, malformed request). The job is failed immediately with code = signer.unreachable.

A process restart resets the in-memory failure counters. If the underlying outage cleared between failures and restart, polling resumes normally on next boot.

Changed in 2.16.0 — every form of "Signer out of reach" counts against the budget

Up to 2.15.x only a bare connection failure was recognized as Signer being unreachable. A 5xx, a 408 or 429, a proxy's error page, or the REST client's own timeout during a poll or download was logged and retried on every tick, uncounted, until Signer:TimeoutHours. From 2.16.0 each of them counts against Signer:MaxConsecutiveApiFailures and bulksigner_signer_api_errors_total, so an outage longer than the budget fails the job signer.unreachable — retry it once Signer is back. An HTTP error Signer's API did not shape, such as a bare 4xx from something in between, now fails the job at once as permanent, as Signer's own refusals always did.

Dispatch vs poll asymmetry.

Signer:MaxConsecutiveApiFailures only protects the poll path. A transient failure during dispatch fails the job on the first error rather than being retried against a budget — by design, since dispatch is a single short call at the start of the job. If your Signer endpoint is flaky enough that dispatch failures matter, retry from the dashboard or REST (POST /api/jobs/{id}/retry) once the upstream is back.

Restart recovery — AwaitingSigner rows are NOT swept​

The startup recovery sweep transitions any stuck Processing / Verifying job to Failed (those were mid-flight when the previous process died) — except a SignerFolder job, which has no file and is ended on Lacuna Signer's word instead (see When the run signing it ends). AwaitingSigner rows are explicitly excluded — the work is parked on the remote side; sweeping them locally would lose data the host has no business invalidating. The poll worker resumes polling them on next boot, exactly where it left off.

What lands in output/​

For LacunaSigner profiles, the bytes promoted to output/ are the bytes Signer signed — the participant's signature on the original document, downloaded after the document concludes. The verify and encrypt stages run on those bytes exactly as they would for a Local profile, so:

  • Verify = true (default) — the signature is verified against the configured policy after download.
  • Encrypt = true + Encryption:Enabled = true — the downloaded bytes are AES-256-GCM-encrypted into a BSENC v1 envelope; the cleartext is never written to output/.

Original input files are deleted from input/ only after the verify stage succeeds — the same invariant as the Local path.

Metrics​

Signer-specific Prometheus instruments are exposed at /api/metrics:

MetricKindWhat it tracks
bulksigner_jobs_dispatched_to_signer_total{profile}CounterSuccessful dispatches to Signer, labeled by profile name.
bulksigner_jobs_awaiting_signerGaugeLive count of AwaitingSigner rows.
bulksigner_signer_poll_duration_secondsHistogramPer-tick duration for one full pass over AwaitingSigner rows.
bulksigner_signer_api_errors_total{op}CounterSigner API failures the poll worker met, labeled by operation (poll, download). Dispatch and remote-cancel failures are on the job row instead — see the asymmetry note above.

The SignerFolder method has instruments of its own — see Metrics and audit below.

Under cluster mode​

With cluster mode on, each instance polls Lacuna Signer only about the documents it dispatched, so two instances never download the same signed bytes. Two consequences for dashboards and alerts: bulksigner_jobs_awaiting_signer is per instance — sum it across the fleet — and a job an instance dispatched before it died is reassigned to a survivor by the takeover sweep. A row carrying no owner at all is polled by nobody. See High availability for the details and the remedy.

Signing in a Lacuna Signer folder — SignerFolder​

New in 2.16.0

A third signing method. Nothing changes for a host with no SignerFolder profile.

Everything above is one direction: this host sends a file to Signer, and a human signs it there. Method = SignerFolder is the other: a person creates a document in Lacuna Signer, in a folder bound to a Bulk Signer profile, with the profile's certificate holder as a participant; this host signs it with the profile's own certificate, and Signer builds the signature into the document and keeps it. No file enters input/, nothing reaches output/, and the document is not tracked once Signer has accepted the signature.

Person Lacuna Signer Bulk Signer
│ creates a document in │ │
│ the bound folder, naming │ │
│ the certificate holder ──▶ │ ── notification (optional) ───────▶ │
│ │ ◀── sweep / check: read it back ─── │
│ │ │ holder's turn? → job
│ │ ◀── start with the certificate ──── │
│ │ ─── hash to sign ─────────────────▶ │
│ │ │ sign with the profile's key
│ │ ◀── complete with the signature ─── │
│ │ embeds it, keeps the document │

The profile​

It keeps a certificate — any source, an uploaded PKCS#12 and Azure Key Vault included, with cluster mode's per-host refusal unchanged — and adds a Signer folder, by Signer's folder id, in place of an input folder. The binding is one-to-one and the folder itself: two profiles may not share a folder, and a document in one of its subfolders is not the profile's. The certificate's holder is identified by the CPF the certificate carries — e-CPF and e-CNPJ certificates both carry one — never by a typed field. The profile reuses this host's Signer:Endpoint and Signer:ApiKey (one tenant per host), and is refused on a host without them. The seed can declare one — the configuration shape and every refusal are in Configuration — and so can the dashboard, below.

Choosing it from the dashboard​

On /profiles/_new, or on the Certificate panel of /profiles/{name}, pick Lacuna Signer folder as the method. The certificate fields stay — this host signs with that key — and a Signer folder picker appears above them: it lists the folders of the Signer organization this host's API key belongs to, searched by name, nested folders included, and stores the folder's id together with the name as you chose it, which is what the profile page shows afterwards. Only that folder is bound, never its subfolders. If Signer cannot be reached the picker says so and keeps the folder already chosen; nothing else on the form is affected. The page also says, beside the picker, that Signer chooses the format and that the ICP-Brasil policy on a PDF is the Signer organization's setting.

On that method the Behaviour panel hides what Signer decides — the input folder, verification, encryption and the CNAB240 check — and saves them off; the format stays visible but is ignored, kept for a later move back to a method that reads it. The save refuses, in your language:

  • no folder, or a folder another profile already signs in — naming that profile. Two saves choosing one folder in the same instant are decided by the store, and the loser is told the same thing;
  • a host with no Signer:* settings;
  • the default profile — it is where bare uploads and unrouted folders land, and this method takes no files;
  • moving a profile onto the method while it still has verification, encryption, certificate validation or the CNAB240 check on, an input folder, or an approval rule — turn those off, clear the folder or remove the rule on their own panels first;
  • moving a profile off the method when it has no format chosen — pick one on the Behaviour panel first;
  • on /profiles/_new, which opens the certificate while you wait, a certificate that carries no CPF: no Signer flow action could ever be matched to it. Use an ICP-Brasil e-CPF or e-CNPJ.

Signer chooses the format, not the profile​

A PDF is signed PAdES, an XML document XAdES, and anything else CAdES (AD-RB) — the profile's Format is ignored, and the job records the format Signer will use. On a PDF, whether the ICP-Brasil policy applies depends on the Signer organization's UseBrazilianPdfSigningPolicies setting, not on Bulk Signer: the product's ADR-Básica default does not reach a signature Signer assembles. Verification, encryption, certificate validation and the CNAB240 check are Signer's or do not apply, so the profile must have Verify, Encrypt, ValidateCertificate and CheckCnab240 off, and carries no approval rule — the flow, approvers included, is decided at Signer.

How a document is found​

Every Signer:FolderSweepIntervalSeconds (default five minutes) a sweep lists the pending documents of every enabled profile's folder and reads each back with this host's own API key. A document becomes a job when it is still in exactly that folder, still pending, and its current pending flow action is a signer action whose participant identifier is the holder's CPF. A document whose earlier participants have not acted yet produces nothing — it is found again once its turn comes, since Signer, not Bulk Signer, keeps that wait.

Each (document, flow action) is final once it has had a job: a document found again — by the next sweep, or by another instance under cluster mode — creates nothing, whatever the existing job's status, and Retry is the only way back (below). The pair is recorded as discovered in a record of its own that deleting the job and Clear Jobs leave in place, so neither makes the sweep sign the document again.

A pending document in the folder with no signing action for the holder at all — not later in the flow, not already done — is one a folder bound one-to-one should not hold: it is recorded once as a job Failed with signer-folder.no-holder-action, so somebody sees it, and every later sweep leaves it alone.

The sweep runs whether or not the pipeline is paused — discovery only queues jobs; pause stops them being claimed — and a disabled profile's folder is skipped. A notification from Signer cuts the wait to seconds — below — and the sweep stays the guarantee either way.

How it is signed​

The job is claimed like any other — under the pause switch and Pipeline:MaxConcurrency, which is why each signature is a job — and the worker:

  1. reads the document back again and checks the flow action is still the holder's and current;
  2. asks Signer for the holder's action URL (CPF and the action's participant email, and the action's id when the holder has more than one pending) and reads the document key and the ticket out of it;
  3. starts a public signature with the certificate, receiving the hash to sign and the digest it was computed under;
  4. refuses anything but SHA-256, SHA-384 and SHA-512 — SHA-1 and an unknown or absent digest by name, and a hash of the wrong width — before the key is touched;
  5. signs the hash as a hash with the profile's key, and hands the signature back to Signer, which builds it into the document.

The job reads Processing, then Verifying while Signer checks the signature, then Completed when Signer accepts it. The job's page and the /jobs list name the document and its MIME type.

When it does not sign​

Every other ending is one of two, and the document is never canceled or refused at Signer on either: this host did not create it, and it stays pending there.

  • Canceled — the document moved on before this host signed it. It was canceled, refused or expired at Signer, the holder's action is already done, or Signer answered the action URL or the start that the action is not pending (NoPendingActionFoundForEmailAndIdentifier, SignatureNotPending, FlowActionNotPending) and a second read agrees — a second read that still shows the action pending means Signer did not match the participant, and the job ends Failed with signer-folder.signature-failed instead. Nothing went wrong here, so the job carries no code; its history says No longer pending at Lacuna Signer: <status>. An action back to waiting for an earlier step is not "moved on" — it is a flow edited under this host, and fails with signer-folder.signature-failed, since a pair that has a job is never rediscovered.
  • Failed with a code — anything else. signer-folder.signature-refused when Signer refused the signature in its answer (its validation results are on the job's history); signer-folder.key-unavailable when the profile's key would not sign; signer.unreachable when Signer could not be reached or did not answer usefully — a refused connection, a timeout, a 5xx, 408 or 429, or an answer that is not Signer's API, such as a proxy's error page; signer-folder.action-url-malformed when Signer's action URL carried no key or ticket; signer-folder.profile-changed when the job's profile has left the method; signer-folder.interrupted when the run signing it ended mid-signature (below); signer-folder.signature-failed for the rest — the document moved out of the folder, the action stopped being the holder's, a digest this host will not sign under — with the reason on the history. Each is in Troubleshooting.

A failure after the signature was handed to Signer — a completion that timed out — says on the job that Signer may have accepted it; a Retry then re-reads the document and ends Canceled rather than sign twice. A failure after the signature was computed but before it was handed over says that instead: the document is untouched at Signer.

When the run signing it ends​

A restart, or an instance that stopped answering under cluster mode: startup recovery, and the survivor that takes the job over, cannot look in output/ for such a job, so they ask Lacuna Signer — one read of the document with this host's API key, nothing else — and end the job on its answer:

The job wasLacuna Signer reportsThe job ends
Processing, the key never asked— (not asked)Back in the queue: nothing was attempted, so this is recovery, not a retry. Its next claim re-reads the document as any claim does
Verifying — the signature was handed to Signerthe holder's flow action CompletedCompleted, its history saying Signer reports the action done. Nothing is written to disk; there is nothing to write
Processing, past the key — the signature never left this hostthe document or the holder's action moved onCanceled, No longer pending at Lacuna Signer: … — the sign stage's own ending, since this job's signature never reached Signer
either, past the keythe holder's action still pending, or anything this host does not read as an endingFailed, signer-folder.interrupted, the history saying whether the signature had been handed over and what Signer reported
either, past the key— Signer is not configured on this host, does not answer, or answers with an errorFailed, signer-folder.interrupted, the history saying Signer could not be asked and why

A job past the key is never returned to the queue, whatever Signer says: that would be a second signature nobody decided on. Check the document at Lacuna Signer before you Retry a signer-folder.interrupted job — a Retry re-reads it and ends Canceled if the holder's action is already done, so it never signs twice either way.

Asking is bounded so that Signer cannot hold a boot: each read is cut off after ten seconds, the whole recovery stops asking after thirty, and once Signer has failed to answer, the rest of that recovery's jobs take the fallback without asking — the host starts regardless. A job whose completion this host could not record after Signer accepted the signature is exactly the Verifying row the table completes at the next start.

Retry is the only way back​

From Failed and from Canceled alike — including an operator's cancel of a Queued job, which is local only and never reaches Signer. A retry is a new job naming the old one as its parent, for the same document and flow action, and it starts over: the claim re-reads the document, asks for a new action URL and opens a new start, whose token is single-use. It signs while the holder's action is still pending and ends Canceled once it is not. Only one job per (document, flow action) is at work at a time, so a second retry while the first is queued is refused with job.already-processing. A document recorded with signer-folder.no-holder-action is not retriable — there is nothing of this host's to retry, and once the holder is added at Signer the next sweep finds the new action by itself.

When the profile itself cannot work​

Three conditions stop a SignerFolder profile without stopping the host or any other profile, and each is reported the way a certificate that will not open is: on the profile's own page and on /profiles as cannot sign, as degraded: true on GET /api/profiles, and as a signing-profile:<name> row on /api/ready that is red but does not fail the response. A job already queued for the profile fails with profile.degraded, quoting the reason; the sweep and notifications create no new job for it, and a Retry does but fails the same way at claim.

  • This host has no Signer:* settings. Every save and the seed refuse the method on such a host, so this is a stored profile whose host lost Signer:Endpoint and Signer:ApiKey afterwards — or a cluster sibling configured differently. With no credential the sweep is not even started, so nothing in the folder would ever be found; rather than do nothing in silence, the profile is degraded naming the two keys. Configure the keys and restart, or move the profile off the method on its page.
  • The certificate carries no CPF. Nothing at Signer could ever be matched to its holder. Found wherever the profile is resolved — at startup, and on the next refresh after a save moved a profile onto the method with such a certificate — never by refusing the boot. Give the profile an ICP-Brasil e-CPF or e-CNPJ (read at the next restart, as every certificate change is), or move it off the method.
  • The bound folder no longer exists at Signer — deleted there. Nothing at startup asks Signer about a folder: the sweep finds it, and only when a folder's pending listing comes back empty does it ask Signer whether the folder is still there, since Signer lists a vanished folder as an empty one. Only Signer's own answer that the folder does not exist (FolderNotFound) degrades the profile; an outage while asking does not — it is that sweep's failure, warned about and asked again on the next one. Bind the profile to another folder on its page: the degradation goes with the old folder, live, with no restart. The sweep also asks again on every pass, so an answer that was wrong — Signer:ApiKey or Signer:Endpoint briefly naming another organization — clears by itself once Signer finds the folder. A restart forgets the degradation, and the first sweep afterwards finds it again.

The sweep skips a degraded profile's folder — no listing, no read, no job, and a notification for it is dropped — and says so once, on the console and in the durable log, rather than on every pass. See Troubleshooting.

No file reaches a SignerFolder profile​

An upload naming one, a rescan or watched folder whose files resolve to one, or a retry of a file job whose profile has since moved to the method is refused at ingestion with profile.takes-no-files, before anything is staged.

Metrics and audit​

MetricKindWhat it tracks
bulksigner_signer_folder_jobs_enqueued_total{profile}CounterJobs created for documents found waiting, and for Retries of them. A document found again creates nothing and is not counted.
bulksigner_signer_folder_signatures_total{outcome}CounterSignature attempts: signed, refused (by Signer, in its answer), no-longer-pending (moved on before a signature existed, ended Canceled) or failed.
bulksigner_signer_folder_sweep_errors_total{op}CounterSigner calls the sweep could not complete (list, folder — asking whether a folder whose listing came back empty still exists — read, enqueue) and passes that failed outright (pass). A failed sweep loses nothing; the next one asks again.
bulksigner_signer_notifications_total{outcome}CounterNotifications received — see below.

A sweep that keeps failing is a readiness row: signer-folder-sweep on /api/ready turns red after three passes in a row each had a failure — a revoked Signer:ApiKey fails every one — and /api/ready/details says since when and what failed last, by stage and kind (never Signer's message, which can quote a document's key). It never moves the verdict: a 503 would not bring Signer back, and would stop everything else this host does. See Troubleshooting.

The operational events are SignerDocumentEnqueued, SignerDocumentWithoutHolderAction, JobSignedAtSigner and JobNoLongerPendingAtSigner.

Notifications — the webhook​

The sweep finds a document within Signer:FolderSweepIntervalSeconds. A notification finds it within seconds: Signer calls this host when a document is created, or when a participant signs or approves — the step before the holder's is how "the holder's turn" is learned, since Signer has no event for an action becoming pending. It is optional, and worth it only where Signer can reach this host; a host with no public ingress loses minutes, never documents.

A notification is a doorbell, not a command. Nothing in its body is believed. This host reads which documents it names and which folder it says each is in, queues a check for each one in an enabled profile's bound folder, and answers — without calling Signer inside the request. The check then does exactly what the sweep does for one document: reads it back from Signer with this host's own API key, and enqueues a job only if it is still in exactly that folder, pending, and the holder's turn. A forged notification can at most make this host ask Signer about a document; a repeated one creates nothing, because the check takes the sweep's path and its idempotency. A disabled profile's documents are acknowledged and dropped.

Setting it up.

  1. Generate a long random secret — at least 32 characters — and set it on this host as Signer:WebhookSecret (the Signer__WebhookSecret environment variable; never in appsettings.json). Restart. With no secret the route answers 404.
  2. In Lacuna Signer, open the organization's integrations page and add a webhook:
    • URL: this host's public address followed by /api/signer/notifications — for example https://bulksigner.example.com/api/signer/notifications. Behind a reverse proxy, the address the proxy publishes.
    • Authentication: Bearer, with the secret from step 1 as the token. Not X-Api-Key — that header is this product's own REST credential, and a notification arriving on it is refused.
    • Events: Signer delivers every event to every webhook of the organization; this host acts on DocumentsCreated, DocumentSigned and DocumentApproved and acknowledges the rest.
  3. Use Signer's test delivery, or create a document in the bound folder, and read /api/ready/details: the signer-notifications row says when the last notification arrived at that instance. Under cluster mode each instance reports only what reached it.

What it answers. 401 for a missing or wrong Bearer token (signer-notification.unauthorized); 202 when a document in a bound folder was queued to be checked, or was already waiting to be; 204 for every other authenticated delivery — an event that cannot make a document wait, a folder that is not bound, a disabled profile's folder, a body this host cannot read — because Signer retries any non-2xx and a retry would change none of these; 503 only when this host failed (signer-notification.unavailable): the operational store is not usable on this instance, or too many checks are already waiting. 429 is the route's own rate limit (RateLimiting:SignerNotification), which Signer also retries. Checks live in memory: a restart forgets those not yet made, and the next sweep finds their documents. The full wire contract is in REST API.

See Troubleshooting for diagnosis steps on:


Next: CNAB240 payment files. Previous: Encryption.