Approvals
Approvals hold a consequential action until someone with authority says yes. The pattern:
- An agent does the work and submits it as a completion.
- The record waits at
PROCESSINGuntil its principal renders a verdict. - The verdict fires a Settlement Signal webhook, which the system that acts (a payment run, a
deploy, a vendor activation) reads before it acts:
SETTLE: go ahead.HOLD: do not.RELEASE: a disputed verdict was overturned.
The examples use principal-gate-generic-v1, the gated example type every new org is seeded with, with two agents:
ap-controlleris the principal, with key$PRINCIPAL_KEY.invoice-agentis the performer, with key$PERFORMER_KEY.
A type of your own works the same way, as long as it declares a completionSchema. Registering
one is covered in Define Custom Types.
1. Create a gated record
The principal creates the record with its own key and names the performer:
curl -s -X POST "$AGLEDGER_API_URL/v1/records" \
-H "Authorization: Bearer $PRINCIPAL_KEY" -H 'Content-Type: application/json' \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"type": "principal-gate-generic-v1",
"gateMode": "principal",
"performerAgentId": "'"$PERFORMER_ID"'",
"criteria": {
"summary": "Release payment of USD 48,200.00 to Northwind Freight for invoice INV-20931",
"amount": 48200, "currency": "USD", "vendor": "Northwind Freight", "dealRef": "INV-20931"
}
}'
{ "id": "01a0dc06-b2cf-7283-8817-4bc1ececa6c2", "status": "CREATED", "gateMode": "principal",
"nextActions": ["register", "propose", "cancel"] }
gateMode: "principal" is what makes the record wait for a person or an agent with authority,
rather than for rules alone. Step 4 covers the difference. State it explicitly on every create.
Who creates it:
- The principal's own agent key. Recommended: the chain attributes the record to the accountable agent.
- An org-admin key, passing
principalAgentId. An agent key cannot name another agent as principal (403Agents can only create records as principal of themselves). - Watch the org-admin shortcut. An org-admin create that passes only
performerAgentIdmakes that agent the principal as well (selfPrincipal: true), so the performer would approve its own work. Passing onlyprincipalAgentIddoes the same the other way round: the principal becomes the performer. Always pass both from an admin key.
Skipping the performer's agreement. Pass "autoActivate": true and the record goes
straight to ACTIVE with acceptanceStatus: "BYPASSED", and the chain records that the
performer never opted in:
curl -s -X POST "$AGLEDGER_API_URL/v1/records" \
-H "Authorization: Bearer $ADMIN_KEY" -H 'Content-Type: application/json' \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"type":"principal-gate-generic-v1","gateMode":"principal",
"principalAgentId":"'"$PRINCIPAL_ID"'","performerAgentId":"'"$PERFORMER_ID"'",
"autoActivate":true,
"criteria":{"summary":"Release payment of USD 12,750.00 to Contoso Metals for invoice INV-20944"}}'
2. The performer accepts or rejects
Without autoActivate, the principal proposes the record and the performer answers:
curl -s -X POST "$AGLEDGER_API_URL/v1/records/$RECORD_ID/transition" \
-H "Authorization: Bearer $PRINCIPAL_KEY" -H 'Content-Type: application/json' \
-H "Idempotency-Key: $(uuidgen)" -d '{"action":"propose"}'
curl -s -X POST "$AGLEDGER_API_URL/v1/records/$RECORD_ID/accept" \
-H "Authorization: Bearer $PERFORMER_KEY" -H 'Content-Type: application/json' \
-H "Idempotency-Key: $(uuidgen)" -d '{"message":"Invoice matched to PO; terms accepted."}'
| Answer | Result |
|---|---|
| Accept | CREATED with acceptanceStatus: "ACCEPTED". The principal then activates it ({"action":"activate"} on /transition) and it becomes ACTIVE. |
Reject (POST /v1/records/{id}/reject) | REJECTED, which is terminal. The terms cannot be renegotiated on the same record; the principal creates a new one. |
In either case the rationale (message) is kept on the record as lastTransitionReason and
delivered as data.reason on the record.proposal_accepted / record.proposal_rejected event.
The performer finds proposals waiting for it with GET /v1/records/agent/proposals, or with
GET /v1/records?actionable=true, which lists everything waiting on that agent.
3. The performer submits the completion
Once the record is ACTIVE, the record's completionHint names the evidence fields its type
requires (summary for this type). The performer submits:
curl -s -X POST "$AGLEDGER_API_URL/v1/records/$RECORD_ID/completions" \
-H "Authorization: Bearer $PERFORMER_KEY" -H 'Content-Type: application/json' \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"evidence":{
"summary":"Payment file PMT-7781 prepared for USD 48,200.00 to Northwind Freight",
"evidenceUrl":"https://erp.example.com/payments/PMT-7781",
"notes":"Three-way match passed"}}'
{ "id": "01a0dc06-b40c-7f8c-b354-b99e122fdb0d", "structuralValidation": "ACCEPTED",
"validationErrors": null, "recordStatus": "PROCESSING" }
Check structuralValidation, not the status code. A completion that does not match the
type's completionSchema is also answered with a 201:
{ "structuralValidation": "INVALID",
"validationErrors": [ { "keyword": "required", "params": { "missingProperty": "summary" },
"message": "must have required property 'summary'" } ],
"recordStatus": "ACTIVE" }
- What an
INVALIDcompletion does. The record staysACTIVE, nothing is written to its chain, and the event isrecord.completion_invalid. Fix the fieldsvalidationErrorsnames and submit again. A fix spends no revision, because theINVALIDcompletion was never evaluated. Each attempt counts towardmaxSubmissionswhen the record sets one; once it is reached, the response stops offering the resubmit and the record waits on its principal. Where the org runsenforcement.maxSubmissionsMode: "advisory", a submission past the cap is accepted instead, and itswarningscarry an entry withkeyword: "maxSubmissions"whoseparamsname the cap and this submission's number; itsCOMPLETION_SUBMITTEDchain entry carries the same assubmissionCapAdvisory. - What an
ACCEPTEDcompletion does. The record moves toPROCESSINGwithawaitingActor: "principal", and the chain gainsCOMPLETION_SUBMITTED. - Listing them.
GET /v1/records/{id}/completionslists every completion, with?structuralValidation=to filter.
4. Dry-run the gate and read its status
Two checks decide a gated record:
- Phase 1 is structural: does the evidence match the schema?
- Phase 2 runs the type's rules, if it has any.
In principal mode, phase 2 is advisory: the engine records what the rules say, and the principal makes the call.
The engine evaluates each completion on its own when it is submitted. POST /v1/records/{id}/evaluate
reads what it found, per completion, and changes nothing; a phase2Result of null means that
evaluation has not finished yet:
curl -s -X POST "$AGLEDGER_API_URL/v1/records/$RECORD_ID/evaluate" \
-H "Authorization: Bearer $PRINCIPAL_KEY" -H 'Content-Type: application/json' -d '{}'
On a type with a deterministic rule, the result names the rule and the value it saw. The
example is terminal-outcome-v1, another seeded example type, whose rule compares the reported
terminalState with the expected one. The record was created with gateMode: "principal" (the
type defaults to auto), and the performer reported the batch as failed:
{
"completions": [{
"phase1Result": { "status": "ACCEPTED" },
"phase2Result": {
"outcome": "reject",
"recommendation": "HOLD",
"checks": { "string:exact": { "pass": false, "expected": "settled", "actual": "failed",
"reason": "Expected exactly \"settled\", got \"failed\"" } }
}
}],
"overallStatus": "PROCESSING"
}
On a type with no rules, such as principal-gate-generic-v1, phase 2 has nothing to check. The
advisory reads accept / SETTLE whenever phase 1 passed, and the decision is entirely the
principal's.
GET /v1/records/{id}/gate-status is the poll:
{ "phase1Status": "passed", "phase2Status": "failed", "pendingRules": [],
"verdict": null, "recommendation": null, "gateMode": "principal" }
recommendation stays null until a verdict renders the signal. Branch on recommendation,
not on phase2Status: an advisory can fail while the principal accepts, and the reverse.
The difference between the two modes is who decides:
gateMode: "auto". The rules render the verdict themselves the moment the completion lands, and the record settles toFULFILLEDorFAILEDwith no principal step. Use it when the rule is the whole decision.principalmode. Use it when a principal's judgment is part of the approval.
5. The principal renders the verdict
Only the record's principal, or an org-admin key, can render it. The performer is refused
(403 WRONG_STRUCTURAL_ROLE). Render it before the record's deadline: past it the verdict is
refused with a 422 and the record expires in the same call (EXPIRED, terminalReason: "TIMED_OUT"), as the expiry sweep would expire it. Once the deadline has passed the record reads
awaitingActor: "system" and its nextSteps stop offering the verdict.
curl -s -X POST "$AGLEDGER_API_URL/v1/records/$RECORD_ID/verdict" \
-H "Authorization: Bearer $PRINCIPAL_KEY" -H 'Content-Type: application/json' \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"completionId":"'"$COMPLETION_ID"'","verdict":"accept",
"checks":{"threeWayMatch":true,"approvalPolicy":"AP-7"}}'
{ "verdict": "accept", "recommendation": "SETTLE", "reporterType": "principal",
"recordStatus": "FULFILLED" }
What goes in the body:
completionIdnames the completion being judged. It is on the record aslatestCompletionId.checksis your structured rationale. Its shape is yours; it is signed into the chain and delivered on the webhook.notesis free text, and becomes the signal'sreason:
curl -s -X POST "$AGLEDGER_API_URL/v1/records/$RECORD_ID/verdict" \
-H "Authorization: Bearer $PRINCIPAL_KEY" -H 'Content-Type: application/json' \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"completionId":"'"$COMPLETION_ID"'","verdict":"reject",
"notes":"Remit-to bank account changed 2 days ago; hold for callback verification.",
"checks":{"bankChangeVerified":false}}'
{ "verdict": "reject", "recommendation": "HOLD", "reporterType": "principal",
"recordStatus": "FAILED" }
The signal itself, with the reason, is on the record (GET /v1/records/{id}):
{ "status": "FAILED",
"settlementSignal": { "recommendation": "HOLD", "outcome": "reject", "reasonCode": "PRINCIPAL_REJECT",
"reason": "Remit-to bank account changed 2 days ago; hold for callback verification." } }
Asking for rework instead of ending there. A rejected record can go back to the performer:
curl -s -X POST "$AGLEDGER_API_URL/v1/records/$RECORD_ID/revision" \
-H "Authorization: Bearer $PRINCIPAL_KEY" -H 'Content-Type: application/json' \
-H "Idempotency-Key: $(uuidgen)" -d '{"reason":"Resubmit after the vendor callback confirms the new account."}'
- What revision does. The record goes to
REVISION_REQUESTED, the performer submits a new completion, and the principal renders a new verdict on that one. - The cap.
maxRevisionsbounds the loop (3 unless the record or the org set another value). Each revision cycle spends one, including a performer's refresh of evidence the principal is holding; fixing a schema-INVALIDcompletion does not. At the cap the call is refused, and the record staysFAILED. - Each verdict is its own signal. A HOLD and then a SETTLE are two signals on one record.
What the Settlement Signal webhook carries
Subscribe the acting system with an org-admin key:
curl -s -X POST "$AGLEDGER_API_URL/v1/webhooks" \
-H "Authorization: Bearer $ADMIN_KEY" -H 'Content-Type: application/json' \
-d '{"url":"https://payments.internal.example.com/agledger","eventTypes":["signal.emitted","dispute.opened","dispute.resolved","dispute.withdrawn"]}'
How it is signed. A subscription that lists signal.emitted is signed with the Server's
own signing key by default (signingAlg: "ed25519" on the response). Receivers verify it
against GET /v1/verification-keys, and no shared secret is issued. Verifying is covered in
Webhooks.
The signal.emitted delivery on the two verdicts above:
{ "type": "signal.emitted", "recordId": "01a0dc06-b2cf-7283-8817-4bc1ececa6c2",
"data": { "recommendation": "SETTLE", "outcome": "accept", "reasonCode": "PRINCIPAL_ACCEPT",
"reason": null, "failingRuleIds": null, "performerAgentId": "01a0dc06-b223-7321-b060-a38d6951c29f",
"type": "principal-gate-generic-v1", "platformRef": null } }
{ "type": "signal.emitted",
"data": { "recommendation": "HOLD", "outcome": "reject", "reasonCode": "PRINCIPAL_REJECT",
"reason": "Duplicate of INV-20911, already paid.", "failingRuleIds": null } }
record.gate_complete carries the same outcome together with the principal's checks and the
completionId.
6. Disputes
Either party can dispute a verdict on a FAILED, FULFILLED or REMEDIATED record. The
performer disputes the rejection of its batch:
curl -s -X POST "$AGLEDGER_API_URL/v1/records/$RECORD_ID/dispute" \
-H "Authorization: Bearer $PERFORMER_KEY" -H 'Content-Type: application/json' \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"grounds":"verdict_disagreement","context":"The 3 returned items were re-sent and settled; the batch settled in full on 2026-09-26."}'
{ "dispute": { "id": "01a0dc06-b649-74ce-91b2-021cd4e3c020", "status": "EVIDENCE_WINDOW",
"initiatedByRole": "performer", "grounds": "verdict_disagreement",
"evidenceWindowClosesAt": "2026-09-28T04:43:42.541Z" },
"autoReadjudication": { "autoResolved": false, "reason": "not_configured" } }
Dispute rules:
groundsis one ofequivalent_item,fraudulent_completion,record_ambiguity,pricing_dispute,quality_issue,verdict_disagreement,other.- The record moves to
DISPUTEDwhile the dispute is open. A type registered withflipRecordStatusOnDispute: falsekeeps its status instead; the dispute is then visible only asdisputeStatuson the record and through the?hasDispute=and?disputeStatus=search filters. - One dispute per record by default (
maxDisputes).
Add evidence while the window is open:
curl -s -X POST "$AGLEDGER_API_URL/v1/records/$RECORD_ID/dispute/evidence" \
-H "Authorization: Bearer $PERFORMER_KEY" -H 'Content-Type: application/json' \
-d '{"evidenceType":"external_lookup","payload":{"source":"bank portal","batch":"B17","settledItems":212,"settledAt":"2026-09-26T14:02:00Z"}}'
Read the dispute and list them:
GET /v1/records/{id}/disputereturns the dispute and its evidence.GET /v1/disputes?status=PENDING_RESOLUTIONis the org-wide queue of disputes waiting on a decision (org-admin key).- A dispute moves from
EVIDENCE_WINDOWtoPENDING_RESOLUTIONwhen its window closes.
Resolve it. The record's principal, or an org-admin key, decides; the performer is refused (403). The path takes the dispute id:
curl -s -X POST "$AGLEDGER_API_URL/v1/disputes/$DISPUTE_ID/resolve" \
-H "Authorization: Bearer $PRINCIPAL_KEY" -H 'Content-Type: application/json' \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"outcome":"OVERTURNED","rationale":"Bank portal confirms all 212 items settled."}'
| Outcome | Record | Signal |
|---|---|---|
OVERTURNED | a FAILED record settles at FULFILLED | RELEASE with reasonCode: "DISPUTE_OVERTURNED" |
UPHELD | returns to the status it held before the dispute | none |
The engine records the decision; it does not weigh the evidence. After the overturn:
{ "status": "FULFILLED", "disputeStatus": "RESOLVED",
"settlementSignal": { "recommendation": "RELEASE", "reasonCode": "DISPUTE_OVERTURNED", "reason": "verdict_disagreement" } }
Withdraw it. The initiator or an org-admin can withdraw a dispute until it is resolved, and the record returns to its pre-dispute status:
curl -s -X POST "$AGLEDGER_API_URL/v1/records/$RECORD_ID/dispute/withdraw" \
-H "Authorization: Bearer $PERFORMER_KEY" -H 'Content-Type: application/json' \
-d '{"reason":"Surcharge billed separately on INV-20958."}'
7. Check clearance before acting
Why the webhook alone is not enough. The system that acts on a verdict should treat each
delivery as a prompt to look, not as the state itself. Ordering is best-effort: a subscription's
waiting deliveries go out lowest eventSequence first, but a failed attempt waits out its backoff
while later events go out, a dead-letter redelivery arrives after everything since, and under a
backlog two deliveries can be in flight at once. eventSequence is the order the events were
queued, so a receiver can drop a delivery whose eventSequence is lower than one it already
handled for the same recordId.
Read the current signal from the record at the moment you act:
curl -s "$AGLEDGER_API_URL/v1/records/$RECORD_ID/gate-status" -H "Authorization: Bearer $ACTING_SYSTEM_KEY"
{ "recommendation": "SETTLE", "verdict": "accept", "phase1Status": "passed", "phase2Status": "passed" }
$ACTING_SYSTEM_KEY can be an admin-observer key: read-only, minted with
POST /v1/admin/api-keys and "scopeProfile": "admin-observer". A read by an org-admin key
of a record it is not party to is logged in the Server's read transparency log
(GET /v1/audit/org-reads).
Act only on SETTLE or RELEASE, read at the moment you act. While the record is DISPUTED,
recommendation and verdict read null: treat that as "not cleared". A type registered with
flipRecordStatusOnDispute: false never reaches DISPUTED, so its gate-status keeps the
pre-dispute signal while a dispute is open; for those types also read disputeStatus on the
record and hold while it is open. To poll without webhooks, run the same call on a schedule, or list records waiting on a principal with
GET /v1/records/search?status=PROCESSING.
Deduplicate deliveries on the X-AGLedger-Idempotency-Key header, which is stable across
retries.
Field shapes for every call above are in the API reference.
Air-gapped
Every step runs against your own Server, and nothing calls out:
- Webhook receivers are on your network. Private addresses need
SSRF_ALLOW_CIDRS, and a plain-HTTP receiver behind a TLS-terminating load balancer needsSSRF_ALLOW_PLAIN_HTTP. - Signatures verify against the Server's own
GET /v1/verification-keys. - Audit exports of approved records verify offline with the verifier from your internal package mirror.