Esc

↑↓ move↵ openIndex · Pagefind

Company Accounting endpoints use a company ID and accounting grants. Project Financials endpoints use a project ID and project permissions; access to one does not automatically grant access to the other.

Accounting API

The construction ledger, as an API.

Create and post journal entries with the same checks the app runs, close and reopen periods, and read the trial balance and GL detail by project and cost code. Every call runs as the token's user, with their accounting permissions, and every posting is audited.

Company Accounting/rest/v1.0/companies/{company_id}/accountingAccounting docs →

Scope and coverage

The company-scoped Accounting app is in preview. It exposes ledger, AP, AR, banking, payroll, fixed asset, report and import workflows: 329 operations on 247 paths. Historical AR reconciliation, WIP posting and ERP sync are not available yet. Project Financials APIs are project-scoped and use separate permissions. Request and response fields for each operation are in the Accounting REST reference; see versioning for compatibility.

Every accounting endpoint, relative to the base path:

General ledger, periods and setup (96)
Accounts payable (65)
Accounts receivable (32)
Fixed assets and equipment (25)
Banking (42)
Imports (10)
Job cost forecast (12)
  • Review a tenant/entity-scoped forecast from dated sources
  • Save a dated PM cost-to-complete input (jobForecastEdit permission; PM grants only on projects they belong to). With no PM input a cost code's EAC is max(revised budget, actual + remaining commitments) (flag unforecast_budget when the budget is higher); a null cost_to_complete selects actual + remaining commitments explicitly and, like any input, needs approval before WIP reads it. Saving clears any approval; saving on a later date than the carried-forward input adds a new dated input. Frozen forecasts dated on or after the input are superseded (kept, with supersededAt), never deleted; the audit lists their ids. Refused (409) in a soft- or hard-closed job cost period and on or before the latest posted WIP period end for the entity.
  • Approve the PM input shown for a cost code on the worksheet date (approveJournal permission; the last editor cannot approve). Only approved inputs may feed WIP. Approving the last pending input for the date freezes the forecast there, superseding (not deleting) an earlier freeze on the same date. Refused (409) in a hard-closed period or on or before the latest posted WIP period end; allowed in a soft-closed period, so a month-end approval freezes at the period end.
  • List accessible projects, entities and source assignments
  • Assign a project's sources to one entity and base currency (unrestricted manageCoa permission)
  • Set a job's WIP contract terms (manageCoa): revenue method (cost_to_cost, or as_invoiced for T&M / cost-plus jobs: revenue equals billings, ASC 606-10-55-18) and/or whether retainage is conditional (WIP presents it as a contract asset, ASC 606-10-45-3). Audited with the reason.
  • List WIP snapshots (newest period end and revision first, at most 100).
  • Compute the next WIP revision for an entity at an ended period (postJournal and viewLedger). Contract = executed prime SOV + approved prime COs; cost = posted job cost; billed = posted AR; EAC = the forecast frozen at the period end (PM approval or soft close) plus job cost posted since the freeze, else the approved live forecast, unless overridden with a reason. Each job uses its revenue method (cost_to_cost or as_invoiced); pending prime COs are the memo column (pendingChangeOrders) and enter the transaction price only through overrides[].change_orders; retainage receivable comes from AR (retainageContractAsset when the job's retainage is conditional); each row records settings.loss_provision_unit; cash position is AR receipts less AP payments on the job (payroll and other direct cash costs are not included). A job whose inputs fail is a compute_failed exception (the other jobs still compute) and blocks approval. Rows are read without the company posting lock; the insert refuses with 409 if job cost or billings were posted, or job forecast inputs changed, meanwhile. Supports Idempotency-Key.
  • Get a WIP snapshot with its job rows; format=csv returns the rows as CSV
  • Approve a draft snapshot (approveJournal; the preparer cannot approve). A snapshot with exceptions needs exceptions_note; compute_failed exceptions cannot be acknowledged. Idempotent; a rejected snapshot is refused.
  • Post an approved snapshot on journal WIP at the period end with autoReverseDate the next day; idempotent on (snapshot, revision). Supersedes an earlier posted revision for the period.
  • Reject a draft or approved snapshot (approveJournal; terminal, compute a new revision), or with return_to_draft send an approved one back to draft (approval and acknowledgment cleared). Audited with the reason.
Payroll (44)
Reports (3)

Authentication, scopes and permissions

Use a bearer token from /oauth/token with the accounting scopes. The token's company must match company_id.

  • accounting:read
    Every accounting GET: ledger, periods, reports, AP, AR, banking, payroll, assets, job cost and imports. The grant still limits what you see.
  • accounting:write
    Every accounting POST, PATCH and DELETE: drafts, approvals, posting, reversals, close, payments, pay runs, reconciliations and imports.

Scopes are necessary, not sufficient: the user also needs an accounting grant with the right permission. Company admins hold every permission. Every permission:

  • view_ledger
    Read entries, balances and reports.
  • post_journal
    Create, submit, post and reverse journal entries; run recurring and auto-reversing generation.
  • approve_journal
    Approve pending entries prepared by someone else.
  • manage_coa
    Accounts, the construction template and settings, including control-account mapping.
  • close_period
    Generate fiscal years; soft-close, close and reopen periods (a hard-closed period needs a second close_period user to confirm the reopen); post into soft-closed periods.
  • ap_enter
    Enter and edit AP bills, vendor credits, payment runs, vendor compliance and lien waivers.
  • ap_approve
    Vote on AP bill approvals (never on a bill you contributed to).
  • release_payments
    Release payment runs, void payments, override holds, and print checks (and reprint them under a new number), produce positive pay and NACHA files.
  • manage_vendor_banking
    Approve vendor bank and tax ID changes (never your own request) and reveal full bank account numbers. Every reveal is audited.
  • ar_enter
    Prepare AR invoices and receipts.
  • ar_approve
    Approve AR invoices and receipts prepared by someone else.
  • payroll_view
    Read payroll setup, employees, runs and payroll reports.
  • payroll_run
    Change payroll setup and employees; prepare, calculate, approve, post and void pay runs; export payroll files.
  • view_payroll_pii
    Reveal masked employee personal data. Every reveal is audited and rate limited; API reveals need a write-scoped token.
  • bank_reconcile
    Bank transactions, statement imports, matching, rules and reconciliations.
  • manage_integrations
    Upload, map, approve and apply accounting CSV imports.
  • manage_grants
    Give and change accounting access.
  • job_forecast_edit
    Enter dated PM cost-to-complete inputs on the job cost forecast (project_manager_view grants only on projects they belong to). Approving an input needs approve_journal.

401 means the token is missing or expired. 403 means another company, a missing scope or permission, a segregation-of-duties block, or a read-only workspace. 404 means not found in this company.

A token for accountingcurl
curl -X POST "https://app.os.construction/oauth/token" \
  -H "Content-Type: application/json" \
  -d '{"grant_type":"password","username":"you@example.com","password":"…",
       "scope":"accounting:read accounting:write"}'

Money is a string

Every amount in a response is a decimal string with exactly two decimals ("2079360.00"), and negative amounts carry a leading minus. Send amounts as strings too, with at most two decimals: "0.005" is refused, not rounded. Parse them with a decimal type (decimal.js, Python's Decimal, BigDecimal), never a float, or 0.1 + 0.2 will eventually show up in someone's trial balance.

Rates and quantities carry up to six decimals. Server-side rounding is half-up to the cent at the last step, and allocations use the largest-remainder method so parts add back to the whole.

Create a journal entry

POST /journal_entries saves a draft. Each line has an account_id and either a debit or a credit, never both, plus optional dimensions: entity_id, project_id, cost_code_id, cost_type_id, vendor_id, customer_id, department_id and more. Set auto_reverse_date to have the accrual reversed on that date. Reversals and recurring entries are generated once a day as of the local date in the company's accounting time zone (the timezone setting), or immediately when a user with post_journal runs Run recurring & auto-reversals on the Journal entries page. Manage recurring templates with /recurring_journals; any edit clears the template's approval, and an auto_post template posts only after a second person approves it.

Drafts may be unbalanced while you build them. Balance per entity, active postable accounts, required dimensions (job cost accounts need a project and cost code) and an open period are enforced at submit and post. Control accounts (AP, AR, retainage) refuse manual lines: post those through their documents.

Draft an accrual
curl -X POST "https://app.os.construction/rest/v1.0/companies/{company_id}/accounting/journal_entries" \
  -H "Authorization: Bearer $OS_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"journal_entry": {
        "posting_date": "2026-10-31",
        "memo": "Accrue October subcontract costs, Riverside",
        "auto_reverse_date": "2026-11-01",
        "lines": [
          { "account_id": 52, "debit": "68000.00", "project_id": 118, "cost_code_id": 330, "cost_type_id": 4 },
          { "account_id": 52, "debit": "42050.00", "project_id": 118, "cost_code_id": 262, "cost_type_id": 4 },
          { "account_id": 27, "credit": "110050.00" }
        ]
      }}'

Submit, approve, post, reverse

Submit runs the full validation and moves the draft to pending_approval. Entries at or above the company's approval threshold must be approved by someone other than the preparer; the preparer gets a 403. Post assigns the JE-YYYY-##### number, writes job cost rows for job cost lines and the audit record in the same transaction, and sends accounting.journal_entry.posted after commit.

Posted entries are immutable: there is no PATCH or DELETE for them. Reverse posts a mirror entry on the date you give (in an open period), links the two both ways and marks the original reversed. Entries created by a subledger document are reversed through that document instead.

Retries are safe where it matters. Reversing an already reversed entry returns the same reversal instead of creating another. Entries created by documents are unique per source document revision, so re-posting the same bill or pay app revision returns the existing entry rather than double-posting.

Lifecyclecurl
# Validate in full and send for approval (balanced, open period, dimensions)
curl -X POST "https://app.os.construction/rest/v1.0/companies/{company_id}/accounting/journal_entries/981/submit" -H "Authorization: Bearer $OS_ACCESS_TOKEN"

# A different user approves (the preparer gets 403)
curl -X POST "https://app.os.construction/rest/v1.0/companies/{company_id}/accounting/journal_entries/981/approve" -H "Authorization: Bearer $APPROVER_TOKEN"

# Post: assigns JE-YYYY-#####, writes job cost rows and the audit record in one transaction
curl -X POST "https://app.os.construction/rest/v1.0/companies/{company_id}/accounting/journal_entries/981/post" -H "Authorization: Bearer $OS_ACCESS_TOKEN"

# Correct a posted entry with a linked mirror entry (repeating returns the same reversal)
curl -X POST "https://app.os.construction/rest/v1.0/companies/{company_id}/accounting/journal_entries/981/reverse" \
  -H "Authorization: Bearer $OS_ACCESS_TOKEN" -H "Content-Type: application/json" \
  -d '{"reversal": {"date": "2026-11-03", "reason": "Wrong cost code"}}'
200 response (trimmed)json
{
  "id": 981,
  "number": "JE-2026-00413",
  "status": "posted",
  "journal": "GJ",
  "entity_id": 1,
  "period_id": 22,
  "posting_date": "2026-10-31",
  "memo": "Accrue October subcontract costs, Riverside",
  "source_type": null,
  "total_debit": "110050.00",
  "total_credit": "110050.00",
  "balanced": true,
  "auto_reverse_date": "2026-11-01",
  "prepared_by_id": 12, "approved_by_id": 31, "posted_by_id": 12,
  "posted_at": "2026-10-31T21:04:11Z",
  "reverses_id": null, "reversed_by_id": null,
  "lines": [
    { "line_no": 1, "account_number": "5200", "account_name": "JC – Subcontract",
      "debit": "110050.00", "credit": "0.00", "project_id": 118, "cost_code_id": 330, … },
    { "line_no": 2, "account_number": "2040", "account_name": "Received Not Invoiced",
      "debit": "0.00", "credit": "110050.00", … }
  ],
  …
}

When the ledger says no

A refused change is a 422 with an errors array of messages, or an object of field errors keyed by field. The ledger doesn't save a partial or unbalanced entry and doesn't round your amounts to make them balance. Segregation-of-duties blocks are 403. The messages below are illustrative; match on the status code, not the wording.

The same rules are enforced again by the database (balance, immutability of posted rows, closed periods), so there is no sequence of API calls that bypasses them.

Typical refusalshttp
HTTP/1.1 422 Unprocessable Entity
{ "errors": ["Debits 110050.00 and credits 110049.99 differ by 0.01"] }

HTTP/1.1 422 Unprocessable Entity
{ "errors": ["October 2026 is closed for AP"] }

HTTP/1.1 422 Unprocessable Entity
{ "errors": { "lines[0].project_id": ["is required for account 5200"] } }

HTTP/1.1 403 Forbidden
{ "errors": ["The user who prepared this entry cannot approve it"] }

Read balances and detail

GET /trial_balance returns debit, credit and net per account from posted lines up to as_of (default today), optionally from from, for one entity_id, project_id or department_id, and split by group_by (entity, project, department, cost_code). Net debits always equal net credits.

GET /general_ledger returns posted lines between from and to in posting order. With account_id it adds the opening balance, a running balance per line and the closing balance. For deep reads, pass each response's next_cursor back as cursor: the next page starts after the last line and carries the running balance forward, so page 500 costs the same as page 1 (next_cursor is null on the last page). Lists page with page and per_page and return Total, Per-Page and Link headers.

Trial balance, register, job costcurl
# Trial balance as of a date, split by project
curl "https://app.os.construction/rest/v1.0/companies/{company_id}/accounting/trial_balance?as_of=2026-10-31&group_by=project" \
  -H "Authorization: Bearer $OS_ACCESS_TOKEN"

# One account's register: opening balance, running balance per line, closing balance
curl "https://app.os.construction/rest/v1.0/companies/{company_id}/accounting/general_ledger?account_id=52&from=2026-10-01&to=2026-10-31" \
  -H "Authorization: Bearer $OS_ACCESS_TOKEN"

# Job cost entries behind a job's actuals (page / per_page, Total + Link headers)
curl "https://app.os.construction/rest/v1.0/companies/{company_id}/accounting/job_cost_entries?project_id=118&per_page=100" \
  -H "Authorization: Bearer $OS_ACCESS_TOKEN"
GET /trial_balance (trimmed, sample)json
{
  "as_of": "2026-10-31",
  "from": null,
  "entity_id": null,
  "group_by": null,
  "rows": [
    { "account_id": 8, "account_number": "1110", "account_name": "Retainage Receivable",
      "account_type": "retainage_receivable", "normal_balance": "debit", "dimension_id": null,
      "debit": "3300000.00", "credit": "0.00", "net_debit": "3300000.00", "net_credit": "0.00" },
    …
  ],
  "totals": { "debit": "…", "credit": "…", "net_debit": "…", "net_credit": "…" }
}

Fiscal years, periods and close

POST /fiscal_years creates a year and its twelve monthly periods from the settings' start month, and returns the existing year if it's already there. Each period has an overall status and a module_status for ap, ar, payroll, jc, cm and gl, each open, soft_closed or closed.

A closed module refuses postings, enforced again by a database trigger. A soft-closed one accepts postings only from users with close_period. Reopening needs a reason, which is written to the audit trail. A hard close also requires configured coverage, current checklist evidence and independent approval; unavailable adapters remain blockers. Prepare and review these in the Accounting periods workspace before calling close.

Close and reopencurl
# Generate the fiscal year's twelve periods (safe to repeat)
curl -X POST "https://app.os.construction/rest/v1.0/companies/{company_id}/accounting/fiscal_years" -H "Authorization: Bearer $OS_ACCESS_TOKEN" \
  -H "Content-Type: application/json" -d '{"fiscal_year": {"year": 2027}}'

# Soft-close AP; a hard close also requires the independently approved checklist and all configured coverage
curl -X POST "https://app.os.construction/rest/v1.0/companies/{company_id}/accounting/periods/22/close" -H "Authorization: Bearer $OS_ACCESS_TOKEN" \
  -H "Content-Type: application/json" -d '{"close": {"module": "ap", "status": "soft_closed"}}'
curl -X POST "https://app.os.construction/rest/v1.0/companies/{company_id}/accounting/periods/22/close" -H "Authorization: Bearer $OS_ACCESS_TOKEN" \
  -H "Content-Type: application/json" -d '{"close": {"module": "all", "status": "closed"}}'

# Reopen one module, with the reason that goes in the audit trail
curl -X POST "https://app.os.construction/rest/v1.0/companies/{company_id}/accounting/periods/22/reopen" -H "Authorization: Bearer $OS_ACCESS_TOKEN" \
  -H "Content-Type: application/json" -d '{"reopen": {"module": "ap", "reason": "Late Summit invoice"}}'

Webhooks

Subscribe to these on a webhook (or to accounting.*). Payloads carry ids, numbers, statuses and links only: never amounts, accounts or party details. Fetch the entry with the link, as a user allowed to see it.

A subscription receives accounting events only while the admin who created it could read them in the app: an accounting grant with view_ledger, not project-scoped, covering the event's entity_id. Events with a null or no entity_id (chart of accounts, parties, periods, NOCs) are company-wide and go only to a creator whose grant covers every entity. If the creator loses that access, pending deliveries are cancelled.

  • accounting.journal_entry.posted
    An entry posted: manual, reversal, or from a subledger document.
  • accounting.journal_entry.reversed
    A posted entry was reversed; carries reversed_by_id.
  • accounting.period.closed
    A period, or one module of it, was closed.
  • accounting.period.soft_closed
    A period or module was soft-closed.
  • accounting.period.reopened
    A period or module was reopened. The reason is in the audit trail, not the payload.
accounting.journal_entry.posted (trimmed)json
{
  "event": "accounting.journal_entry.posted",
  "data": {
    "id": 981,
    "number": "JE-2026-00413",
    "journal": "GJ",
    "status": "posted",
    "source_type": null,
    "source_id": null,
    "links": { "self": "https://app.os.construction/rest/v1.0/companies/1/accounting/journal_entries/981",
               "app": "https://app.os.construction/companies/1/accounting/journal/981" }
  },
  …
}

Integration checklist

  • Use a dedicated user with a narrow accounting grant for each integration, so its postings are attributable and its permissions minimal.
  • Keep the preparer and approver different people in automated flows. Approvals by the posting user will be refused.
  • Store our id beside your own record and retry with it. Never create a second draft because a response timed out; read first.
  • Date entries in an open period. If you sync from another system, check the period status before pushing.
  • Send an Idempotency-Key on every create, including master data and pay runs, so a timed-out request can be retried safely.
  • Pull the ledger into another system with GET /integrations/exports/journals?target=<name>&since_last=true: each posted entry is sent to a target once, so a back-dated post is never missed. Re-download a lost file with batch_id from the X-Export-Batch-Id header.
  • Reconcile: compare /trial_balance with your system after every sync, account by account.

More on retries and the error format under Limits, paging & errors.

Ask the OS

Reading this page

Ask anything about this page. Answers use what you're reading as context.

Suggested

    AI · may be imperfect · sample data where shown

    Open file