AI extraction gives you answers that look right. Tickmarks tells you which ones are.

PreviewExtraction is live, with pricing still proposed and billing not live yet. Read the docs or get an API key.

Describe your fields and the rules between them. Every value comes back checked against those rules, with the text it came from. When something doesn't add up, you see exactly where.

Invoice INV-004211total == net + vat

Tickmarks lists the repairs that would close it. It doesn't pick one; you do.

Close the loop

Doesn't close. Net plus VAT is €121.00 and the total says €112.00. Nothing in the invoice says which is wrong, so all 3 fields come back conflict and no value goes into data.

Closes. The total becomes €121.00, derived from net plus VAT.

Closes. VAT becomes €12.00, derived from the total minus net.

Closes. Net becomes €91.00, derived from the total minus VAT.

A document, a request, and what comes back

The request sends the document text and a schema that names the fields and the rules between them. The response says, for every field, what Tickmarks made of it.

Choose an example

An invoice that doesn't add up

Net €100, VAT €21, total €112. One of those is wrong, and nothing in the invoice says which. All 3 fields come back conflict, with no value in data and the possible repairs listed. A person makes the call.

Document, and what came back for each field
TextFieldStatusSource
Invoice INV-004211numberresolvedobserved
Net: EUR 100.00netconflictwithheld
VAT (21%): EUR 21.00vatconflictwithheld
Total: EUR 112.00totalconflictwithheld
Request POST /v1/extractions
{
  "document": "Invoice INV-004211. Net: EUR 100.00. VAT (21%): EUR 21.00. Total: EUR 112.00.",
  "language": "en",
  "schema": {
    "hydra_schema": "1.0",
    "name": "invoice",
    "fields": [
      { "name": "number", "type": "string" },
      { "name": "net", "type": "money", "currency": "EUR" },
      { "name": "vat", "type": "money", "currency": "EUR" },
      { "name": "total", "type": "money", "currency": "EUR" }
    ],
    "relations": [{ "id": "sum", "expr": "total == net + vat" }]
  }
}
Response
{
  "data": {
    "number": "INV-004211",
    "net": null, "vat": null, "total": null
  },
  "fields": {
    "number": { "status": "resolved", "source": "observed" },
    "net": { "status": "conflict" },
    "vat": { "status": "conflict" },
    "total": { "status": "conflict" }
  },
  "components": [{
    "conflicts": [{
      "relation": "sum",
      "repairs": [
        { "drops": "total", "posterior": 0.392482,
          "derived": { "amount": "121.00", "currency": "EUR" } },
        { "drops": "vat", "posterior": 0.333061,
          "derived": { "amount": "12.00", "currency": "EUR" } },
        { "drops": "net", "posterior": 0.274457,
          "derived": { "amount": "91.00", "currency": "EUR" } }
      ]
    }]
  }]
}

The extraction's response, shortened to the parts that matter here.

A lease with no end date

The lease starts on 1 March 2027 and runs for 12 months. It never states an end date, but the relation can compute one. The end date comes back resolved with its source set to derived, and derived_by points to how.

Document, and what came back for each field
TextFieldStatusSource
The lease starts on 1 March 2027startDateresolvedobserved
and runs for 12 months.durationresolvedobserved
Not in the textendDateresolvedderived
Request POST /v1/extractions
{
  "document": "The lease starts on 1 March 2027 and runs for 12 months.",
  "language": "en",
  "schema": {
    "hydra_schema": "1.0",
    "name": "lease",
    "fields": [
      { "name": "startDate", "type": "date" },
      { "name": "endDate", "type": "date" },
      { "name": "duration", "type": "duration", "basis": "calendar" }
    ],
    "relations": [
      { "id": "term", "expr": "endDate == startDate + duration" }
    ]
  }
}
Response
{
  "data": {
    "startDate": "2027-03-01",
    "endDate": "2028-03-01",
    "duration": "P12M"
  },
  "fields": {
    "endDate": {
      "status": "resolved",
      "source": "derived",
      "value": "2028-03-01",
      "confidence": { "posterior": 0.8827 },
      "derived_by": ["d0"]
    }
  },
  "derivations": {
    "d0": {
      "relation": "term",
      "solved_for": "endDate",
      "result": "2028-03-01"
    }
  }
}

The extraction's response, shortened to the parts that matter here.

An invoice that adds up

Net €100, VAT €21, total €121. Every value fits the relation, so every field is resolved and data is ready to use as it is.

Document, and what came back for each field
TextFieldStatusSource
Invoice INV-004211numberresolvedobserved
Net: EUR 100.00netresolvedobserved
VAT (21%): EUR 21.00vatresolvedobserved
Total: EUR 121.00totalresolvedobserved
Request POST /v1/extractions
{
  "document": "Invoice INV-004211. Net: EUR 100.00. VAT (21%): EUR 21.00. Total: EUR 121.00.",
  "language": "en",
  "schema": {
    "hydra_schema": "1.0",
    "name": "invoice",
    "fields": [
      { "name": "number", "type": "string" },
      { "name": "net", "type": "money", "currency": "EUR" },
      { "name": "vat", "type": "money", "currency": "EUR" },
      { "name": "total", "type": "money", "currency": "EUR" }
    ],
    "relations": [{ "id": "sum", "expr": "total == net + vat" }]
  }
}
Response
{
  "data": {
    "number": "INV-004211",
    "net": { "amount": "100.00", "currency": "EUR" },
    "vat": { "amount": "21.00", "currency": "EUR" },
    "total": { "amount": "121.00", "currency": "EUR" }
  },
  "fields": {
    "total": { "status": "resolved", "source": "observed" }
  }
}

The extraction's response, shortened to the parts that matter here.

resolved
Fits every rule. Its value is in data.
conflict
Values that break a relation together. Withheld, with the repairs listed.
missing
Not in the document, and no relation can compute it.
observed
Read from the document, with the text it came from.
derived
Not in the document. Computed by a relation, with derived_by saying how.

Checked against your rules, not just extracted

Your rules decide the answer

Relations like total == net + vat aren't a check run afterwards. Tickmarks uses them to choose between candidate values, so the answer you get is one that fits.

An invoice shows two totals, €121 and €112. Tickmarks returns €121, the only one that equals net plus VAT. When no candidate fits, as in the invoice above, you get a conflict instead.

Missing values are worked out, and labelled

If the rules can compute a value the document doesn't state, Tickmarks computes it and says so. You always know what was read and what was derived.

A lease starts on 1 March 2027 and runs 12 months. It never states an end date, but endDate == startDate + duration computes one:

"endDate": {
  "status": "resolved",
  "source": "derived",
  "value": "2028-03-01",
  "derived_by": ["d0"]
}

When it doesn't add up, you'll know

If the document contradicts itself, Tickmarks doesn't quietly pick a side. It marks the fields as a conflict and shows what each repair would give, so a person makes the call.

Net €100, VAT €21, total €112: all 3 fields come back conflict, with 3 repairs listed. Try them in the loop.

Also in every response: the exact source text for each value, a confidence and a status. Arithmetic is exact, so 0.1 + 0.2 == 0.3 is true, and schemas are type-checked when they compile.

For developers building invoice, lease and contract workflows

Indie developers, startups and small teams putting document data into a product or an internal tool. Stop writing the checks after the model. Put them in the schema.

And for the people relying on the numbers: finance and legal teams, bookkeepers, freelancers and small businesses. If the total isn't net plus VAT, you'll hear about it.

Tickmarks reads text. For a scan or a photo, run OCR first and send the text; built-in OCR is planned. Scans, photos and PDFs.

Planned price: €0.005 per read, excl. VAT

Prepaid, 2,000 reads for €10, and 10 free reads for every new account. Most invoices take one read. No contract. How reads add up

Plausible isn't the same as right.

See how a schema, a relation and a response fit together, then write the rules your documents already follow.