Docs
A sheet is a contract about what one row must be: named fields with real types, required and unique rules, and the exact set of values an enum accepts. Throw a messy spreadsheet at it and you get back mapped, validated rows, cell by cell. Nothing exports until every cell passes.
What you can do
- Import your own files. Drop a CSV, paste cells copied from Sheets or Excel, or hand it a PDF price list or a screenshot of a table. Columns are matched to your fields, values are coerced (
$185,000to a number,8/4/26to a date), and whatever cannot be coerced is flagged for you. - Paste a link. A link to a page becomes one row: the product's name, price, image, and whatever else your fields ask for, read off what the page publishes about itself. Paste several at once and you get a row each, which is how a wish list or a shortlist gets built. A link that points at a file (a Google Sheet, a CSV, a spreadsheet on Dropbox) brings in the whole file instead.
- Collect data from other people. Turn on an import link and anyone (a customer, a vendor, a colleague with no account) can send you rows. They map and fix their own file; it reaches you already clean.
- Let an agent do the intake. Connect the MCP server and your assistant creates sheets, imports rows, and fixes cells against the same engine. Export refuses while anything is still invalid, so the assistant cannot talk its way past bad data.
- See who changed what. Every cell records the actor that wrote it: you, an assistant working over MCP, whoever sent the file, or the sheet's memory of an earlier correction. The grid shows the value each change replaced, and puts back everything from any one of those sources in a single action, so an assistant's forty edits are forty you can review and revert together.
- Send the result. Export clean CSV or JSON for a system, or a one-page PDF report for a person.
Start here
- Define a sheet. Describe the rows you receive (“a client: name, email, size, budget”) or drop a sample file, and the fields are drafted for you. Every name, type, and rule stays editable inline.
- Import something messy. The grid shows exactly which cells failed and why. Fix them once: corrections you make on rejected values are remembered, and the next file that carries the same mistake arrives already fixed.
- Wire it in. Add the MCP connector to your assistant, or share an import link with whoever keeps sending you spreadsheets.
Add the MCP connector to any assistant (Claude, ChatGPT, Cursor, Zed): create sheets, import rows, and get an export that refuses while the data is dirty.
Priced on imports and document pages, not seats. The trial is the whole product, import links included, for 14 days; Pro is what keeps it running.
Authentication
Sheets are private to your workspace. Sign in to define one. An assistant connects over MCP with a one-time sign-in (no API key to copy around), covered in Connect your AI. Import links are the one unauthenticated path: the key in the URL is the credential, it only ever writes, and it never reveals what the sheet already holds.
curl -X POST https://typesheet.com/api/import/<key>/imports \
-H "Content-Type: application/json" \
-d '{"columns":["Name","Email"],"rows":[["Maya Chen","maya@northwind.co"]]}'Webhooks
A sheet can POST its certified records to a URL of yours the moment an import is marked ready. Every request is signed, retried on failure, and written down: the sheet’s rail shows the last few deliveries and why any of them failed.
The Typesheet-Signature header carries a timestamp and an HMAC-SHA256 of timestamp.bodyunder the sheet’s signing secret, which is in the rail beside the destination. Verify it before trusting the payload: the endpoint is a public URL, and the signature is what separates our POST from anyone else’s. Reject a timestamp more than a few minutes old, and compare in constant time.
import { createHmac, timingSafeEqual } from "node:crypto"
export function verify(rawBody: string, header: string, secret: string): boolean {
const parts = new Map(header.split(",").map((p) => p.trim().split("=")))
const t = Number(parts.get("t"))
const v1 = parts.get("v1")
if (!Number.isFinite(t) || !v1) return false
// Refuse a replay of a captured request.
if (Math.abs(Date.now() / 1000 - t) > 300) return false
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest()
const got = Buffer.from(v1, "hex")
return expected.length === got.length && timingSafeEqual(expected, got)
}Retries reuse the same Typesheet-Delivery id, so make your handler idempotent on it. Four attempts go out over about thirty seconds; a 4xx other than 408 or 429 is taken as a real refusal and is not retried. Answer 2xx once you have the payload, and do your own work afterwards.