> This page is for SDKs.

> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.spreadspace.app/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.spreadspace.app/_mcp/server.

# Changelog

Release notes for `@spreadspace/sdk` on [npm](https://www.npmjs.com/package/@spreadspace/sdk),
`spreadspace` on [PyPI](https://pypi.org/project/spreadspace/) and `SpreadSpace` on
[NuGet](https://www.nuget.org/packages/SpreadSpace). The three packages share one release
and version independently, so a release that changes one language leaves the other two at
their version. Dates are the day the version reached its registry. Changes to the API itself
are on the [API changelog](/api/changelog).

## Unreleased

* **`api_version` on the parsed webhook event** in all three packages: the API
  version the delivery was rendered under (`2026-10-07` today), read from the
  envelope when it carries one. `event.api_version` in TypeScript, optional on
  `WebhookEvent` and on the receiver's `UnknownWebhookEvent`; `event.api_version`
  in Python, `None` when absent; `ApiVersion` in C#, `null` when absent. A
  delivery recorded before the field existed still verifies and parses, and an
  `api_version` that is present but not a string is a body error naming the
  field.
* **The upload routes move under `/api/uploads` and the job reads under
  `/api/jobs`.** `client.documents.upload` (`client.Documents.UploadAsync` in
  C#) posts `POST /api/uploads/presigned-url` and
  `POST /api/uploads/{job_id}/confirm`, and `JobHandle.status()` / `wait()` and
  `client.documents.status(jobId)` read `GET /api/jobs/{job_id}/status`, which
  now takes `jobs:read` or `documents:read` on the upload lane; the batch reads
  are `POST /api/uploads/confirm-batch` and `POST /api/jobs/status-batch`, and
  the download link is `GET /api/jobs/{job_id}/download-url` with
  `document_id`. No method name changes. The routes under `/api/documents` that
  these replace answer an API key 404.
* **`document_ids` on the job status** in place of `extracted_document_ids`
  (`JobStatus.document_ids` in TypeScript and Python, `DocumentIds` in C#).
* **A loan's document summaries read as the document does.**
  `LoanDocumentSummary` carries `document_type`, `status` and `status_reason` in
  place of `document_category`, `extraction_status` and `unextractable_reason`
  (`DocumentType`, `Status`, `StatusReason` in C#).
* **`source_document_id` on the borrower owner** in place of `source_doc_id`.

## 2.0.0 (2026-10-19)

The list envelope, the move of the typed reads to `client.documents` and the
retired per-type routes below are the breaking changes behind the major
version. 1.2.0 never reached a registry; its entries ship here.

* **Every cursor-paged list answers one envelope, `{ data, has_more, next_cursor }`.**
  `has_more` is true exactly when another page follows, and `next_cursor` is
  null on the last page. `limit` is a request parameter only; it no longer
  comes back on the envelope. The pagers still stop on a null `next_cursor`
  (`for await` over an `AsyncPager` in TypeScript, iterating a `PageIterator`
  in Python and C#), so code that iterates a list is unchanged; code that reads
  a page's envelope finds `has_more` where it read `limit`: `CursorPaginated<T>`
  in TypeScript, and `ProfitLossMappingList` and `BalanceSheetMappingList` in
  all three packages (`has_more` in Python, `HasMore` in C#, in place of
  `limit` / `Limit`). The exports list, `GET /api/extractions/exports`, answers
  under `data` like every other list where it answered under `exports`:
  `client.exports.list` (`client.Exports.List` in C#) reads the new key, and a
  raw read of the route must too. The pager's items key (`itemsKey` in
  TypeScript and C#, `items_key` in Python) stays overridable and defaults to
  `data`.

* **`document_type` on every Report Data object.** Each of the fifteen
  families carries `document_type` as its first key, a fixed name for the
  family (`tax_return`, `w2`, `paystub`, `personal_financial_statement`,
  `balance_sheet`, `profit_loss_statement`, `cash_flow_statement`,
  `equity_statement`, `comprehensive_income_statement`,
  `investment_income_statement`, `supporting_table`, `debt_schedule`,
  `bank_statement`, `ar_aging`, `ap_aging`), so a `ReportData` read narrows to
  its family on that one key: a literal type in TypeScript and Python,
  `DocumentType` (required) in C#.

* **`client.documents` replaces `client.extractions`.** The typed reads move to
  `client.documents` (`client.documents` in Python, `client.Documents` in C#),
  one `get<Family>` / `list<Family>s` pair per document family plus
  `getDocument` and `listDocuments` (`get_document` / `list_documents` in
  Python; `GetDocumentAsync` / `ListDocumentsAsync` and their `...RawAsync`
  twins in C#). A read takes the document id alone; a list takes its filters
  (`borrowerId`, `loanId`, `updatedSince`, `include`) and resolves to one page.

* **Two document routes.** The reads go through `GET /api/documents` and
  `GET /api/documents/{document_id}`. A document carries its id, type, borrower,
  loan, period, status and times, and its extracted object in `report_data`
  (on the list, with `include=report_data`). `document_type` narrows the list
  to one family; `updated_since` lists the documents changed at or after a
  time, deletions included.

* **The per-type reads are retired.** The routes under
  `GET /api/borrowers/{borrower_id}/extractions/` that served one family's
  objects (`/{doc_id}/tax-return`, `/tax-returns`, `/{doc_id}/report-data`,
  `/reports/{report_type}` and the rest) leave the public contract and answer
  an API key 404. Read the same objects through the two document routes.

* **Job results carry no bodies.** `GET /api/jobs/{job_id}/results` lists each
  document of the job by id and status; read a body through
  `GET /api/documents/{document_id}`.

* **`document.deleted`.** A new webhook event, typed in all three packages,
  fires once for each public document deleted on its own, with its upload, or
  with its loan or borrower. It carries the document, its borrower and loan,
  its `document_type` and `deleted_at`.

* **Typed reads for paystubs.** `client.documents.getPaystub(documentId)` and
  `.listPaystubs({ borrowerId, loanId })` (`get_paystub` / `list_paystubs` in
  Python; `GetPaystubAsync` / `ListPaystubsAsync` and their `...RawAsync` twins in
  C#) read a Paystub document through `GET /api/documents/{document_id}` and
  `GET /api/documents` with `document_type=paystub`, typed `PaystubDocument` and
  `PaystubDocumentList` in all three packages: the document envelope with the
  Paystub object in `report_data`, one employer's statement to one employee of
  one pay period's gross pay, deductions and net pay, this period and year to
  date, the employee's Social Security number reduced to its last four digits,
  money as two-decimal strings. The read refuses a document of another type
  (`document_type_mismatch`); the list answers the envelope above, one document
  a row, with `report_data` present under `include=report_data`.

* **`job_title` on the Paystub object.** `PaystubReportData` gains an optional
  `job_title` in all three packages (`job_title?: string | null` in TypeScript,
  a key that may be absent in Python, `JobTitle` without `required` in C#): the
  employee's job title or position as the stub prints it, null when the stub
  prints none, and absent on a paystub extracted before the field was
  introduced. A paystub read from a payroll feed carries null; a paystub
  uploaded as a PDF fills it when the stub prints one.

* **`entity_source` on the Equity Statement object.**
  `EquityStatementReportData` gains an optional `entity_source` in all three
  packages (`EntitySource` in C#): where the entity name was obtained when the
  statement does not name it, `page_image` from the page image or
  `filing_cover` from the filing cover, and absent when no additional source
  was used.

* **The upload helper's storage key can be null.** `PresignedUrl`'s `s3Key`
  (`s3_key` in Python, `S3Key` in C#) is typed `string | null` in TypeScript,
  `Optional[str]` in Python and `string?` in C#, and is null when the presigned
  URL response omits the storage key: `parsePresignedUrl` answers null where it
  answered `undefined`, and `PresignedUrl.FromJson` in C# answers null where it
  answered an empty string. `uploadDocument` (`upload_document` in Python,
  `Upload.UploadDocumentAsync` in C#) needs only `job_id`, `upload_url` and
  `expires_in_seconds` from that response to put the bytes and confirm the
  upload. The helpers read the snake_case response the API serves and still
  accept its legacy camelCase keys (`jobId`, `uploadUrl`, ...), a populated
  camelCase key taking precedence as before.

* **Typed reads for investment income statements and supporting tables.**
  `client.documents.getInvestmentIncomeStatement(documentId)`,
  `.listInvestmentIncomeStatements({ borrowerId, loanId })`,
  `.getSupportingTable(documentId)` and
  `.listSupportingTables({ borrowerId, loanId })`
  (`get_investment_income_statement` / `list_investment_income_statements` /
  `get_supporting_table` / `list_supporting_tables` in Python;
  `GetInvestmentIncomeStatementAsync` / `ListInvestmentIncomeStatementsAsync` /
  `GetSupportingTableAsync` / `ListSupportingTablesAsync` and their `...RawAsync`
  twins in C#) read an Investment Income Statement or a Supporting Table
  document through the two document routes, typed
  `InvestmentIncomeStatementDocument`, `InvestmentIncomeStatementDocumentList`,
  `SupportingTableDocument` and `SupportingTableDocumentList` in all three
  packages. The object in `report_data`: the net investment income table's
  printed lines and headline rows, money as two-decimal strings; or a table
  from the notes to the financial statements as printed: its columns, each with
  its head and the dates it covers, and its lines, one cell per column, with
  `kinds` saying what each cell holds (a money cell is a two-decimal string,
  every other cell keeps its printed figure or text), and the checks that do
  not hold. The lists page like every other list, `limit` and `cursor` in and
  the envelope above out.

* **A Form 1040-SR is its own typed-read branch.** `TaxReturnReportData` gains
  `TaxReturnReportData_TaxReturn1040SR` in all three packages, with
  `AsForm1040SR()` beside `AsForm1040()` in C#: a return printed on the senior
  form answers `form: "1040SR"`, its `data` is the Form 1040's own lines,
  schedules and attached forms, and its `k1_partners` is always empty. In 1.1.0
  the published contract named `1040` alone, so a senior return matched no
  branch of the union and C#'s `AsForm1040()` answered null.

* **Path parameters say what they identify.** The published path parameters
  are `{borrower_id}` on the borrower routes, `{loan_id}` on the loan routes
  and `{webhook_id}` on the webhook routes, where each was `{id}`, and
  `{document_id}` on every extraction route, where it was `{doc_id}`. The URLs
  do not change. The statement mapping reads name their document parameter the
  same way: `getProfitLoss(borrowerId, documentId)` and
  `getBalanceSheet(borrowerId, documentId)` in TypeScript,
  `get_profit_loss(borrower_id, document_id)` and
  `get_balance_sheet(borrower_id, document_id)` in Python, where a keyword
  call that passed `doc_id=` passes `document_id=`, and
  `GetProfitLossAsync(borrowerId, documentId)` and
  `GetBalanceSheetAsync(borrowerId, documentId)` with their `...RawAsync`
  twins in C#, where a named argument `docId:` becomes `documentId:`.

* **The borrower-scoped job and extraction reads are retired.**
  `GET /api/borrowers/{borrower_id}/jobs`, its results twin
  `GET /api/borrowers/{borrower_id}/jobs/{job_id}/results` and
  `GET /api/borrowers/{borrower_id}/extractions` leave the public contract and
  answer an API key 404. They duplicated `GET /api/jobs`,
  `GET /api/jobs/{job_id}/results` and `GET /api/documents`; read through
  those. `GET /api/jobs` gains `borrower_id` and `loan_id` filters, and the
  three jobs list methods take them: `client.jobs.list({ borrowerId, loanId })`
  in TypeScript, `client.jobs.list(borrower_id=..., loan_id=...)` in Python and
  `client.Jobs.List(borrowerId: ..., loanId: ...)` and `ListTyped` in C#, each
  sent only when supplied. `borrower_id` lists one borrower's uploads, and a
  borrower you cannot see is a 404; `loan_id` lists one loan's uploads, with
  `borrower_id` the loan must belong to that borrower, and a loan you cannot
  reach is a 404. In C# the two filters come before `limit`, as on
  `Loans.ListTyped`, so a positional `List(limit)` call names `limit:`.

* **Webhook envelopes and payloads.** The envelope carries `created_at`, the
  RFC 3339 time the event was created, such as
  `2026-10-15T14:03:27.123456+00:00`, in place of `created`, the epoch seconds:
  `created_at: string` on the TypeScript envelope, `WebhookEvent.created_at`
  (a `str`) in Python and `WebhookEvent.CreatedAt` (a `string`) in C#. Every
  verifier refuses a body without a non-empty string `created_at`, with
  "Webhook body is missing a `created_at` field." `document.processed`,
  `extraction.ready` and `extraction.updated` no longer carry `extraction_id`,
  which was always the `document_id` (`ExtractionId` leaves the three C#
  payload records). `job.completed` carries `document_count` in place of
  `document_count_total` (`DocumentCount` in C#), beside
  `document_count_succeeded` and `document_count_failed`.
  `memo_render.succeeded`, `memo_render.failed` and `memo_render.cancelled`
  carry `borrower_id` beside `loan_id` (`BorrowerId` in C#, a positional
  parameter after `LoanId`). `extraction.mapped` no longer carries
  `engine_version`, `chart_version`, `chart_document_version` or
  `chart_rules_version`; `ExtractionMappedPayload` drops them in all three
  packages.

* **Loan fields.** A loan read with an API key no longer carries
  `borrower_name` (the name is `borrower.name` under `include=borrower`),
  `loan_type_label` or `loan_sub_type_label`, which were screen text, or
  `interest_rate`. It carries `interest_rate_percent`, the rate as a decimal
  string with three decimal places such as `"5.125"`, null when unset. The
  create and update requests take `interest_rate_percent`, a decimal string
  with up to three decimal places, in place of `interest_rate`, a number.
  `loan_type` is one of `alternative_financing`, `sba`, `commercial_term`,
  `commercial_real_estate`, `residential_mortgage`, `auto`, `personal` and
  `transaction`, and `rate_type` one of `fixed`, `sofr` and `prime`, the same
  lowercase spellings the responses carry. `Loan` and `CreateLoanRequest`
  follow in all three packages: TypeScript and Python export the two value
  sets as `LoanType` and `RateType` and type the request's `loan_type` and
  `rate_type` with them; in C#, `InterestRate` (a `decimal?`) becomes
  `InterestRatePercent` (a `string?`) and `BorrowerName`, `LoanTypeLabel` and
  `LoanSubTypeLabel` leave `Loan`.

* **Embed handle and session lifetimes.** `POST /api/embed/iframe-urls` takes
  `expires_in_seconds`, the handle's own lifetime (60 by default, at most
  300\), in place of `handle_lifetime_seconds`, and
  `session_expires_in_seconds`, the lifetime of the session the exchange mints
  (3600 by default, at most 86400), in place of `token_lifetime_seconds`; the
  iframe handle read names that field `session_expires_in_seconds` too.
  `client.embed.iframeUrls.create({ expires_in_seconds, session_expires_in_seconds })`
  in TypeScript,
  `create_iframe_url(expires_in_seconds=..., session_expires_in_seconds=...)`
  in Python and
  `CreateIframeUrlAsync(expiresInSeconds: ..., sessionExpiresInSeconds: ...)`
  in C# send the new keys.

* **Three more reads answer the list envelope.** The attribute versions list,
  `GET /api/loans/{loan_id}/attributes/versions`, answers
  `{ data, has_more, next_cursor }` with the snapshot summaries under `data`,
  in place of `{ loan_id, snapshots }`. It is still at most 200 rows and not
  paged, so `has_more` is false and `next_cursor` null. `AttributeVersions`
  follows in all three packages (`data`, `has_more` and `next_cursor`; `Data`,
  `HasMore` and `NextCursor` in C#). The export map,
  `GET /api/chart-of-accounts/export-map` and its `PUT`, answers the same
  envelope with the entries under `data`, in place of `{ system, entries }`.
  The `PUT` body is still `{ system, entries }`, and the active export system
  is read from `active_export_system` on `GET /api/chart-of-accounts`
  (`client.chartOfAccounts.get()`, `client.chart_of_accounts.get()` in Python,
  `client.ChartOfAccounts.GetAsync()` in C#). `ChartOfAccountsExportMap`
  follows in all three packages. The Bank Statement Account list,
  `GET /api/borrowers/{borrower_id}/extractions/bank-statement-accounts`
  (`listBankStatementAccounts`, `list_bank_statement_accounts` in Python,
  `ListBankStatementAccountsAsync` in C#), moves onto the envelope as well.

* **Five raw list routes answer the list envelope.**
  `GET /api/borrowers/{borrower_id}/extractions/fin-ebitda-addbacks`,
  `GET /api/borrowers/{borrower_id}/extractions/owners`,
  `GET /api/borrowers/{borrower_id}/extractions/query`,
  `GET /api/loans/{loan_id}/memos` and `GET /api/organizations` answer
  `{ data, has_more, next_cursor }` with their rows under `data`. The packages
  have no methods for them; a raw read of any of the five reads `data`.

* **`status` is no longer accepted on borrower create.**
  `POST /api/borrowers` takes the borrower's fields without `status`, and a
  new borrower always starts in `good_standing`. `CreateBorrowerRequest` drops
  `status` in all three packages (`Status` in C#); the borrower read still
  carries it.

* **Every statement family's keys follow the public naming rules.** The
  public wire now carries one name per fact and one shape per name across the
  fifteen Report Data families, applied by a key-edit layer that the schemas,
  the examples and the packages' typed reads share:
  the AR Aging, AP Aging and Debt Schedule carry one `entity` object,
  `{ name, source }`, in place of `company_name`, `entity_source` and
  `applicant`; the Supporting Table's `entity` and `cpa_engagement` are the
  `entity` and `assurance` objects; every masked identifier reads under its
  `_last4` name (`ein_last4`, `employer_ein_last4`, `taxpayer_ssn_last4`,
  `spouse_ssn_last4`, `partner_tin_last4`, `shareholder_tin_last4` and the
  rest), and the `_id_kind` keys read `_tin_kind`; `taxpayer_name`, `holder`
  and the Personal Financial Statement's `entity` read `entity_name`; the
  Bank Statement's `message_customer` reads `message` and its `matches` reads
  `matched_transactions`; the Personal Financial Statement's `details` reads
  `detail`; the installment sale row's `period` reads `period_raw`; a return's
  first-item copies (`form_w2`, `schedule_c`, `schedule_se`, `schedule_f`,
  `form_6198`, `form_7203`, `form_6252`, `form_4562`) fold into their lists
  (`forms_w2`, `schedules_c` and so on), and a W-2 upload answers `forms_w2`
  at the root in place of `data`; the Bank Statement's camelCase keys read in
  snake_case (`daily_balances`, `underwriting_flags`, `account_number_last4`,
  `average_daily_balance`, `closing_balance`, `opening_balance`,
  `transaction_count`, `days_negative`, `net_cash_flow`, `total_deposits`,
  `total_withdrawals`, `check_number`, `line_order`, `transaction_type` and
  the rest); under a named parent, a flag's `detail` reads
  `supporting_transactions` under `underwriting_flags`, a tax statement's
  `preparer` reads `software` under `statements`, and every Schedule K-1 box
  that takes code letters reads `_by_code` under `data`
  (`line_11_other_income_by_code` and the rest, on both schedules); the NOL
  carryforward row's `tax_year` is an integer like every other `tax_year`,
  and a status word on an aging row is lowercase. The pipeline's own
  bookkeeping leaves the bodies (`detected_year`, `as_of_date`,
  `as_of_source`, `period_source`, `scale_multiplier`, `reportType`, the
  `source_key` copies, `proprietor_name`, `ssn`, `w2_count`, `layout`,
  `rescued_fields`, `page_indices`, `billable` and their kin): the document
  envelope's `period` (`start`, `end`, `fiscal_year`, `label_raw`, `source`)
  carries what they said. The typed reads in all three packages follow the
  new keys.

* **Every date on the families is a date.** The printed dates on the Report
  Data families carry `format: date`: the agings' `transaction_date` and
  `due_date`, the Debt Schedule's `maturity_date` and `original_date`, the
  Personal Financial Statement's `date_purchased`, `quotation_date` and
  `signed_dates`, and seventeen keys on the Tax Return such as
  `date_acquired`, `date_sold`, `date_placed_in_service` and
  `s_election_effective_date`. A printed date that does not resolve to a full
  date reads null, with the printed text kept in the `_raw` sibling
  (`maturity_date_raw`).

* **`currency` on the document envelope.** Every document read and list item
  carries `currency` right after `period`, an ISO 4217 code: the code the
  document prints where it prints one, else `USD`, the platform's reporting
  currency. The agings' body `currency` leaves with it. The envelope types in
  all three packages carry the key.

* **Job statuses are lowercase.** The job status, the status batch, the batch
  confirm and its child jobs, and the confirm upload answer `pending`,
  `processing`, `completed`, `failed`, `error` and `duplicate` in place of the
  upper-case words. The status enums in all three packages follow.

* **`error_message` on the batch upload rows.** The per-file error of the
  batch presign, the batch confirm and its child jobs reads `error_message`
  (the name the job results element already used), so `error` is the house
  error envelope's object alone.

* **`document_id` on loan document summaries; job results lose
  `extraction_id`.** A loan's document summary names its document
  `document_id` in place of `extracted_document_id`; the job results element
  drops `extraction_id`, the same id as its `document_id`; and the Bank
  Statement Account list names each row's document `document_id`. The hand
  written loan document summary model reads `document_id` in all three
  packages.

* **The test event, and an honest body error.** `webhook.test` is the
  twenty-first event type: one real, signed, retried delivery to one endpoint,
  sent from Send test event on the Webhooks tab or by the new
  `client.webhooks.sendTest(endpointId, { livemode })` (`send_test` in Python,
  `Webhooks.SendTestAsync` in C#), which answers `delivery_id`, `event_ref`,
  `livemode` and `scheduled_at`. The receivers route it to `onWebhookTest`
  (`on_webhook_test`, `OnWebhookTest`) and, with no handler, answer 200 and
  drop it; its payload (`webhook_id`, `requested_at`, `message`) is typed in
  TypeScript and C#. A body that verified but is not the envelope (not JSON,
  or `id`, `type`, `created_at`, `tenant_id`, `livemode` or `data` missing or
  of the wrong type) now raises `WebhookBodyError` (`WebhookBodyException` in
  C#), whose message names the field, in place of the signature error, and the
  receivers answer `400 {"error":"invalid_body"}` for it; `livemode` is
  required, and C#'s `WebhookEvent.Livemode` is a `bool`, no longer nullable.
  `WebhookSignatureError` is raised for signature and freshness failures only.

## 1.1.0 (2026-10-08)

* **Pins API version `2026-10-07` by default**; `apiVersion` / `api_version` /
  `ApiVersion` still overrides it per client or per call.
* **Statement mappings and the chart of accounts.**
  `client.statementMappings.getProfitLoss(borrowerId, docId)` and
  `.listProfitLoss(borrowerId, { loanId, limit })` read a profit and loss
  statement's mapping onto the chart of accounts, and `.getBalanceSheet` and
  `.listBalanceSheet` a balance sheet's (`client.statement_mappings.get_profit_loss`
  / `.list_profit_loss` / `.get_balance_sheet` / `.list_balance_sheet` in
  Python, `client.StatementMappings.GetProfitLossAsync` / `.ListProfitLoss` /
  `.ListProfitLossPages` / `.GetBalanceSheetAsync` / `.ListBalanceSheet` /
  `.ListBalanceSheetPages` in C#), each statement through its own read pair
  returning its own object: `status` `ready` or `unmappable`, a ready
  mapping's `payload` with its `mapping_state` (`mapped` or `unfooted`), the
  tree of sections and accounts with a figure per period, every printed line
  and the row it is filed under, the headline measures and the foot each
  period walks to (a profit and loss statement's printed bottom line and the
  residual against it; a balance sheet's printed total on each side, the
  total each side's lines walk to, the residual on each side and the
  imbalance between the sides); a page of either list also names the
  documents whose first mapping is being prepared. A read on a document of the
  other statement answers the not-found error. `client.chartOfAccounts.get()`,
  `.getExportMap()` and `.replaceExportMap({ system, entries })`
  (`client.chart_of_accounts` in Python, `client.ChartOfAccounts` in C#)
  read the workspace's chart document and read or replace its export map,
  the workspace's own code and name for each chart account in one export
  system. Typed in all three packages per statement (`ProfitLossMapping`,
  `ProfitLossMappingPayload`, `ProfitLossMappingFoot`,
  `ProfitLossMappingMeasures`, `ProfitLossMappingList` and their
  `BalanceSheetMapping` twins, with `StatementMappingNode`,
  `StatementMappingLine`, `StatementMappingSource`, `StatementMappingPeriod`
  and `StatementMappingUnmappedLine` shared, a node's `section`, `role` and
  `measure` in the vocabulary of its statement; `ChartOfAccounts`,
  `ChartOfAccountsExportMap` and their parts); every figure stays the
  two-decimal string the wire carries. A mapping being prepared answers 404
  with `Retry-After`, read as `retryAfter` / `retry_after` / `RetryAfter` on
  the not-found error; a workspace whose plan lacks the feature answers the
  permission error with type `statement_mapping_not_in_plan`; a save of the
  export map that crosses another is the conflict error. The
  `extraction.mapped` event joins the union and the receiver
  (`onExtractionMapped` / `on_extraction_mapped` / `OnExtractionMapped`),
  typed `ExtractionMappedPayload` with `statement` (`profit_and_loss` or
  `balance_sheet`) naming the read to call: a pointer to the mapping, never
  the figures.
* **`memo.finalized` and `memo.reopened` carry `spread_snapshots`**, the
  spread figures the memo was approved on: one entry per entity its figure
  exhibits cite, each a `snapshot_id`, its `version` and the `entity` (`key`
  and `name`), and an empty list when the memo has none. A pointer, not the
  figures: read them from the attributes snapshot by id.
  `MemoSpreadSnapshot` in TypeScript, where the field is optional for events
  emitted before it existed; `MemoSpreadSnapshot` and `MemoSpreadSnapshotEntity`
  in C#, where `SpreadSnapshots` is an init property that reads as empty on
  such an event, so the five-parameter constructors and `Deconstruct` stay as
  1.0.0 shipped them. Python reads it with `event.data.get("spread_snapshots", [])`.
* **`unextractable_reason`** on the typed loan document rows
  (`LoanDocumentSummary`) and job status (`JobStatus`) in all three packages:
  a sentence you can show the uploader for a document that could not be
  extracted, null otherwise. The API returns the same field on job results.
* **`document.failed` carries `message`**, the sentence you can show the
  uploader when the document could not be extracted and there is one; its
  `reason` is `unextractable`. `DocumentFailedPayload.message` in TypeScript;
  `DocumentFailedPayload.Message` in C#, an init property, so the
  six-parameter constructor and `Deconstruct` stay as 1.0.0 shipped them.
  Python reads it with `event.data.get("message")`.
* **`balance_source` on bank statement report data** (`BankStatementReportData`
  in all three packages): `feed_balance` when the statement prints no period
  balances, so its opening and closing balances are the linked account's
  current balance carried back through its own transactions; absent when the
  statement prints its balances.
* **`entity` on attribute versions rows** (`AttributeEntity` in all three
  packages): the scope a snapshot was built under, its `key` and `name`, or
  null.
* **`@spreadspace/sdk`:** an upload or intake submission from a `.json` or
  `.zip` path goes out as `application/json` or `application/zip`, the content
  types the API accepts for those files.
* **`@spreadspace/sdk`:** an error raised while putting a document to its
  presigned upload URL names the bucket host only. The signed URL no longer
  appears in the message: its query string is the upload credential for as
  long as it lives, and a logged `err.message` would have carried it.
* **Typed reads for equity statements, comprehensive income statements and
  Form W-2.** `client.extractions.getEquityStatement(borrowerId, docId)` and
  `.listEquityStatements(borrowerId, { loanId })`,
  `.getComprehensiveIncomeStatement` / `.listComprehensiveIncomeStatements`,
  and `.getW2` / `.listW2s` (`get_equity_statement` /
  `list_equity_statements`, `get_comprehensive_income_statement` /
  `list_comprehensive_income_statements`, `get_w2` / `list_w2s` in Python;
  `GetEquityStatementAsync` / `ListEquityStatementsAsync`,
  `GetComprehensiveIncomeStatementAsync` /
  `ListComprehensiveIncomeStatementsAsync`, `GetW2Async` / `ListW2sAsync` and
  their `...RawAsync` twins in C#), typed `EquityStatementReportData`,
  `ComprehensiveIncomeStatementReportData` and `W2ReportData` in all three
  packages. The statement lists answer under `statements`, the W-2 list under
  `forms`. On an equity statement a row carries one entry in `values` and one
  in `printed` per column: a cell printed as money is the two-decimal string
  every family carries (a par value column keeps its printed figure), and a
  share count is a whole number as text.
* **A Form W-2 uploaded on its own is read through its own methods** and is
  not part of the tax return union: `TaxReturnReportData` has no `w2` variant
  (in C#, no `AsW2()` on the tax return), and the tax returns list does not
  return a W-2 uploaded on its own. The W-2s a Form 1040 carries under
  `forms_w2` are unchanged.
* **`form` on the two aging lists is typed.** `listAccountsReceivableAgings`
  and `listAccountsPayableAgings` take `form?: 'summary' | 'detail'` in
  TypeScript, and the Python methods the same two values as a `Literal`; C#
  keeps a string.

## 1.0.0 (2026-09-22)

* **`client.intake`** (`client.Intake` in C#) submits a document without a
  modeled borrower through `POST /api/intake`, which needs `intake:write`.
  `create` and `confirm` (`CreateAsync` / `ConfirmAsync`) are the raw legs;
  `submit` (`SubmitAsync`) runs the create, the upload and the confirm and
  returns a job handle that also carries the provisioned loan and borrower
  ids. `completeIntake` below finishes the provisioned borrower's profile.
* **Typed extraction reads** carry, on tax returns, `current_tax_year` and
  `prior_tax_year` on each two-year comparison row (when its activity
  worksheet prints its own year pair) and `writeins` on the Schedule M-1 of
  Forms 1065, 1120 and 1120-S (the reasons the preparer wrote on an itemize
  line, each with its `line`, `label`, `family` and `amount`); and, on the
  freeform debt schedule, `reporting_unit` and `scale_multiplier`.
* **`borrowers.completeIntake` / `borrowers.complete_intake` / `Borrowers.CompleteIntakeAsync`**
  finishes an intake borrower's profile through
  `POST /api/borrowers/{id}/complete-intake`: it takes the business name and
  moves the borrower out of intake into the curated list. The route needs
  `borrowers:write`, and a borrower already completed answers `409`. The
  `CompleteIntakeRequest` model carries the name.
* **`memo.finalized` and `memo.reopened` are in the event unions**, with typed
  payloads keyed on the exact wire names: `memo_id`, `loan_id`, `borrower_id`,
  `render_id`, `finalized_at` on the finalize pointer, and the same ids plus
  `reopened_at` on the reopen. Both are pointers and fetch nothing:
  `memo.finalized` says the memo's final PDF is ready to pull from
  `GET /api/loans/{loan_id}/memos/{memo_id}/pdf`, and `memo.reopened` says that
  same PDF is kept but the memo is under revision until the next
  `memo.finalized`, so mark your copy stale. `render_id` is nullable on the
  reopen, for a memo with no final PDF to name. The receiver hands them to
  `onMemoFinalized` / `on_memo_finalized` / `OnMemoFinalized` and
  `onMemoReopened` / `on_memo_reopened` / `OnMemoReopened`.

## 0.6.0 (2026-09-15)

The typed extraction models follow the 2026-09-14 schemas. Every money key
in `report_data`, on every document family, is a JSON string with exactly two
decimals: `string` in TypeScript, `str` in Python, and `string` in C#.
Attribute snapshots and export payloads use the same two-decimal strings.
Loan `requested_amount`, extraction query amounts and totals, and EBITDA
write-in amounts use the same two-decimal strings. Loan create and update requests accept amounts from
`"0.00"` through `"999999999999.99"`; invalid formats return `400 validation_failed`.

## 0.5.0 (2026-09-13)

* Typed borrower, loan, and job workflows, including create/get helpers and
  typed pagination using the public wire models.
* Caller-owned upload operation keys derive stable, distinct presign and confirm
  keys. Request options reach both API stages and the applicable mutation helpers.
* C# adds typed status/wait helpers and typed lists while preserving its raw APIs.

## 0.4.0 (2026-09-09)

Typed extraction reads. The extraction read operations declare their response
schemas (one JSON Schema per document family, on `api/Api.json` and the served
`/v1/openapi.json`), and every SDK carries an `extractions` resource generated
from that contract: `sdk/typescript/scripts/gen-extraction-types.mjs` reads
`fern/public-schemas/_index.json` and the published schemas and writes the wire
types and the resource for all three packages (drift gate in
`scripts/preflight.sh` and in each package's tests). A family that lands in
the index gets its types and methods on the next run; nothing is hand-written
per family.

* **`@spreadspace/sdk`:** `client.extractions` with one `get<Family>` /
  `list<Family>s` pair per document family (`getTaxReturn` /
  `listTaxReturns`, ...), `getReportData` (any category, in the shape of its
  family) and `getReportDataByCategory`. The wire types are exported under
  the names the OpenAPI document gives them (`TaxReturnReportData`,
  `BankStatementReportData`, `<Family>ReportDataListResponse`, ...), from
  `src/extractions-types.ts`.
* **`@spreadspace/sdk`, values:** the transport preserves JSON strings and
  numbers without conversion. No wrapper type sits between the wire and the
  caller.
* **`spreadspace` (PyPI):** `client.extractions` with `get_<family>` /
  `list_<family>s`, `get_report_data` and `get_report_data_by_category`,
  annotated with `TypedDict`s from `spreadspace.extractions` (3.9-safe). A
  JSON number arrives as `int` or `Decimal` (`spreadspace.extractions.Number`).
* **`SpreadSpace` (NuGet):** `client.Extractions` with `Get<Family>Async` /
  `List<Family>sAsync` returning System.Text.Json records, each with a
  `...RawAsync` twin returning `JsonElement?`. A family whose schema is a
  `oneOf` per form binds to a wrapper carrying `Raw`, the discriminator and
  one typed `As<Value>()` accessor per shape (the key's name leads only when
  the value starts with a digit, as in `AsForm1065()`); the report-data reads
  return `JsonElement`.

## 0.3.0 (2026-09-05)

Extraction exports as a first-class resource in all three packages, typed
attribute reads in Python and C#, and filters on the two list reads.

### Extraction exports

The routes: `POST /api/extractions/exports` enqueues (202, or 200 on an
`Idempotency-Key` replay), `GET /api/extractions/exports/{export_id}` reads,
`GET /api/extractions/exports` lists (its items key is `exports`),
`POST /api/extractions/exports/{export_id}/cancel` cancels (409 once the export
finished; idempotent when already cancelled). The envelope is
`ExtractionExportResponse`: `export_id`, `borrower_id`, `loan_id`, `format`,
`status`, `progress`, `document_counts`, `skipped_document_ids`, `bundle`,
`download_url`, `download_expires_at`, `error_code`, `error_message`,
`created_at`, `started_at`, `completed_at`, `expires_at`, `links`.

* **`@spreadspace/sdk`:** `client.exports` with `create()`, `get()`, `list()`
  and `cancel()`. `src/helpers/exports.ts` carries `ExtractionExport`
  (`exportId`, `borrowerId`, `loanId`, `format`, ...), `ExportHandle` (its
  `.export` getter), `ExportFailedError` (default type `export_failed`),
  `ExportTimeoutError` (type `export_timeout`), `getExtractionExport` /
  `cancelExtractionExport`, `ExportsTransport`, `ExportWaitOptions` and
  `ListExportsParams`.
* **`spreadspace` (PyPI):** `client.exports` with `create`, `get`,
  `list(status=...)` and `cancel(export_id)`. `spreadspace.helpers.exports`
  carries `ExtractionExport`, `ExportHandle` (`.export`), `ExportFailedError`
  (`.export`), `ExportTimeoutError`, `get_extraction_export` /
  `cancel_extraction_export`, and `wait()` with its backoff.
* **`SpreadSpace` (NuGet):** `client.Exports` with `CreateAsync`, `GetAsync`,
  `List(status, limit, apiVersion)` and `CancelAsync(exportId)`. `Exports.cs`
  carries `ExtractionExport` (`ExportId`, `BorrowerId`, `LoanId`, `Format`,
  ...), `ExportHandle` (`.Id`, `.Export`), `ExportFailedException`,
  `ExportTimeoutException` and the static `Exports` class
  (`GetExtractionExportAsync` / `CancelExtractionExportAsync`). `WaitAsync`
  defaults: 300s timeout, 2s poll, 10s backoff cap.

The create body is `{borrower_id, loan_id, document_ids, format}`, with
null/undefined fields omitted rather than sent as wire nulls. `download_url` /
`download_expires_at` are minted fresh on every read while the export is
succeeded and unexpired. `document_counts` (`requested` / `exported` /
`skipped`), `skipped_document_ids` (the ids counted in `skipped`), `bundle`
(`name` / `size_bytes` / `line_item_count`, null until succeeded) and a
`download` member on `links` describe the result. Statuses include
`cancelling`, a cancel that landed mid-bundle, which the worker flips to
`cancelled` at the next document boundary. It is NOT terminal: the terminal set
is `succeeded` / `failed` / `cancelled` and `wait()` / `wait` / `WaitAsync`
polls straight through it.

`client.exports.downloadLink(exportId)` / `client.exports.download_link(export_id)` /
`client.Exports.GetDownloadLinkAsync(exportId)`, with the same method on the
handle (after a refresh), GETs the export and returns `ExportDownloadLink`
(`url` + `expiresAt` / `expires_at` / `ExpiresAt`) from one read, so the expiry
describes the link you are about to hand out. Until the export is succeeded it
raises `ExportNotReadyError` / `ExportNotReadyException` (error type
`export_not_ready`, message naming the current status). The free functions:
`getExtractionExportDownloadLink` / `get_extraction_export_download_link` /
`GetExtractionExportDownloadLinkAsync`.

Six webhook events are in the typed lists and the receivers:
`export.succeeded` / `export.failed` / `export.cancelled` (identifiers, format
and the terminal timestamp, never a URL; re-fetch the export) and
`memo_render.succeeded` / `memo_render.failed` / `memo_render.cancelled`
(`render_id`, `memo_id`, `loan_id`; a render carries no borrower id). Receivers
carry the matching handler slots: `onExportSucceeded` to
`onMemoRenderCancelled` / `on_export_succeeded` to `on_memo_render_cancelled` /
`OnExportSucceeded` to `OnMemoRenderCancelled`.

Also, in all three packages:

* **`create` takes what the server requires.** `borrower_id`, `loan_id` and
  `document_ids` are required parameters: `ExtractionExportParams` in TS
  (`borrowerId: string; loanId: string; documentIds: string[]`), keyword-only
  `str` / `Sequence[str]` in Python, positional `string` / `IEnumerable<string>`
  in C#, so a create the server would answer with 400 does not compile or
  type-check. `format` is optional and typed: `ExtractionExportFormat`
  (`'xlsx' | 'csv' | 'json'`) / `ExportFormat` (a `Literal`) / the
  `ExtractionExportFormats` constants; `status` likewise as
  `ExtractionExportStatus` / `ExportStatus` / `ExtractionExportStatuses`.
* **Upload helpers take the loan id** (the API requires `loan_id` on presign,
  answering 400 without it). `UploadOptions.loanId` is a required `string` in
  TS, `loan_id: str` is keyword-only required on `upload_document` /
  `client.documents.upload` in Python, and `string loanId` is the second
  positional parameter of every `UploadAsync` / `UploadDocumentAsync` overload
  in C# (`borrowerId` follows it and stays optional; named-argument call sites
  are unaffected). A blank value raises `UploadError` / `ValueError` /
  `ArgumentException` with the message "loanId is required: every upload
  belongs to a loan" before any request is made.
* **`exports.list()` yields `ExtractionExport` rows**, mapped the way `get()`
  maps one. The pagers carry the one hook this needs: `mapItem` on the TS
  `PaginateOptions`, `map_item` on the Python `paginate` / `PageIterator`, and
  a `PageIterator<T>` in C# whose `await foreach` / `ToListAsync` yield mapped
  rows while `PagesAsync` keeps the raw envelopes.
* **`TERMINAL_EXPORT_STATUSES`** in the TS index and in
  `spreadspace.helpers.exports`, beside `TERMINAL_JOB_STATUSES`; C#'s is
  `ExtractionExport.TerminalStatuses`.
* **Handles do not serialize their transport.** The TS `ExportHandle` and
  `JobHandle` carry a `toJSON()` that writes the last-known export / job
  status, so `JSON.stringify(handle)` in a log line cannot leak the API key.
  The Python and C# handles do not expose theirs.
* **`@spreadspace/sdk` exports the export and memo-render webhook payload
  types** (`ExportSucceededPayload`, `ExportFailedPayload`,
  `ExportCancelledPayload`, `MemoRenderSucceededPayload`,
  `MemoRenderFailedPayload`, `MemoRenderCancelledPayload`) from the package
  index.

### Typed attribute reads and list filters

One generator writes the `attributes.v1` types for all three packages from one
source, `web/src/components/spread/attributes/attributeTypes.ts` (the builder's
own wire shape) plus the four envelope schemas in `api/Api.json`, under one
drift gate (`node scripts/gen-attributes-types.mjs --check`, from
`sdk/typescript`). A construct the mapper does not understand fails the run
loudly instead of being guessed at.

* **`@spreadspace/sdk`:** attribute payload types are generated alongside
  Python and C# from that one source (`src/attributes-payload.ts`).
* **`spreadspace` (PyPI):** typed reads and receiver. `loans.attributes.get` /
  `.versions` / `.get_snapshot` are annotated `AttributesEnvelope` /
  `AttributeVersions` / `AttributeSnapshot` (the transport's parsed JSON,
  cast), the receiver's `attributes` argument is an `AttributeSnapshot`
  (`None` only when `fetch_attributes=False`), and every payload and manifest
  type ships as a 3.9-safe `TypedDict` / `Literal` in `spreadspace.attributes`,
  re-exported from the package root.
* **`SpreadSpace` (NuGet):** typed reads. `GetAsync` returns
  `Task<AttributesEnvelope>`, `VersionsAsync` returns
  `Task<AttributeVersions>` and `GetSnapshotAsync` returns
  `Task<AttributeSnapshot>`; a 2xx body that is not the documented shape throws
  `SpreadSpaceException`. The untyped reads are `GetRawAsync` /
  `VersionsRawAsync` / `GetSnapshotRawAsync` (same parameters,
  `Task<JsonElement?>`) for callers who bind it themselves.
  `SpreadFinalizedContext.Attributes` is an `AttributeSnapshot?`, so
  `ctx.Attributes!.Payload` reaches the figures without a `JsonElement` walk.
* **`versions(loanId, status)` filters the snapshot history to one status.**
  `versions(loanId, { status: 'final' })` / `versions(loan_id, status="final")` /
  `VersionsAsync(loanId, "final")` is the backfill read, and its first row is
  the loan's current `final`. An unknown status is a 400, and the 200-row cap
  applies AFTER the filter, so a loan with a long autosave history still hands
  back its finals in one call.
* **`deliveries(endpointId, ...)` takes `status`, `event_type` and `loan_id`.**
  Every delivery row carries the loan its payload named (`loan_id`, lifted out
  of the envelope at enqueue), so "what did you send for this loan" is one call
  instead of a full scan. The TypeScript `deliveries` default row type is
  `WebhookDeliverySummary`.
* **`spreadspace` (PyPI): `mypy --strict` is green and enforced in CI.** The
  hand-written layer's generic containers (`dict`, `list`, `frozenset`,
  `tuple`) are parameterized, `wait()` and the upload/pagination helpers
  declare real return types, and the dev extra pins `mypy>=1.10,<2` (mypy 2
  dropped `python_version = "3.9"`, this package's floor). The generated core
  is excluded from the check and its own gate is that it compiles.

## 0.1.8 (2026-08-18)

All three packages.

* **`loans.attributes`: a loan's finalized figures, read as data.** Three reads
  behind one namespace: `get(loanId, {status})` returns the latest snapshot of
  a status (`final` by default, plus `machine`, its documents-only twin, and
  `draft`, the latest autosave); a loan nobody has finalized answers a null
  snapshot, not a 404. `versions(loanId)` returns the whole history in one
  call because the server caps it at the 200 newest and does not paginate it:
  an envelope, not a pager. The by-id read (`retrieve` in TypeScript,
  `get_snapshot` in Python, `GetSnapshotAsync` in C#) carries the payload and
  manifest. All three need the `spreads:read` scope.
* **`extraction.updated`, `spread.finalized` and `spread.reopened` are in the
  event unions**, with typed payloads keyed on the exact wire names:
  `snapshot_id`, `machine_snapshot_id`, `version`, `finalized_at` on the
  finalize pointer; `extraction_version`, `previous_extraction_version`,
  `reason` on the rewrite. `spread.reopened` is the other half of the finalize
  pointer: it names the `final` snapshot that was in force when an analyst
  reopened the board (kept and unchanged, `snapshot_id` + `version` +
  `reopened_at`), so mark your copy stale until the next `spread.finalized`;
  the receiver hands it to `onSpreadReopened` / `on_spread_reopened` /
  `OnSpreadReopened` and fetches nothing. `extraction.ready`'s payload type
  carries the `enrichment` state and `extraction_version`. The unions are what
  a release knows, NOT a validation gate: an endpoint subscribed to `*`
  receives types added later, and the verifier still verifies them and hands
  back the raw `data`.
* **A webhook receiver that implements the delivery contract for you.**
  `client.webhooks.receiver({...})` (`.CreateReceiver(...)` in C#) verifies the
  signature over the raw bytes, drops a redelivery whose event id it has
  already processed, dispatches to a typed per-event handler, reads the
  snapshot a `spread.finalized` NAMES on that event, by id, from `GET
  /api/loans/{loan_id}/attributes/{snapshot_id}`, and returns the status our
  retry policy expects: 400 on a bad signature (a 4xx other than 429 is dead on
  arrival, and a signature that failed once fails forever), 500 when your
  handler or the attributes read throws (5xx is what the retry curve acts on,
  and the event id stays unrecorded so the retry re-runs it), 200 otherwise,
  including for an event nobody handles. The read is never the loan's LATEST
  `final` snapshot: delivery is at-least-once and unordered, so a retried or
  replayed finalize landing after a reopen-and-refinalize would otherwise hand
  the handler the newer figures under the older event. The handler therefore
  receives the snapshot BARE (`snapshot_id`, `version`, `status`,
  `spread_board_id`, `created_at`, `finalized_at`, `finalized_by`, `payload`,
  `manifest`), not the `{loan_id, snapshot}` envelope the `?status=` read
  answers with. Dedupe defaults to the last 10,000 ids in memory;
  **a multi-instance or serverless deployment must pass a shared store**
  (`dedupe` / `Dedupe`), since delivery is at-least-once with no server-side
  dedupe.
* **TypeScript types the payoff object.** `AttributeSnapshot`,
  `AttributesEnvelope` and `AttributeVersions` are exported, alongside the
  `attributes.v1` payload and manifest types (`LoanAttributesPayload`,
  `LoanAttributesManifest` and the shapes under them), generated from the
  builder's own wire shape and gated against drift. `loans.attributes.get` /
  `.versions` / `.retrieve` and the receiver's `attributes` context are typed
  by default instead of `unknown`; the generics stay, so a caller can still
  override.
* **The verifiers take several secrets.** `verifyWebhook` /
  `verify_webhook_signature` / `WebhookVerifier.Verify` accept a list as well
  as a single secret: during a rotation the retiring secret stays valid for a
  24-hour receiver-side grace window and each delivery is signed with exactly
  one of the two, so a receiver that knows only one drops real deliveries. Any
  candidate that matches verifies; the compare is constant-time.
* **Embed mints take `external_user_id`.** Both mint shapes, `POST
  /api/embed/sessions` and `POST /api/embed/iframe-urls`, accept an optional
  opaque, stable end-user id (max 256 chars); the session response echoes it.
  Supplying it is what unlocks per-user server-kept state inside the embed
  (saved boards and view settings, and the memo-template surface), keyed to
  `(tenant, external_user_id)`; on the iframe path it is stored on the handle
  and copied onto the session at exchange. In C#, `CreateAsync` /
  `CreateIframeUrlAsync` take `externalUserId` BEFORE `idempotencyKey` in the
  parameter list; named arguments (the documented style) are the safe call.
* No other API surface changes. The pinned default `SpreadSpace-Version`
  remains `2026-05-03`.

## 0.1.6 (2026-08-08)

All three packages.

* **The webhooks resource is on `/api/webhooks/*`** in every package.
  Drift-guard tests pin the TS and Python source trees to the public paths
  (the C# paths collapse to one pinned `BasePath` const).
* **Spec guard.** The public spec build computes reachability and drops
  unreferenced schemas, and a `spec-guard` job in `sdk.yml` fails the release
  before any package is built if an internal schema reaches the public spec.
* **Version-drift guard per package.** Every SDK carries its version twice,
  the manifest the registry publishes and the runtime constant the
  `User-Agent` carries; each package has a test asserting the two agree, and
  TypeScript also checks `package-lock.json`.
* No API surface changes. The pinned default `SpreadSpace-Version` remains
  `2026-05-03`.

## 0.1.4 (2026-07-30)

`@spreadspace/sdk` 0.1.4 (npm); `spreadspace` and `SpreadSpace` 0.1.2 (PyPI,
NuGet). Publishing runs through registry trusted publishing (OIDC), and
`@spreadspace/sdk` holds the API key in an ECMAScript `#private` field so it
cannot be reached by enumeration, serialization, or a stray log of the client
instance. No API surface changes.

## 0.1.1 (2026-06-20)

`spreadspace` (PyPI) only. Ships `py.typed` so downstream type-checkers see the
SDK's inline types, and trims the unused generated core from the wheel.

## 0.1.0 (2026-06-19)

First release of all three packages: hand-written transport (retries,
idempotency keys, dated API-version header, typed errors), resource helpers,
pagination, upload helpers, and webhook signature verification.