> This page is for SDKs.

> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.spreadspace.app/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.spreadspace.app/_mcp/server.

# C#

> The official .NET SDK, SpreadSpace on NuGet: typed exceptions, retries, idempotency, and IAsyncEnumerable pagination. Targets net8.0.

The official .NET SDK, published to NuGet as **`SpreadSpace`**. It wraps the
[API Reference](/api) with a typed exception hierarchy, automatic
retries, idempotency, and cursor pagination exposed as `IAsyncEnumerable`
(`await foreach`). Targets `net8.0`.

## Install

```bash
dotnet add package SpreadSpace
```

## Construct the client

```csharp
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.

```csharp
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.

```csharp
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, `PUT`s 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.

```csharp
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`.

```csharp
// 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.

```csharp
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](/api/spreads). `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.

```csharp
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](/api/api-reference/debt-schedules/object)
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.

```csharp
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`.

```csharp
// 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`.

```csharp
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.

```csharp
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](/api/spreads)). Full option list on
the [Webhooks page](/api/webhooks#receive-events-with-the-sdk).

```csharp
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`.

```csharp
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](/embed/overview). `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](/embed/server) covers every field.

```csharp
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.

```csharp
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})");
}
```

| Exception                 | HTTP                                 |
| ------------------------- | ------------------------------------ |
| BadRequestException       | 400                                  |
| AuthenticationException   | 401                                  |
| PermissionDeniedException | 403                                  |
| NotFoundException         | 404                                  |
| ConflictException         | 409                                  |
| RateLimitException        | 429 (honors `Retry-After`)           |
| InternalServerException   | 5xx                                  |
| NetworkException          | transport 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.

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