InvarentDeveloper documentation

Invarent API quickstart

Create, approve and post your first journal.

This guide takes a new portal workspace from two API keys to a posted journal entry and a trial balance that ties, using only curl or Node.js. The request sequence on this page is the same one an automated test runs against the API.

API keys needed
2
API calls
9
Environment
Sandbox
Largest entry the API authorises itself
1000.00
01

Before you start

Sign up in the portal at app.invarent.com/signup, verify your email, create your organization and enrol multi-factor authentication. Creating the organization provisions a sandbox workspace: a legal entity, one USD book, one open accounting period for the current month and an eight-account starter chart of accounts. The book starts with no journal entries.

curl and jq, or Node.js 20 or later Everything runs in your sandbox
02

Create two API keys in the portal

Open Developer in the portal (app.invarent.com/developer) and create a scoped key twice. For each key choose the Sandbox environment and these scopes: tenant:read, coa:read, ledger:read, ledger:draft, ledger:post, reports:read. Name them Quickstart preparer and Quickstart approver. The portal shows each key once, so copy it when it appears.

Why two keys

Invarent does not let the actor that prepared a journal entry approve it. Every API key is its own actor, so the key that creates and submits the entry cannot approve it, and a second key can. With one key, the approve call fails with 403 SEPARATION_OF_DUTIES_VIOLATION. The check runs in the API and again in the database when the entry is approved and when it is posted.

This separates credentials, not people. Invarent cannot tell that two keys belong to the same person, so who holds each key is your control. In production, give the approver key to a different person or system than the preparer key.

Signup also shows one broad “Sandbox API key” once, on its confirmation screen; the Developer page lists it as “Owner bootstrap key”. It is its own actor too and can stand in for either key, but two named keys with only the scopes above can be revoked independently.

export INVARENT_BASE_URL="https://api.invarent.com"
export INVARENT_PREPARER_KEY="<the first key you created in the portal>"
export INVARENT_APPROVER_KEY="<the second key you created in the portal>"
03

Run the sequence

The steps share variables, so run them in order in one file: save the curl steps as quickstart.sh and run bash quickstart.sh, or save the Node steps as quickstart.mjs and run node quickstart.mjs. Each step stops with the API’s error response if anything fails. Every POST carries a unique Idempotency-Key, and money is always a decimal string.

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.

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
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:read
  • GET /v1/periods?bookId={bookId} with the preparer key; scope tenant:read
  • GET /v1/accounts?bookId={bookId}&limit=100 with the preparer key; scope coa:read
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
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
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
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
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
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
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
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
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
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
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
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
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
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));
04

What you have now

A posted entry is immutable; a correction is a reversal draft (POST /v1/journal-entries/{id}:reverse, scope ledger:reverse), never an edit. The trial balance reads only POSTED entries, so seeing total debits equal total credits there means the entry went through the full approval and posting controls.

05

Where this quickstart stops

Posting needs a single-use approval grant bound to the entry’s content hash. For a manual journal entry whose total debits are 1000.00 or less, the API mints that grant itself when you call :post without a grantId. Above that amount the call returns an approvalRequestId and the entry stays APPROVED until a human approves the grant; that flow is not covered here. Per-tenant auto-approval policies (/v1/approval-policies) apply to sub-ledger and accounting-event releases, not to manual journal entries, so the second key is always needed here.

Everything above happens in a SANDBOX tenant with SANDBOX keys. LIVE businesses and keys are separate and are created from a paid account.