Pular para o conteúdo principal

Approvals

Some payment files should not be signed until a person has looked at them. A signing profile can require that: a job routed through it stops before the signer, parks in AwaitingApproval, and waits until enough people from a fixed list have approved. Then it signs.

Read this first

The per-job approval page is not authenticated. Anyone who can open a job's approval link can approve — or reject — as anyone in that job's pool. That is a deliberate design decision for this version, not an oversight, and it changes how you must handle the link. See Security.

The optional approver portal and Microsoft Entra ID sign-in both narrow this considerably.

Turning it on

Add an Approval block to a signing profile. It requires CheckCNAB240: true on the same profile — an approver who cannot be shown the amount is not approving anything meaningful, so the parse is a precondition rather than a recommendation.

{
"Signing": {
"Profiles": [
{
"Name": "pagamentos-bb",
"Format": "Cades",
"Method": "Local",
"CheckCNAB240": true,
"Certificate": {
"Source": "Pfx",
"Pfx": { "Path": "/etc/bulksigner/pagamentos.pfx", "Password": "" }
},
"Approval": {
"MinimumApprovers": 2,
"ExpiresAfter": "2.00:00:00",
"Approvers": [
{ "Name": "Maria Silva", "Email": "maria@empresa.com.br", "Cpf": "123.456.789-09" },
{ "Name": "João Souza", "Email": "joao@empresa.com.br", "Cpf": "111.444.777-35" },
{ "Name": "Ana Costa", "Email": "ana@empresa.com.br", "Cpf": "529.982.247-25" }
]
}
}
]
}
}

Approvers is a pool, not a checklist. With three entries and MinimumApprovers: 2, any two of the three satisfy the job; no individual is required.

Write ExpiresAfter with its days component

"2.00:00:00" is the forty-eight-hour window above. A three-component value is hh:mm:ss only while the first number is 23 or less; at 24 and above .NET reads that number as days, so "48:00:00" is forty-eight days. The validator does not refuse it — a long window may be deliberate — but the startup banner warns at or above 24 days, naming the resolved figure and the spelling that fixes it:

pagamentos-bb Cades · cert=Pfx · verify=on · encrypt=off · validate-cert=on · cnab240=on · approval=2/3 · expires=1152h

WARN Profile 'pagamentos-bb' has an approval wait budget of 1152h (48 days) …
Forty-eight hours is "2.00:00:00". Ignore this if the long window is deliberate.

Boot is the only moment this is catchable — every other surface shows the deadline once a job has already parked under it. Read the banner after editing the value.

Every key, its type and its default is in Configuration. Startup refuses, before the first job runs: an Approval block without CheckCNAB240; an empty pool; a MinimumApprovers below 1 or larger than the pool; a malformed email, or the same email twice; a CPF whose check digits do not match; a non-positive ExpiresAfter. A half-configured authorisation rule is not a degraded feature — it is a gate that looks closed and is not.

The life of a parked job

  1. Parse. The worker stages a copy of the file in processing/<jobid>/, parses it as a CNAB240 remessa, and records the total, the payment and cancellation counts, the payment-date range, the payer, and a SHA-256 of the exact bytes it read.
  2. Park. The profile's approval rule — pool, quorum and wait budget — is copied onto the job and the job moves to AwaitingApproval. The worker's concurrency slot is released immediately, so a parked payroll costs nothing while it waits and a MaxConcurrency = 1 deployment keeps working.
  3. Wait. Approvers open the link and decide. Each gets exactly one decision.
  4. Release, or stop. The moment the quorum is met the job returns to Queued and the pipeline is woken. A single rejection instead ends the job as Canceled — see Rejection is a veto — and so does the wait budget running out, if the profile set one.
  5. Sign. The ordinary claim path picks it up, resumes on the staged copy, re-checks the payment dates and the content hash, and signs.

There is no background worker for any of this. Approval state lives in the same database the handler writes to, so the instant the quorum is satisfied is known where it happens; the one thing driven by a clock — expiry — rides the pipeline's existing poll loop.

The wait budget

ExpiresAfter is optional and absent by default, in which case a parked job waits indefinitely. Set it and a job nobody decides on inside the window is canceled:

  • The reason recorded on the timeline is Approval window expired., followed by how long it waited and how many approvals it had collected.
  • The staged copy moves to error/, exactly as a rejection's and an operator's cancel do. The original stays in input/ and the watcher will not auto-resurrect it.
  • An ApprovalExpired operational event is recorded, and bulksigner_approvals_expired_total{profile} increments.
  • Approvals already recorded are kept. So is the frozen rule. An expiry ends the waiting; it does not erase the part that happened.

The window is measured against the budget frozen onto that job, never the one currently in appsettings.json, so shortening the value in configuration does not retroactively expire jobs people are still deciding on. The check runs on the pipeline's poll loop, so a job is canceled within one Pipeline:PollIntervalSeconds of its deadline rather than exactly on it.

Two properties worth knowing before you set it:

  • A pause does not extend it. The budget is a wall-clock deadline, not a budget of pipeline uptime, so a pipeline paused across a window will expire the jobs whose windows closed during the pause.
  • A race is resolved in the humans' favour. If a quorum is met, a rejection lands, or an operator cancels at the same moment the sweep runs, whoever got there first wins.
Expiry is housekeeping, not a correctness control

What protects the money in a payment file that sat too long is the payment-date guard, which refuses to sign a remessa whose payment dates have passed however the delay arose — including on a profile with no wait budget at all.

The frozen rule

When a job parks, the approver pool, the quorum and the wait budget are snapshotted onto the job and never re-read from configuration. Editing appsettings.json and restarting does not change what a parked job requires.

This is deliberate and load-bearing. Without it, dropping MinimumApprovers from 3 to 1 and restarting would satisfy every parked job's quorum at once — configuration would be an authorisation bypass. It would also make the audit trail lie: somebody who approved under "2 of 3" would afterwards appear to have approved under "1 of 3".

What the approver sees

The per-job page lives at /approve/{jobId} and shows:

File nameas it arrived
Grand totalsum of inclusão records, in reais; exclusões are counted, never netted
Paymentsnumber of inclusão records
Cancellationsnumber of exclusão records
Payment datesearliest–latest, or a single date when the whole file pays out on one day
PayerNome da Empresa and Número de Inscrição from the Header do Arquivo
Progress"1 of 2 approvals", who has decided, and who has not
Content hashthe SHA-256 the approval will be bound to
Deadlinewhen the request expires — shown only when the profile sets a wait budget

Plus, when the job is a retry of a previously approved one, a line saying who approved the parent and whether the file is byte-for-byte identical to what they saw. Those approvals do not carry over — a retry needs its own.

An approver picks their address from the pool, optionally writes a reason, and clicks Approve or Reject. Rejecting takes a second confirming click. A decision is final either way; changing one means asking an operator to cancel the job and re-run it.

The picker is the anonymous path. A reader the server can already name — a portal-link session, or a Microsoft Entra sign-in carrying the Approver role — is told who they are instead of being asked, and their decision records the method that identified them.

The individual payments

Under the figures, the same paginated payment table the operator's job page uses — every value-bearing record in the file, one row each.

A total alone is not an approval; it is a rubber stamp. "R$ 1.240.000 across 312 payments, yes or no" gives a human no way to spot the extra zero in a payroll lote, the beneficiary who appears twice, or the account number that quietly changed since last month. Those are precisely the errors this gate exists to catch, and every one of them is invisible in a grand total.

ColumnOn the anonymous approval pageWhy
Record, lote, segmentin fullWhere in the file this is, and what kind of payment
Name on the recordin fullThis is the decision. A duplicated or unexpected beneficiary is only visible here
Payment datein fullPart of the decision — a date nobody expected is a reason to reject
Amountin fullThe decision. Exclusão rows are labelled and struck through, and are not in the total
CPF / CNPJcheck digits only***.***.***-09Not needed to decide. Enough to tell two same-named people apart
Accountlast digits only***149-4, branch omittedNot needed to decide. Enough to answer "has this account changed?"

Masked columns are captioned (partial) — an unqualified "CPF" heading over a masked value reads as the whole number, and an approver comparing it against a document would conclude the file is wrong.

The masking rule follows the reader, not the page. An approver the server can name — through a portal link or an Entra sign-in — sees the identifiers whole, on this page and in their queue. The reduction exists for the surface reachable by whoever holds a forwarded URL.

Some rows legitimately have neither identifier nor account: a boleto (segment J), a tribute (N) and a concessionária payment (O) are paid against a barcode or to the government. Those cells show an em-dash — an absence, not a mask. On a tribute line the name is the taxpayer, not the recipient, and the page says so above the table.

note

The table is only there while the job is in flight. The line detail is purged at the transition into any terminal status (Retention), so an approver opening a link for a job that has already been decided sees the totals and a note saying the lines are gone.

What the approval page deliberately does not offer

  • No raw file download, on any approval surface. A rendered, paginated table is a bounded disclosure in service of one decision; the file itself is a complete machine-readable dump of every beneficiary's CPF and bank account. Raw bytes stay behind the authenticated operator surface (GET /api/jobs/{id}/output). Unmasking the table for an identified approver did not unlock the bytes.
  • No anonymous index of pending approvals. No unauthenticated route lists jobs awaiting approval; the page is reachable only with a specific job id, and job ids are v4 GUIDs. The approver portal is an index, but it carries an authorization policy and lists only the jobs whose frozen pool names the person reading it.

The approver portal

One link per payment file, forwarded by an operator, works for one file and stops working for somebody who approves forty a month. Turn on ApproverPortal (Configuration) and each approver gets one durable link instead, which opens their own queue at /approvals:

{
"ApproverPortal": {
"Enabled": true,
"LinkSecret": "…"
}
}

In practice set LinkSecret via ApproverPortal__LinkSecret — 32 characters minimum, enforced at startup. Then open the System dashboard page: every configured approver is listed with their personal link. Send each person only their own, once — the link does not expire and does not change.

What it shows

Three tabs, cut by the approver's own decision rather than by job status:

TabHolds
Needs youParked files you have not decided about. Your actual work.
Waiting on othersParked files you have decided about, still short of quorum.
DecidedFiles you decided that have left the gate, within DecidedLookback (90 days by default).

The first two are both AwaitingApproval — a job with one of three approvals is simultaneously "pending" and "partially approved" — which is why the page is not cut on status.

Each row is one line: the file name, the grand total, the payment and exclusão counts, the tally, and the decide-by deadline if the profile sets one. The payer appears only when the queue holds more than one. Plus one figure chosen because it catches the error this gate exists for:

  • Largest single payment — where an extra zero shows. A grand total is a number nobody holds a prior for; one payment an order of magnitude above its neighbours is visible at a glance.

The tally chip is a link. 1 of 2 approvals tells you how many; clicking it opens the job's own page in a new tab, which is the only place that answers which of you, when, and — on a rejection — why. What an approver sees there is not the operator's view: only jobs their frozen pool names them in, no approval link, no pool CPFs, and no Retry, Cancel or Download.

No duplicate detection

A comparison against the same payer's previous file was removed in favour of a queue that reads at a glance, so nothing in the product now flags a file resent twice. The payment-date range came off the row too, but that one was belt-and-braces over a machine check — the pipeline still refuses a remessa whose payment dates have passed, on every profile.

Selecting what to act on

Each row on Needs you carries a checkbox, and a bar above the list totals what you have ticked. The select-all box shows three states — none, some, all. The other two tabs have neither checkbox nor bar.

The bar carries two figures: Selected total is the money; Selected payments is how many payments those files come to, with any exclusões counted separately — (+3 exclusão) — never netted off. An extra zero shows up in the amount; a file sent twice or cut off half way through shows up in the count.

Ticking survives expanding and collapsing a row, and clears for any file that leaves the list while you are looking at it.

Files with no grand total are excluded from the sum and disclosed beside it as a count — 2 files have no total and are not in this figure — rather than counted as zero. A profile can gate a job for approval without checking CNAB240, so a parked file need not be a remessa. If every ticked file lacks a total the figure is an em-dash, never R$ 0,00; a remessa of nothing but exclusões still shows R$ 0,00.

Approving a batch

Approve N selected acts on the ticked rows and on nothing else. There is no separate "approve all" — ticking the header checkbox and pressing this button is what that means.

It confirms first, in a dialog that restates the count, the total, and the largest single file in the batch. There is no undo behind it.

Then the approvals run one after another, and every selected file is attempted regardless of what the ones before it returned. The report has two parts: an aggregate (9 of 12 approved; 6 went on to signing), and a list naming every file that did not go through, and why.

Expect some. Each approval is an independent call, and a colleague may have acted while you were reading — so already decided, no longer awaiting approval — it is Canceled (which is what a colleague's rejection looks like from here), and you are not in this file's approver pool are all ordinary outcomes. Files that were approved untick themselves; files that failed stay ticked, so pressing the button again retries exactly those.

There is no bulk reject

On this or any other surface. Rejecting is a judgement about one file's contents, and it destroys the job irreversibly; N of those in one click is a different act with no reviewable subject.

Approving or rejecting one file

Approve is one click from the row. Reject is on the row too, but opens a modal dialog carrying the irreversibility warning and an optional reason, and its Yes, reject is the act — the button on the row only asks. An approval releases a file the pipeline will still content-check; a rejection destroys the job irreversibly, and from a list of near-identical rows a misplaced click cancels the wrong payroll.

Who gets paid expands the row into the payment table, with identifiers whole.

Taking a list with you

Every tab carries an Export to Excel button, in the same place on all three, disabled rather than hidden when the tab is empty. It downloads the tab you are on as an .xlsx workbook: one row per payment file, never one per beneficiary.

It exports the whole tab, not your ticked rows — the tick marks belong to approving a batch and exist on Needs you alone.

TabWhat the export is for
Needs youPlanning a morning's approvals before you start clicking
Waiting on othersChasing the colleagues holding up files you already decided
DecidedAnswering "what did I approve last month" without asking an operator

Above the table, the workbook states who generated it, at what moment, from which list, and which clock the timestamps are on. On the Decided export it also states its two bounds — the lookback window it covers, and, when the 200-row cap bit, that the list was cut short. The decided list is a window, never a complete history, and a truncated list circulated as a complete one is how somebody concludes a file they approved was never sent.

Practical notes:

  • Money and counts are real numbers, so they sum, filter and pivot. A file with no CNAB240 total leaves those cells empty rather than 0.
  • The payer's CPF/CNPJ is text, punctuated, so leading zeros survive. Dates are real date cells.
  • The contents are in your display language; the file name is not. It is an accent-free slug with an ISO date — approvals-needs-you-2026-08-12.xlsx.
  • Nothing changes when you export. No job moves and no decision is recorded. The service logs one line saying you did.
  • No payment line reaches the workbook. No beneficiary name, tax id, branch or account; the only identification document on the sheet is the payer's.

There is no account and no password behind the portal. Anyone holding an approver's link is that approver, as far as the product can tell.

  • Send each link privately, to one person. A forwarded link is a delegated approval.
  • To revoke one person, remove them from every profile's Approvers. Their link stops working immediately. Jobs already parked with them in the pool keep their entry — the frozen rule does not move.
  • To revoke everybody, change ApproverPortal:LinkSecret. Every link breaks at once.

Decisions made through the portal record LinkDerivedEmail rather than SelfDeclaredEmail. That is stronger in the way that matters most in practice — the person deciding could not have named somebody else, because the portal never offers the choice — and it is still not authentication.

Signing in with Microsoft Entra ID

When the deployment enables the optional Entra sign-in, an approver with the Approver app role reaches the same portal by signing in with their Microsoft account — no link needed.

  • The role opens the door; the pool still scopes the jobs. Which payment files the person sees and may decide remains the frozen pool, matched by the email their directory asserts. An Entra Approver whose address is in no pool sees an empty portal; an account whose token carries no email claim is refused outright.
  • Decisions record EntraIdEmail — the first identification method that is authentication: the directory verified who was present, where a link only narrows who could have been impersonated. When one person holds both a link session and an Entra session, the stronger method is recorded.
  • Links coexist, deliberately. Pools name arbitrary emails, and a client's finance manager need hold no account in the deployment's tenant.
  • The per-job page recognises them too. An Entra-signed-in Approver opening /approve/{jobId} is named instead of asked, and shown the beneficiary identifiers whole when the job's frozen pool includes their email. An Administrator-only sign-in gets none of this — the page treats them as anonymous, because recognising an operator there would be operator-on-behalf-of approval.

Proving it is you

ApproverSecondFactor:Enabled puts an RFC 6238 authenticator app between an approver and a decision. Off by default, so nothing about an existing deployment changes until somebody chooses it. Host-wide rather than per profile, deliberately: a per-profile rule would be frozen onto the job at the park, and authentication must not be in that snapshot — otherwise editing configuration could be an authorisation bypass.

Each approver binds one authenticator, once, through the portal: a QR code, a manual-entry secret, and a live code confirmed before anything is stored. Afterwards the first decision made on a browser asks for the current six digits. Entering them opens a verification window (ApproverSecondFactor:VerificationWindow, twenty minutes by default) during which nothing on that browser asks again, however many files are cleared.

The window is absolute from the moment the code was typed, and belongs to the browser session rather than to the person — proving the factor on a laptop at home does nothing for the machine left signed in at the office, which is precisely the unattended session the control exists to close. Zero is a legitimate setting and means "prompt on every decision".

Other behaviour worth knowing:

  • A code is single-use. Five consecutive wrong ones close that approver's enrolment for five minutes. Both counters live on the enrolment row, so a restart clears neither.
  • Operators get an Approver second factor list on the System page — one row per configured approver, enrolled or not, with the date — and a Reset button. That is the lost-phone path, and it is recorded under the operator's name as its own audit event.
  • Seeds are encrypted at rest under a key derived from the required ApproverSecondFactor:SeedSecret. Seeds are random per approver, so holding the first factor cannot mint the second. Losing or rotating that secret means every approver enrols again.
  • Every decision row records whether a factor was verified, and when.
  • The window crosses instances. It lives in the operational store keyed by an identifier carried inside the cookie, so a window opened via one instance is honoured via another with nothing added.
Breaking, on opt-in: enabling the factor withdraws POST /api/approvals/{id}

That route refuses every call while the setting is on, with 403 and approval.second-factor-required, and there is nothing a caller can send that would satisfy it — no header, no key, no body field — because what is missing is a proven presence and only a browser session can carry one.

Any approval driven from an ERP, a scheduler or a script stops on the day the setting is flipped, and the operator flipping it is usually not the person whose integration stops. Treat it as a coordinated change rather than a configuration tweak.

There is deliberately no authenticated approve endpoint to move to: an approve route behind the API key would be weaker than the anonymous page, since that key lives in the ERP's configuration, the deploy pipeline and a production settings file — so "an approver decided" would mean "something holding the operator credential decided". GET /api/jobs/{id}/approvals is untouched, so a system that watches approval state keeps working. It is only the deciding that moves to the portal.

The anonymous per-job page splits by reader rather than by route. With the factor on, /approve/{jobId} opened by somebody the host cannot identify renders read-only: every figure, every payment line, and exactly the masking it used before — this narrows nothing about what a forwarded link discloses and must not be read as having improved it — with the decision panel replaced by a route to the portal, and the self-declared warning gone with it, because there is no longer a self-declared decision for it to warn about. The same URL opened by a reader holding a portal link or an Entra session behaves exactly as the portal does: the same controls, the same code prompt, and the same window, so verifying in the portal and then following a link from last week's mail does not ask twice.

The boot banner follows the same rule. The warning that fires on every approval-configured profile — "decisions on this build are self-declared" — is false once the factor is on, so with the setting enabled it becomes an informational line stating the actual posture, including that the REST route now refuses every call.

This does not make an operator unable to be an approver

TOTP is symmetric, an operator can read every approver link and reset every enrolment, so an operator can still be any approver. Binding an approver to the CPF in the frozen pool via an ICP-Brasil certificate remains outstanding, and this control must not be described as having closed that gap.

Every key, its bounds and the three boot refusals are in Configuration.

Rejection is a veto

One rejection stops the job, whatever the quorum arithmetic says. A pool of three with a quorum of one still stops when one person rejects, even though two people who have not decided could each have released it on their own.

This is not how a vote works, and deliberately so. A rejection is not a withheld vote to be made up by others — it is a person asserting that the file is wrong, and a quorum does not get to outvote that.

So a vetoed job reports its approval count honestly — "2 of 2 approvals — rejected" is not a contradiction, it is what happened — but it never proceeds.

What happens to the job

It becomes Canceled, not Failed:

  • The staged copy moves to error/, preserving the exact bytes that were rejected.
  • The original stays in input/, and the watcher will not auto-resurrect it.
  • Retry does not apply. Retry only accepts Failed. A rejected file comes back only through a deliberate rescan or re-upload — finance corrects it and re-submits.

Rejections are told apart from operator cancellations by the audit trail, not by the status: the job's timeline names the rejecting approver and their reason, and an ApprovalRejected operational event is recorded.

The narrow race, and what covers it

Where the job isWhat stops it
Still parked in AwaitingApprovalthe rejection cancels it directly
Released to Queued but not yet claimedthe same cancel — its status guard covers Queued too
Already claimed by a worker (Processing)the pipeline's own pre-sign veto check refuses to sign it

On the third path the job ends as Failed with approval.rejected rather than Canceled, since Processing has no legal transition to Canceled. Both outcomes leave the file unsigned, which is the property that matters.

A rejection that arrives after the signature has been computed cannot un-compute it. Nothing short of holding a lock across somebody's deliberation would close that.

What is approved

Bytes, not a job id.

The copy staged at parse time is the canonical artifact for the whole approval window. The input file is never re-read as the artifact to be signed, and the parse never runs a second time — so a file changed in input/ during the wait cannot take the place of the one that was approved.

Immediately before signing, the staged bytes are re-hashed and compared against the hash recorded at parse. A mismatch fails the job hard with approval.content-changed: never a silent re-parse, never a proceed. The check runs on the local sign path and the remote-signer upload path alike.

The input file is read once more, but only after the signature exists and only to answer a different question: is this still the file that was staged, and may it therefore be deleted? See Operations.

If the staged copy disappears the job fails. There is no honest way to continue — rebuilding it from input/ would sign something nobody approved. Retry the job; a retry is a new job, and it parks again.

If the service restarts mid-wait nothing happens, which is the point. Startup recovery deliberately skips AwaitingApproval: a parked job is not "in progress at last shutdown", it is a job waiting on a person. The row and the staged copy both survive.

Cancelling a parked job

POST /api/jobs/{id}/cancel, or the Cancel button on the job page. A parked job is cancelable precisely because nothing is holding it. The staged copy moves to error/ and the file stays in input/; the watcher honours the cancel and will not auto-resurrect it.

Security

It confers the power to release a payment file for signature — and to stop one — and it checks nothing about who is using it.

  • Send it only to the people in the pool, and only through a channel you would use for the payment file itself.
  • Do not forward it, and tell approvers not to. One forwarded link is enough for one person to satisfy a multi-person quorum, because all they need is two addresses from the list.
  • Do not put the service on a network the approvers' browsers can reach if you cannot accept that. Distribute the figures another way and cancel/re-run instead.

Rejection is the gentler half of that capability: whoever holds the link can also stop a legitimate payment file, and the remedy — correct and re-submit — is an inconvenience rather than a loss. It is still an unauthenticated denial of service against a specific payroll.

Job ids are v4 GUIDs, so the URL is not guessable in practice, and the route has its own rate-limit budget (RateLimiting:Approval, ten requests per minute per address by default).

Refusals are deliberately coarse: an address that is well-formed but not in the pool and an address that is not an address at all both return approval.unknown-approver.

What every approval records

FieldMeaning
ApproverEmailnormalized (trimmed, lower-cased); unique per job, enforced by a database index
ApproverName, ApproverCpfcopied from the frozen pool, never from the request
DecisionApproved or Rejected
Reasonfree text the decider typed, or null; repeated in the job's timeline
IdentificationMethodSelfDeclaredEmail on the anonymous page, LinkDerivedEmail through a portal-link session, EntraIdEmail through a Microsoft Entra sign-in
ContentSha256the bytes this decision is about
DecidedAtUTC
IpAddressthe connection's remote address, or null. Behind a reverse proxy this is the proxy unless forwarded headers are configured
UserAgentverbatim, truncated to 512 characters, or null

IdentificationMethod exists so that as stronger identification arrives, earlier approvals stay visibly what they were in the same table rather than being retroactively blessed. Members are added, never repurposed, and no row is ever migrated onto a new value.

Approvals recorded from the dashboard or the portal carry no IP or user agent: those paths run over the Blazor circuit, where there is no HTTP request to read them from. The REST route records both.

Personal data

The pool holds a name, an email and a CPF per approver, and every approval row copies them. The CPF is display and audit only: validated for check digits at boot, normalized to eleven bare digits, and nothing branches on it.

Cpf is in the structured-property redaction allowlist, so it cannot reach a durable log. Approver addresses are masked (m***@empresa.com.br) in console narration and operational events; the full addresses live in the frozen snapshot and the approval rows. See Security.

Retention

Approval rows and the frozen rule are never purged, including when the job reaches a terminal status. Who authorised a payment, and under what rule, is exactly what an audit asks for after the fact. This is the deliberate opposite of the CNAB240 line detail, which is purged at terminal status — see Retention.

REST

This route is withdrawn when the second factor is on

ApproverSecondFactor:Enabled = true makes POST /api/approvals/{id} refuse every call with 403 and approval.second-factor-required, and no header, key or body field satisfies it. If an ERP or scheduler drives approvals here, read Proving it is you before enabling the factor. GET /api/jobs/{id}/approvals is unaffected.

Deciding is one anonymous route:

curl -X POST http://localhost:8080/api/approvals/3f2a…/ \
-H 'Content-Type: application/json' \
-d '{"email":"maria@empresa.com.br"}'
{
"jobId": "3f2a…",
"approverName": "Maria Silva",
"approved": 2,
"required": 2,
"outstanding": 0,
"quorumMet": true,
"released": true
}

To reject, add decision (and optionally reason):

curl -X POST http://localhost:8080/api/approvals/3f2a…/ \
-H 'Content-Type: application/json' \
-d '{"email":"maria@empresa.com.br","decision":"rejected","reason":"valor errado no lote 2"}'
{
"jobId": "3f2a…",
"approverName": "Maria Silva",
"reason": "valor errado no lote 2",
"terminated": true
}

decision accepts approved or rejected, case-insensitively. Omitting it means approved — a client written before rejection existed keeps working unchanged. Anything else is refused rather than interpreted: "reject" — plausible, wrong, one letter away — must not resolve to either.

A rejection returns 200, not a 4xx. It is what the caller asked for and it succeeded. terminated is false only in the narrow race where a worker had already claimed the job.

Refusals carry a stable code:

CodeStatusMeaning
job.not-found404no job with that id
approval.not-pending409the job accepts no decision in its current status
approval.unknown-approver403the address is not in the job's frozen pool (also returned for a malformed address, deliberately)
approval.already-decided409this approver has decided; decisions are final
approval.unknown-decision400decision was neither approved nor rejected
validation.reason-too-long400reason exceeds 512 characters. Refused rather than truncated
approval.job-incomplete500the job is parked but its frozen rule or content hash is missing

Reading the state from another system

Reading is two authenticated routes, for compliance reporting, an external dashboard, or a monitor watching for jobs parked longer than some threshold:

  • GET /api/jobs/{id} carries an approval summary — the frozen quorum, the pool size, how many people have approved and rejected, vetoed, parkedSince and the expiry deadline if the rule set one. null on any job that never parked. Branch on vetoed, not on arithmetic of your own: quorumReached can be true on a job a veto has already stopped.
  • GET /api/jobs/{id}/approvals returns the frozen pool with each member's decision, and the decision list. CPF is masked to its check digits on both. 404 with approval.not-required on a job that never parked — a distinct answer from a parked job nobody has decided on, which is 200 with an empty list.

Every figure comes from the rule frozen onto the job, never from appsettings.json.

There is no REST approve endpoint

Behind the API key it would be worse than the anonymous page: the key sits in an ERP's configuration, a deploy pipeline and a production settings file, so it would become an approve-anything credential for everyone holding any of those. Anonymous it would be a scriptable bulk-approve loop over every parked job. The actor this gate exists for is a person reading a payment breakdown, not an integration.

Metrics

MetricTypeLabelsMeaning
bulksigner_jobs_awaiting_approvalgaugejobs currently parked; set from a scan, so it is right after a restart
bulksigner_jobs_parked_for_approval_totalcounterprofilejobs that parked
bulksigner_approvals_recorded_totalcounterprofiledecisions recorded, one per person per job — approvals and rejections
bulksigner_approvals_rejected_totalcounterprofilethe rejection subset. Deliberately separate from bulksigner_jobs_canceled_total, which counts what an operator did
bulksigner_jobs_released_by_approval_totalcounterprofileparked jobs whose quorum was met
bulksigner_approvals_expired_totalcounterprofileparked jobs canceled because their frozen wait budget elapsed. The one series that counts nobody acting — the one to alert on
bulksigner_jobs_content_changed_totalcounterprofilepre-sign content-binding failures. Should be flat at zero forever

An expiry rate that climbs usually says something about your distribution of the approval link — the product sends no mail, so a lapsed window generally means the link never reached anybody.

Statistics

Approval waits are excluded from the pipeline's elapsed-time statistics, the same way the AwaitingSigner wait is. An approval wait is measured in hours of somebody's attention, and folding it into the queue/sign/verify averages would swamp every number with a quantity the pipeline neither caused nor can improve.

Concretely: parking discards the job's in-flight timing entry, and a released job opens a fresh one whose queue wait is measured from the moment it re-entered the queue. See Job statistics.

Troubleshooting

A job is parked and nobody can approve it. Check the pool on the job page: it is the pool frozen at park time, not the one in your configuration file. If the people listed are wrong, cancel the job, fix the profile, and re-run the file.

An approver gets "not an approver for this job". Their address is not in the frozen pool. Compare it against the pool shown on the job page — leading/trailing spaces and capitalisation do not matter, anything else does.

A released job failed with approval.content-changed. The staged copy in processing/<jobid>/ was modified after the approvers saw it. The job's folder is now under error/. Do not re-sign it — find out what wrote to processing/, then re-run the original file from input/ so it is parsed, totalled and approved afresh.

A job says "2 of 2 approvals — rejected". Both readings are true. The count is the arithmetic and the outcome is the veto. The timeline names the rejecting approver and their reason.

A job failed with approval.rejected instead of being cancelled. The rejection landed after a worker had already claimed the job, so the pipeline refused the signature rather than the approval handler cancelling it. The file is unsigned, which is the point.

A job was canceled with "Approval window expired." Nobody decided inside the profile's ExpiresAfter window. The staged copy is under error/<jobid>/, the original is still in input/, and any approvals that were recorded are still on the job page. Retry does not apply — re-run the file through Rescan or Upload. If windows keep lapsing, either the link is not reaching people or the budget is shorter than your approvers' working rhythm.

A parked job expired while the pipeline was paused. Expected — see The wait budget.

An approver wants to undo a rejection. They cannot, and neither can an operator. A decision is immutable. Re-submit the file; the new job parks and the pool is asked again.

More failure modes in Troubleshooting.


Next: Retention. Previous: CNAB240 payment files.