---
title: "Lacuna Signer integration"
description: "Operator walkthrough for routing a Bulk Signer profile through Lacuna Signer instead of a local certificate, and when to pick each signing method."
canonical: "https://docs.lacunasoftware.com/en-us/articles/bulk-signer/lacuna-signer"
locale: "en"
product: "Bulk Signer"
last_updated: "2026-10-12"
---

# 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](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/lacuna-signer#signing-in-a-lacuna-signer-folder--signerfolder) below.

## Architecture summary

```text
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:

```text
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](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/lacuna-signer#notifications--the-webhook). 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](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/configuration#signer--lacuna-signer-connection).

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.

> **Warning: 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.

> **Warning: 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](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/lacuna-signer#when-the-profile-itself-cannot-work), 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](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/configuration#signingprofiles--per-folder-signing-profiles)),
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](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/lacuna-signer#signing-in-a-lacuna-signer-folder--signerfolder) 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.

```json
"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](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/approvals#the-signer-set).

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.

> **Warning: 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](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/operations#clear-jobs).

> **Note: 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.

> **Warning: 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.

> **Note: 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`](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/lacuna-signer#signing-in-a-lacuna-signer-folder--signerfolder) job, which has no file and is ended
on Lacuna Signer's word instead (see
[When the run signing it ends](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/lacuna-signer#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`:

| 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](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/lacuna-signer#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](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/high-availability) for the details and the remedy.

## Signing in a Lacuna Signer folder — `SignerFolder`

> **Tip: 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.

[![Signing in a Lacuna Signer folder: the document is created and waits for the holder's turn at Lacuna Signer, Bulk Signer finds it, signs the hash Signer prepares with the company's certificate, and Signer builds the signature into the document](https://docs.lacunasoftware.com/images/bulk-signer/signer-folder.png)](https://docs.lacunasoftware.com/images/bulk-signer/signer-folder.png)

*Select the diagram to open it at full size.*

The calls between the three parties:

```text
 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](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/certificates), 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](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/configuration#signing-in-a-lacuna-signer-folder--method--signerfolder) — 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](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/operations#deleting-a-job) and [Clear Jobs](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/operations#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](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/lacuna-signer#notifications--the-webhook) — 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](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/troubleshooting#a-lacuna-signer-folder-job-ends-failed-or-canceled).

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](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/high-availability), 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 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](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/troubleshooting#a-signer-folder-profile-is-degraded-saying-its-certificate-carries-no-cpf).

### 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](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/lacuna-signer#notifications--the-webhook). |

**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](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/troubleshooting#the-signer-folder-sweep-row-on-apireadydetails-is-red).

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](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/rest-api#signer-notifications).

## Troubleshooting cross-links

See [Troubleshooting](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/troubleshooting) for diagnosis steps on:

- Signer API unreachable / 5xx storm
- Wrong API key — `401`s from every call
- Document stuck `Pending` past `Signer: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 `Failed` or `Canceled`](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/troubleshooting#a-lacuna-signer-folder-job-ends-failed-or-canceled)
- [A Signer-folder profile is degraded](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/troubleshooting#a-signer-folder-profile-is-degraded-saying-this-host-has-no-lacuna-signer-settings) — no `Signer:*` settings, no CPF, or a folder that no longer exists
- [The `signer-folder-sweep` readiness row is red](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/troubleshooting#the-signer-folder-sweep-row-on-apireadydetails-is-red)
- [Documents are found only at the sweep interval, not within seconds](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/troubleshooting#lacuna-signer-documents-are-found-only-at-the-sweep-interval-not-within-seconds)

---

**Next:** [CNAB240 payment files](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/cnab240). **Previous:** [Encryption](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/encryption).
