Developers

API & MCP guide

Send a multifamily rent roll (Excel, CSV or PDF); get back one row per unit, a unit mix summary, and a report that checks the extraction against the figures the document itself prints. Send a T12 operating statement; get back its last 12 months of line items, each in a standard underwriting category, with a category summary checked against the statement's own EGI, operating expenses and NOI. Use it from your code (REST) or from an AI assistant (MCP), for your own documents or, with a workspace per client or per team, for other people's.

Start here

Two ways to use the API. Pick yours; the rest of this page is the reference for both.

Inside your company

Your own software sends your company's documents

  1. In the console, under Developers, make a company key. Name it after what will use it (for example "Production server"). It is shown once: store it on your server.
  2. Send a document: the quick start below is the whole call. The result is in the response.
  3. For many files, or to be called back instead of waiting: queued jobs and webhooks.

You can skip everything about workspaces. If a key may have been seen by the wrong person, replace it: one step, and the old one stops working.

In your product, for your clients

Your product sends documents for your customers

  1. Make one company key in the console and keep it on your server. Your customers never see it and need no QuickData account.
  2. With each document, send your own id for the customer in the X-QD-Customer header. It is a label on the document: nothing is created and nothing has to be set up, whether you have six customers or sixty thousand.
  3. Read usage by customer whenever you bill: GET /v1/usage/summary?group_by=customer, or the console's Usage page. Everything stays on your one invoice.

The whole flow, with the calls: serving your own customers. Your system decides who may use it; QuickData counts for whom. A workspace is for something else: a team or a client that gets a key of its own.

1. Authentication

Every call carries an API key: Authorization: Bearer rrx_live_…. Create keys in the console (Developers) or with POST /v1/keys (one per integration, each with a name, so you can revoke or replace one without touching the others); a key is shown once, when created. Keep keys on your server — never in a browser or a mobile app.

KeyWhat it can do
Organization key
(the console calls it a company key)
Extract in any workspace (named per call with the X-QD-Workspace header; the default workspace without it) and manage the organization: workspaces, members, keys, webhooks, settings. Only an owner creates one.
Workspace keyExtract in its one workspace, and read that workspace's results, usage and settings. Nothing else: it cannot see other workspaces or the organization's volume. The key for one client's or one team's integration.
A member's own keyWhat that person can do, where they can do it. It stops working when they leave the organization.

An account that never creates a workspace has one (the default) and one kind of key: everything on this page works without thinking about workspaces. AI assistants that connect through a sign-in (Claude, ChatGPT, Grok, Gemini apps) don't need a key: they sign in with your QuickData account. See MCP.

2. Quick start

curl -X POST https://data.quickdata.ai/v1/extract \
  -H "Authorization: Bearer $QD_KEY" \
  -F "file=@rent_roll.xlsx"

This call is synchronous: the result is in the response. Most files take 3–15 seconds; a file the checks hold for review can take up to about 90 seconds while it is rechecked. Set your client timeout to 180 seconds. To send many files, or to be called back instead of waiting, use queued jobs.

3. Extract a rent roll

POST/v1/extract
Field (multipart)RequiredMeaning
fileyesThe rent roll: .xlsx .xlsm .xls .csv .pdf, up to 25 MB. Multi-sheet workbooks are fine: the rent roll sheet is found automatically.
verifynotrue (default) also checks each unit against its own row in the document. false is a little faster.
referencenoYour own id for the document (a deal, a property, your client's file; up to 200 characters). It comes back in the result, is kept in your usage log, and can be filtered and totalled on (usage).
metadatanoA small JSON object of your own (up to 2,000 characters), returned with the result and in webhooks. QuickData never reads it.
HeaderMeaning
X-QD-WorkspaceThe workspace the document is read for: its id (ws_…) or your own id for it. Optional: without it, the key's own workspace, or the default one. ?workspace= does the same.

POST /v1/extract/rent_roll is the same call addressed by document type; operating statements go to /v1/extract/t12. Not sure which it is? POST /v1/extract/auto tells a rent roll from a T12 by the file itself and returns that type's result, with detected: {"doc_type", "auto": true, "why"}. With the optional field expected (rent_roll or t12: what your user said it is), that type is kept unless the file is plainly the other. A file sent as the wrong type fails (not billed) with an error saying what it looks like.

Every document that is read is counted. Nothing of a document that reads cleanly is stored (what is kept), not even a fingerprint of the file, so the service cannot tell that a file was sent before: the same file sent again is read again, with your settings as they are then, and counted again. A document that cannot be read (report.status failed) is not counted. usage.cached is always false and stays for programs that read it. Your allowance is in usage on every result (free_documents_remaining, documents_this_period) and, on POST /v1/extract, in the response headers X-Free-Documents-Remaining and X-Documents-This-Period; a workspace key and a member's key get billed only, since the totals are the organization's.

4. The response

{
  "rows":     [ { "unit_num": "101", "unit_type": "A1", "sqft": "725", ... } ],
  "unit_mix": { "by_type": [ ... ], "totals": { ... } },
  "report":   { "status": "ok", "file": "rent_roll.xlsx", "format": "xlsx", "elapsed_s": 2.2,
                "validation": { "passed": true, "review_reasons": [], "checks": [
                    { "check": "total_units", "extracted": 23, "document": 23, "match": true, "diff": 0.0 }, ... ],
                  "findings": [ ... ], "rent_confirmed": { "by": "document", "note": "..." } },
                "totals": { "extracted": { ... }, "document": { ... }, "where_the_document_prints_them": { ... } },
                "unit_check": { "units_checked": 23, "units_to_look_at": [] }, "source": { ... }, ... },
  "usage":    { "billed": true, "cached": false, "free_documents_remaining": 0, "documents_this_period": 42 },
  "workspace": { "id": "ws_k3J9…", "name": "Default", "external_id": null },
  "reference": "deal-77", "metadata": { "analyst": "kim" }          // only when you sent them
}

Rows — one per unit

Every value is a string ("" when the document has none); dates are ISO YYYY-MM-DD; money and areas are plain numbers without separators.

FieldMeaning
unit_numUnit number as printed
unit_typeFloor plan / unit type. When the document lists none and units differ in size, the square footage ("800 SF")
sqft, br, bathSquare feet, bedrooms, bathrooms (where the document prints them)
occupancyOCC — a resident is in place, including on notice · VAC — vacant, model, down, employee, applicant-only, or pre-leased with move-in after the report date
tenantResident name (blank for vacant units)
move_in, move_out, lease_start, lease_endDates
rent_marketMarket rent
rent_tenantRent billed to the resident (month-to-month rent included). Vacant units carry none.
rent_gov_subsidyGovernment subsidy / housing assistance (HAP)
rent_totalTotal rent charge = rent_tenant + rent_gov_subsidy (never other charges)
is_mtm, mtm_chargeMonth-to-month lease ("true") and the month-to-month fee, which is a charge, not rent
model_employee_down_unit"yes" for a model, employee, office or down unit (counted vacant unless your setting counts them as the rent roll does); "" otherwise
other_chargesEvery other charge code on the unit: [{"code": "Pet Rent", "amount": "25"}, ...] — parking, pets, utilities, amenity rent, concessions (negative)

Unit mix

unit_mix.by_type has one line per unit type (sorted by beds, baths, size); unit_mix.totals the property. Numbers are numbers; null when the document doesn't print the figure.

FieldMeaning
unit_type, beds, bathsThe type; beds and baths from the rows or the type's name
avg_sqft, units, mix_pct, occupiedAverage SF, number of units, share of the property (%), occupied units
avg_rent_in_place, total_rent_in_placeIn-place rent = total rent charge (resident + subsidy), averaged over the units that carry one; the total equals the report's total rent charge
avg_rent_market, total_rent_marketMarket rent, averaged and summed
totals.total_sqftTotal rentable square feet

Report

FieldMeaning
statusok, needs_review or failed — see below
validation.checksEach figure compared with the document's own: total_units, occupied_units, vacant_units, total_charges (total rent charge) decide the status; total_market_rent, total_tenant_rent, total_gov_subsidy are for reference. Each has extracted, document (null when the document doesn't print it), match, and a note when a difference is fully explained (e.g. the document counts a model unit as occupied). A check that fails because the document disagrees with itself carries document_issue with a note saying so: its unit list is shorter than the count its own summary prints, every listed unit was read and the rent matches the document's total — e.g. "the document lists 236 units; its summary counts 292; 56 occupied units are on no row". Show the note in place of the two numbers. The status stays needs_review.
validation.review_reasonsWhy a document needs review, in plain words
validation.findingsThings worth knowing that don't hold the file — units past their move-out date still counted as occupied, an occupied unit with no rent printed
validation.charge_codes, unit_types, column_totals, status_countsWhen the document prints a charge-code summary, a unit-type summary, a totals row or a status summary: each of its lines against the units (extracted, document, match)
validation.rent_confirmedHow the tenant rent was confirmed: by is "document" (it matches a rent total the document prints), "review" (the document prints none; a second reading checked the rent), or null, with a note. An ok document is shown as Reconciled only with "document"; otherwise as Checked or Rent not confirmed (see Status)
validation.spot_check_unitsUnits the per-unit check flagged in a document whose figures match — worth a glance, not holding the file
validation.sectionsA report that stacks several properties: the checks for each
rent_noteWhere tenant rent was read when the rent roll has no plain rent column — e.g. "Tenant rent is taken from 'Scheduled Charges' because this rent roll has no rent column."
settingsThe settings the document was read with (its workspace's, or the organization's)
totalsThe extracted and printed figures, and where the document prints them
unit_check, unit_checksThe per-unit check against each unit's own row (units_checked, units_to_look_at), and how many units each check noted (by_check)
double_checkUnits the extraction had a reason to doubt (an occupied unit with no rent read while its own lines print one, a resident that is a date, a unit number like no other), read a second time: units_checked; corrected, rents the first reading left blank, filled in from the cell that prints them and listed in low_confidence as corrected; left_out, records that are not units (a page header or footer); to_check, units highlighted in low_confidence. Present only when a unit was in doubt
second_pass, reviewWhen the first reading didn't match, what was rechecked and why a file is held — e.g. "This file is a move-in report, not a rent roll"
notesAnything else about the file in plain words — e.g. a workbook with several rent roll tabs, and which one was read
low_confidenceCells to double-check (row = position in rows) and document lines that may be units but were not extracted
sourceThe sheet, header row and columns the rows were read from

Status

StatusWhat it meansBilled
okUnit, occupied and vacant counts and the total rent charge match every figure the document prints (exactly, or with a stated reason). The screens and the Excel file call it Reconciled only when the tenant rent matches a rent total the document prints; when the document prints none, Checked (a second reading checked the rent) or Rent not confirmed (validation.rent_confirmed)yes
needs_reviewOne of those disagrees, or the document prints none of them and a unit looks off. review_reasons says what to look at; the rows are still returned.yes
failedNo unit table could be readno

5. Extract a T12 (operating statement)

POST/v1/extract/t12 multipart file: .xlsx .xls .csv .pdf, up to 25 MB · one document

The monthly statement is found on its sheet, the last 12 months are kept (a statement with more months: the most recent 12; fewer, or months with no activity: extracted and marked needs_review), and every account line gets one standard category — decided by its name, with the heading it sits under as a clue. Headings, subtotals and totals are recognised and carry no category. The goal is to reconcile the statement: every line above its Net Operating Income is counted as the statement counts it (interest income included); only lines printed below NOI — debt service, capital items, depreciation — are kept with an empty category and counted in neither total. A line a person should look at (one that underwriting often leaves out, a line the statement prints under expenses that QuickData counts in revenue or the reverse, an uncertain category) is marked with the reason in review_note.

The response

{
  "doc_type": "t12",
  "columns":  ["2023-10-01", "2023-11-01", ..., "2024-09-01"],
  "period":   { "start": "10-2023", "end": "09-2024", "months": 12, "label": "10-2023 through 09-2024" },
  "rows": [
    { "label": "4000-0000 Gross Potential Rent", "canonical_label": "Gross Potential Rent (GPR)", "custom_label": "",
      "is_subtotal": false, "values": ["290854", "273999", ...], "total": "3167690.96", "document_total": "3167690.96",
      "review_note": "", "source_row": 50 },
    { "label": "TOTAL INCOME", "canonical_label": "Effective Gross Income (EGI)", "is_subtotal": true, ... }
  ],
  "summary":  { "blocks": [ ... ], "egi": {"t12": ..., "t6": ..., "t3": ..., "t1": ...}, "total_operating_expenses": { ... },
                "noi": { ... }, "ratios": { "rubs_to_utilities": ..., "vacancy": ..., "economic_vacancy": ..., "expense_ratio": ... } },
  "report":   { "status": "ok", "file": "t12.xlsx", "doc_type": "t12", "format": "xlsx", "months_in_document": 12,
                "validation": { "passed": true, "review_reasons": [], "lines_to_review": [ ... ], "checks": [
                    { "check": "egi", "label": "Effective Gross Income (EGI)", "extracted": 3239290.52, "document": 3239290.52,
                      "match": true, "row_label": "TOTAL REVENUE", "extracted_by_month": [ ... ], "document_by_month": [ ... ] }, ... ] },
                "flags": [ ... ], "source": { "sheet": "Report1", "header_row": 5 }, "counts": { "lines": 99, "subtotals": 21 } },
  "usage":    { ... }
}
FieldMeaning
rows[].canonical_labelThe standard category of a line item; on the rows that print the statement's own EGI, total operating expenses and NOI, that figure's name; "" on other subtotals and on lines below NOI
rows[].custom_labelYour custom category, when the line is one (below)
rows[].values, totalOne string per month in columns (oldest first), "" when empty; revenue deductions (vacancy, concessions, bad debt, loss-to-lease, employee units) are negative; total is the 12 months' sum; document_total is the statement's own Total for the row, on the same sign ("" when it prints none, or when its Total also covers months before the 12)
periodstart and end of the 12 months as MM-YYYY; columns carries each month's first day
rows[].review_noteWhy a person should look at this line, in plain words ("" for most lines). Never changes a figure or the status. Also listed in report.validation.lines_to_review (line = position in rows, label, reason).
summary.blocksTotal Rental Revenue, Total Other Income and Total Operating Expenses: each standard category, then your custom categories (with their parent), and the block total — as t12 (the 12 months) and t6, t3, t1 annualized (×2, ×4, ×12)
report.validation.checksegi, opex, noi: the categorized lines against the rows where the statement prints them, month by month (extracted, document, match, months_off). A difference that comes only from where a line is counted passes, with explained naming the lines and amounts: the statement prints the line under expenses and QuickData counts it in revenue (EGI), or the reverse (for example, a utility reimbursement printed against the utility expense). NOI is the same either way.
report.reviewWhen a check still disagrees: why, in plain words (explanation), and the rows it re-read (corrections, line = position in rows). A statement whose own subtotals don't add up says so.
report.validation.subtotals_not_matching_their_linesSubtotals of the statement that differ from the lines printed above them (the statement's own arithmetic), when there are any
report.flagsWhat the statement leaves out, without affecting the status: no management fee, no real estate tax or no insurance line

Status: ok — every figure the statement prints (EGI, total operating expenses, NOI) matches or is explained · needs_review — one disagrees, none is printed, fewer than 12 months, or a month with no activity (report.months_without_activity) · failed — no monthly statement found (not billed). The Excel export of a T12 is the QuickData T12 worksheet: the T-12 / T-6 / T-3 / T-1 summary with data checks and twelve custom expense rows you can name, and every line — its 12 months and the statement's own Total — with a category dropdown you can change; lines to review are highlighted with their note.

Custom categories

Split a standard category your way — Electricity and Gas under Utilities, Pet Rent under Other Income. A line is placed in its standard category first, then in one of that category's custom categories when it clearly is one. Custom lines keep the parent in canonical_label, name the custom category in custom_label, and count in their own summary row and in the parent's block total. Manage them in the console (T12 categories) or here:

GET/v1/t12/categories → the standard categories by block, with your custom ones under their parents
PUT/v1/t12/categories body {"custom": [{"name": "Electricity", "parent": "Utilities", "description": "optional"}]} — replaces the list (up to 60)

Without a workspace these are the organization's categories. Named with the X-QD-Workspace header, a workspace gets a list of its own; {"inherit": true} makes it follow the organization's again.

6. Queued jobs, batches & webhooks

An extract call holds the connection until the result is ready. A job does not: the call answers at once with a job id, the document waits in a queue, and you collect the result when it is read, by asking or by being told. Use jobs for volume, for files behind a link, and whenever your software should not wait.

POST/v1/jobs → 202 {"job_id": "j_…", "status": "queued", …}
FieldMeaning
fileThe document (multipart), as for an extract call
file_urlInstead of file: a link the service downloads it from (a form field, or a JSON body). A pre-signed S3 link, or a Dropbox or Google Drive share link, works. Public http(s) addresses only, up to 25 MB. When the link does not end in the file's extension, add filename.
doc_typeauto (default: a rent roll or a T12, detected from the file), rent_roll or t12
reference, metadata, verifyAs for an extract call. The X-QD-Workspace header too.

A header Idempotency-Key: <any string of yours> makes a retry of the same request return the same job (200, with idempotent_replay: true) instead of queueing the document twice. A key is remembered for 24 hours, within the workspace it was used in.

curl -X POST https://data.quickdata.ai/v1/jobs \
  -H "Authorization: Bearer $QD_KEY" -H "Idempotency-Key: deal-77-rr" \
  -F "file=@rent_roll.xlsx" -F "reference=deal-77"
# {"job_id": "j_Zk3…", "status": "queued", "filename": "rent_roll.xlsx", "reference": "deal-77", ...}

curl https://data.quickdata.ai/v1/jobs/j_Zk3… -H "Authorization: Bearer $QD_KEY"
# {"job_id": "j_Zk3…", "status": "done", "result_status": "ok", "result": { "rows": [...], ... }, "downloads": {...}}
GET/v1/jobs/{job_id} → the job; once it has ended and while its result is held, result (the same an extract call returns) and downloads. ?include_rows=false leaves the rows out.
GET/v1/jobs → your jobs, newest first, without results. Filters: status, batch, workspace.
GET/v1/jobs/{job_id}/result.xlsx · result.json — with your key, or the signed link in downloads (it works for as long as the result is held)
DELETE/v1/jobs/{job_id} → removes the result now, instead of leaving it to wait out its time
StatusMeaning
queuedWaiting for a worker. Jobs are read in turn; when several organizations have jobs waiting, each gets its turn, so one large batch does not hold up another's single file.
processingBeing read.
doneRead: result_status is ok or needs_review. The result is held 24 hours (result_expires_at), then removed; the job's record (file name, status, times, your reference) stays in your list for 30 days.
failedNo result. error_code says why: no_table_found (nothing to read in the file; the report is in result), file_url_unreachable, payment_failed, daily_limit_reached, processing_error, interrupted (send it again). Not billed.
waiting_upload, expiredOnly for upload links: no file has arrived yet, or none arrived in time.

A document is counted and billed when it is read, exactly as an extract call's. If the service restarts while a job is being read, the job goes back in the queue and is read again (once); nothing queued is lost.

Batches

POST/v1/batches → 202 {"batch_id": "b_…", "status": "processing", "counts": {...}, "jobs": [...]}

Up to 50 documents in one call: files (multipart, the field files repeated), links (file_urls: a JSON list of links, or of {"url", "filename", "reference"}), or both. Each becomes a job of its own with its own result; reference and metadata apply to the batch and to every job that does not name its own. Nothing is queued unless every document of the batch can be.

curl -X POST https://data.quickdata.ai/v1/batches -H "Authorization: Bearer $QD_KEY" \
  -F "files=@oak_rr.xlsx" -F "files=@oak_t12.xlsx" -F "files=@pine_rr.pdf" -F "reference=portfolio-12"

curl -X POST https://data.quickdata.ai/v1/batches -H "Authorization: Bearer $QD_KEY" -H "Content-Type: application/json" \
  -d '{"file_urls": ["https://files.example.com/oak_rr.xlsx",
                     {"url": "https://drive.example.com/d/abc", "filename": "pine_rr.pdf", "reference": "pine"}]}'
GET/v1/batches/{batch_id} → status (processing, completed), counts (total, queued, processing, done, failed), and each job with its file, status and result status
GET/v1/batches/{batch_id}/results.zip → every held result in one file: an Excel workbook per document (?format=json, or both). With your key, or the signed link in downloads.zip.
GET/v1/batches → your batches, newest first, with their counts

Webhooks

Register an https address and QuickData calls it when a job or a batch has finished, so your software need not poll. Manage endpoints in the console (Developers → Webhooks) or here; up to five per organization. The address must be a public one; the service never calls into a private network.

POST/v1/webhooks body {"url": "https://…", "events": ["job.completed", "job.failed", "batch.completed"], "workspace": "…", "description": "…"} → the endpoint, with its signing secret. events defaults to all; workspace limits it to that workspace's documents.
GET/v1/webhooks · /v1/webhooks/{id} · PATCH (url, events, active, {"roll_secret": true}) · DELETE
POST/v1/webhooks/{id}/test → sends a webhook.test event now and reports how your endpoint answered · GET/v1/webhooks/{id}/deliveries → what was sent in the last 7 days, and how it was answered
POST https://your-app.com/webhooks/quickdata
Content-Type: application/json
QD-Event: job.completed
QD-Delivery: evt_8fK2…
QD-Signature: t=1791100000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

{ "id": "evt_8fK2…", "type": "job.completed", "created": 1791100000,
  "data": { "job_id": "j_Zk3…", "status": "done", "result_status": "ok", "doc_type": "rent_roll", "filename": "rent_roll.xlsx",
            "reference": "deal-77", "metadata": { "analyst": "kim" }, "rows": 23, "batch_id": null,
            "workspace": { "id": "ws_k3J9…", "name": "Acme Capital", "external_id": "client-1042" },
            "error": null, "error_code": null,
            "result_url": "https://data.quickdata.ai/v1/jobs/j_Zk3…", "result_expires_at": 1791186400 } }
EventWhen
job.completedA document was read (queued, or dropped on an upload link): its result is ok or needs_review. Fetch it from result_url with your key.
job.failedA job ended without a result; error_code says why.
batch.completedEvery job of a batch has ended. Sent once, with the batch's counts.

A delivery never carries the document's content: it says which job, and where to fetch the result. Answer 2xx within 10 seconds; anything else is tried again after 30 seconds, 2 minutes, 10 minutes, 1 hour and 6 hours, then given up. While an endpoint is failing, everything waiting for it is held back with it rather than tried one by one, and other endpoints are not delayed. A delivery can arrive more than once and out of order: use id to ignore one you have seen. Redirects are not followed, and the answer's body is not read.

Check the signature before trusting a delivery: v1 is the HMAC-SHA256, in hex, of <t>.<the raw body> with the endpoint's secret, and t is the moment that attempt was sent (reject ones older than a few minutes; a retry carries a new t).

import hashlib, hmac, time

def verify(secret: str, raw_body: bytes, header: str, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))          # the QD-Signature header
    good = hmac.new(secret.encode(), parts["t"].encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(good, parts["v1"]) and abs(time.time() - int(parts["t"])) <= tolerance

7. Upload links

For files that are not on your server — your client's, a colleague's, or one attached in a chat app — create a page where a person drops them. The documents are read on your account, for the workspace that created the link, and counted like any other.

POST/v1/uploads → {"job_id", "upload_url", "expires_at"} — the page works for 30 minutes, for one file
Parameter (query, or a JSON body)Meaning
doc_typeauto (default), rent_roll or t12
max_filesAbove 1 (up to 50), the page takes several files and the answer has a batch_id instead of a job_id: each file dropped becomes a job of that batch, and the page shows each file as it finishes.
referenceYour own id, carried by every document dropped on the page. Send it in the body rather than in the address when it should not appear in a server's access log.

Collect the results with GET /v1/jobs/{job_id} (or GET /v1/batches/{batch_id}), or take the webhook: a document dropped on an upload link announces itself like any other job, and a link for several files announces its batch (batch.completed) once it has taken all its files or its 30 minutes are over. The page shows the person the outcome and download links for the files they dropped, until an hour after the link stops taking files. A link stops working when the key that made it is revoked or its workspace is archived.

Whose name is on the page. By default the page is QuickData's. Set your organization's brand_name (PATCH /v1/organization, or Settings in the console) and the page carries your name instead, with no QuickData header, footer or links; set it to "" for no name at all. The same name replaces QuickData's in Excel and JSON downloads (Harlow RR.xlsx becomes Harlow RR Acme.xlsx rather than Harlow RR QD.xlsx).

8. Excel export

POST/v1/export/xlsx body {"rows": [...], "report": {...}, "filename": "Harlow RR 10.22.24.xlsx"}

Returns a workbook named after your file with QD appended (Harlow RR 10.22.24 QD.xlsx; your brand_name instead, when you set one): a Units sheet with one column per charge code, a Unit Mix sheet, a Review sheet of low-confidence cells (highlighted on Units, with the reason as a comment) and a Validation sheet with the checks.

9. Organizations: workspaces, members & keys

Your account is an organization: the company that holds the plan, the bill and the limits. Inside it you can separate work into workspaces and give other people access as members. A workspace is made on purpose, for a party that gets access of its own; to count documents for your own customers you need none (customer ids). The same model serves two cases:

Passing API access on to a clientA company delegating access
A workspace isa client whose own system calls the APIone team, fund or office
It has its ownAPI keys, rent roll settings, T12 categories, usage, and (if you set one) daily limit
Who calls the APIthe client's system, with its workspace key; or your software for it, with an organization key and the X-QD-Workspace headereach team's integration with its workspace key; people in the console
The billone invoice to the organization; GET /v1/usage/summary breaks the documents out per workspace
GET/v1/organization → name, brand_name, plan, limits and how documents are handled · PATCH {"name": …, "brand_name": …}

Workspaces

POST/v1/workspaces body {"name": "Acme Capital", "external_id": "client-1042", "daily_limit": 50, "settings": {…}, "t12_categories": [...]} → 201 the workspace (id: ws_…)
GET/v1/workspaces · /v1/workspaces/{workspace} · PATCH (name, external_id, daily_limit, status) · DELETE (archives it)

Only name is required. external_id is your own id for the client or team (unique within your organization): wherever a workspace is named — the X-QD-Workspace header, a path, a filter — your id works as well as ours, so you need not store ours. daily_limit caps that workspace's documents per rolling 24 hours, inside the organization's own limit. Archiving a workspace stops extraction for it and revokes its keys; its usage stays in your log, and PATCH {"status": "active"} reopens it.

Settings and T12 categories per workspace. A workspace follows the organization's until it is given its own: call PUT /v1/settings or PUT /v1/t12/categories with the X-QD-Workspace header (or pass settings / t12_categories when creating it). {"inherit": true} makes it follow the organization's again. GET /v1/workspaces/{workspace} shows both, with inherited.

Customer ids: whose document it is

A company that serves customers of its own does not need a workspace or a key for each of them. Send your id for the customer with the document, in the header X-QD-Customer (or ?customer= in the address), on any call that sends one: /v1/extract, /v1/jobs, /v1/batches, an upload link when you create it.

Members and roles

POST/v1/members body {"email": "kim@acme.com", "role": "member", "workspaces": ["client-1042"]} → 201 the member, with status invited; they are emailed, and become active when they sign in with that address and accept. Several people at once: {"emails": ["kim@acme.com", "lee@acme.com"]} → {"invited": [...], "skipped": [{"email", "reason", "code"}]}
POST/v1/members/{id}/invite → sends the invitation email again to someone who has not answered yet
GET/v1/members (each with documents: what that person has read since ?since=, by default the start of this month; and seat) · PATCH/v1/members/{id} (role, workspaces: "all" or a list) · DELETE (they are signed out, and keys they made for themselves stop working)

The answer's keys_created lists the organization and workspace keys that person created that still work (they saw each of those once); a PATCH that ends someone's time as an owner or admin lists them too. Add ?revoke_created_keys=true to revoke them in the same call (an admin's call revokes the workspace keys; an organization key is an owner's to revoke), or replace them one by one. A person leaves on their own with DELETE/v1/members/me (an organization keeps at least one owner).

RoleCan
OwnerEverything: the plan, the card and invoices, organization keys, and who the owners are. An organization always has at least one.
AdminWorkspaces, members (not owners), workspace keys, settings, T12 categories, webhooks, the activity log.
MemberExtract documents and see results and usage in the workspaces they were given; keep API keys of their own. Sees no billing and no other workspace.

An invitation gives nobody anything until the person accepts it: they sign in with the address it was sent to (email or Google), see which organization asked and who, and choose. A person is an active member of one organization at a time. Single sign-on through your identity provider is not available yet.

Keys

POST/v1/keys body {"name": "Acme integration", "workspace": "client-1042"} → the key, once. Without workspace: an organization key (owners only). {"personal": true}: a key of your own, whatever your role: the documents it reads are yours. In a company on seats, a person who is signed in gets a key of their own unless they name a workspace or send {"scope": "organization"}.
GET/v1/keys → every key with its scope (organization, workspace, member), workspace, last use and the member who created it (created_by; a key made with a key carries that key's maker) · DELETE/v1/keys/{id} → revoked at once
POST/v1/keys/{id}/replace → a new key with the same name and the same reach (in the response, once); the old one stops working in the same step. For a key that may have been seen by someone who should not have it. A person's own key is replaced by that person.

If a key is exposed: replace it (or revoke it) at once; nothing else changes, and your other keys keep working. To change a key with no interruption: create a second key, move your software to it, then revoke the first. Several keys of the same kind work side by side (up to 10 active for the organization, for one workspace, or for one person): a new key never replaces an old one unless you ask for that.

GET/v1/audit-log → who did what, newest first: members added and removed, keys created and revoked, workspaces, settings, webhooks, held documents deleted. ?before=<id> for the next page.

Serving your own customers: the whole flow

One company key on your server, and your own id for the customer on every call. Your customers are told apart by that id: there is no key to issue to each of them, nothing to create before a new customer's first document, and no list of them to keep in QuickData.

# 1. each document: your key, your id for the customer, your reference for the document
curl -X POST https://data.quickdata.ai/v1/jobs -H "Authorization: Bearer $QD_ORG_KEY" \
  -H "X-QD-Customer: cust_1042" \
  -F "file=@rent_roll.xlsx" -F "reference=deal-77"
#    ...your webhook endpoint is told when it is read (the event carries "customer": "cust_1042");
#    fetch the result and delete it when you have it

# 2. whenever you bill your customers: documents per customer, the busiest first
curl "https://data.quickdata.ai/v1/usage/summary?group_by=customer&start=2026-10-01&end=2026-11-01&limit=100" -H "Authorization: Bearer $QD_ORG_KEY"
# {"lines": [{"customer": "cust_1042", "documents": 212, "billed": 207, "free": 0, "failed": 5, "needs_review": 14,
#             "first_at": 1790812800, "last_at": 1793404800}, ...], "total_lines": 9418, "totals": {...}}

Your customers never need a QuickData account: your software calls the API for them, and your system decides who may. When a client's own system should call the API directly, or a team needs its own settings or daily limit, make a workspace and give it a key: that key works for that workspace and nothing else, and can send customer ids of its own.

10. Account, usage & settings

CallReturns
GET /v1/meWho is asking: the organization, your role, the workspace this call works in, the limits, and how documents are handled (data). For owners, admins and organization keys also the plan, status, free documents left, documents this period and the estimated charge.
GET /v1/usage?limit=200Your documents, newest first: file, type, rows, status, billed, seconds, and where each came from (workspace_id, customer, reference, key_id, member_id, job_id), and review: why a document needed review, in plain words (the result's review reasons; null when it did not), and rent_check: for a rent roll read ok, "document", "review" or "none" (how its rent was confirmed: validation.rent_confirmed; null otherwise). The full checks are not kept: they come with the result. Filters: workspace, customer, reference, start, end (a date YYYY-MM-DD or unix seconds; end is exclusive), member (a member's id). An entry whose details were cleared or never recorded has no file name, rows or reference.
POST /v1/usage/clearOwners only. Clears the document log: every entry keeps its count line (when, type, status, how it was charged) and loses its file name, row count, reference and review reasons. The records and held results of jobs that have ended, what was sent to your webhook endpoints, and every document held for you (GET /v1/held-documents) are deleted with it. It cannot be undone; the activity log records who cleared. Returns how much went.
GET /v1/usage/summary?group_by=workspaceDocuments counted per workspace, customer (your own customers), reference, key, member, doc_type, day or month: documents, billed, free, seat, failed, needs_review, with the same filters (and cached: documents from before results stopped being stored that were served from the cache and not billed; none since). What a provider reads to bill its own clients.
GET /v1/usage/daily?days=30 · GET /v1/usage.csv · GET /v1/usage/summary.csvDocuments per day · the log as CSV (the same filters) · the counts per customer as CSV
GET /v1/settings · PUT /v1/settingsHow your rent rolls are read: {"non_revenue_vacant": true} (the default) counts model, employee, office and down units as vacant; false counts one as occupied when the rent roll marks it occupied and its own occupied count needs it. With the Deal Screener, screener holds your own default assumptions, pass targets, and tax rates by state (set in the console's Settings). Each result names the settings it was read with in report.settings. The organization's by default; a workspace's own with the X-QD-Workspace header.
GET /v1/held-documentsThe documents QuickData is holding for you right now, and until when (what is kept). An empty list means nothing of yours is held.
GET /v1/samples · POST /v1/extract/sample/{id}Built-in samples (three rent rolls, two T12s; doc_type on each) and their results — free, for trying the API

A workspace key, and a member limited to some workspaces, see only those workspaces' documents in every one of these. Plan changes, invoices and the card are in the console, for the organization's owners.

The two plans: Excel Add-In and API Plan

An account has the Excel Add-In, the API Plan, or both. GET /v1/me says which in company: seats is the Excel Add-In, api the API Plan. The rule is one sentence: a document a person reads signed in counts on that person's seat; a document sent with a key counts on the API Plan.

PlanWhat it is, and how it is charged
API Plan (api: true)For your software. $99 a month with 100 documents, then $1 each (or pay-as-you-go, $3 a document, for an account that has this plan alone). An account with the API Plan alone (seats: false) counts every document on it, including one a person tries in the console; people can be added to help run the account at no charge. The Excel add-in is not part of it: a call from the add-in answers 403 excel_plan_required.
Excel Add-In (seats: true)For people. $99 a month for each person who has joined, the Deal Screener included (an invitation costs nothing until it is accepted). A document a person reads, signed in (in Excel, in the console, through an AI assistant) or with a key of their own, is theirs: usage.seat is true and it is not charged, within the fair use your terms set for one person; a document beyond that is $1. A document that waited (a queued job, a file through an upload link) is read only while its sender may still send it: the person is still a member, or, for a key, the API Plan is still on. Such an account adds the API Plan with a switch its owner holds.

Each usage entry says where the document came from in source: excel (the add-in), console (the console's pages), api (a key), upload_link, mcp (an AI assistant). GET /v1/usage, the summary and the CSV take ?source=, and the summary groups by it (group_by=source). The Excel add-in may name itself with the header X-QD-Client: excel.

POST/v1/api-access body {"on": true} → {"on", "ends_at"}. An owner of a company that has the Excel Add-In, signed in to the console (a key cannot switch it): the API Plan, on or off. On: $99 a month with 100 documents, then $1 each, on the same invoice, the rest of the current period charged in proportion to its days; organization and workspace keys work. Off: it works to the end of the billing period, then stops. While it is off those keys answer 403 api_access_off.

Excel add-in updates

The add-in checks for a new version itself. Each new build is published once, by QuickData, and from then on it is the file behind every download button and the version every add-in is offered.

GET/v1/addin → {"available": true, "version": "2.0.1", "filename", "bytes", "sha256", "released", "notes", "min_supported_version", "url": "/download/excel-add-in"}, or {"available": false} while nothing is published. Compare version with the running add-in number by number (2.0.10 is after 2.0.9) and offer the update only when it is higher, with notes. An add-in older than min_supported_version ("" when none) should update before it sends documents.
GET/download/excel-add-in → the installer, as a file, with X-QD-Addin-Version and X-QD-Addin-SHA256. Check its size and SHA-256 against GET /v1/addin before installing it. No sign-in is needed: anyone can download the add-in (using it takes a sign-in with an Excel Add-In seat). Without a sign-in, one address may download it up to 30 times an hour (then 429 rate_limited).

GET /v1/addin takes the add-in's sign-in (the rrx_session cookie, with X-QD-Client: excel-addin/<version>), the console's, or an API key.

11. How requests are processed

Two ways to send a document. An extract call (POST /v1/extract, /v1/extract/t12, /v1/extract/auto) starts work the moment the file arrives and answers with the result in the same response: keep the connection open (most files take 3–15 seconds, up to about 90) with a client timeout of 180 seconds. A queued job (POST /v1/jobs, /v1/batches) answers at once and is read in turn; you poll, or take the webhook. Both read the document the same way, count it the same way and return the same result.

Many requests at once

Each organization may have a number of documents being read at the same moment (8 by default; limits.documents_in_flight in GET /v1/me), across its extract calls and its queued jobs. An extract call beyond that is refused with 429 too_many_in_flight and Retry-After: 3; a queued job simply waits its turn. So for volume, queue: send the files to /v1/jobs or /v1/batches as fast as you like, within the per-minute limit, and let the queue pace them. One organization's backlog does not delay another's documents.

Limits

LimitValuePast it
File size25 MB413 file_too_large
Documents started per minute, per key (or per signed-in person)120 by default (limits.documents_per_minute). Every extraction call counts one; a batch counts each of its documents. Reading your account, jobs and usage does not count.429 rate_limited, with Retry-After (seconds). Every counted call answers with X-RateLimit-Limit and X-RateLimit-Remaining.
Documents being read at once, per organization8 by default (limits.documents_in_flight)Extract calls: 429 too_many_in_flight. Queued jobs wait.
Documents per organization per rolling 24 hours200 by default (limits.documents_per_day). Every document that is read counts, billed or not.429 daily_limit_reached until earlier documents pass out of the 24 hours. Contact support to raise it.
Documents per workspace per rolling 24 hoursNone unless you set one (daily_limit on the workspace)429 workspace_limit_reached; the other workspaces go on
Documents in one batch · waiting in the queue50 · 1,000 per organization400 batch_too_large · 429 queue_full
Rows in one MCP tool result400, then page with get_extraction. REST results are never paged.
Workspaces · members · webhook endpoints2,000 active · 200 · 5400 limit_reached
API keys · custom T12 categories10 active organization keys, 10 per workspace, 10 per member · 60 per list400

The per-minute, in-flight and daily limits can be raised for an organization: contact support.

Retries

A document is counted when it has been read, whether or not your program was still there to receive the result: a synchronous call that is sent again is read and counted again. Where a retry must never count twice, send the document as a job with an Idempotency-Key of your own: a retried POST /v1/jobs with the same key returns the job you already have, and nothing is read a second time. The key is yours; nothing about the file is kept to recognise it.

You receivedRetry?
No response, or the connection droppedA job: yes, with the same Idempotency-Key (not counted again). A synchronous extract call: yes, and it is counted again if the first one was read
429 rate_limited or too_many_in_flightYes, after the seconds in Retry-After
429 daily_limit_reached, workspace_limit_reachedLater: the limit frees up as earlier documents pass out of the 24 hours
502 / 503Yes, after a minute, then with growing pauses
500No: an anonymized copy of the file is saved for our team, and the message carries a reference number to quote
4xx otherwiseNo: fix the request
200 with report.status: "failed", or a job failed with no_table_foundNo: no unit table (or monthly statement) was found in the file. Not billed.
A job failed with interruptedYes: send it again

12. Errors

Every error is JSON with two fields: {"detail": "what happened, in words", "code": "a_stable_word"}. Show detail to a person; let your code act on code (and the HTTP status). A request that does not fit an endpoint answers 422 with detail listing each field that is wrong.

StatuscodeWhat to do
400invalid_request, batch_too_large, limit_reached, last_owner, file_url_unreachableFix the request
401invalid_api_key, session_expired, access_removedCheck the Authorization header; create a key in the console
402payment_failed, account_inactiveAn owner updates the card in the console, then retry
403forbidden (this key or person may not do that), workspace_forbidden, workspace_archived, link_expired (a signed download link)Use a key with the right scope; get fresh links from GET /v1/jobs/{job_id}
404not_found, workspace_not_found, job_not_found, batch_not_found, key_not_found, member_not_found, webhook_not_found, result_not_available, unknown_document_typeSomething of another organization answers 404 too: it does not exist for you. workspace_not_found: X-QD-Workspace names a workspace you have not made (your own customer's id goes in X-QD-Customer)
409 / 410external_id_taken, member_exists, job_not_finished, link_used, conflict / link_expired (an upload link)—
413 / 415file_too_large / unsupported_file_typeUp to 25 MB; .xlsx .xlsm .xls .csv .pdf
422invalid_requestFix the fields named in detail
429rate_limited, too_many_in_flight, daily_limit_reached, workspace_limit_reached, queue_fullSee limits and retries
500processing_errorAn anonymized copy of the file is saved for our team; the message carries a reference number
502 / 503upstream_unavailable / unavailable, at_capacityRetry after a minute, with backoff

13. Your documents: what is kept, and for how long

QuickData stores nothing of a document that reads cleanly. The file is read to produce the result, the result is returned to you, and neither is kept. This applies to every account.

WhatKept
The file you sendHeld in a temporary file while it is read, and removed the moment the reading ends. A queued job's file waits on disk until a worker has read it, then is removed.
The resultAn extract call's: not stored. A queued job's or an upload link's: held 24 hours to be collected (data.results_held_hours in GET /v1/me), then removed. DELETE /v1/jobs/{job_id} removes it sooner.
A document that could not be read, or whose result needs reviewAn anonymized copy of the file and what was read from it are saved, so our team can find the cause and improve the extractor. Residents' names, resident ids, email addresses and phone numbers are replaced by stand-ins, in the copy and in everything read from it; the layout, unit numbers, dates and figures stay as printed. The file as you sent it is never stored: it is read, copied and checked, and dropped. A scanned PDF (no text to copy) keeps no file, only what was read from it. It is deleted when that is done, and 30 days after it was last sent at the latest: the copy and everything read from it, a queued job's held result of it included. GET /v1/held-documents lists what is held for you right now (kept says what is kept of the file), and your activity log records each deletion. With no document retention on, nothing at all is saved.
Your usage logFor each document: its file name, the time, type, number of rows, status, workspace, your reference and whether it was billed. Nothing read from the document, and no fingerprint of the file. For as long as the account exists (GET /v1/usage).
Job recordsFile name, status, times and your reference, without the result: 30 days. A link a file was fetched from (file_url) is dropped as soon as the job has ended.
Webhook deliveries · the activity log7 days (never a document's content) · for as long as the account exists
What our AI processing providers receiveText from the document and its file name, with nothing that says who sent it. Neither provider may train its models on it. The first helps read every document and keeps nothing (zero data retention). The second checks some documents, or parts of them (when the first is unsure, when the document's own totals don't confirm the result, or when something looks wrong), and deletes what it receives within 30 days, or later only where its safety systems flag misuse or the law requires it. No document retention does not change this.
API keys · webhook secretsKeys are stored hashed; a key cannot be shown again after it is created. A webhook's signing secret can be read again by an owner or admin, and replaced.

One organization never sees another's documents, results, usage or settings, and a workspace key never sees another workspace's. Your data is never sold or shared with marketers.

Clearing the log, and no document retention

An owner can remove what the table above lists at any time: POST /v1/usage/clear erases the details of every log entry and deletes job records, held results, webhook delivery records and every document held for the organization. The count lines stay, so invoices still add up.

An owner can also switch on no document retention: PATCH /v1/organization with {"no_document_retention": true} (data.no_document_retention in GET /v1/me). From then on a log entry counts the document and says nothing about it (no file name, rows or reference), no document is saved when it cannot be read or needs review, a job's result still waits to be collected (data.results_held_hours) but the record of the job is deleted a day after it ended rather than kept for 30, and a webhook delivery's record goes once the delivery was made. Our own server logs say nothing of such a document either: not its name, not what an error said. The cost: when a document reads badly, nothing of it is kept for our team to look into. The switch governs what QuickData keeps; what the AI processing providers keep is in the table above.

14. MCP: connect an AI assistant

QuickData is a remote MCP server at https://data.quickdata.ai/mcp (Streamable HTTP). Once connected, ask your assistant things like "extract this rent roll and show me the unit mix".

ToolWhat it does
extract_rent_rollExtract from a link (file_url: Dropbox, Google Drive, pre-signed S3) or a small file as base64
extract_rent_roll_fileExtract a file attached in the chat (ChatGPT passes attachments to tools)
extract_t12, extract_t12_fileThe same for a T12 operating statement
request_upload_linkA one-time page where you drop the file — for apps that can't pass attachments to tools (it detects a rent roll or a T12; doc_type to fix one)
get_extractionThe result of an upload (and the next page of rows for large properties)
screen_deal, get_deal_screenWith the Deal Screener: screen a multifamily deal from its OM, or its rent roll and T12 (links or base64), and get the verdict, the price tested, the most you can pay, the returns, and links to its page and Excel model (deal screens)

Every result includes a summary, the unit mix, the rows (400 at a time) and Excel/JSON download links. An assistant's documents belong to the workspace of its key (a workspace key), of the X-QD-Workspace header (an organization key), or of the person who signed in; each counts against the same limits as an API call.

Claude (claude.ai, Desktop, mobile)

  1. Open Settings → Connectors and choose Add custom connector.
  2. Name it QuickData and enter the URL https://data.quickdata.ai/mcp. Leave the advanced OAuth fields empty.
  3. Click Add, then Connect, and sign in with your QuickData account.
  4. In a chat, enable QuickData from the tools menu. Attach a rent roll and ask Claude to extract it — Claude will give you an upload link for the file (Claude can't pass attachments to connectors), or share a Dropbox/Drive link instead.

On Team and Enterprise plans an Owner adds the connector for the organization first.

Claude Code

claude mcp add --transport http quickdata https://data.quickdata.ai/mcp \
  --header "Authorization: Bearer $QD_KEY"

Or leave out --header and run /mcp in Claude Code to sign in with your account.

ChatGPT

  1. Available on Plus, Pro, Business, Enterprise and Education, on the web. Turn on Developer mode (Settings → Security and login; on Business/Enterprise an admin enables it for the workspace).
  2. Create an app: Plugins/Apps → + (create developer-mode app). Name QuickData, MCP server URL https://data.quickdata.ai/mcp, authentication OAuth.
  3. Click Scan tools, sign in with your QuickData account, then Create.
  4. In a chat, choose QuickData from the + menu and attach a rent roll: ChatGPT passes the file straight to extract_rent_roll_file. (If it doesn't — some mobile versions — ask for an upload link.)

Grok

  1. Go to grok.com/connectors, choose New connector → Custom.
  2. Enter https://data.quickdata.ai/mcp and sign in with your QuickData account when asked.
  3. In a chat, ask Grok to extract a rent roll; share a link or ask for an upload link.

Through the xAI API, add the server as a tool: {"type": "mcp", "server_url": "https://data.quickdata.ai/mcp", "server_label": "quickdata", "authorization": "$QD_KEY"}.

Gemini

  1. Gemini app (personal Google account with Gemini Spark, where Google offers it): Settings & help → Connected apps → Custom apps → Add a custom app, enter https://data.quickdata.ai/mcp, then sign in with your QuickData account.
  2. Gemini CLI: add to ~/.gemini/settings.json:
{
  "mcpServers": {
    "quickdata": {
      "httpUrl": "https://data.quickdata.ai/mcp",
      "headers": { "Authorization": "Bearer $QD_KEY" }
    }
  }
}

Any other client (Cursor, VS Code, Windsurf, agents)

Point any Streamable-HTTP MCP client at the URL with your key:

{
  "mcpServers": {
    "quickdata": {
      "url": "https://data.quickdata.ai/mcp",
      "headers": { "Authorization": "Bearer $QD_KEY" }
    }
  }
}

A client that only runs local (stdio) servers can bridge with npx mcp-remote https://data.quickdata.ai/mcp --header "Authorization: Bearer $QD_KEY". Clients that support sign-in can connect without a key and sign in with your account.

From your own code, through a model provider's API

client.beta.messages.create(
    model=MODEL, max_tokens=4096, betas=["mcp-client-2025-11-20"],
    mcp_servers=[{"type": "url", "url": "https://data.quickdata.ai/mcp", "name": "quickdata",
                  "authorization_token": QD_KEY}],
    tools=[{"type": "mcp_toolset", "mcp_server_name": "quickdata"}],
    messages=[{"role": "user", "content": "Extract the rent roll at https://…/rr.xlsx"}])

15. Deal screens (with the Deal Screener)

An account with the Deal Screener screens multifamily deals from here too: the same screen as the console's, with the account's own default assumptions and targets (Settings), counted as one deal screen of the person who makes it. A deal screen is a person's: send your own key (a key of your own, made signed in), or call signed in (the Excel add-in) or through an AI assistant signed in as you (screen_deal, MCP). An organization key does not make deal screens (403 personal_key_required); an account without the Deal Screener gets 403 screener_plan_required. The Excel Add-In includes the Deal Screener. The free Deal Screener (no card, through March 31, 2027) makes 15 deal screens a calendar month; past them, or after that date, a call answers 402 upgrade_needed, with upgrade: where the account's owner adds the Excel Add-In.

CallWhat it does
POST /v1/screensMultipart: om (the offering memorandum, PDF, up to 50 MB), and/or rent_roll and t12 (Excel, CSV, or PDF, up to 25 MB each). The OM alone, the rent roll and T12 together, or all three. Answers {"job": "scr_…", "links": {"web"}} at once; the screen takes a minute or two. (One rent roll or T12 alone is read as that document, and counted as one.)
GET /v1/screens/{job}status (queued, reading, building, done, failed), and once done result: verdict, price (the price tested: the OM's asking price, else the max price that passes), cap (the going-in cap: Year-1 NOI at today's rents, after reserves, divided by the price), exit_cap, max_price, passing_cap (the going-in cap at the max price), irr, dscr, units, start (what set the price: asking, max, default for a 6.00% going-in cap when no price passes, or cap for a going-in cap you typed), watch; links.web (the deal's page in the console) and links.excel (its Excel model: a signed link that needs no key, for as long as the result is kept). Ask every few seconds.
GET /v1/screens/{job}/screenEverything the deal's page shows: the property, the T12, the unit mix, every assumption and target, the watch list.
GET /v1/screens/{job}/xlsxThe Excel model: one page with live formulas, and the parsed rent roll and T12 as tabs.

How the screen prices a deal

The price is the input. The screen starts at the OM's asking price. When the OM gives no asking price, it starts at the max price that passes, which is the highest price at which every test with a target passes. When no price passes, it starts at the price a 6.00% going-in cap gives. The price stays fixed when assumptions change. Type another price to test the deal at that price. Or type a going-in cap rate: the price then comes from that cap rate, and it moves when a Year-1 figure changes, for as long as the cap rate box is filled. A price you type clears the cap rate.

The going-in cap is a result. It is Year-1 NOI at today's rents, after reserves and with property taxes at the price, divided by the price. Every Year-1 figure counts in it. Rent changes and the renovation count in the returns and in the max price, not in the going-in cap. The cap rates are on NOI after reserves, which is the basis that cap rate surveys and lenders use.

Where a sale resets taxes, Year-1 taxes are the reset at the price: the price, times the assessed share, times the tax rate, plus special assessments. With the setting "Taxes never below the seller's bill" at Yes (the default), Year-1 taxes are the reset or the seller's bill, whichever is higher. That amount grows through the hold at the tax growth rate. You can also type the Year-1 property taxes on the operating statement; the screen then uses that amount, whatever the price. The sale price is the NOI after reserves in the year after the sale, with the next buyer's taxes at the sale price under the same rule, divided by the exit cap. The exit cap is one input, 6.00% by default: the national multifamily average cap rate (5.75% in the first half of 2026) plus 0.25 point for the property aging. A blank target skips its test. The account's defaults (Settings) set the start of every screen.

Results are kept for 24 hours, then only a record of the screen (who, when, the verdict, the number of units). Your files are deleted once they are read (what is kept).

Questions: support (at) quickdata (dot) ai