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_versionon the parsed webhook event in all three packages: the API version the delivery was rendered under (2026-10-07today), read from the envelope when it carries one.event.api_versionin TypeScript, optional onWebhookEventand on the receiver’sUnknownWebhookEvent;event.api_versionin Python,Nonewhen absent;ApiVersionin C#,nullwhen absent. A delivery recorded before the field existed still verifies and parses, and anapi_versionthat is present but not a string is a body error naming the field.- The upload routes move under
/api/uploadsand the job reads under/api/jobs.client.documents.upload(client.Documents.UploadAsyncin C#) postsPOST /api/uploads/presigned-urlandPOST /api/uploads/{job_id}/confirm, andJobHandle.status()/wait()andclient.documents.status(jobId)readGET /api/jobs/{job_id}/status, which now takesjobs:readordocuments:readon the upload lane; the batch reads arePOST /api/uploads/confirm-batchandPOST /api/jobs/status-batch, and the download link isGET /api/jobs/{job_id}/download-urlwithdocument_id. No method name changes. The routes under/api/documentsthat these replace answer an API key 404. document_idson the job status in place ofextracted_document_ids(JobStatus.document_idsin TypeScript and Python,DocumentIdsin C#).- A loan’s document summaries read as the document does.
LoanDocumentSummarycarriesdocument_type,statusandstatus_reasonin place ofdocument_category,extraction_statusandunextractable_reason(DocumentType,Status,StatusReasonin C#). source_document_idon the borrower owner in place ofsource_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_moreis true exactly when another page follows, andnext_cursoris null on the last page.limitis a request parameter only; it no longer comes back on the envelope. The pagers still stop on a nullnext_cursor(for awaitover anAsyncPagerin TypeScript, iterating aPageIteratorin Python and C#), so code that iterates a list is unchanged; code that reads a page’s envelope findshas_morewhere it readlimit:CursorPaginated<T>in TypeScript, andProfitLossMappingListandBalanceSheetMappingListin all three packages (has_morein Python,HasMorein C#, in place oflimit/Limit). The exports list,GET /api/extractions/exports, answers underdatalike every other list where it answered underexports:client.exports.list(client.Exports.Listin C#) reads the new key, and a raw read of the route must too. The pager’s items key (itemsKeyin TypeScript and C#,items_keyin Python) stays overridable and defaults todata. -
document_typeon every Report Data object. Each of the fifteen families carriesdocument_typeas 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 aReportDataread narrows to its family on that one key: a literal type in TypeScript and Python,DocumentType(required) in C#. -
client.documentsreplacesclient.extractions. The typed reads move toclient.documents(client.documentsin Python,client.Documentsin C#), oneget<Family>/list<Family>spair per document family plusgetDocumentandlistDocuments(get_document/list_documentsin Python;GetDocumentAsync/ListDocumentsAsyncand their...RawAsynctwins 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/documentsandGET /api/documents/{document_id}. A document carries its id, type, borrower, loan, period, status and times, and its extracted object inreport_data(on the list, withinclude=report_data).document_typenarrows the list to one family;updated_sincelists 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}/resultslists each document of the job by id and status; read a body throughGET /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, itsdocument_typeanddeleted_at. -
Typed reads for paystubs.
client.documents.getPaystub(documentId)and.listPaystubs({ borrowerId, loanId })(get_paystub/list_paystubsin Python;GetPaystubAsync/ListPaystubsAsyncand their...RawAsynctwins in C#) read a Paystub document throughGET /api/documents/{document_id}andGET /api/documentswithdocument_type=paystub, typedPaystubDocumentandPaystubDocumentListin all three packages: the document envelope with the Paystub object inreport_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, withreport_datapresent underinclude=report_data. -
job_titleon the Paystub object.PaystubReportDatagains an optionaljob_titlein all three packages (job_title?: string | nullin TypeScript, a key that may be absent in Python,JobTitlewithoutrequiredin 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_sourceon the Equity Statement object.EquityStatementReportDatagains an optionalentity_sourcein all three packages (EntitySourcein C#): where the entity name was obtained when the statement does not name it,page_imagefrom the page image orfiling_coverfrom the filing cover, and absent when no additional source was used. -
The upload helper’s storage key can be null.
PresignedUrl’ss3Key(s3_keyin Python,S3Keyin C#) is typedstring | nullin TypeScript,Optional[str]in Python andstring?in C#, and is null when the presigned URL response omits the storage key:parsePresignedUrlanswers null where it answeredundefined, andPresignedUrl.FromJsonin C# answers null where it answered an empty string.uploadDocument(upload_documentin Python,Upload.UploadDocumentAsyncin C#) needs onlyjob_id,upload_urlandexpires_in_secondsfrom 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_tablesin Python;GetInvestmentIncomeStatementAsync/ListInvestmentIncomeStatementsAsync/GetSupportingTableAsync/ListSupportingTablesAsyncand their...RawAsynctwins in C#) read an Investment Income Statement or a Supporting Table document through the two document routes, typedInvestmentIncomeStatementDocument,InvestmentIncomeStatementDocumentList,SupportingTableDocumentandSupportingTableDocumentListin all three packages. The object inreport_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, withkindssaying 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,limitandcursorin and the envelope above out. -
A Form 1040-SR is its own typed-read branch.
TaxReturnReportDatagainsTaxReturnReportData_TaxReturn1040SRin all three packages, withAsForm1040SR()besideAsForm1040()in C#: a return printed on the senior form answersform: "1040SR", itsdatais the Form 1040’s own lines, schedules and attached forms, and itsk1_partnersis always empty. In 1.1.0 the published contract named1040alone, so a senior return matched no branch of the union and C#‘sAsForm1040()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)andgetBalanceSheet(borrowerId, documentId)in TypeScript,get_profit_loss(borrower_id, document_id)andget_balance_sheet(borrower_id, document_id)in Python, where a keyword call that passeddoc_id=passesdocument_id=, andGetProfitLossAsync(borrowerId, documentId)andGetBalanceSheetAsync(borrowerId, documentId)with their...RawAsynctwins in C#, where a named argumentdocId:becomesdocumentId:. -
The borrower-scoped job and extraction reads are retired.
GET /api/borrowers/{borrower_id}/jobs, its results twinGET /api/borrowers/{borrower_id}/jobs/{job_id}/resultsandGET /api/borrowers/{borrower_id}/extractionsleave the public contract and answer an API key 404. They duplicatedGET /api/jobs,GET /api/jobs/{job_id}/resultsandGET /api/documents; read through those.GET /api/jobsgainsborrower_idandloan_idfilters, and the three jobs list methods take them:client.jobs.list({ borrowerId, loanId })in TypeScript,client.jobs.list(borrower_id=..., loan_id=...)in Python andclient.Jobs.List(borrowerId: ..., loanId: ...)andListTypedin C#, each sent only when supplied.borrower_idlists one borrower’s uploads, and a borrower you cannot see is a 404;loan_idlists one loan’s uploads, withborrower_idthe loan must belong to that borrower, and a loan you cannot reach is a 404. In C# the two filters come beforelimit, as onLoans.ListTyped, so a positionalList(limit)call nameslimit:. -
Webhook envelopes and payloads. The envelope carries
created_at, the RFC 3339 time the event was created, such as2026-10-15T14:03:27.123456+00:00, in place ofcreated, the epoch seconds:created_at: stringon the TypeScript envelope,WebhookEvent.created_at(astr) in Python andWebhookEvent.CreatedAt(astring) in C#. Every verifier refuses a body without a non-empty stringcreated_at, with “Webhook body is missing acreated_atfield.”document.processed,extraction.readyandextraction.updatedno longer carryextraction_id, which was always thedocument_id(ExtractionIdleaves the three C# payload records).job.completedcarriesdocument_countin place ofdocument_count_total(DocumentCountin C#), besidedocument_count_succeededanddocument_count_failed.memo_render.succeeded,memo_render.failedandmemo_render.cancelledcarryborrower_idbesideloan_id(BorrowerIdin C#, a positional parameter afterLoanId).extraction.mappedno longer carriesengine_version,chart_version,chart_document_versionorchart_rules_version;ExtractionMappedPayloaddrops them in all three packages. -
Loan fields. A loan read with an API key no longer carries
borrower_name(the name isborrower.nameunderinclude=borrower),loan_type_labelorloan_sub_type_label, which were screen text, orinterest_rate. It carriesinterest_rate_percent, the rate as a decimal string with three decimal places such as"5.125", null when unset. The create and update requests takeinterest_rate_percent, a decimal string with up to three decimal places, in place ofinterest_rate, a number.loan_typeis one ofalternative_financing,sba,commercial_term,commercial_real_estate,residential_mortgage,auto,personalandtransaction, andrate_typeone offixed,sofrandprime, the same lowercase spellings the responses carry.LoanandCreateLoanRequestfollow in all three packages: TypeScript and Python export the two value sets asLoanTypeandRateTypeand type the request’sloan_typeandrate_typewith them; in C#,InterestRate(adecimal?) becomesInterestRatePercent(astring?) andBorrowerName,LoanTypeLabelandLoanSubTypeLabelleaveLoan. -
Embed handle and session lifetimes.
POST /api/embed/iframe-urlstakesexpires_in_seconds, the handle’s own lifetime (60 by default, at most 300), in place ofhandle_lifetime_seconds, andsession_expires_in_seconds, the lifetime of the session the exchange mints (3600 by default, at most 86400), in place oftoken_lifetime_seconds; the iframe handle read names that fieldsession_expires_in_secondstoo.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 andCreateIframeUrlAsync(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 underdata, in place of{ loan_id, snapshots }. It is still at most 200 rows and not paged, sohas_moreis false andnext_cursornull.AttributeVersionsfollows in all three packages (data,has_moreandnext_cursor;Data,HasMoreandNextCursorin C#). The export map,GET /api/chart-of-accounts/export-mapand itsPUT, answers the same envelope with the entries underdata, in place of{ system, entries }. ThePUTbody is still{ system, entries }, and the active export system is read fromactive_export_systemonGET /api/chart-of-accounts(client.chartOfAccounts.get(),client.chart_of_accounts.get()in Python,client.ChartOfAccounts.GetAsync()in C#).ChartOfAccountsExportMapfollows in all three packages. The Bank Statement Account list,GET /api/borrowers/{borrower_id}/extractions/bank-statement-accounts(listBankStatementAccounts,list_bank_statement_accountsin Python,ListBankStatementAccountsAsyncin 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}/memosandGET /api/organizationsanswer{ data, has_more, next_cursor }with their rows underdata. The packages have no methods for them; a raw read of any of the five readsdata. -
statusis no longer accepted on borrower create.POST /api/borrowerstakes the borrower’s fields withoutstatus, and a new borrower always starts ingood_standing.CreateBorrowerRequestdropsstatusin all three packages (Statusin 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
entityobject,{ name, source }, in place ofcompany_name,entity_sourceandapplicant; the Supporting Table’sentityandcpa_engagementare theentityandassuranceobjects; every masked identifier reads under its_last4name (ein_last4,employer_ein_last4,taxpayer_ssn_last4,spouse_ssn_last4,partner_tin_last4,shareholder_tin_last4and the rest), and the_id_kindkeys read_tin_kind;taxpayer_name,holderand the Personal Financial Statement’sentityreadentity_name; the Bank Statement’smessage_customerreadsmessageand itsmatchesreadsmatched_transactions; the Personal Financial Statement’sdetailsreadsdetail; the installment sale row’speriodreadsperiod_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_cand so on), and a W-2 upload answersforms_w2at the root in place ofdata; 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_typeand the rest); under a named parent, a flag’sdetailreadssupporting_transactionsunderunderwriting_flags, a tax statement’spreparerreadssoftwareunderstatements, and every Schedule K-1 box that takes code letters reads_by_codeunderdata(line_11_other_income_by_codeand the rest, on both schedules); the NOL carryforward row’stax_yearis an integer like every othertax_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, thesource_keycopies,proprietor_name,ssn,w2_count,layout,rescued_fields,page_indices,billableand their kin): the document envelope’speriod(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_dateanddue_date, the Debt Schedule’smaturity_dateandoriginal_date, the Personal Financial Statement’sdate_purchased,quotation_dateandsigned_dates, and seventeen keys on the Tax Return such asdate_acquired,date_sold,date_placed_in_serviceands_election_effective_date. A printed date that does not resolve to a full date reads null, with the printed text kept in the_rawsibling (maturity_date_raw). -
currencyon the document envelope. Every document read and list item carriescurrencyright afterperiod, an ISO 4217 code: the code the document prints where it prints one, elseUSD, the platform’s reporting currency. The agings’ bodycurrencyleaves 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,errorandduplicatein place of the upper-case words. The status enums in all three packages follow. -
error_messageon the batch upload rows. The per-file error of the batch presign, the batch confirm and its child jobs readserror_message(the name the job results element already used), soerroris the house error envelope’s object alone. -
document_idon loan document summaries; job results loseextraction_id. A loan’s document summary names its documentdocument_idin place ofextracted_document_id; the job results element dropsextraction_id, the same id as itsdocument_id; and the Bank Statement Account list names each row’s documentdocument_id. The hand written loan document summary model readsdocument_idin all three packages. -
The test event, and an honest body error.
webhook.testis 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 newclient.webhooks.sendTest(endpointId, { livemode })(send_testin Python,Webhooks.SendTestAsyncin C#), which answersdelivery_id,event_ref,livemodeandscheduled_at. The receivers route it toonWebhookTest(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, orid,type,created_at,tenant_id,livemodeordatamissing or of the wrong type) now raisesWebhookBodyError(WebhookBodyExceptionin C#), whose message names the field, in place of the signature error, and the receivers answer400 {"error":"invalid_body"}for it;livemodeis required, and C#‘sWebhookEvent.Livemodeis abool, no longer nullable.WebhookSignatureErroris raised for signature and freshness failures only.
1.1.0 (2026-10-08)
- Pins API version
2026-10-07by default;apiVersion/api_version/ApiVersionstill 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.getBalanceSheetand.listBalanceSheeta balance sheet’s (client.statement_mappings.get_profit_loss/.list_profit_loss/.get_balance_sheet/.list_balance_sheetin Python,client.StatementMappings.GetProfitLossAsync/.ListProfitLoss/.ListProfitLossPages/.GetBalanceSheetAsync/.ListBalanceSheet/.ListBalanceSheetPagesin C#), each statement through its own read pair returning its own object:statusreadyorunmappable, a ready mapping’spayloadwith itsmapping_state(mappedorunfooted), 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_accountsin Python,client.ChartOfAccountsin 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,ProfitLossMappingListand theirBalanceSheetMappingtwins, withStatementMappingNode,StatementMappingLine,StatementMappingSource,StatementMappingPeriodandStatementMappingUnmappedLineshared, a node’ssection,roleandmeasurein the vocabulary of its statement;ChartOfAccounts,ChartOfAccountsExportMapand their parts); every figure stays the two-decimal string the wire carries. A mapping being prepared answers 404 withRetry-After, read asretryAfter/retry_after/RetryAfteron the not-found error; a workspace whose plan lacks the feature answers the permission error with typestatement_mapping_not_in_plan; a save of the export map that crosses another is the conflict error. Theextraction.mappedevent joins the union and the receiver (onExtractionMapped/on_extraction_mapped/OnExtractionMapped), typedExtractionMappedPayloadwithstatement(profit_and_lossorbalance_sheet) naming the read to call: a pointer to the mapping, never the figures. memo.finalizedandmemo.reopenedcarryspread_snapshots, the spread figures the memo was approved on: one entry per entity its figure exhibits cite, each asnapshot_id, itsversionand theentity(keyandname), and an empty list when the memo has none. A pointer, not the figures: read them from the attributes snapshot by id.MemoSpreadSnapshotin TypeScript, where the field is optional for events emitted before it existed;MemoSpreadSnapshotandMemoSpreadSnapshotEntityin C#, whereSpreadSnapshotsis an init property that reads as empty on such an event, so the five-parameter constructors andDeconstructstay as 1.0.0 shipped them. Python reads it withevent.data.get("spread_snapshots", []).unextractable_reasonon 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.failedcarriesmessage, the sentence you can show the uploader when the document could not be extracted and there is one; itsreasonisunextractable.DocumentFailedPayload.messagein TypeScript;DocumentFailedPayload.Messagein C#, an init property, so the six-parameter constructor andDeconstructstay as 1.0.0 shipped them. Python reads it withevent.data.get("message").balance_sourceon bank statement report data (BankStatementReportDatain all three packages):feed_balancewhen 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.entityon attribute versions rows (AttributeEntityin all three packages): the scope a snapshot was built under, itskeyandname, or null.@spreadspace/sdk: an upload or intake submission from a.jsonor.zippath goes out asapplication/jsonorapplication/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 loggederr.messagewould 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_w2sin Python;GetEquityStatementAsync/ListEquityStatementsAsync,GetComprehensiveIncomeStatementAsync/ListComprehensiveIncomeStatementsAsync,GetW2Async/ListW2sAsyncand their...RawAsynctwins in C#), typedEquityStatementReportData,ComprehensiveIncomeStatementReportDataandW2ReportDatain all three packages. The statement lists answer understatements, the W-2 list underforms. On an equity statement a row carries one entry invaluesand one inprintedper 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:
TaxReturnReportDatahas now2variant (in C#, noAsW2()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 underforms_w2are unchanged. formon the two aging lists is typed.listAccountsReceivableAgingsandlistAccountsPayableAgingstakeform?: 'summary' | 'detail'in TypeScript, and the Python methods the same two values as aLiteral; C# keeps a string.
1.0.0 (2026-09-22)
client.intake(client.Intakein C#) submits a document without a modeled borrower throughPOST /api/intake, which needsintake:write.createandconfirm(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.completeIntakebelow finishes the provisioned borrower’s profile.- Typed extraction reads carry, on tax returns,
current_tax_yearandprior_tax_yearon each two-year comparison row (when its activity worksheet prints its own year pair) andwriteinson the Schedule M-1 of Forms 1065, 1120 and 1120-S (the reasons the preparer wrote on an itemize line, each with itsline,label,familyandamount); and, on the freeform debt schedule,reporting_unitandscale_multiplier. borrowers.completeIntake/borrowers.complete_intake/Borrowers.CompleteIntakeAsyncfinishes an intake borrower’s profile throughPOST /api/borrowers/{id}/complete-intake: it takes the business name and moves the borrower out of intake into the curated list. The route needsborrowers:write, and a borrower already completed answers409. TheCompleteIntakeRequestmodel carries the name.memo.finalizedandmemo.reopenedare in the event unions, with typed payloads keyed on the exact wire names:memo_id,loan_id,borrower_id,render_id,finalized_aton the finalize pointer, and the same ids plusreopened_aton the reopen. Both are pointers and fetch nothing:memo.finalizedsays the memo’s final PDF is ready to pull fromGET /api/loans/{loan_id}/memos/{memo_id}/pdf, andmemo.reopenedsays that same PDF is kept but the memo is under revision until the nextmemo.finalized, so mark your copy stale.render_idis nullable on the reopen, for a memo with no final PDF to name. The receiver hands them toonMemoFinalized/on_memo_finalized/OnMemoFinalizedandonMemoReopened/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.extractionswith oneget<Family>/list<Family>spair per document family (getTaxReturn/listTaxReturns, …),getReportData(any category, in the shape of its family) andgetReportDataByCategory. The wire types are exported under the names the OpenAPI document gives them (TaxReturnReportData,BankStatementReportData,<Family>ReportDataListResponse, …), fromsrc/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.extractionswithget_<family>/list_<family>s,get_report_dataandget_report_data_by_category, annotated withTypedDicts fromspreadspace.extractions(3.9-safe). A JSON number arrives asintorDecimal(spreadspace.extractions.Number).SpreadSpace(NuGet):client.ExtractionswithGet<Family>Async/List<Family>sAsyncreturning System.Text.Json records, each with a...RawAsynctwin returningJsonElement?. A family whose schema is aoneOfper form binds to a wrapper carryingRaw, the discriminator and one typedAs<Value>()accessor per shape (the key’s name leads only when the value starts with a digit, as inAsForm1065()); the report-data reads returnJsonElement.
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.exportswithcreate(),get(),list()andcancel().src/helpers/exports.tscarriesExtractionExport(exportId,borrowerId,loanId,format, …),ExportHandle(its.exportgetter),ExportFailedError(default typeexport_failed),ExportTimeoutError(typeexport_timeout),getExtractionExport/cancelExtractionExport,ExportsTransport,ExportWaitOptionsandListExportsParams.spreadspace(PyPI):client.exportswithcreate,get,list(status=...)andcancel(export_id).spreadspace.helpers.exportscarriesExtractionExport,ExportHandle(.export),ExportFailedError(.export),ExportTimeoutError,get_extraction_export/cancel_extraction_export, andwait()with its backoff.SpreadSpace(NuGet):client.ExportswithCreateAsync,GetAsync,List(status, limit, apiVersion)andCancelAsync(exportId).Exports.cscarriesExtractionExport(ExportId,BorrowerId,LoanId,Format, …),ExportHandle(.Id,.Export),ExportFailedException,ExportTimeoutExceptionand the staticExportsclass (GetExtractionExportAsync/CancelExtractionExportAsync).WaitAsyncdefaults: 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:
createtakes what the server requires.borrower_id,loan_idanddocument_idsare required parameters:ExtractionExportParamsin TS (borrowerId: string; loanId: string; documentIds: string[]), keyword-onlystr/Sequence[str]in Python, positionalstring/IEnumerable<string>in C#, so a create the server would answer with 400 does not compile or type-check.formatis optional and typed:ExtractionExportFormat('xlsx' | 'csv' | 'json') /ExportFormat(aLiteral) / theExtractionExportFormatsconstants;statuslikewise asExtractionExportStatus/ExportStatus/ExtractionExportStatuses.- Upload helpers take the loan id (the API requires
loan_idon presign, answering 400 without it).UploadOptions.loanIdis a requiredstringin TS,loan_id: stris keyword-only required onupload_document/client.documents.uploadin Python, andstring loanIdis the second positional parameter of everyUploadAsync/UploadDocumentAsyncoverload in C# (borrowerIdfollows it and stays optional; named-argument call sites are unaffected). A blank value raisesUploadError/ValueError/ArgumentExceptionwith the message “loanId is required: every upload belongs to a loan” before any request is made. exports.list()yieldsExtractionExportrows, mapped the wayget()maps one. The pagers carry the one hook this needs:mapItemon the TSPaginateOptions,map_itemon the Pythonpaginate/PageIterator, and aPageIterator<T>in C# whoseawait foreach/ToListAsyncyield mapped rows whilePagesAsynckeeps the raw envelopes.TERMINAL_EXPORT_STATUSESin the TS index and inspreadspace.helpers.exports, besideTERMINAL_JOB_STATUSES; C#‘s isExtractionExport.TerminalStatuses.- Handles do not serialize their transport. The TS
ExportHandleandJobHandlecarry atoJSON()that writes the last-known export / job status, soJSON.stringify(handle)in a log line cannot leak the API key. The Python and C# handles do not expose theirs. @spreadspace/sdkexports 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_snapshotare annotatedAttributesEnvelope/AttributeVersions/AttributeSnapshot(the transport’s parsed JSON, cast), the receiver’sattributesargument is anAttributeSnapshot(Noneonly whenfetch_attributes=False), and every payload and manifest type ships as a 3.9-safeTypedDict/Literalinspreadspace.attributes, re-exported from the package root.SpreadSpace(NuGet): typed reads.GetAsyncreturnsTask<AttributesEnvelope>,VersionsAsyncreturnsTask<AttributeVersions>andGetSnapshotAsyncreturnsTask<AttributeSnapshot>; a 2xx body that is not the documented shape throwsSpreadSpaceException. The untyped reads areGetRawAsync/VersionsRawAsync/GetSnapshotRawAsync(same parameters,Task<JsonElement?>) for callers who bind it themselves.SpreadFinalizedContext.Attributesis anAttributeSnapshot?, soctx.Attributes!.Payloadreaches the figures without aJsonElementwalk.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 currentfinal. 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, ...)takesstatus,event_typeandloan_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 TypeScriptdeliveriesdefault row type isWebhookDeliverySummary.spreadspace(PyPI):mypy --strictis 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 pinsmypy>=1.10,<2(mypy 2 droppedpython_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 (finalby default, plusmachine, its documents-only twin, anddraft, 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 (retrievein TypeScript,get_snapshotin Python,GetSnapshotAsyncin C#) carries the payload and manifest. All three need thespreads:readscope.extraction.updated,spread.finalizedandspread.reopenedare in the event unions, with typed payloads keyed on the exact wire names:snapshot_id,machine_snapshot_id,version,finalized_aton the finalize pointer;extraction_version,previous_extraction_version,reasonon the rewrite.spread.reopenedis the other half of the finalize pointer: it names thefinalsnapshot 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 nextspread.finalized; the receiver hands it toonSpreadReopened/on_spread_reopened/OnSpreadReopenedand fetches nothing.extraction.ready’s payload type carries theenrichmentstate andextraction_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 rawdata.- 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 aspread.finalizedNAMES on that event, by id, fromGET /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 LATESTfinalsnapshot: 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,AttributesEnvelopeandAttributeVersionsare exported, alongside theattributes.v1payload and manifest types (LoanAttributesPayload,LoanAttributesManifestand the shapes under them), generated from the builder’s own wire shape and gated against drift.loans.attributes.get/.versions/.retrieveand the receiver’sattributescontext are typed by default instead ofunknown; the generics stay, so a caller can still override. - The verifiers take several secrets.
verifyWebhook/verify_webhook_signature/WebhookVerifier.Verifyaccept 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/sessionsandPOST /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/CreateIframeUrlAsynctakeexternalUserIdBEFOREidempotencyKeyin the parameter list; named arguments (the documented style) are the safe call. - No other API surface changes. The pinned default
SpreadSpace-Versionremains2026-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 pinnedBasePathconst). - Spec guard. The public spec build computes reachability and drops
unreferenced schemas, and a
spec-guardjob insdk.ymlfails 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-Agentcarries; each package has a test asserting the two agree, and TypeScript also checkspackage-lock.json. - No API surface changes. The pinned default
SpreadSpace-Versionremains2026-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.