Pular para o conteúdo principal

REST API

Lacuna Bulk Signer exposes a small REST surface alongside the operator dashboard. This page covers authentication, the error envelope, rate limiting, and what each endpoint group does — with curl examples for the common shapes.

dica

The live OpenAPI reference with full request/response schemas is served at /scalar/v1 while the service is running. This page is the conceptual guide; the live reference is the source of truth for field-level detail.

Authentication

Two schemes share one authorization policy:

SchemeHeader / cookieIssued viaUsed by
API keyX-API-Key: <key> (header name from Auth:ApiKeyHeader)Set in Auth:ApiKey config / envProgrammatic clients
CookieCookie: lbs-auth=<token> (name from Auth:CookieName)POST /api/auth/login form submitOperators / dashboard

The API-key comparison runs in constant time. Both schemes back the same policy on every protected endpoint. See Security for rotation and ACLs.

Anonymous endpoints:

  • GET /api/health
  • GET /api/ready
  • POST /api/auth/login
  • POST /api/auth/logout
  • GET /login (dashboard, anonymous layout)

Every other endpoint requires authentication.

Error envelope

Every error response is a ProblemDetails body (RFC 9457) with a stable machine-readable slug in the code extension:

{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.5",
"title": "Job not found.",
"status": 404,
"code": "job.not-found",
"traceId": "00-…-00",
"requestId": "0HMV…"
}

Programmatic clients should dispatch on codetitle is human prose and may be rephrased or localized. The full inventory:

CodeTypical statusWhat it means
job.not-found404No job with the given id.
job.not-queued409Cancel attempted on a job that is no longer Queued (in-flight jobs are sacred).
job.race-lost409The worker picked the job up before the action committed; retry.
job.not-failed409Retry attempted on a job that is not in Failed state.
job.input-missing409Retry attempted but the original input file is no longer on disk.
job.output-unavailable404Output download requested on a job that has no output yet (not completed).
job.output-gone404Output download requested but the file is missing from output/.
job.already-processing409Upload conflicted with an active job for the same on-disk file.
upload.empty400Multipart file field is missing or zero bytes.
upload.too-large413Upload exceeds Upload:MaxBytes.
upload.invalid-name400Multipart file part is missing a filename header.
upload.format-unsupported400?format=… value is not a recognized signature format.
validation.reason-too-long400A reason field on pause/cancel exceeds the max length.
validation.filter-invalid400A query-string filter (e.g. ?status=…) is not a recognized value.
auth.misconfigured401Auth:ApiKey is empty at runtime — fix the config, not the request.
auth.invalid-credentials401Wrong API key or expired cookie.
folder.not-found404POST /api/rescan?folder=<name> named a folder not in Storage:Inputs[].
profile.not-found400POST /api/files?profile=<name> named a profile not in Signing:Profiles[].
signer.document-rejectedAudited on the failed job. Set when Lacuna Signer reports the document Refused, Expired, or Canceled.
signer.timeoutAudited on the failed job. Set when an AwaitingSigner row exceeds Signer:TimeoutHours.
signer.unreachableAudited on the failed job. Set when the Lacuna Signer API returned a permanent error (e.g. invalid API key).
rate-limited429Per-IP fixed-window limit exceeded.
internal500Framework-generated 500 (no business code involved).

In Production, the error customizer strips detail, instance, and any extension other than code, traceId, requestId, errors. No stack traces escape. In Development, full details flow through.

A code value is never renamed or repurposed — new codes are only added, so a client matching on code is safe across upgrades.

Rate limiting

Per-IP fixed-window limiters, configured under RateLimiting: (see Configuration). Two policies:

PolicyDefaultEndpoints
Upload30 / 60 sPOST /api/files
Actions60 / 60 sPOST /api/jobs/{id}/retry, POST /api/jobs/{id}/cancel, POST /api/pipeline/pause, POST /api/pipeline/resume, GET /api/pipeline/state, POST /api/rescan, POST /api/cleanup

Over-limit responses are 429 Too Many Requests with code = "rate-limited" and a Retry-After header.

Endpoint groups

Authentication

MethodPathPurpose
POST/api/auth/loginForm POST. Exchanges an API key for a session cookie. Anonymous.
POST/api/auth/logoutClears the cookie and redirects to /login.

Form fields for /api/auth/login:

FieldRequiredNotes
ApiKeyyesMatched against Auth:ApiKey in constant time.
ReturnUrlnoLocal-relative path to land on after login. Open-redirect attempts are rewritten to /.

Programmatic clients usually skip cookies and send X-API-Key directly on every request.

Files

MethodPathPurpose
POST/api/filesMultipart upload of one file for signing. Upload rate-limited.

Query parameters:

ParameterTypeNotes
formatenumOptional override (Pades, Cades, Xades). Default: extension-based auto-detect.
profilestringOptional. Names an entry in Signing:Profiles[]. Null/omitted falls back to the default profile. Unknown names return 400 with code = "profile.not-found".
curl -X POST http://localhost:8080/api/files \
-H "X-API-Key: $BULK_SIGNER_API_KEY" \
-F "file=@report.pdf" \
-F "format=Pades" # optional override; default is auto-detect by extension

# Route an upload through a specific profile (e.g. contracts):
curl -X POST "http://localhost:8080/api/files?profile=contracts" \
-H "X-API-Key: $BULK_SIGNER_API_KEY" \
-F "file=@nda.pdf"

Response (202 Accepted):

{
"jobId": "9b62…",
"fileName": "report.pdf",
"originalPath": "/var/lib/bulksigner/input/<guid>.pdf",
"format": "Pades",
"status": "Queued"
}

Possible errors: upload.empty, upload.too-large, upload.invalid-name, upload.format-unsupported, profile.not-found, job.already-processing, rate-limited.

Jobs

MethodPathPurpose
GET/api/jobsList jobs, newest first. Query: status, profile, page, pageSize (max 200).
GET/api/jobs/{id}One job + its history.
GET/api/jobs/{id}/outputStream the signed (and possibly encrypted) output. .enc filename when encrypted.
POST/api/jobs/{id}/retryCreate a new job with the same input and ParentJobId = {id}. Only valid when the source job is Failed. Actions rate-limited.
POST/api/jobs/{id}/cancelCancel a Queued or AwaitingSigner job. In-flight local jobs return 409 with code = "job.not-queued". Actions rate-limited.

List Queued jobs:

curl "http://localhost:8080/api/jobs?status=Queued&page=1&pageSize=50" \
-H "X-API-Key: $BULK_SIGNER_API_KEY"

Response:

{
"items": [
{
"id": "9b62…",
"fileName": "report.pdf",
"originalPath": "/var/lib/bulksigner/input/<guid>.pdf",
"format": "Pades",
"source": "Upload",
"status": "Queued",
"createdAt": "2026-05-26T13:42:11Z",
"updatedAt": "2026-05-26T13:42:11Z",
"parentJobId": null,
"errorMessage": null,
"profileName": "default"
}
],
"page": 1,
"pageSize": 50,
"totalCount": 1
}

GET /api/jobs/{id} returns the same shape plus a history array of { id, timestamp, status, message } entries (one per state transition).

Retry / cancel are POST with no body required:

curl -X POST "http://localhost:8080/api/jobs/$ID/retry" \
-H "X-API-Key: $BULK_SIGNER_API_KEY"

curl -X POST "http://localhost:8080/api/jobs/$ID/cancel" \
-H "X-API-Key: $BULK_SIGNER_API_KEY"

Retry on success returns:

{ "newJobId": "fc12…", "parentJobId": "9b62…", "status": "Queued" }

Pipeline

MethodPathPurpose
GET/api/pipeline/stateCurrent paused / pausedAtUtc / resumedAtUtc / pausedBy / reason plus live worker capacity. Actions rate-limited.
POST/api/pipeline/pauseIdempotent hold on the worker. Survives restart. Optional reason. Actions rate-limited.
POST/api/pipeline/resumeIdempotent resume. Actions rate-limited.

Pause / resume accept an optional JSON body { "reason": "…" } (max length enforced — over-limit returns validation.reason-too-long):

curl -X POST "http://localhost:8080/api/pipeline/pause" \
-H "X-API-Key: $BULK_SIGNER_API_KEY" \
-H "Content-Type: application/json" \
-d '{"reason":"Quarterly maintenance"}'

State response:

{
"paused": true,
"pausedAtUtc": "2026-05-26T15:00:00Z",
"resumedAtUtc": null,
"pausedBy": "operator",
"reason": "Quarterly maintenance",
"maxConcurrency": 4,
"jobsInFlight": 2,
"jobsInFlightByFormat": {
"pades": 1,
"cades": 1,
"xades": 0,
"total": 2
}
}

maxConcurrency is the configured Pipeline:MaxConcurrency (read once at startup; restart to change). jobsInFlight and jobsInFlightByFormat count rows currently in Processing or Verifying. Operators watching a drain after a pause will see paused: true while jobsInFlight counts down to 0.

Actions

MethodPathPurpose
POST/api/rescanRe-enqueue every file in every configured input folder. Accepts ?folder=<name> to scope to one folder. Actions rate-limited.
POST/api/cleanupApply retention to processing/, output/, error/. Currently a no-op stub; see Retention. Actions rate-limited.
# Rescan every configured folder
curl -X POST "http://localhost:8080/api/rescan" \
-H "X-API-Key: $BULK_SIGNER_API_KEY"

# Rescan just one folder
curl -X POST "http://localhost:8080/api/rescan?folder=legal" \
-H "X-API-Key: $BULK_SIGNER_API_KEY"

Rescan response shape:

{
"folders": [
{
"name": "default",
"path": "/var/lib/bulksigner/input",
"scanned": 4, "enqueued": 3, "alreadyActive": 0, "ignored": 1, "errors": 0,
"enqueuedFiles": ["a.pdf", "b.pdf", "c.xml"]
}
],
"totals": { "folders": 1, "scanned": 4, "enqueued": 3, "alreadyActive": 0, "ignored": 1, "errors": 0 }
}

An unknown ?folder=<name> returns 404 with code = "folder.not-found" and the configured names in detail. Cleanup returns 200 OK while the retention service is the null stub.

System

MethodPathAuthPurpose
GET/api/healthAnonymousLiveness — 200 OK if the host process is up.
GET/api/readyAnonymousReadiness — JSON body listing DB / per-folder / license probes. 503 if any probe fails.
GET/api/foldersAuthorizedPer-folder runtime state: name, absolute path, exists, status, last enqueue time, last error, lifetime processed count, file count (capped at 50).
GET/api/metricsAuthorized when Metrics:RequireApiKey = true (default)Prometheus exposition.
GET/api/whoamiAuthorizedEchoes the authenticated identity (operator + scheme used).

/api/health is always anonymous so external health checkers (load balancers, Docker HEALTHCHECK, Kubernetes livenessProbe) need no credentials. /api/ready is anonymous and returns a structured body — examine the body for which probe failed.

Metrics

/api/metrics exposes the following instruments (Prometheus format):

MetricKindWhat it tracks
bulksigner_jobs_enqueued_total{folder=...}CounterEvery successful enqueue. The folder label is the Storage:Inputs[].Name, or "(upload)" for REST uploads.
bulksigner_jobs_completed_totalCounterJob reached Completed.
bulksigner_jobs_failed_totalCounterJob reached Failed.
bulksigner_jobs_canceled_totalCounterOperator-canceled jobs.
bulksigner_pipeline_pause_totalCounterPause transitions.
bulksigner_pipeline_resume_totalCounterResume transitions.
bulksigner_pipeline_pausedGauge1 paused / 0 running.
bulksigner_files_encrypted_totalCounterBSENC v1 envelopes written.
bulksigner_jobs_in_flightGaugeLive count of Processing + Verifying.
bulksigner_signing_duration_seconds{format=Pades|Cades|Xades}HistogramSign + verify + promote duration.
bulksigner_jobs_dispatched_to_signer_total{profile}CounterSuccessful dispatches to Lacuna Signer, labeled by profile.
bulksigner_jobs_awaiting_signerGaugeLive count of AwaitingSigner rows.
bulksigner_signer_poll_duration_secondsHistogramPer-tick duration of one full pass over AwaitingSigner rows.
bulksigner_signer_api_errors_total{op}CounterLacuna Signer API errors, labeled by operation.

A minimal Prometheus scrape config (assuming the scraper sits inside the trust boundary and Metrics:RequireApiKey = false):

scrape_configs:
- job_name: bulksigner
static_configs:
- targets: ['bulksigner:8080']
metrics_path: /api/metrics

When Metrics:RequireApiKey = true, set the API key on the scraper. Prometheus supports authorization/basic_auth; for the X-API-Key header, use a sidecar reverse proxy that injects the header, or set Metrics:RequireApiKey = false after locking the network down.

Live reference

The OpenAPI reference UI is served at http://<host>:8080/scalar/v1. It carries the canonical schema for every endpoint, including request/response shapes and query parameter lists. If a programmatic client needs anything not covered here, the live reference is the next stop.


Next: Encryption — optional post-signing encryption. Previous: Dashboard.