C#
The official .NET SDK, published to NuGet as SpreadSpace. It wraps the
API Reference with a typed exception hierarchy, automatic
retries, idempotency, and cursor pagination exposed as IAsyncEnumerable
(await foreach). Targets net8.0.
Install
Construct the client
The constructor throws when it finds no key, from neither the argument nor the environment variable. Construct the client where a missing key should fail: at startup, or on first use, not in a static initializer every test touches.
Paginate (auto-paged borrowers)
ListTyped returns an IAsyncEnumerable<T> of typed records that
fetches pages on demand until the cursor is exhausted. No has_more / total
bookkeeping; iteration simply stops when next_cursor is null.
The raw List methods remain available and return JsonElement rows.
Create and retrieve a loan
Borrower, loan, and job reads return typed records with their wire identifiers.
Upload a document and wait
Upload mints a presigned URL, PUTs the bytes to that URL, then confirms;
raw bytes never transit a SpreadSpace endpoint body. Every upload belongs to a
loan, so loanId is required; borrowerId is optional. With wait: true the
helper also polls to a terminal job status before returning. Files up to 250 MB
(a PDF over 1,500 pages is refused when processing starts); a ZIP up to 250 MB
whose members are each up to 100 MB uncompressed. Accepted formats: PDF, HTML
(rendered to PDF before classification), ZIP, Excel and JSON.
Terminal job statuses are COMPLETED / FAILED (PENDING / PROCESSING
are in flight). A FAILED job throws UploadException from WaitAsync or
WaitTypedAsync. StatusTypedAsync and WaitTypedAsync add typed results; the
raw StatusAsync and WaitAsync methods remain available.
The SDK accepts a caller-owned operationKey. Persist a unique key before
starting each upload operation and reuse it only when retrying that same
operation with the same file and parameters. Persist the accepted job ID before
waiting so a restart can resume status reads. Presigned URLs expire: reusing the
same key does not make an upload restartable indefinitely or create a new
operation. Once a job is accepted, resume that job rather than upload again.
Submit a document through intake
The intake helper creates a submission, uploads the file, and confirms the
upload, with no borrower or loan modeled first: the API provisions both and
returns their ids on the handle. A path, raw bytes, or a stream is accepted, as
in the upload helper. Requires an API key holding the intake:write scope;
WaitTypedAsync and client.Documents.StatusAsync poll
GET /api/documents/{job_id}/status, which needs documents:read on the same
key. Once your system holds the verified business name,
Borrowers.CompleteIntakeAsync finishes the borrower’s profile; it needs
borrowers:write, and a borrower already completed answers 409.
clientReference groups repeat submissions: the same reference resolves to the
same provisioned loan and borrower instead of creating another pair, and omitting it
creates a fresh pair every time. The operation key yields stable, distinct
create and confirm idempotency keys. After confirm is accepted, resume status
checks with client.Documents.StatusAsync(jobId).
Create an extraction export and wait
An export is a resource you create and poll:
CreateAsync starts it and
returns a handle; WaitAsync polls it to a terminal status. A failed export
throws ExportFailedException; a timeout throws ExportTimeoutException.
borrowerId, loanId and documentIds are required, as they are on the
server. format is json, csv, or xlsx; xlsx is available for bank
statements only, so pass it only when every document in the export is a bank
statement. client.Exports.GetAsync reads one, client.Exports.List(status: ...)
pages through yours as ExtractionExport rows, and client.Exports.CancelAsync
stops one still queued or running. The download link is minted fresh on every
read, so read the export again when you need a new one, or call
client.Exports.GetDownloadLinkAsync(exportId), which returns the link together
with the expiry from that same read.
Read a loan’s finalized attributes
When an analyst finalizes a spread, its figures freeze
as an attribute snapshot. GetAsync returns the latest of a status,
VersionsAsync the history (newest first, capped at 200 and not paged),
GetSnapshotAsync any one snapshot by id. All three are typed: they return
AttributesEnvelope / AttributeVersions / AttributeSnapshot records, whose
Payload and Manifest carry the attributes.v1 shape, so the figures are
reachable without a JsonElement walk. Each read keeps an untyped twin for
callers who bind the body themselves: GetRawAsync, VersionsRawAsync and
GetSnapshotRawAsync, same parameters, returning Task<JsonElement?>.
VersionsAsync takes a status of its own. You get the filtered history, the
first row is the loan’s current one of that status, and the 200 cap applies
after the filter.
Read an extracted document
client.Extractions reads the extracted documents on a
borrower’s file as typed records: one Get<Family>Async / List<Family>sAsync
pair per document family (GetTaxReturnAsync / ListTaxReturnsAsync,
GetBankStatementAsync / ListBankStatementsAsync, GetDebtScheduleAsync /
ListDebtSchedulesAsync, …), named after the family as its object page
titles it. A retrieve returns the family record, bound with System.Text.Json
from the schema its object page
shows; a list returns the { borrower_id, <items>: [...] } envelope with one
row per document. A list takes an optional loanId that scopes the rows to
one loan; the two aging lists also take a form (summary or detail).
Each read has a ...RawAsync twin returning JsonElement?,
and GetReportDataAsync reads any category as JsonElement in the shape of
its family; GetReportDataByCategoryAsync reads the borrower’s newest document
of one category by its slug, on one loan when loanId is given. A family whose schema is one shape per form binds to a wrapper
carrying Raw, the discriminator and one typed As...() accessor per shape;
the accessor is named after the discriminator value (AsSba2202() for
form_variant sba_2202, AsSummary() for report_subtype summary) and
takes the discriminator key in front when the value starts with a digit
(AsForm1065() for form 1065). Every call needs the extractions:read
scope.
Read a statement mapping
A profit and loss statement’s or a balance sheet’s mapping onto the chart of
accounts, read by document id, each statement through its own read:
client.StatementMappings.GetProfitLossAsync(borrowerId, docId) returns a
ProfitLossMapping record and GetBalanceSheetAsync(borrowerId, docId) a
BalanceSheetMapping. The Status is ready or unmappable. A ready mapping
carries a Payload whose Statement names the kind (profit_and_loss or
balance_sheet) and whose MappingState is mapped (the Tree lists every
section and account with its figure per period, and each printed line names
the node it is filed under) or unfooted (the lines are returned as printed
and Foot says by how much each period misses: a profit and loss statement’s
printed bottom line, or on a balance sheet each side’s printed total and the
imbalance between the sides). Every figure is a string with exactly two
decimals, positive in the direction of the node’s Effect; parse with
decimal.Parse when computing.
client.StatementMappings.ListProfitLoss(borrowerId, loanId: ...) pages the
borrower’s profit and loss mappings newest document first as
ProfitLossMapping rows, and ListProfitLossPages(...) yields each envelope
as a ProfitLossMappingList whose PendingDocumentIds names the documents
whose first mapping is being prepared; ListBalanceSheet and
ListBalanceSheetPages page the balance sheets the same way. Each read keeps a
...RawAsync twin returning JsonElement?. All four need extractions:read
and the Statement mapping feature on the workspace’s plan.
A mapping being prepared answers 404 with a Retry-After header: the
NotFoundException carries it as RetryAfter, so read again after that many
seconds. A document with no mapping, or one of the other statement (a balance
sheet on the profit loss read), is a NotFoundException whose RetryAfter is
null. A workspace whose plan does not include the feature is a
PermissionDeniedException whose ErrorType is
statement_mapping_not_in_plan.
The chart of accounts and the export map
client.ChartOfAccounts.GetAsync() reads the workspace’s chart document
(Chart, null until the workspace saves one, when the platform’s accounts
apply), its Version, the version of its caption rules and its
ActiveExportSystem. GetExportMapAsync() reads the active export map: the
workspace’s own code and name for each chart account, in one export system.
ReplaceExportMapAsync(request) replaces that system’s whole map and makes the
system the active one: an account left out leaves the map, at most 1,000
entries, and a save that crosses another throws ConflictException. A mapping
read after a save is stale until it is prepared again with the new codes. The
reads need chart_of_accounts:read, chart_of_accounts:write or
extractions:read; the save needs chart_of_accounts:write.
Verify a webhook signature
Verify the SpreadSpace-Signature header against the exact raw request body
you received on the wire, not re-serialized JSON, or the HMAC won’t match.
VerifyAndParse throws WebhookSignatureException on any failure and otherwise
returns the parsed, typed event with typed As*() accessors.
Receive webhooks
The receiver does the whole loop (verify, dedupe, dispatch, answer) and
on spread.finalized reads the snapshot the event names for you (by id, never
the loan’s latest: see Spreads). Full option list on
the Webhooks page.
Hand it the raw body. A framework that parses JSON first has already changed the bytes the signature covers.
To audit what we sent, client.Webhooks.Deliveries(endpointId, status: …, eventType: …, loanId: …) pages the delivery log filtered. Every row carries
the loan its payload named.
Receive extraction.mapped
extraction.mapped fires once for every mapping of a profit and loss statement
or a balance sheet: the first, and each one made again after the extraction,
the chart of accounts, the caption rules or the export map changed, or the
statement was corrected (Reason is first, extraction, chart,
correction or logic). A pointer, not the figures: Statement says which
statement was mapped, profit_and_loss or balance_sheet, and so which read
returns it, client.StatementMappings.GetProfitLossAsync(payload.BorrowerId, payload.DocumentId) or GetBalanceSheetAsync(...). The receiver dispatches
it to OnExtractionMapped with an ExtractionMappedContext whose Payload is
the typed ExtractionMappedPayload.
Embed
client.Embed.Sessions mints the credentials that authorize an
embedded surface. CreateIframeUrlAsync returns a
single-use SignedUrl for the iframe as an EmbedIframeUrl record.
CreateAsync returns a loan-scoped EmbedToken for a client of your own as an
EmbedSession record, and RevokeAsync ends that session.
Both mints need embed:write on the key, so construct a client with the
embed-minting key, which the samples read from SPREADSPACE_EMBED_API_KEY.
Name scopes on every mint: read scopes are granted by mint authority, and a
write scope is granted only when the request names it and only if the minting
key holds it. Mint from your server covers every field.
Handle errors
Every API error maps to a typed exception. Match on the type; for the stable
machine code read the ErrorType property (the wire error.type), never the
message. Each exception carries RequestId (from the X-Request-ID response
header). Quote it in support tickets.
The HTTP-status exceptions derive from ApiStatusException →
SpreadSpaceException; NetworkException derives from SpreadSpaceException
directly. 429, 5xx, and
transport errors retry automatically with exponential backoff + full jitter;
other 4xx never retry. Tune with MaxRetries on SpreadSpaceClientOptions.
Money is exact
Every money key in report_data is a JSON string with exactly two decimals,
bound to string. Attribute snapshots and export payloads use the same
two-decimal strings. Parse with decimal.Parse(value, CultureInfo.InvariantCulture)
when computing.
Loan requested_amount, extraction query amounts and totals, and EBITDA write-in amounts are also strings.
Create a loan with RequestedAmount = "250000.00"; the allowed amount range is
"0.00" through "999999999999.99". Invalid formats return
400 validation_failed. Parse monetary strings with decimal.Parse when computing.