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:
- The pipeline worker claims
Queuedjobs and, for LacunaSigner profiles, only dispatches them to Signer (upload + create-document) and transitions them toAwaitingSigner. 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. - A separate poll worker wakes every
Signer:PollIntervalSeconds(default 30 s) and walks everyAwaitingSignerrow. 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:
| Key | Type | Default | Env override | Used by | Required when |
|---|---|---|---|---|---|
Signer:Endpoint | string | "" | Signer__Endpoint | both methods | Any profile uses LacunaSigner or SignerFolder, or anything else under Signer: is set. Cloud default: https://signer.lacunasoftware.com. |
Signer:ApiKey | string | "" | Signer__ApiKey | both methods | REQUIRED, SECRET, same condition. Format: application-id|secret. |
Signer:PollIntervalSeconds | int | 30 | Signer__PollIntervalSeconds | LacunaSigner | optional — how often the poll worker walks the AwaitingSigner rows. 1–3600. |
Signer:TimeoutHours | int | 168 (7 days) | Signer__TimeoutHours | LacunaSigner | optional — how long a dispatched document may wait for its participant. 1–8760. |
Signer:MaxConsecutiveApiFailures | int | 5 | Signer__MaxConsecutiveApiFailures | LacunaSigner | optional — the per-document poll budget below. 1–100. |
Signer:FolderSweepIntervalSeconds | int | 300 (5 minutes) | Signer__FolderSweepIntervalSeconds | SignerFolder | optional — how often every bound Signer folder is listed; the longest a document waits for this host to notice it. 60–86400. |
Signer:WebhookSecret | string | "" (notifications off) | Signer__WebhookSecret | SignerFolder | optional, 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.
Signer:* block is judged on its ownUp 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 = LacunaSigneris refused when the host has noSigner:*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.
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.
| Switching | When it takes effect | What happens to the other block |
|---|---|---|
| Local or Signer folder → Lacuna Signer | The 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 folder | The 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 certificate | The 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 coordinate | The 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 = LacunaSignerrequires a non-emptySigner:{Name, Email, Identifier}block. The validator refuses partial blocks.Method = LacunaSignerforbids aCertificate:*block (no local cert involved).Method = LacunaSignerforbidsValidateCertificate = true(there's no local cert to validate).Method = Localrules are unchanged: cert block required,Signerblock ignored if present.Method = LacunaSignercannot be combined with an approval rule whose signer set isProfileKeyAndApprovers— 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
- Operator drops a file into a folder watched by a LacunaSigner profile (or
POST /api/files?profile=contracts). - Watcher / endpoint enqueues the job;
Status = Queued. - 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 toAwaitingSignerwith the remote document id recorded; the slot is released. - Signer emails the participant; the participant signs through the Signer UI on their own time.
- The poll worker ticks every
Signer:PollIntervalSeconds. On each tick it loads everyAwaitingSignerrow, 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 toCompleted. - Refused / Expired / Canceled → transitions to
Failedwithsigner.document-rejected. - Local timeout (
AwaitingSignerlonger thanSigner:TimeoutHours) → transitions toFailedwithsigner.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:
- The job transitions to
Canceledlocally — same handler, same audit trail. - 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.
- If the remote cancel failed, the participant may still see the document in their Signer inbox. The
local job is correctly
Canceledregardless.
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 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.
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,408or429, 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. OnceSigner:MaxConsecutiveApiFailuresis exceeded for a single document, that job is failed withcode = 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.
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.
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 tooutput/.
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:
| Metric | Kind | What it tracks |
|---|---|---|
bulksigner_jobs_dispatched_to_signer_total{profile} | Counter | Successful dispatches to Signer, labeled by profile name. |
bulksigner_jobs_awaiting_signer | Gauge | Live count of AwaitingSigner rows. |
bulksigner_signer_poll_duration_seconds | Histogram | Per-tick duration for one full pass over AwaitingSigner rows. |
bulksigner_signer_api_errors_total{op} | Counter | Signer 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
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
defaultprofile — 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:
- reads the document back again and checks the flow action is still the holder's and current;
- 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;
- starts a public signature with the certificate, receiving the hash to sign and the digest it was computed under;
- 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;
- 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 endsFailedwithsigner-folder.signature-failedinstead. Nothing went wrong here, so the job carries no code; its history saysNo 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 withsigner-folder.signature-failed, since a pair that has a job is never rediscovered.Failedwith a code — anything else.signer-folder.signature-refusedwhen Signer refused the signature in its answer (its validation results are on the job's history);signer-folder.key-unavailablewhen the profile's key would not sign;signer.unreachablewhen Signer could not be reached or did not answer usefully — a refused connection, a timeout, a5xx,408or429, or an answer that is not Signer's API, such as a proxy's error page;signer-folder.action-url-malformedwhen Signer's action URL carried no key or ticket;signer-folder.profile-changedwhen the job's profile has left the method;signer-folder.interruptedwhen the run signing it ended mid-signature (below);signer-folder.signature-failedfor 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 was | Lacuna Signer reports | The 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 Signer | the holder's flow action Completed | Completed, 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 host | the document or the holder's action moved on | Canceled, No longer pending at Lacuna Signer: … — the sign stage's own ending, since this job's signature never reached Signer |
| either, past the key | the holder's action still pending, or anything this host does not read as an ending | Failed, 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 error | Failed, 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 lostSigner:EndpointandSigner:ApiKeyafterwards — 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:ApiKeyorSigner:Endpointbriefly 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
| Metric | Kind | What it tracks |
|---|---|---|
bulksigner_signer_folder_jobs_enqueued_total{profile} | Counter | Jobs 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} | Counter | Signature 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} | Counter | Signer 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} | Counter | Notifications 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.
- Generate a long random secret — at least 32 characters — and set it on this host as
Signer:WebhookSecret(theSigner__WebhookSecretenvironment variable; never inappsettings.json). Restart. With no secret the route answers404. - 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 examplehttps://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,DocumentSignedandDocumentApprovedand acknowledges the rest.
- URL: this host's public address followed by
- Use Signer's test delivery, or create a document in the bound folder, and read
/api/ready/details: thesigner-notificationsrow 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.
Troubleshooting cross-links
See Troubleshooting for diagnosis steps on:
- Signer API unreachable / 5xx storm
- Wrong API key —
401s from every call - Document stuck
PendingpastSigner:TimeoutHours - Operator canceled but the participant still sees the document
- The dashboard does not show the Lacuna Signer panel even though a profile uses it
- A Lacuna Signer folder job ends
FailedorCanceled - A Signer-folder profile is degraded — no
Signer:*settings, no CPF, or a folder that no longer exists - The
signer-folder-sweepreadiness row is red - Documents are found only at the sweep interval, not within seconds
Next: CNAB240 payment files. Previous: Encryption.