Approvals

Approvals hold a consequential action until someone with authority says yes. The pattern:

The examples use principal-gate-generic-v1, the gated example type every new org is seeded with, with two agents:

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:

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."}'
AnswerResult
AcceptCREATED 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" }

4. Dry-run the gate and read its status

Two checks decide a gated record:

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:

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:

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

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:

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."}'
OutcomeRecordSignal
OVERTURNEDa FAILED record settles at FULFILLEDRELEASE with reasonCode: "DISPUTE_OVERTURNED"
UPHELDreturns to the status it held before the disputenone

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: