> ## Documentation Index
> Fetch the complete documentation index at: https://docs.trycactus.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Start an underwriting

> Upload documents, assign their roles, and pass the same setup options as the app.

The Partner API starts the same underwriting workflow as Cactus's automated
underwriting setup page. You supply the property address and asset class
instead of choosing an existing app deal.

## 1. Register and upload each document

For each file, call `POST /v1/documents` with its filename, `declared_type`,
and exact `byte_size`. Save the returned `id`, then `PUT` the file's raw
bytes to `upload.url`, using the returned upload headers. Registration alone
does not upload the file. See the [upload walkthrough](/quickstart#1-register-the-document).

Repeat for the rent roll, T-12, and any other documents. You do **not** need
to call `/v1/extractions` first; underwriting reads the documents itself.
Already uploaded documents can be reused in later runs.

## 2. Start the underwriting

Send `documents` as objects containing a `document_id` and its `role`.
There is no `document_ids` field. Each role corresponds to a document slot
on the app's setup page.

```bash theme={null}
curl -X POST https://api.trycactus.com/v1/underwritings \
  -H "Authorization: Bearer $CACTUS_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: duet-underwriting-001" \
  -d '{
    "property": {
      "address": {
        "line1": "904 W Montana St",
        "city": "Chicago",
        "state": "IL",
        "postal_code": "60614"
      },
      "asset_class": "multifamily"
    },
    "end_user_ref": "customer-123",
    "documents": [
      {"document_id": "doc_rent_roll", "role": "rent_roll"},
      {"document_id": "doc_t12", "role": "t12"}
    ],
    "options": {
      "delivery": "private",
      "perspective": "broker",
      "mode": "medium",
      "notes": "Evaluate property taxes after reassessment."
    }
  }'
```

| App setup control | API field | Available values |
| - | - | - |
| Perspective | `options.perspective` | `broker` (default). Investor is coming soon in the app and is rejected by the API. |
| Mode | `options.mode` | `light`, `medium` (default), `heavy` |
| Notes | `options.notes` | Optional text, up to 10,000 characters; outer whitespace is trimmed |
| Document slots | `documents[].role` | `rent_roll`, `t12`, `financial_model`, `offering_memorandum`, `supporting` (the app's Other slot) |

At least one document is required, with at most one file per role and no
file selected twice. Rent rolls, T-12s, and supporting documents accept PDF,
XLS, XLSX, and XLSM; financial models accept spreadsheets only; offering
memoranda accept PDF only. Occupancy Report and Management Summary remain
coming-soon slots in the app; they are not separate API roles.

Prefer explicit roles. If omitted, the API infers a role from the uploaded
document's `declared_type`. To upload a financial model, register it as
`other_standard` or `other_complex`, then select `role: "financial_model"`
here. The document's declared type and its role in a particular run are
separate fields.

Return targets are not exposed by this API. Use `options.delivery: "private"`
for an embedded integration: `end_user_ref` is required, `notification_email`
is optional, and completion creates no public link and sends no email.
The default, `public_link`, requires `notification_email` and creates and
emails a public report link. Supply a stable opaque `end_user_ref` to isolate
each customer's analyses. Reuse the same idempotency key when retrying the
same underwriting request; change it for a new run.

## 3. Wait for the result

The response is `202 Accepted` with an `uw_...` ID. Poll
`GET /v1/underwritings/{id}` using `poll_after_seconds`, or receive
`underwriting.completed` / `underwriting.failed` through a
[webhook](/webhooks). Check both terminal statuses.

On completion, the response includes structured `results` and
`report.excel_url`; `report.url` is included only for public-link delivery. Call `GET /v1/underwritings/{id}/model` for a fresh
workbook download URL. On failure, inspect `error` for the reason.

Sandbox keys return example results without running the AI workflow or
sending email. Use a live key to validate actual document processing.

## Customer ownership and access

Reuse the same `end_user_ref` for every underwriting belonging to a customer.
Each run receives a separate `uw_...` ID. Store both in your backend:

| Your customer reference | Cactus underwriting ID |
| - | - |
| `customer-123` | `uw_first_run` |
| `customer-123` | `uw_second_run` |
| `customer-456` | `uw_third_run` |

References are scoped to your partner account, so another partner using the
same reference does not share your customer's organization. If a customer
is a company with several users sharing analyses, use a stable customer
account identifier and enforce individual users' permissions in your app.

`end_user_ref` and the underwriting ID are identifiers, not credentials.
Your API key authorizes access to your partner's underwritings, including
those belonging to different customers. Keep it on your server. Derive the
customer reference from your authenticated session and verify a run's
ownership before returning it to a browser. The list filter is a convenience;
it does not authenticate an end user. The single-run GET endpoint currently
checks partner ownership, not a caller-supplied end-user reference.

For integrations serving multiple customers, ask Cactus to enable
`requires_end_user_ref` on your partner account. This rejects starts without
a customer reference; it does not replace authorization when viewing a run.

## Embed a private report

Your backend authenticates the customer and checks their permission to view
this underwriting. It then requests a session with your **server-side API key**:

```bash theme={null}
curl -X POST https://api.trycactus.com/v1/underwritings/uw_.../embed-sessions \
  -H "Authorization: Bearer $CACTUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"end_user_ref":"customer-123","parent_origin":"https://app.partner.example"}'
```

Cactus checks that the underwriting belongs to your partner account and the
exact customer reference, that its results are complete, and that the parent
origin is registered on your account. Ask Cactus to register each exact HTTPS
origin before integrating; wildcards and arbitrary origins are rejected.
Use an API key with the `underwriting` scope. Sandbox example underwritings
have no underlying app report and cannot be embedded.

The `201` response contains `id`, `underwriting_id`, `url`,
`launch_expires_at`, and `session_ttl_seconds` (900). Return only the launch
URL and session ID to your authorized browser, with `Cache-Control: no-store`.
Set the iframe's `src` to that URL immediately:

```html theme={null}
<iframe
  title="Underwriting results"
  src="URL_RETURNED_BY_YOUR_BACKEND"
  referrerpolicy="no-referrer"
  style="width:100%;height:80vh;border:0"
></iframe>
```

The fragment contains a **single-use launch ticket**, valid for 60 seconds.
The frame removes it from its URL and exchanges it for a report-only session
held in memory with a renewable 15-minute access lease. No Cactus login or third-party cookies are
required. Never log or persist the launch URL, put your API key in the browser,
or use either customer/run identifier as a credential. Derive `end_user_ref`
from your authenticated session rather than trusting browser input. The
registered origin restricts framing; it does not authenticate a customer.

This is the app's eight-chapter report in a read-only presentation. Editing,
AI follow-up, and file downloads are not exposed in the iframe. Your backend
can still use the authenticated model endpoint if you want to provide a
separate download. A new session reads the current report, following any
completed revisions in the same report family; an already open frame keeps
its loaded snapshot.

A page reload requires a **new** launch URL because the browser credential is
held only in memory. Session creation deliberately does not replay an
idempotent response: each successful request creates a new single-use ticket.

### Keep an authorized viewer's report open

The report can stay open indefinitely while your application continues to
validate the viewer. Your backend renews its access lease every five minutes:

```bash theme={null}
curl -X POST \
  https://api.trycactus.com/v1/underwritings/uw_.../embed-sessions/emb_.../renew \
  -H "Authorization: Bearer $CACTUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"end_user_ref":"customer-123","parent_origin":"https://app.partner.example"}'
```

The response is `200` with `id` and `expires_at`. The same iframe and token
keep working; no navigation, report reload, or scroll reset occurs. The frame
observes the renewed deadline on its normal access checks. Renewal cannot
revive a revoked session or extend an unused launch ticket.

Implement a small authenticated route in **your backend** which:

1. Reads the currently signed-in customer and rechecks their permission to
   view the underwriting. Never trust a browser-supplied customer reference.
2. Verifies that the requested embed session belongs to that customer/run.
3. Calls the renewal endpoint using your server-side Cactus key and the
   configured parent origin.

Call that route from your host page every five minutes and when the tab
returns to the foreground. For example (the route below is yours to implement,
not a Cactus endpoint):

```js theme={null}
const renew = async () => {
  const response = await fetch("/your-backend/underwriting-embed/renew", {
    method: "POST",
    credentials: "same-origin",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ underwritingId, sessionId }),
  });
  // Your backend returns 401/403 when the user is no longer authorized.
  if (response.status === 401 || response.status === 403) {
    iframe.remove();
  }
};
const timer = setInterval(() => void renew(), 5 * 60_000);
const resume = () => {
  if (document.visibilityState === "visible") void renew();
};
document.addEventListener("visibilitychange", resume);
// On page teardown: clearInterval(timer) and removeEventListener.
// On logout/access removal: remove the iframe and revoke its session below.
```

Keep your normal CSRF protection on this host route. Retry transient failures;
the console retries once a minute after a failed renewal. A successful fresh
backend authorization can resume a lease that expired while a laptop was
asleep. The frame reconnects automatically using its in-memory credential.
It hides the report while the lease is expired, and never extends its own
permission just because its tab remains open.

The lease limits access when the partner stops confirming authorization
(for example after logout, permission removal, or an abandoned tab). A
transient network error or 5xx response does not immediately close a report
whose last confirmed lease is still valid.

To revoke a launch ticket and its session (for example on logout), call:

```bash theme={null}
curl -X DELETE \
  https://api.trycactus.com/v1/underwritings/uw_.../embed-sessions/emb_... \
  -H "Authorization: Bearer $CACTUS_API_KEY"
```

The response is `204`. Every subsequent access is rejected. The frame checks
access every 15 seconds and clears the report on revocation or expiry
(browser background throttling can delay the visual update). Disabling the
partner, revoking/expiring the originating key, removing its scope or the
registered origin also invalidates access. Revocation cannot erase data a
viewer has already copied or captured.

Existing `report.url` links remain public bearer links, readable by anyone
holding them until sharing is revoked. Creating or revoking an embed session
does not disable an existing public link. Use private delivery **when starting
new underwritings** to avoid creating one.

## Test in the admin console

Open **API Console → Embedded report**, choose a completed underwriting with
an end-user reference, and select **Open embedded report**. The console calls
the public session API from its server and loads the returned URL in a real
iframe. **Revoke session** leaves the frame open so you can see its access
check clear the report. Sessions renew through the console's authenticated
server action while the page remains open, without reloading the iframe.
Changing the customer reference demonstrates the
ownership check (404). New underwritings in the console default to private
delivery.

## Shared results components

The embed composes `BrokerSharedReport` / `SharedDealJourneyWorkspace` from
the same eight production chapter components used by the signed-in result
workspace. Chapter layouts, charts, tables, and presentation fixes therefore
carry through after deployment. It does not copy a separate set of chapters.
Owner-only controls (editing, Sage, sharing, history) remain outside this
composition. New data fields need to be explicitly added to the safe shared
report projection before the embed can display them.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.