Skip to navigation

Statement mapping

A profit and loss statement placed on the chart of accounts: the figures a loan origination system posts, recomputable from the body.

The mapping covers profit and loss statements. Reading one requires a plan that includes statement mapping; other plans get 403 with statement_mapping_not_in_plan.

What a mapping is

A mapping is one profit and loss statement placed on the workspace’s chart of accounts. Every line the statement prints is filed on an account, every section and account carries its figure per period, the headline measures (gross profit, operating income, income before income taxes, net income) are stated, and every figure says where in the document it comes from. The body is a contract you can check: the tree adds up to the cent by the rules under The arithmetic, and what it adds up to is the bottom line the statement prints.

For every usable profit and loss statement a mapping is prepared after extraction. It is read by document id, or listed per borrower, in the Statement Mappings group of the API reference. Both reads need the extractions:read scope.

OperationReturns
GET /api/borrowers/{borrowerId}/extractions/{docId}/mappingOne document’s latest mapping, the Statement Mapping object.
GET /api/borrowers/{borrowerId}/extractions/mappingsA borrower’s mappings, one per document, newest document first, as { data, limit, next_cursor, pending_document_ids }. ?loanId= narrows it to one loan; limit is 1 to 25 and defaults to 10.
curl https://api.spreadspace.app/api/borrowers/4a2c8e1f6b3d5a7c9e0f1b2d/extractions/7a1d4e9c2b3f4a5d8e6c1b0f/mapping \
-H "Authorization: Bearer ss_live_..."

The response carries the mapping’s identity and versions on the envelope and the statement itself in payload:

KeyWhat it says
mapping_idThe mapping’s id. A statement gets a new mapping, under a new id, whenever its extraction, the workspace’s chart of accounts, caption rules or export map, or the mapping logic changes.
statusready or unmappable.
payload.mapping_stateOn a ready mapping, mapped or unfooted.
extraction_versionThe revision of the document’s extraction the mapping was made from, the same extraction_version the document list and the document webhooks carry.
chart_document_version, chart_rules_versionThe versions of the workspace’s chart of accounts and caption rules the mapping was made under, the version and rules_version that GET /api/chart-of-accounts returns.
staleTrue when the chart of accounts, the caption rules, the export map or the mapping logic changed after this mapping was made.

Every figure is a decimal string with exactly two decimal places, and every values array, every measure and every entry of foot run in the order of payload.periods, so position i of each belongs to period i. See the value conventions.

When a mapping is ready

A mapping is prepared after the statement’s extraction, so it follows extraction.ready. Three signals say when it is ready:

  • The event. extraction.mapped fires once for every mapping: the first, and each one made again. reason says why: first for the document’s first mapping, extraction when the extraction was revised, chart when the workspace’s chart of accounts, caption rules or export map changed, logic when the mapping logic changed, and correction when none of those moved and the statement was corrected in the workspace. The event is a pointer: the document, its borrower and loan, the mapping_id, status and mapping_state, the versions and the time, never a figure. Read the mapping it names and let it replace the copy you hold. See Webhooks for the envelope, the fields and the delivery rules.
  • The retrieve. While the statement’s first mapping is being prepared, the retrieve answers 404 with a Retry-After header saying how many seconds to wait before reading again. A statement nobody has asked for is prepared by the read that finds it, so the first read of a document can answer this way. A 404 without Retry-After means no mapping is being prepared: the document is not a profit and loss statement, or is not one you can see.
  • The list. pending_document_ids names the documents of the borrower, or of the loan when loanId is given, whose first mapping is still being prepared. Read the list until it is empty and every statement the borrower holds is in data.

A mapping that reads stale: true is still the latest one stored, served while a fresh one is prepared: the chart of accounts, the caption rules, the export map or the mapping logic moved after it was made. Read again to receive the new one, or wait for the extraction.mapped that announces it.

curl "https://api.spreadspace.app/api/borrowers/4a2c8e1f6b3d5a7c9e0f1b2d/extractions/mappings?loanId=7d1e3b5a9c2f4e6b8d0a3c5e&limit=25" \
-H "Authorization: Bearer ss_live_..."

Mapped, unfooted, unmappable

statusmapping_stateWhat you holdWhat to do
readymappedThe statement’s lines were placed on the chart of accounts and walk to its printed bottom line. tree carries the statement on the chart, every placed line names the node it is filed on, and the measures are the tree’s own figures.Post the tree: each account’s figure per period, with its external_code where your export map gives one.
readyunfootedThe lines could not be made to walk to the printed bottom line. tree is null and filed_under is null on every line; lines and measures state the statement as printed, and foot says by how much each period misses.Hold the statement for review, or post the printed measures. A statement corrected in the workspace is mapped again, and extraction.mapped says so with reason: correction.
unmappablenoneThe statement could not be read as a profit and loss statement at all. error_code is unmappable and payload is null.Nothing to post. The document’s own profit loss statement read still returns its extraction.

The arithmetic

The tree is a contract you can recompute from the body, to the cent.

  • Every figure on a section or an account is positive in the direction of the node’s effect, adds or subtracts: a positive figure on a cost is a cost, a negative one is a credit against it, and a gain or loss on dispositions is positive for a gain.
  • A node’s figure is the sum of the nodes under it, measures aside, each added when its effect is the node’s own and subtracted otherwise, plus the filed_values of the lines placed on the node itself, plus its unitemized. A null counts as no figure.
  • unitemized is the part of a node’s figure that no printed line accounts for: the statement prints a total and no detail for part of it, or the printed figures were rounded. It is 0.00 where the lines account for all of it.
  • Gross profit is revenue less cost of goods sold. Operating income is gross profit less operating expenses. Income before income taxes is operating income plus other income, less other expenses, less interest, plus gains on dispositions. The net income row is the bottom line the statement prints.
  • The sections at the top of the tree, measures aside, each counted by its effect, sum to foot.line_sum, and foot.residual is printed_bottom_line less line_sum. On a mapped statement the residual is within foot.tolerance, what the footing allows for the rounding of printed figures: a dollar, plus half a dollar for each printed figure the walk counts below the operating income line. A residual of 3.00 beside a tolerance of 8.50 is a statement printed in whole dollars, not a defect.
  • A line’s values are the figures as the statement prints them, in the statement’s own sign presentation. Its filed_values are what the tree counts, positive in the direction of the node’s effect.

A worked example

A statement for the year ended December 31, 2025 prints ten lines, in whole dollars:

Printed lineFigurePlaced on
Consulting fees418,600.00pl.revenue.sales
Total income418,600.00none, a total
Rent72,000.00pl.opex.rent
Salaries196,300.00pl.opex.labor.salaries
Insurance14,900.00pl.opex.insurance
Insurance refund(1,200.00)pl.opex.insurance, at -1200.00
Total expenses284,000.00none, a total
Net operating income134,600.00none, a total
Interest expense9,800.00pl.interest
Net income124,801.00none, the bottom line

Its tree, showing the three sections that carry a figure (the other five are listed with null figures), the two measure rows, and the accounts the lines landed on:

Nodekeyeffectvaluesunitemized
Net Revenuepl.revenueadds418600.000.00
    Salespl.revenue.salesadds418600.000.00
Gross profitm:gross_profitnull418600.00null
Operating expensespl.opexsubtracts284000.002000.00
    Labor costpl.opex.laborsubtracts196300.000.00
        Salaries and wagespl.opex.labor.salariessubtracts196300.000.00
    Rentpl.opex.rentsubtracts72000.000.00
    Insurancepl.opex.insurancesubtracts13700.000.00
Interest expensepl.interestsubtracts9800.000.00
Net incomem:net_incomenull124801.00null

Read it with the rules above:

  • pl.opex.insurance holds two printed lines. Insurance is filed at 14900.00. Insurance refund is printed as (1,200.00), a credit against the cost, so its values entry is -1200.00 and so is its filed_values entry: a negative figure on a subtracts node. The account’s figure is 14900.00 + (-1200.00) = 13700.00, and its unitemized is 0.00.
  • pl.opex holds no line of its own. Its accounts sum to 196300.00 + 72000.00 + 13700.00 = 282000.00, but the statement prints Total expenses of 284000.00 and gives no line for the other 2000.00, so the section’s unitemized is 2000.00 and its figure is 284000.00, the total as printed.
  • Gross profit is revenue less cost of goods sold. This statement prints no cost of goods sold, so m:gross_profit is 418600.00.
  • Operating income is 418600.00 - 284000.00 = 134600.00, the Net operating income the statement prints. Income before income taxes is 134600.00 - 9800.00 = 124800.00: no other income, no other expenses, no gains on dispositions.
  • m:net_income is 124801.00, the bottom line the statement prints.

The two lines placed on the insurance account, as lines carries them:

[
{
"key": "pl:insurance",
"label": "Insurance",
"depth": 0,
"is_total": false,
"values": ["14900.00"],
"sources": [{ "kind": "printed", "document_id": "8e2f5a7c1b4d4c6e9a0b3d5f", "address": "lines.4.0", "pages": [1] }],
"filed_under": "pl.opex.insurance",
"filed_values": ["14900.00"]
},
{
"key": "pl:insurance refund",
"label": "Insurance refund",
"depth": 0,
"is_total": false,
"values": ["-1200.00"],
"sources": [{ "kind": "printed", "document_id": "8e2f5a7c1b4d4c6e9a0b3d5f", "address": "lines.5.0", "pages": [1] }],
"filed_under": "pl.opex.insurance",
"filed_values": ["-1200.00"]
}
]

The foot for the period:

{
"printed_bottom_line": "124801.00",
"printed_bottom_line_source": {
"kind": "printed",
"document_id": "8e2f5a7c1b4d4c6e9a0b3d5f",
"address": "lines.9.0",
"pages": [1]
},
"line_sum": "124800.00",
"residual": "1.00",
"tolerance": "1.50"
}

line_sum is the three sections by their effect: 418600.00 - 284000.00 - 9800.00 = 124800.00. The statement prints 124801.00, so the residual is 1.00. The tolerance is 1.50: a dollar, plus half a dollar for the one printed figure the walk counts below the operating income line, the interest expense. The residual is within it, so the statement is mapped: it was printed in whole dollars, and a dollar of rounding sits between its lines and its bottom line. measures state the tree’s own figures: revenue 418600.00, gross_profit 418600.00, operating_expenses 284000.00, operating_income 134600.00, pretax_income 124800.00, net_income 124801.00.

Sources

Every figure states where it comes from. A source on a tree node, on a printed line or on the foot carries:

  • kind: printed when the document prints the figure, derived when the figure is computed from figures the document prints, such as a section total the statement does not print or a measure.
  • document_id: the document the figure comes from.
  • address: a printed figure’s place in the document’s report data, for example lines.4.0 for the figure of lines[4] in the first period column of the profit loss statement read, so a mapped figure joins back to the printed line it came from. Null on a derived figure.
  • pages: the pages the figure is printed on, numbered from 1. A derived figure lists the pages of the figures it is computed from.

A node’s sources and a line’s sources run in the order of periods, one entry per figure, null where the figure is null.

Marks and unmapped lines

Three lists on a tree node name its printed lines:

  • member_keys: every printed line the node holds, those on the nodes under it included; direct_member_keys: the lines placed on the node itself. Each is the key of a member of lines.
  • flagged_keys: lines marked for a reader’s attention: an expense line whose caption names no expense (uncategorized, suspense), names something that is not an expense (an owner’s draw, a loan payment, an asset purchase), or is a bare catch-all such as other or miscellaneous. A marked line stays where it is placed; the mark says a person should look.
  • reclassified_keys: always empty. The key stays so the shape of a node is unchanged.

unmapped_lines lists the printed lines that carry a figure and that no account received, each with the section it prints in (revenue, cogs, other_income, opex, other_expenses, interest, dispositions or taxes, or null when the line prints outside every section). On a mapped statement such a line is counted on its section’s own row, so the tree still foots; suggested_account_id is reserved and null. On an unfooted statement no line is placed, and unmapped_lines names every line that would have been.

unmapped_export_account_ids lists the account ids the workspace’s export map gives a code that this mapping’s tree does not list, so a map entry that names an account outside the profit and loss chart is visible from the mapping itself.

The chart of accounts

The tree is the workspace’s chart of accounts with this statement’s figures. GET /api/chart-of-accounts returns the chart document the workspace saved, its version, the rules_version of its caption rules and its active_export_system. Until the workspace saves a chart, chart is null, version is 0 and the platform’s accounts below apply. A workspace’s own arrangement keeps every platform account it leaves out in force, and the tree of every mapping lists the accounts in force with their ids, so the tree is the authority on which accounts exist for that statement. The read needs the chart_of_accounts:read, chart_of_accounts:write or extractions:read scope.

curl https://api.spreadspace.app/api/chart-of-accounts \
-H "Authorization: Bearer ss_live_..."

A section that has no accounts of its own holds its lines on the section’s row, whose key is pl. followed by the section (pl.interest) and whose account_id is null.

The platform’s accounts

The account ids an export map keys on, section by section, in tree order. Sub-accounts are indented under their parent.

Net Revenue

The revenue section, key pl.revenue.

Account idLabel
pl.revenue.salesSales
pl.revenue.returns_allowancesReturns and allowances
pl.revenue.unclassifiedUnclassified
pl.revenue.otherOther

COGS

The cogs section, key pl.cogs.

Account idLabel
pl.cogs.purchasesPurchases
pl.cogs.laborLabor (COGS)
pl.cogs.depreciationDepreciation
pl.cogs.inventoryInventory
pl.cogs.other_costsOther costs

Other income

The other_income section, key pl.other_income.

No accounts under this section.

Operating expenses

The opex section, key pl.opex.

Account idLabel
pl.opex.advertisingAdvertising
pl.opex.commissionsCommissions
pl.opex.officerOfficer compensation
pl.opex.laborLabor cost
pl.opex.labor.salaries    Salaries and wages
pl.opex.labor.payroll_taxes    Payroll taxes
pl.opex.labor.pension    Pension and profit-sharing plans
pl.opex.labor.benefits    Employee benefits
pl.opex.labor.workers_comp    Workers compensation
pl.opex.rentRent
pl.opex.professionalProfessional fees and services
pl.opex.research_developmentResearch and development
pl.opex.daDepreciation & amortization
pl.opex.depreciation    Depreciation
pl.opex.amortization    Amortization
pl.opex.taxesTaxes and licenses
pl.opex.bad_debtsBad debts
pl.opex.charitableCharitable contributions
pl.opex.softwareSoftware and subscriptions
pl.opex.bank_chargesBank charges
pl.opex.repairsRepairs and maintenance
pl.opex.utilitiesUtilities
pl.opex.insuranceInsurance
pl.opex.administrativeAdministrative
pl.opex.unclassifiedUnclassified
pl.opex.gaOther G&A
pl.opex.ga.travel    Travel and entertainment
pl.opex.ga.meals    Meals
pl.opex.ga.office    Office and supplies
pl.opex.ga.auto    Auto and vehicle

Other expenses

The other_expenses section, key pl.other_expenses.

No accounts under this section.

Interest expense

The interest section, key pl.interest.

No accounts under this section.

Gain (loss) on dispositions

The dispositions section, key pl.dispositions.

No accounts under this section.

Income taxes

The taxes section, key pl.taxes.

No accounts under this section.

The export map

An export map gives each chart account the code, and the name, it has in your own system, so a mapped statement arrives with your codes on it. A workspace holds one map per export system and one active system.

OperationWhat it does
GET /api/chart-of-accounts/export-mapThe active map: system, and entries in order of account id, each an account_id, an external_code and an external_label. Until a map is saved, system is null and entries is empty. Needs chart_of_accounts:read, chart_of_accounts:write or extractions:read.
PUT /api/chart-of-accounts/export-mapReplaces one system’s whole map and makes that system the active one. Needs chart_of_accounts:write.
curl -X PUT https://api.spreadspace.app/api/chart-of-accounts/export-map \
-H "Authorization: Bearer ss_live_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 8c1f2e3d-4a5b-4c6d-9e8f-0a1b2c3d4e5f" \
-d '{"system": "ledger_one", "entries": [{"account_id": "pl.revenue.sales", "external_code": "4000", "external_label": null}, {"account_id": "pl.opex.rent", "external_code": "6100", "external_label": "Rent expense"}]}'

The body is the whole map. system is the export system’s name: a lowercase letter followed by up to 31 lowercase letters, digits, underscores or hyphens. entries holds at most 1,000 entries, each naming an account once: an account_id from the tree (pl.opex.rent, or t: followed by up to 48 lowercase letters, digits and hyphens for an account the workspace added), an external_code of 1 to 64 letters, digits, dots, underscores, colons or hyphens, and an external_label of 200 characters or fewer, or null. An account left out of the body leaves the map. The response is the map as saved; a concurrent save of the same map answers 409.

After a save, every mapping read answers stale: true until that mapping has been prepared again with the new codes, and extraction.mapped announces it with reason: chart. A mapping then carries the system in payload.export_system, each account’s code and name in external_code and external_label on its tree node (null where the map gives none), and, in unmapped_export_account_ids, the account ids the map names that the tree does not list.

Scopes and the plan

OperationScopePlan
The two mapping readsextractions:readStatement mapping. Other plans get 403 with statement_mapping_not_in_plan.
The chart read and the export map readchart_of_accounts:read, chart_of_accounts:write or extractions:readSpreads. Other plans get 403 with spreads_not_in_plan.
The export map savechart_of_accounts:writeSpreads. Other plans get 403 with spreads_not_in_plan.

The extraction.mapped event is sent to a workspace whose plan includes statement mapping. A document you cannot see answers 404 before any plan signal. Every reference page states its scope and its rate limit lane; see Authentication for scopes and Errors for the envelope.