Skip to navigation

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

dotnet add package SpreadSpace

Construct the client

using SpreadSpace;
// ss_test_ -> isolated sandbox tenant; ss_live_ -> real workspace data (same
// base URL). Omit apiKey to read SPREADSPACE_API_KEY from the environment.
using var client = new SpreadSpaceClient(apiKey: "ss_test_...");

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.

await foreach (var borrower in client.Borrowers.ListTyped())
{
Console.WriteLine(borrower.BorrowerId);
}
// Filters + page size:
await foreach (var b in client.Borrowers.ListTyped(intake: true, limit: 50)) { /* ... */ }
// Loans (optionally scoped to a borrower) and jobs page the same way:
await foreach (var loan in client.Loans.ListTyped(borrowerId: "4a2c8e1f6b3d5a7c9e0f1b2d")) { /* ... */ }
await foreach (var j in client.Jobs.ListTyped()) { /* ... */ }

Create and retrieve a loan

Borrower, loan, and job reads return typed records with their wire identifiers.

var createdBorrower = await client.Borrowers.CreateAsync(new CreateBorrowerRequest { Name = "Example borrower" });
var createdLoan = await client.Loans.CreateAsync(createdBorrower.BorrowerId, new CreateLoanRequest { Name = "Example loan", RequestedAmount = "250000.00" });
var retrievedLoan = await client.Loans.GetAsync(createdLoan.LoanId);
Console.WriteLine(retrievedLoan.LoanId);

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.

var operationKey = "statement-upload-001"; // Persist this before starting the upload.
var job = await client.Documents.UploadAsync(
"/path/to/statement.pdf",
loanId: "7d1e3b5a9c2f4e6b8d0a3c5e",
borrowerId: "4a2c8e1f6b3d5a7c9e0f1b2d",
operationKey: operationKey);
await File.WriteAllTextAsync("accepted-job-id.txt", job.Id);
var completedJob = await job.WaitTypedAsync(timeout: TimeSpan.FromMinutes(10));
Console.WriteLine(completedJob.Status);
// After a restart, read the accepted job instead of uploading again:
var acceptedJobId = await File.ReadAllTextAsync("accepted-job-id.txt");
var resumedJob = await client.Jobs.GetAsync(acceptedJobId);
Console.WriteLine(resumedJob.Status);
// From raw bytes / a stream, fileName + contentType are required:
await client.Documents.UploadAsync(
bytes, loanId: "7d1e3b5a9c2f4e6b8d0a3c5e", fileName: "statement.pdf", contentType: "application/pdf");

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.

// From a path; file name and content type are derived from the path.
var intakeJob = await client.Intake.SubmitAsync(
"/path/to/statement.pdf",
clientReference: "application-123", // groups repeat submissions onto one loan
operationKey: "application-123-document-1"); // persist before the first attempt
Console.WriteLine($"{intakeJob.Id} {intakeJob.LoanId} {intakeJob.BorrowerId}"); // persist all three
var intakeFinished = await intakeJob.WaitTypedAsync(timeout: TimeSpan.FromMinutes(10));
// From raw bytes or a stream, fileName and contentType are required:
await client.Intake.SubmitAsync(
bytes, fileName: "statement.pdf", contentType: "application/pdf");
var intakeBorrower = await client.Borrowers.CompleteIntakeAsync(intakeJob.BorrowerId, new CompleteIntakeRequest { Name = "Acme Corporation" }); // once your system holds the verified name

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.

var exp = await client.Exports.CreateAsync(
borrowerId: "4a2c8e1f6b3d5a7c9e0f1b2d",
loanId: "7d1e3b5a9c2f4e6b8d0a3c5e",
documentIds: new[] { "7a1d4e9c2b3f4a5d8e6c1b0f", "2a3b4c5d6e7f809112345678" },
format: "json");
var done = await exp.WaitAsync(timeout: TimeSpan.FromMinutes(5));
Console.WriteLine(done.Status); // "succeeded"
Console.WriteLine(done.DownloadUrl); // download link, when present

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.

var latest = await client.Loans.Attributes.GetAsync(loanId, status: "final");
var history = await client.Loans.Attributes.VersionsAsync(loanId, status: "final");
var snapshot = await client.Loans.Attributes.GetSnapshotAsync(loanId, snapshotId);

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.

var firstBorrower = (await client.Borrowers.List().ToListAsync())[0];
var firstBorrowerId = firstBorrower.GetProperty("borrower_id").GetString()!;
var listing = await client.Extractions.ListDebtSchedulesAsync(firstBorrowerId);
foreach (var row in listing.Schedules)
Console.WriteLine($"{row.ExtractedDocumentId} {row.ReportData.FormVariant}");
if (listing.Schedules.Count > 0)
{
var schedule = await client.Extractions.GetDebtScheduleAsync(
firstBorrowerId, listing.Schedules[0].ExtractedDocumentId);
// One shape per form variant: narrow with the accessor for the shape the
// schedule is on before reading its fields.
if (schedule.AsSba2202() is { } sba)
Console.WriteLine($"{sba.AsOfDate} {sba.DebtSchedule.Count} liabilities on SBA Form 2202");
else if (schedule.AsFreeform() is { } freeform)
Console.WriteLine($"{freeform.AsOfDate} {freeform.DebtSchedule.Count} liabilities in the filer's own columns");
// Any category, as JsonElement in the shape of its family:
var anyBody = await client.Extractions.GetReportDataAsync(
firstBorrowerId, listing.Schedules[0].ExtractedDocumentId);
Console.WriteLine(anyBody.ValueKind);
}

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.

// One profit and loss statement's mapping, by document id. A mapping being
// prepared throws NotFoundException with RetryAfter set; none at all,
// NotFoundException without it.
try
{
var mapping = await client.StatementMappings.GetProfitLossAsync(firstBorrowerId, "7a1d4e9c2b3f4a5d8e6c1b0f");
Console.WriteLine($"{mapping.Status} {mapping.Stale} {mapping.Payload?.MappingState}");
foreach (var node in mapping.Payload?.Tree ?? [])
{
// Every figure is a two-decimal string, positive in the direction of Effect.
Console.WriteLine($"{node.Kind} {node.Label} {node.Effect} {node.Values[0]} {node.ExternalCode}");
}
foreach (var foot in mapping.Payload?.Foot ?? [])
Console.WriteLine($"{foot.PrintedBottomLine} {foot.Residual} {foot.Tolerance}");
}
catch (NotFoundException ex) when (ex.RetryAfter is { } seconds)
{
Console.WriteLine($"being prepared; read again in {seconds}s");
}
catch (PermissionDeniedException ex) when (ex.ErrorType == "statement_mapping_not_in_plan")
{
Console.WriteLine("the workspace's plan does not include statement mapping");
}
// A balance sheet's mapping, through its own read. Its foot carries each side's
// printed total, the residual on each side and the imbalance between them.
var sheetMapping = await client.StatementMappings.GetBalanceSheetAsync(firstBorrowerId, "3c8b1f5e9a2d4c7b6e0f1a2b");
foreach (var sheetFoot in sheetMapping.Payload?.Foot ?? [])
Console.WriteLine($"{sheetFoot.PrintedTotalAssets} {sheetFoot.AssetsResidual} {sheetFoot.LiabilitiesAndEquityResidual} {sheetFoot.Imbalance}");
// Every profit and loss mapping on the borrower's file, newest document first.
// Each envelope also names the documents whose first mapping is being prepared;
// ListBalanceSheetPages pages the balance sheets the same way.
await foreach (var page in client.StatementMappings.ListProfitLossPages(firstBorrowerId, loanId: "7d1e3b5a9c2f4e6b8d0a3c5e"))
{
foreach (var row in page.Data)
Console.WriteLine($"{row.DocumentId} {row.Status} {row.Payload?.MappingState}");
Console.WriteLine(string.Join(",", page.PendingDocumentIds));
}

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.

var chart = await client.ChartOfAccounts.GetAsync();
Console.WriteLine($"{chart.Version} {chart.RulesVersion} {chart.ActiveExportSystem}"); // Version is 0 until the workspace saves a chart
var exportMap = await client.ChartOfAccounts.GetExportMapAsync();
Console.WriteLine($"{exportMap.System} {exportMap.Entries.Count}");
// The whole map for one export system; an account left out leaves the map.
var savedMap = await client.ChartOfAccounts.ReplaceExportMapAsync(new SaveChartOfAccountsExportMapRequest
{
System = "ledger_one",
Entries =
[
new ChartOfAccountsExportMapEntry { AccountId = "pl.revenue.sales", ExternalCode = "4000", ExternalLabel = "Sales" },
new ChartOfAccountsExportMapEntry { AccountId = "pl.opex.rent", ExternalCode = "6100", ExternalLabel = "Rent expense" },
new ChartOfAccountsExportMapEntry { AccountId = "bs.current_assets.cash", ExternalCode = "1000", ExternalLabel = "Cash" },
],
});
Console.WriteLine(string.Join(",", savedMap.Entries.Select(entry => entry.ExternalCode)));

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.

using SpreadSpace;
using var client = new SpreadSpaceClient(); // reads SPREADSPACE_API_KEY
try
{
var ev = WebhooksResource.VerifyAndParse(rawBody, signatureHeader, "whsec_...");
if (ev.Type == "job.completed")
Console.WriteLine(ev.AsJobCompleted().JobId);
if (ev.Type == "spread.finalized")
{
// The snapshot this event names, by id, not "the latest final".
var p = ev.AsSpreadFinalized();
var snapshot = await client.Loans.Attributes.GetSnapshotAsync(p.LoanId, p.SnapshotId);
}
}
catch (WebhookSignatureException) { /* reject with 400 */ }
// Verify-only: WebhooksResource.VerifySignature(rawBody, signatureHeader, "whsec_...");

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.

var receiver = client.Webhooks.CreateReceiver(new WebhookReceiverOptions {
Secrets = new[] { secret },
OnSpreadFinalized = async ctx => await SaveFinalizedFiguresAsync(ctx.Payload.LoanId, ctx.Attributes),
OnSpreadReopened = async ctx => await MarkFinalizedFiguresStaleAsync(ctx.Payload.SnapshotId, ctx.Payload.ReopenedAt),
});
// ASP.NET minimal API
app.MapPost("/webhooks/spreadspace", async (HttpRequest request, CancellationToken ct) =>
{
var raw = await new StreamReader(request.Body).ReadToEndAsync(ct);
var headers = request.Headers.ToDictionary(h => h.Key, h => h.Value.ToString());
var receipt = await receiver.HandleAsync(raw, headers, ct);
return Results.Content(receipt.Body, "application/json", statusCode: receipt.StatusCode);
});

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.

var mappingReceiver = client.Webhooks.CreateReceiver(new WebhookReceiverOptions {
Secrets = new[] { secret },
OnExtractionMapped = async ctx =>
{
// A pointer: read the mapping through the read Statement names, where your own scope and plan apply.
if (ctx.Payload.Statement == "balance_sheet")
{
var bsMapping = await client.StatementMappings.GetBalanceSheetAsync(ctx.Payload.BorrowerId, ctx.Payload.DocumentId);
Console.WriteLine($"{ctx.Payload.Reason} {bsMapping.Status} {bsMapping.Payload?.MappingState}");
}
else
{
var plMapping = await client.StatementMappings.GetProfitLossAsync(ctx.Payload.BorrowerId, ctx.Payload.DocumentId);
Console.WriteLine($"{ctx.Payload.Reason} {plMapping.Status} {plMapping.Payload?.MappingState}");
}
},
});

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.

using var embedClient = new SpreadSpaceClient(Environment.GetEnvironmentVariable("SPREADSPACE_EMBED_API_KEY"));
var handle = await embedClient.Embed.Sessions.CreateIframeUrlAsync(
"7d1e3b5a9c2f4e6b8d0a3c5e",
"spreading",
scopes: new[] { "documents:read", "extractions:read", "spreads:read" },
externalUserId: "usr_1234");
Console.WriteLine($"{handle.SignedUrl} {handle.ExpiresAt}"); // return SignedUrl to the page
var session = await embedClient.Embed.Sessions.CreateAsync(
"7d1e3b5a9c2f4e6b8d0a3c5e",
scopes: new[] { "documents:read", "extractions:read", "spreads:read" },
externalUserId: "usr_1234");
Console.WriteLine($"{session.SessionId} {session.Persistence}"); // "user" when an id was sent
await embedClient.Embed.Sessions.RevokeAsync(session.SessionId);

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.

try
{
await foreach (var b in client.Borrowers.List()) { /* ... */ }
}
catch (RateLimitException ex)
{
Console.WriteLine($"rate limited; retry after {ex.RetryAfter}s (request {ex.RequestId})");
}
catch (SpreadSpaceException ex)
{
Console.WriteLine($"{ex.ErrorType}: {ex.Message} (request {ex.RequestId})");
}
ExceptionHTTP
BadRequestException400
AuthenticationException401
PermissionDeniedException403
NotFoundException404
ConflictException409
RateLimitException429 (honors Retry-After)
InternalServerException5xx
NetworkExceptiontransport failure (no HTTP response)

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.

decimal amount = decimal.Parse(element.GetProperty("amount").GetString()!,
System.Globalization.CultureInfo.InvariantCulture);