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

1using SpreadSpace;
2
3// ss_test_ -> isolated sandbox tenant; ss_live_ -> real workspace data (same
4// base URL). Omit apiKey to read SPREADSPACE_API_KEY from the environment.
5using 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)

List endpoints return a PageIterator, an IAsyncEnumerable<JsonElement> that fetches pages on demand until the cursor is exhausted. No has_more / total bookkeeping; iteration simply stops when next_cursor is null.

1await foreach (var borrower in client.Borrowers.List())
2{
3 Console.WriteLine(borrower.GetProperty("id").GetString());
4}
5
6// Filters + page size:
7await foreach (var b in client.Borrowers.List(intake: true, limit: 50)) { /* ... */ }
8
9// Loans (optionally scoped to a borrower) and jobs page the same way:
10await foreach (var loan in client.Loans.List(borrowerId: "abc123")) { /* ... */ }
11await foreach (var j in client.Jobs.List()) { /* ... */ }

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. With wait: true the helper also polls to a terminal job status before returning. Files up to 100 MB; a ZIP’s members up to 50 MB each.

1var job = await client.Documents.UploadAsync(
2 "/path/to/statement.pdf",
3 borrowerId: "abc123",
4 wait: true,
5 timeout: TimeSpan.FromMinutes(10));
6Console.WriteLine(job.Id);
7
8// From raw bytes / a stream, fileName + contentType are required:
9await client.Documents.UploadAsync(
10 bytes, fileName: "statement.pdf", contentType: "application/pdf");

Terminal job statuses are COMPLETED / FAILED (PENDING / PROCESSING are in flight). A FAILED job throws UploadException from WaitAsync.

Create an extraction export and wait

Long-running work is modeled as an async operation: CreateAsync enqueues it and returns a handle; WaitAsync polls to a terminal status. A failed operation throws AsyncOperationException; a timeout throws AsyncOperationTimeoutException. 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.

1var exportOp = await client.Exports.CreateAsync(borrowerId: "abc123", format: "json");
2
3var op = await exportOp.WaitAsync(timeout: TimeSpan.FromMinutes(5));
4Console.WriteLine(op.Status); // "succeeded"
5Console.WriteLine(op.ResultUrl); // 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.

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

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.

1using SpreadSpace;
2
3using var client = new SpreadSpaceClient(); // reads SPREADSPACE_API_KEY
4
5try
6{
7 var ev = WebhooksResource.VerifyAndParse(rawBody, signatureHeader, "whsec_...");
8 if (ev.Type == "job.completed")
9 Console.WriteLine(ev.AsJobCompleted().JobId);
10 if (ev.Type == "spread.finalized")
11 {
12 // The snapshot this event names, by id, not "the latest final".
13 var p = ev.AsSpreadFinalized();
14 var snapshot = await client.Loans.Attributes.GetSnapshotAsync(p.LoanId, p.SnapshotId);
15 }
16}
17catch (WebhookSignatureException) { /* reject with 400 */ }
18
19// 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.

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

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.

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.

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

All derive from ApiStatusExceptionSpreadSpaceException. 429, 5xx, and transport errors retry automatically with exponential backoff + full jitter; other 4xx never retry. Tune with MaxRetries on SpreadSpaceClientOptions.

Money is exact

Monetary values decode losslessly to System.Decimal (via JsonElement.GetDecimal() on the raw number token); there’s no float64 intermediate, so cents are preserved exactly.

1decimal amount = element.GetProperty("amount").GetDecimal();