Skip to navigation

Changelog

Releases of the official TypeScript, Python, and C# SDKs, newest first.

Release notes for @spreadspace/sdk on npm, spreadspace on PyPI and SpreadSpace on NuGet. 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.

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 TypedDicts 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.