StabisoDocs

REST API

Post a journal entry and have it approved

A journal entry is the basic posting of the ledger. This page creates one as a person, shows how the approval policy turns an agent's entry into a request for a second person, and how a posted entry is corrected by a reversal. It also shows what idempotency keys do when a request is repeated.

Before you start

  • Three kinds of key take part. $ERP_KEY is the first key of the demo organization (a human key with every scope). The system issued it, so it belongs to no person: it may set up, draft and post, but it can never approve anything. $ALICE_KEY and $BOB_KEY are keys of two people, users of the organization who signed in and created a key for themselves (Alice has every scope, Bob read, draft, post and approve). The page also creates an agent key. Getting started explains where such keys come from; conventions explains who counts as who.
  • The entity is CH01 (Demo AG, CHF). January to August 2026 are closed, September is open. The default approval policy is in force: people post directly with the post scope, entries drafted by agents always wait for a person.

1. Create an agent key

Only an admin key creates keys. An agent key can never hold the approve or admin scope. The secret (key) is shown once; the page stores it as $AGENT_KEY.

curl -X POST "$ERP_URL/v1/api-keys" \
  -H "Authorization: Bearer $ERP_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "bookkeeping agent", "actor_type": "agent", "scopes": ["read", "draft", "post"]}'
{"id": "<uuid>", "key": "<any>", "actor_type": "agent", "scopes": ["read", "draft", "post"]}

2. Post an entry as a person

A person with the post scope posts at once when the policy allows it. Idempotency-Key is required on every entry you create: a retry with the same key and body gives the stored answer. Amounts are decimal strings. Journal entries created over the API are always manual; entries of the subledgers (invoices, payments, depreciation) are created by their own endpoints.

curl -X POST "$ERP_URL/v1/journal-entries" \
  -H "Authorization: Bearer $ERP_KEY" \
  -H "Idempotency-Key: je-rent-1" \
  -H "Content-Type: application/json" \
  -d '{
    "entity": "CH01",
    "entry_date": "2026-09-15",
    "description": "Office rent September",
    "lines": [
      {"account": "6000", "debit": "2500.00"},
      {"account": "1000", "credit": "2500.00"}
    ]
  }'
{
  "result": {
    "status": "posted",
    "policy": {"decision": "allow", "rule_id": "humans_allowed"},
    "approval_id": null
  },
  "entry": {
    "id": "<uuid>",
    "status": "posted",
    "source": "manual",
    "entry_date": "2026-09-15",
    "description": "Office rent September",
    "lines": [
      {"line_no": 1, "debit": "2500.00", "credit": "0.00"},
      {"line_no": 2, "debit": "0.00", "credit": "2500.00"}
    ]
  }
}

result.policy says why: the rule that fired in the approval policy. The entry has a gap-free number and a place in the entity's hash chain once it is posted.

3. Repeat the request

The network failed and the client does not know whether the entry was created. Sending the same request with the same Idempotency-Key returns the stored response and creates nothing.

curl -X POST "$ERP_URL/v1/journal-entries" \
  -H "Authorization: Bearer $ERP_KEY" \
  -H "Idempotency-Key: je-rent-1" \
  -H "Content-Type: application/json" \
  -d '{
    "entity": "CH01",
    "entry_date": "2026-09-15",
    "description": "Office rent September",
    "lines": [
      {"account": "6000", "debit": "2500.00"},
      {"account": "1000", "credit": "2500.00"}
    ]
  }'
{"result": {"status": "posted"}, "entry": {"status": "posted"}}

The same key with a different request is a mistake and is refused.

curl -X POST "$ERP_URL/v1/journal-entries" \
  -H "Authorization: Bearer $ERP_KEY" \
  -H "Idempotency-Key: je-rent-1" \
  -H "Content-Type: application/json" \
  -d '{
    "entity": "CH01",
    "entry_date": "2026-09-15",
    "description": "Office rent September",
    "lines": [
      {"account": "6000", "debit": "2600.00"},
      {"account": "1000", "credit": "2600.00"}
    ]
  }'
{"error": {"code": "idempotency_key_reused"}}

4. An agent proposes an entry

The agent drafts an accrual. The default policy has the rule agent_manual_journals: manual entries created by agents are always reviewed, so the entry is not posted. It waits as pending_approval with an approval request, and has no number yet.

curl -X POST "$ERP_URL/v1/journal-entries" \
  -H "Authorization: Bearer $AGENT_KEY" \
  -H "Idempotency-Key: je-accrual-1" \
  -H "Content-Type: application/json" \
  -d '{
    "entity": "CH01",
    "entry_date": "2026-09-30",
    "description": "Accrued audit fees September",
    "lines": [
      {"account": "6500", "debit": "3000.00"},
      {"account": "2300", "credit": "3000.00"}
    ]
  }'
{
  "result": {
    "status": "pending_approval",
    "number": null,
    "policy": {"decision": "require_approval", "rule_id": "agent_manual_journals"},
    "approval_id": "<uuid>"
  },
  "entry": {"status": "pending_approval", "number": null}
}

5. Review the approval queue

Anyone with read sees the queue. status defaults to pending; requested_by names the agent, reason the policy rule.

curl "$ERP_URL/v1/approvals" -H "Authorization: Bearer $ERP_KEY"
[
  {
    "id": "<uuid>",
    "object_type": "journal_entry",
    "action": "post",
    "status": "pending",
    "reason": "Manual journal entries created by agents are always reviewed.",
    "requested_by": {"type": "agent", "name": "bookkeeping agent"},
    "summary": {"description": "Accrued audit fees September", "entry_date": "2026-09-30"}
  }
]

6. Approve it

Approving needs a person with the approve scope. An agent is refused before its scopes are looked at: approvals are reserved for humans (policy_denied).

curl -X POST "$ERP_URL/v1/approvals/$APPROVAL_ID/approve" \
  -H "Authorization: Bearer $AGENT_KEY"
{"error": {"code": "policy_denied", "rule_id": "agents_never_approve"}}

The first key has the scope but belongs to nobody, so it cannot decide either.

curl -X POST "$ERP_URL/v1/approvals/$APPROVAL_ID/approve" \
  -H "Authorization: Bearer $ERP_KEY"
{"error": {"code": "forbidden"}}

A person with the scope approves. The entry is posted in the same transaction.

curl -X POST "$ERP_URL/v1/approvals/$APPROVAL_ID/approve" \
  -H "Authorization: Bearer $ALICE_KEY" \
  -H "Content-Type: application/json" \
  -d '{"comment": "Agreed to the audit fee budget"}'
{
  "status": "approved",
  "comment": "Agreed to the audit fee budget",
  "decided_by": {"type": "human"}
}
curl "$ERP_URL/v1/journal-entries/$ACCRUAL_ID" -H "Authorization: Bearer $ERP_KEY"
{"status": "posted", "number": "<any>", "description": "Accrued audit fees September"}

7. Four eyes between people

The same rule applies to people, and it compares people, not keys. Alice creates a second key for a script that may draft and approve but not post. The script's entry waits (the policy says allow but the key has no post scope), and neither the script's key nor Alice's other key may approve it: both are Alice.

curl -X POST "$ERP_URL/v1/api-keys" \
  -H "Authorization: Bearer $ALICE_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "alice script", "actor_type": "human", "scopes": ["read", "draft", "approve"]}'
{"actor_type": "human", "scopes": ["read", "draft", "approve"]}
curl -X POST "$ERP_URL/v1/journal-entries" \
  -H "Authorization: Bearer $MAKER_KEY" \
  -H "Idempotency-Key: je-fees-1" \
  -H "Content-Type: application/json" \
  -d '{
    "entity": "CH01",
    "entry_date": "2026-09-20",
    "description": "Bank fees September",
    "lines": [
      {"account": "6940", "debit": "35.00"},
      {"account": "1000", "credit": "35.00"}
    ]
  }'
{
  "result": {
    "status": "pending_approval",
    "policy": {"decision": "allow"},
    "approval_id": "<uuid>"
  }
}
curl -X POST "$ERP_URL/v1/approvals/$MAKER_APPROVAL_ID/approve" \
  -H "Authorization: Bearer $MAKER_KEY"
{"error": {"code": "four_eyes", "action": "approve"}}
curl -X POST "$ERP_URL/v1/approvals/$MAKER_APPROVAL_ID/approve" \
  -H "Authorization: Bearer $ALICE_KEY"
{"error": {"code": "four_eyes", "action": "approve"}}

Bob is another person. He rejects it with a comment; the entry becomes rejected and stays in the history.

curl -X POST "$ERP_URL/v1/approvals/$MAKER_APPROVAL_ID/reject" \
  -H "Authorization: Bearer $BOB_KEY" \
  -H "Content-Type: application/json" \
  -d '{"comment": "Fees are booked from the bank statement"}'
{"status": "rejected", "comment": "Fees are booked from the bank statement"}

8. Reverse a posted entry

A posted entry never changes. A reversal is a new entry with the debit and credit swapped, linked to the original through reverses_id. It needs the post scope, a reason (required, at most 500 characters; it is kept on the reversal and in the audit trail), a person (an agent's reversal is refused with 403) and an open period. on defaults to the original date.

curl -X POST "$ERP_URL/v1/journal-entries/$RENT_ID/reverse" \
  -H "Authorization: Bearer $ERP_KEY" \
  -H "Idempotency-Key: je-rent-reverse-1" \
  -H "Content-Type: application/json" \
  -d '{"on": "2026-09-30", "reason": "booked twice"}'
{"status": "posted", "entry_date": "2026-09-30", "reverses_id": "<uuid>"}

An entry can be reversed once.

curl -X POST "$ERP_URL/v1/journal-entries/$RENT_ID/reverse" \
  -H "Authorization: Bearer $ERP_KEY" \
  -H "Idempotency-Key: je-rent-reverse-2" \
  -H "Content-Type: application/json" \
  -d '{"reason": "again"}'
{"error": {"code": "entry_already_reversed"}}

Without a reason nothing happens.

curl -X POST "$ERP_URL/v1/journal-entries/$RENT_ID/reverse" \
  -H "Authorization: Bearer $ERP_KEY" \
  -H "Idempotency-Key: je-rent-reverse-3" \
  -H "Content-Type: application/json" \
  -d '{}'
{"detail": ["<any>", "..."]}

Who may reverse what is a policy decision (action reverse_entry):

  • Only manual entries and the opening entry of a data import are reversed here (the opening entry always waits for a second person; then the import accepts the balances again). An entry of a subledger or process (invoice, credit note, bill, payment, depreciation, FX revaluation, bank posting, VAT settlement, intercompany, revenue recognition, year-end close) answers 409, and the message says where its correction belongs: a reversal here would leave the document, payment or asset posted while the ledger effect disappeared.
  • A person reverses their own entry at once. If someone else created, posted or approved the entry, or the approval policy would not let one person post an entry of that size, the answer is 202 with an approval request (object_type journal_reversal); a second person decides it with POST /v1/approvals/{id}/approve, and the reversal is posted then, after everything is checked again. Nobody approves their own request.

9. Check the books

The trial balance after all of this still balances, and the hash chain of the entity verifies.

curl "$ERP_URL/v1/entities/CH01/reports/trial-balance?as_of=2026-09-30" \
  -H "Authorization: Bearer $ERP_KEY"
{"entity": "CH01", "currency": "CHF", "as_of": "2026-09-30", "balanced": true}
curl "$ERP_URL/v1/entities/CH01/chain-verification" -H "Authorization: Bearer $ERP_KEY"
{"entity": "CH01", "valid": true}

What can go wrong

An entry that does not balance is refused before anything is stored.

curl -X POST "$ERP_URL/v1/journal-entries" \
  -H "Authorization: Bearer $ERP_KEY" \
  -H "Idempotency-Key: je-bad-1" \
  -H "Content-Type: application/json" \
  -d '{
    "entity": "CH01",
    "entry_date": "2026-09-15",
    "description": "Unbalanced",
    "lines": [
      {"account": "6000", "debit": "100.00"},
      {"account": "1000", "credit": "90.00"}
    ]
  }'
{"error": {"code": "entry_does_not_balance", "message": "entry does not balance: debit 100.00 != credit 90.00"}}

Control accounts (receivables, payables, bank) are posted through their subledgers, not by hand.

curl -X POST "$ERP_URL/v1/journal-entries" \
  -H "Authorization: Bearer $ERP_KEY" \
  -H "Idempotency-Key: je-bad-2" \
  -H "Content-Type: application/json" \
  -d '{
    "entity": "CH01",
    "entry_date": "2026-09-15",
    "description": "Bank by hand",
    "lines": [
      {"account": "1020", "debit": "100.00"},
      {"account": "1000", "credit": "100.00"}
    ]
  }'
{"error": {"code": "control_account_manual"}}

A closed period rejects postings.

curl -X POST "$ERP_URL/v1/journal-entries" \
  -H "Authorization: Bearer $ERP_KEY" \
  -H "Idempotency-Key: je-bad-3" \
  -H "Content-Type: application/json" \
  -d '{
    "entity": "CH01",
    "entry_date": "2026-08-15",
    "description": "Too late",
    "lines": [
      {"account": "6000", "debit": "100.00"},
      {"account": "1000", "credit": "100.00"}
    ]
  }'
{"error": {"code": "period_closed", "message": "period 2026-08 is closed", "period": "2026-08"}}

A request without the required Idempotency-Key is refused and names the header.

curl -X POST "$ERP_URL/v1/journal-entries" \
  -H "Authorization: Bearer $ERP_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "entity": "CH01",
    "entry_date": "2026-09-15",
    "description": "No key",
    "lines": [
      {"account": "6000", "debit": "100.00"},
      {"account": "1000", "credit": "100.00"}
    ]
  }'
{"error": {"code": "invalid", "header": "Idempotency-Key"}}

Reference

Stabiso, the Enterprise System of Record · generated from the code and its guides · release 6f03a5e846b3