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.
Two ways to use the API. Pick yours; the rest of this page is the reference for both.
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.
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.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.
Base URL https://data.quickdata.ai · Interactive reference: /docs/reference · Machine-readable: /openapi.json · Extractor version 2026-10-01.own-unit-ids-and-charge-lines
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.
| Key | What 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 key | Extract 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 key | What 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.
curl -X POST https://data.quickdata.ai/v1/extract \
-H "Authorization: Bearer $QD_KEY" \
-F "file=@rent_roll.xlsx"
import os, requests
r = requests.post("https://data.quickdata.ai/v1/extract",
headers={"Authorization": f"Bearer {os.environ['QD_KEY']}"},
files={"file": open("rent_roll.xlsx", "rb")},
timeout=180)
r.raise_for_status()
result = r.json()
print(result["report"]["status"], len(result["rows"]), "units")
for line in result["unit_mix"]["by_type"]:
print(line["unit_type"], line["units"], line["avg_rent_in_place"])
const form = new FormData();
form.append("file", new Blob([await fs.promises.readFile("rent_roll.xlsx")]), "rent_roll.xlsx");
const r = await fetch("https://data.quickdata.ai/v1/extract", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.QD_KEY}` },
body: form,
});
if (!r.ok) throw new Error((await r.json()).detail);
const { rows, unit_mix, report } = await r.json();
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.
/v1/extract| Field (multipart) | Required | Meaning |
|---|---|---|
file | yes | The rent roll: .xlsx .xlsm .xls .csv .pdf, up to 25 MB. Multi-sheet workbooks are fine: the rent roll sheet is found automatically. |
verify | no | true (default) also checks each unit against its own row in the document. false is a little faster. |
reference | no | Your 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). |
metadata | no | A small JSON object of your own (up to 2,000 characters), returned with the result and in webhooks. QuickData never reads it. |
| Header | Meaning |
|---|---|
X-QD-Workspace | The 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.
{
"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
}
Every value is a string ("" when the document has none); dates are ISO YYYY-MM-DD; money and areas are plain numbers without separators.
| Field | Meaning |
|---|---|
unit_num | Unit number as printed |
unit_type | Floor plan / unit type. When the document lists none and units differ in size, the square footage ("800 SF") |
sqft, br, bath | Square feet, bedrooms, bathrooms (where the document prints them) |
occupancy | OCC — 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 |
tenant | Resident name (blank for vacant units) |
move_in, move_out, lease_start, lease_end | Dates |
rent_market | Market rent |
rent_tenant | Rent billed to the resident (month-to-month rent included). Vacant units carry none. |
rent_gov_subsidy | Government subsidy / housing assistance (HAP) |
rent_total | Total rent charge = rent_tenant + rent_gov_subsidy (never other charges) |
is_mtm, mtm_charge | Month-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_charges | Every other charge code on the unit: [{"code": "Pet Rent", "amount": "25"}, ...] — parking, pets, utilities, amenity rent, concessions (negative) |
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.
| Field | Meaning |
|---|---|
unit_type, beds, baths | The type; beds and baths from the rows or the type's name |
avg_sqft, units, mix_pct, occupied | Average SF, number of units, share of the property (%), occupied units |
avg_rent_in_place, total_rent_in_place | In-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_market | Market rent, averaged and summed |
totals.total_sqft | Total rentable square feet |
| Field | Meaning |
|---|---|
status | ok, needs_review or failed — see below |
validation.checks | Each 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_reasons | Why a document needs review, in plain words |
validation.findings | Things 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_counts | When 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_confirmed | How 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_units | Units the per-unit check flagged in a document whose figures match — worth a glance, not holding the file |
validation.sections | A report that stacks several properties: the checks for each |
rent_note | Where 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." |
settings | The settings the document was read with (its workspace's, or the organization's) |
totals | The extracted and printed figures, and where the document prints them |
unit_check, unit_checks | The 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_check | Units 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, review | When 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" |
notes | Anything else about the file in plain words — e.g. a workbook with several rent roll tabs, and which one was read |
low_confidence | Cells to double-check (row = position in rows) and document lines that may be units but were not extracted |
source | The sheet, header row and columns the rows were read from |
| Status | What it means | Billed |
|---|---|---|
| ok | Unit, 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_review | One 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 |
| failed | No unit table could be read | no |
/v1/extract/t12 multipart file: .xlsx .xls .csv .pdf, up to 25 MB · one documentThe 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.
{
"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": { ... }
}
| Field | Meaning |
|---|---|
rows[].canonical_label | The 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_label | Your custom category, when the line is one (below) |
rows[].values, total | One 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) |
period | start and end of the 12 months as MM-YYYY; columns carries each month's first day |
rows[].review_note | Why 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.blocks | Total 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.checks | egi, 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.review | When 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_lines | Subtotals of the statement that differ from the lines printed above them (the statement's own arithmetic), when there are any |
report.flags | What 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.
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:
/v1/t12/categories → the standard categories by block, with your custom ones under their parents/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.
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.
/v1/jobs → 202 {"job_id": "j_…", "status": "queued", …}| Field | Meaning |
|---|---|
file | The document (multipart), as for an extract call |
file_url | Instead 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_type | auto (default: a rent roll or a T12, detected from the file), rent_roll or t12 |
reference, metadata, verify | As 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": {...}}
import os, time, requests
H = {"Authorization": f"Bearer {os.environ['QD_KEY']}"}
job = requests.post("https://data.quickdata.ai/v1/jobs", headers=H, files={"file": open("rent_roll.xlsx", "rb")},
data={"reference": "deal-77"}, timeout=60).json()
while True:
j = requests.get(f"https://data.quickdata.ai/v1/jobs/{job['job_id']}", headers=H, timeout=60).json()
if j["status"] in ("done", "failed"):
break
time.sleep(2)
if j["status"] == "done":
print(j["result_status"], len(j["result"]["rows"]), "rows")
else:
print(j["error_code"], j.get("error"))
/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./v1/jobs → your jobs, newest first, without results. Filters: status, batch, workspace./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)/v1/jobs/{job_id} → removes the result now, instead of leaving it to wait out its time| Status | Meaning |
|---|---|
queued | Waiting 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. |
processing | Being read. |
done | Read: 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. |
failed | No 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, expired | Only 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.
/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"}]}'
/v1/batches/{batch_id} → status (processing, completed), counts (total, queued, processing, done, failed), and each job with its file, status and result status/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./v1/batches → your batches, newest first, with their countsRegister 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.
/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./v1/webhooks · /v1/webhooks/{id} · PATCH (url, events, active, {"roll_secret": true}) · DELETE/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 answeredPOST 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 } }
| Event | When |
|---|---|
job.completed | A 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.failed | A job ended without a result; error_code says why. |
batch.completed | Every 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
import crypto from "node:crypto";
function verify(secret, rawBody, header, tolerance = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("="))); // the QD-Signature header
const good = crypto.createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
const same = good.length === (parts.v1 || "").length &&
crypto.timingSafeEqual(Buffer.from(good), Buffer.from(parts.v1));
return same && Math.abs(Date.now() / 1000 - Number(parts.t)) <= tolerance;
}
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.
/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_type | auto (default), rent_roll or t12 |
max_files | Above 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. |
reference | Your 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).
/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.
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 client | A company delegating access | |
|---|---|---|
| A workspace is | a client whose own system calls the API | one team, fund or office |
| It has its own | API keys, rent roll settings, T12 categories, usage, and (if you set one) daily limit | |
| Who calls the API | the client's system, with its workspace key; or your software for it, with an organization key and the X-QD-Workspace header | each team's integration with its workspace key; people in the console |
| The bill | one invoice to the organization; GET /v1/usage/summary breaks the documents out per workspace | |
/v1/organization → name, brand_name, plan, limits and how documents are handled · PATCH {"name": …, "brand_name": …}/v1/workspaces body {"name": "Acme Capital", "external_id": "client-1042", "daily_limit": 50, "settings": {…}, "t12_categories": [...]} → 201 the workspace (id: ws_…)/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.
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.
customer in the result, the job and the webhook. Nothing is created for it and there is no limit on how many you use.. _ : @ / + = -, starting with a letter or a digit. Use the stable id your own system has for the customer. An id that does not fit answers 400 invalid_request, and the document is not read.GET /v1/usage?customer=…, GET /v1/usage/summary?group_by=customer (a line for each customer, the busiest first; limit, offset and q page and search them; total_lines says how many there are) and GET /v1/usage/summary.csv./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"}]}/v1/members/{id}/invite → sends the invitation email again to someone who has not answered yet/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).
| Role | Can |
|---|---|
| Owner | Everything: the plan, the card and invoices, organization keys, and who the owners are. An organization always has at least one. |
| Admin | Workspaces, members (not owners), workspace keys, settings, T12 categories, webhooks, the activity log. |
| Member | Extract 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.
/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"}./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/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.
/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.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.
| Call | Returns |
|---|---|
GET /v1/me | Who 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=200 | Your 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/clear | Owners 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=workspace | Documents 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.csv | Documents per day · the log as CSV (the same filters) · the counts per customer as CSV |
GET /v1/settings · PUT /v1/settings | How 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-documents | The 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.
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.
| Plan | What 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.
/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.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.
/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./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.
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.
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.
| Limit | Value | Past it |
|---|---|---|
| File size | 25 MB | 413 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 organization | 8 by default (limits.documents_in_flight) | Extract calls: 429 too_many_in_flight. Queued jobs wait. |
| Documents per organization per rolling 24 hours | 200 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 hours | None 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 queue | 50 · 1,000 per organization | 400 batch_too_large · 429 queue_full |
| Rows in one MCP tool result | 400, then page with get_extraction. REST results are never paged. | |
| Workspaces · members · webhook endpoints | 2,000 active · 200 · 5 | 400 limit_reached |
| API keys · custom T12 categories | 10 active organization keys, 10 per workspace, 10 per member · 60 per list | 400 |
The per-minute, in-flight and daily limits can be raised for an organization: contact support.
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 received | Retry? |
|---|---|
| No response, or the connection dropped | A 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_flight | Yes, after the seconds in Retry-After |
429 daily_limit_reached, workspace_limit_reached | Later: the limit frees up as earlier documents pass out of the 24 hours |
502 / 503 | Yes, after a minute, then with growing pauses |
500 | No: an anonymized copy of the file is saved for our team, and the message carries a reference number to quote |
4xx otherwise | No: fix the request |
200 with report.status: "failed", or a job failed with no_table_found | No: no unit table (or monthly statement) was found in the file. Not billed. |
A job failed with interrupted | Yes: send it again |
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.
| Status | code | What to do |
|---|---|---|
| 400 | invalid_request, batch_too_large, limit_reached, last_owner, file_url_unreachable | Fix the request |
| 401 | invalid_api_key, session_expired, access_removed | Check the Authorization header; create a key in the console |
| 402 | payment_failed, account_inactive | An owner updates the card in the console, then retry |
| 403 | forbidden (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} |
| 404 | not_found, workspace_not_found, job_not_found, batch_not_found, key_not_found, member_not_found, webhook_not_found, result_not_available, unknown_document_type | Something 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 / 410 | external_id_taken, member_exists, job_not_finished, link_used, conflict / link_expired (an upload link) | — |
| 413 / 415 | file_too_large / unsupported_file_type | Up to 25 MB; .xlsx .xlsm .xls .csv .pdf |
| 422 | invalid_request | Fix the fields named in detail |
| 429 | rate_limited, too_many_in_flight, daily_limit_reached, workspace_limit_reached, queue_full | See limits and retries |
| 500 | processing_error | An anonymized copy of the file is saved for our team; the message carries a reference number |
| 502 / 503 | upstream_unavailable / unavailable, at_capacity | Retry after a minute, with backoff |
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.
| What | Kept |
|---|---|
| The file you send | Held 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 result | An 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 review | An 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 log | For 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 records | File 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 log | 7 days (never a document's content) · for as long as the account exists |
| What our AI processing providers receive | Text 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 secrets | Keys 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.
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.
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".
| Tool | What it does |
|---|---|
extract_rent_roll | Extract from a link (file_url: Dropbox, Google Drive, pre-signed S3) or a small file as base64 |
extract_rent_roll_file | Extract a file attached in the chat (ChatGPT passes attachments to tools) |
extract_t12, extract_t12_file | The same for a T12 operating statement |
request_upload_link | A 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_extraction | The result of an upload (and the next page of rows for large properties) |
screen_deal, get_deal_screen | With 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.
https://data.quickdata.ai/mcp. Leave the advanced OAuth fields empty.On Team and Enterprise plans an Owner adds the connector for the organization first.
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.
https://data.quickdata.ai/mcp, authentication OAuth.extract_rent_roll_file. (If it doesn't — some mobile versions — ask for an upload link.)https://data.quickdata.ai/mcp and sign in with your QuickData account when asked.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"}.
https://data.quickdata.ai/mcp, then sign in with your QuickData account.~/.gemini/settings.json:{
"mcpServers": {
"quickdata": {
"httpUrl": "https://data.quickdata.ai/mcp",
"headers": { "Authorization": "Bearer $QD_KEY" }
}
}
}
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.
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"}])
client.responses.create(
model=MODEL,
tools=[{"type": "mcp", "server_label": "quickdata", "server_url": "https://data.quickdata.ai/mcp",
"headers": {"Authorization": f"Bearer {QD_KEY}"}, "require_approval": "never"}],
input="Extract the rent roll at https://…/rr.xlsx and summarize the unit mix")
client.responses.create(
model=MODEL,
tools=[{"type": "mcp", "server_url": "https://data.quickdata.ai/mcp", "server_label": "quickdata",
"authorization": QD_KEY}],
input=[{"role": "user", "content": "Extract the rent roll at https://…/rr.xlsx"}])
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.
| Call | What it does |
|---|---|
POST /v1/screens | Multipart: 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}/screen | Everything the deal's page shows: the property, the T12, the unit mix, every assumption and target, the watch list. |
GET /v1/screens/{job}/xlsx | The Excel model: one page with live formulas, and the parsed rent roll and T12 as tabs. |
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