01 · Set up the shell or script
Reads the base URL and the two keys from environment variables and defines a helper. Nothing is sent yet.
Scroll horizontally for the complete command →
set -euo pipefail
: "${INVARENT_BASE_URL:?set INVARENT_BASE_URL, for example https://api.invarent.com}"
: "${INVARENT_PREPARER_KEY:?set INVARENT_PREPARER_KEY to the first portal key}"
: "${INVARENT_APPROVER_KEY:?set INVARENT_APPROVER_KEY to the second portal key}"
RUN="$(date +%s)-$$-$RANDOM"
fail() { echo "$*" >&2; exit 1; }
Node.js (fetch) version of this step
Scroll horizontally for the complete command →
const base = (process.env.INVARENT_BASE_URL ?? "").replace(/\/$/, "");
const preparerKey = process.env.INVARENT_PREPARER_KEY;
const approverKey = process.env.INVARENT_APPROVER_KEY;
if (!base || !preparerKey || !approverKey) {
throw new Error(
"Set INVARENT_BASE_URL, INVARENT_PREPARER_KEY and INVARENT_APPROVER_KEY",
);
}
const run = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
async function call(method, path, key, { body, idempotencyKey } = {}) {
const response = await fetch(`${base}${path}`, {
method,
headers: {
Authorization: `Bearer ${key}`,
...(body === undefined ? {} : { "Content-Type": "application/json" }),
...(idempotencyKey === undefined
? {}
: { "Idempotency-Key": `quickstart-${run}-${idempotencyKey}` }),
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
return { status: response.status, json: await response.json() };
}
// Like call(), but throws with the API's error envelope on any 4xx or 5xx.
async function ok(...args) {
const result = await call(...args);
if (result.status >= 400) {
throw new Error(
`${args[0]} ${args[1]} failed: ${result.status} ${JSON.stringify(result.json)}`,
);
}
return result.json;
}
02 · Find your book, open period and accounts
A new workspace has one book, one open period and an eight-account starter chart. The script takes the newest book, its first OPEN period, and the accounts coded 1000 (Cash) and 3000 (Owner Equity).
GET /v1/books with the preparer key; scope tenant:readGET /v1/periods?bookId={bookId} with the preparer key; scope tenant:readGET /v1/accounts?bookId={bookId}&limit=100 with the preparer key; scope coa:read
Scroll horizontally for the complete command →
BOOKS="$(curl -sS "$INVARENT_BASE_URL/v1/books" \
-H "Authorization: Bearer $INVARENT_PREPARER_KEY")"
BOOK_ID="$(jq -er '.data[-1].id' <<<"$BOOKS")" || fail "books: $BOOKS"
ENTITY_ID="$(jq -er '.data[-1].legal_entity_id' <<<"$BOOKS")"
CURRENCY="$(jq -er '.data[-1].functional_currency' <<<"$BOOKS")"
PERIODS="$(curl -sS "$INVARENT_BASE_URL/v1/periods?bookId=$BOOK_ID" \
-H "Authorization: Bearer $INVARENT_PREPARER_KEY")"
PERIOD_ID="$(jq -er '[.data[] | select(.status == "OPEN")][0].id' <<<"$PERIODS")" || fail "periods: $PERIODS"
EFFECTIVE_DATE="$(jq -er '[.data[] | select(.status == "OPEN")][0].start_date' <<<"$PERIODS")"
ACCOUNTS="$(curl -sS "$INVARENT_BASE_URL/v1/accounts?bookId=$BOOK_ID&limit=100" \
-H "Authorization: Bearer $INVARENT_PREPARER_KEY")"
CASH_ID="$(jq -er '.data[] | select(.code == "1000") | .id' <<<"$ACCOUNTS")" || fail "accounts: $ACCOUNTS"
EQUITY_ID="$(jq -er '.data[] | select(.code == "3000") | .id' <<<"$ACCOUNTS")"
Node.js (fetch) version of this step
Scroll horizontally for the complete command →
const books = await ok("GET", "/v1/books", preparerKey);
const book = books.data.at(-1);
if (!book) throw new Error("No book found for this key's tenant");
const periods = await ok("GET", `/v1/periods?bookId=${book.id}`, preparerKey);
const period = periods.data.find((p) => p.status === "OPEN");
const accounts = await ok(
"GET",
`/v1/accounts?bookId=${book.id}&limit=100`,
preparerKey,
);
const cash = accounts.data.find((a) => a.code === "1000");
const equity = accounts.data.find((a) => a.code === "3000");
if (!period || !cash || !equity) {
throw new Error("Expected an OPEN period and accounts 1000 and 3000");
}
03 · Create a balanced draft
Money is a decimal string, never a JSON number. Each line carries exactly one of debit or credit. Every POST needs a unique Idempotency-Key.
POST /v1/journal-entries with the preparer key; scope ledger:draft
Scroll horizontally for the complete command →
DRAFT="$(jq -n \
--arg entityId "$ENTITY_ID" --arg bookId "$BOOK_ID" --arg periodId "$PERIOD_ID" \
--arg effectiveDate "$EFFECTIVE_DATE" --arg cash "$CASH_ID" --arg equity "$EQUITY_ID" \
--arg currency "$CURRENCY" \
'{
entityId: $entityId,
bookId: $bookId,
periodId: $periodId,
effectiveDate: $effectiveDate,
memo: "Quickstart: owner contribution",
lines: [
{accountId: $cash, debit: "100.00", currency: $currency, memo: "Cash in"},
{accountId: $equity, credit: "100.00", currency: $currency, memo: "Owner equity"}
]
}')"
CREATED="$(curl -sS -X POST "$INVARENT_BASE_URL/v1/journal-entries" \
-H "Authorization: Bearer $INVARENT_PREPARER_KEY" \
-H "Idempotency-Key: quickstart-$RUN-create" \
-H "Content-Type: application/json" \
--data "$DRAFT")"
JOURNAL_ID="$(jq -er 'select(.status == "DRAFT") | .id' <<<"$CREATED")" || fail "create: $CREATED"
echo "Draft journal $JOURNAL_ID"
Node.js (fetch) version of this step
Scroll horizontally for the complete command →
const draft = await ok("POST", "/v1/journal-entries", preparerKey, {
idempotencyKey: "create",
body: {
entityId: book.legal_entity_id,
bookId: book.id,
periodId: period.id,
effectiveDate: period.start_date,
memo: "Quickstart: owner contribution",
lines: [
{
accountId: cash.id,
debit: "100.00",
currency: book.functional_currency,
memo: "Cash in",
},
{
accountId: equity.id,
credit: "100.00",
currency: book.functional_currency,
memo: "Owner equity",
},
],
},
});
if (draft.status !== "DRAFT") throw new Error(`Unexpected: ${JSON.stringify(draft)}`);
console.log(`Draft journal ${draft.id}`);
04 · Submit the draft
Moves the entry from DRAFT to SUBMITTED, the state in which a second actor may approve it.
POST /v1/journal-entries/{id}:submit with the preparer key; scope ledger:draft
Scroll horizontally for the complete command →
SUBMITTED="$(curl -sS -X POST "$INVARENT_BASE_URL/v1/journal-entries/${JOURNAL_ID}:submit" \
-H "Authorization: Bearer $INVARENT_PREPARER_KEY" \
-H "Idempotency-Key: quickstart-$RUN-submit" \
-H "Content-Type: application/json" \
--data '{}')"
jq -e '.status == "SUBMITTED"' <<<"$SUBMITTED" >/dev/null || fail "submit: $SUBMITTED"
Node.js (fetch) version of this step
Scroll horizontally for the complete command →
const submitted = await ok(
"POST",
`/v1/journal-entries/${draft.id}:submit`,
preparerKey,
{ idempotencyKey: "submit", body: {} },
);
if (submitted.status !== "SUBMITTED") {
throw new Error(`Unexpected: ${JSON.stringify(submitted)}`);
}
05 · Watch separation of duties refuse a self-approval (optional)
The same key that prepared the entry tries to approve it. The API refuses with 403 SEPARATION_OF_DUTIES_VIOLATION. This step is a proof, not a requirement: delete it and the rest still works.
POST /v1/journal-entries/{id}:approve with the preparer key; scope approvals:approve or ledger:post
Scroll horizontally for the complete command →
REFUSED="$(curl -sS -w '\n%{http_code}' -X POST "$INVARENT_BASE_URL/v1/journal-entries/${JOURNAL_ID}:approve" \
-H "Authorization: Bearer $INVARENT_PREPARER_KEY" \
-H "Idempotency-Key: quickstart-$RUN-self-approve" \
-H "Content-Type: application/json" \
--data '{}')"
REFUSED_STATUS="${REFUSED##*$'\n'}"
REFUSED_CODE="$(jq -r '.error.code' <<<"${REFUSED%$'\n'*}")"
[ "$REFUSED_STATUS" = "403" ] && [ "$REFUSED_CODE" = "SEPARATION_OF_DUTIES_VIOLATION" ] \
|| fail "expected 403 SEPARATION_OF_DUTIES_VIOLATION, got: $REFUSED"
echo "Self-approval refused: $REFUSED_STATUS $REFUSED_CODE"
Node.js (fetch) version of this step
Scroll horizontally for the complete command →
const refused = await call(
"POST",
`/v1/journal-entries/${draft.id}:approve`,
preparerKey,
{ idempotencyKey: "self-approve", body: {} },
);
if (
refused.status !== 403 ||
refused.json.error?.code !== "SEPARATION_OF_DUTIES_VIOLATION"
) {
throw new Error(
`Expected 403 SEPARATION_OF_DUTIES_VIOLATION, got ${refused.status} ${JSON.stringify(refused.json)}`,
);
}
console.log(`Self-approval refused: ${refused.status} ${refused.json.error.code}`);
06 · Approve with the second key
A different key is a different actor, so the approval is accepted and the entry becomes APPROVED.
POST /v1/journal-entries/{id}:approve with the approver key; scope approvals:approve or ledger:post
Scroll horizontally for the complete command →
APPROVED="$(curl -sS -X POST "$INVARENT_BASE_URL/v1/journal-entries/${JOURNAL_ID}:approve" \
-H "Authorization: Bearer $INVARENT_APPROVER_KEY" \
-H "Idempotency-Key: quickstart-$RUN-approve" \
-H "Content-Type: application/json" \
--data '{}')"
jq -e '.status == "APPROVED"' <<<"$APPROVED" >/dev/null || fail "approve: $APPROVED"
Node.js (fetch) version of this step
Scroll horizontally for the complete command →
const approved = await ok(
"POST",
`/v1/journal-entries/${draft.id}:approve`,
approverKey,
{ idempotencyKey: "approve", body: {} },
);
if (approved.status !== "APPROVED") {
throw new Error(`Unexpected: ${JSON.stringify(approved)}`);
}
07 · Post the entry
With no grantId, the API evaluates its fixed approval policy for the entry's total debits and, at or below 1000.00, mints a single-use grant bound to the entry's content hash, then posts in the same transaction.
POST /v1/journal-entries/{id}:post with the approver key; scope ledger:post
Scroll horizontally for the complete command →
POSTED="$(curl -sS -X POST "$INVARENT_BASE_URL/v1/journal-entries/${JOURNAL_ID}:post" \
-H "Authorization: Bearer $INVARENT_APPROVER_KEY" \
-H "Idempotency-Key: quickstart-$RUN-post" \
-H "Content-Type: application/json" \
--data '{}')"
jq -e '.status == "POSTED"' <<<"$POSTED" >/dev/null || fail "post: $POSTED"
echo "Posted journal $JOURNAL_ID, content hash $(jq -r '.contentHash' <<<"$POSTED")"
Node.js (fetch) version of this step
Scroll horizontally for the complete command →
const posted = await ok(
"POST",
`/v1/journal-entries/${draft.id}:post`,
approverKey,
{ idempotencyKey: "post", body: {} },
);
if (posted.status !== "POSTED") throw new Error(`Not posted: ${JSON.stringify(posted)}`);
console.log(`Posted journal ${posted.id}, content hash ${posted.contentHash}`);
08 · Read the trial balance and see it tie
Reads POSTED activity only. Total debits must equal total credits, and both must be non-zero now that an entry is posted.
GET /v1/reports/trial-balance?bookId={bookId}&periodId={periodId} with the approver key; scope reports:read
Scroll horizontally for the complete command →
TRIAL_BALANCE="$(curl -sS "$INVARENT_BASE_URL/v1/reports/trial-balance?bookId=$BOOK_ID&periodId=$PERIOD_ID" \
-H "Authorization: Bearer $INVARENT_APPROVER_KEY")"
jq -e '.grandTotal.debit == .grandTotal.credit and (.grandTotal.debit | test("[1-9]"))' \
<<<"$TRIAL_BALANCE" >/dev/null || fail "trial balance does not tie: $TRIAL_BALANCE"
echo "Trial balance ties: $(jq -c '.grandTotal' <<<"$TRIAL_BALANCE")"
Node.js (fetch) version of this step
Scroll horizontally for the complete command →
const trialBalance = await ok(
"GET",
`/v1/reports/trial-balance?bookId=${book.id}&periodId=${period.id}`,
approverKey,
);
const { debit, credit } = trialBalance.grandTotal;
if (debit !== credit || !/[1-9]/.test(debit)) {
throw new Error(`Trial balance does not tie: ${JSON.stringify(trialBalance.grandTotal)}`);
}
console.log("Trial balance ties:", JSON.stringify(trialBalance.grandTotal));