Dataset sync

Read the cited dataset incrementally — cursors, updated_since, per-cell citations

GET /v1/blueprints/{blueprint_id}/entities/{entity_key} is the canonical read: the cross-run, canonical rows of one entity, with citations. Entity keys come from GET /v1/blueprints/{blueprint_id}, and the ids from GET /v1/blueprints (discover, don't guess).

bash
curl -s "$BASE_URL/v1/blueprints/$BP/entities/companies?limit=50&receipts=true" \
  -H "Authorization: Bearer $DEEPSIEVE_API_KEY"
Response· json
{"data": [{"id": "…", "updated_at": "2026-08-03T11:02:44+00:00",
           "name": "Acme KK", "hq_city": "Osaka",
           "citations": {"hq_city": {"value": "Osaka", "confidence": 0.93,
                                     "evidence_id": "…",
                                     "source_urls": ["https://…"],
                                     "retrieved_at": "2026-08-01T…"}}}],
 "has_more": true, "next_cursor": "MjAyNi0wOC0wMy4uLg",
 "entity": {"key": "companies", "columns": [{"name": "name", "type": "text"}]},
 "blueprint": {"domain_name": "…", "entities": ["companies"]}}

Parameters:

  • limit (≤200) + cursor — keyset pagination ordered by (updated_at, id) ascending. Stable while rows are inserted.
  • updated_since=<iso-timestamp> — the sync primitive: only rows changed since your last sync. Store your high-water mark (or the final cursor) and poll on your own schedule — or let the dataset.updated webhook tell you when.
  • fields=name,hq_city — column subset.
  • receipts=true — nest per-cell citations. This is the differentiator; use it.

Reading a citation honestly

Each cell carries the same verdict the app shows, not just a number:

FieldMeaning
verdictsupported · leans-supported · uncertain · corrected · contradicted · unverifiable · not checked
confidence0–1 float, or null when the cell was never graded
citedfalse when the cell has no source URL — never present it as sourced
verdict_notethe QA agent's short justification
source_urls, evidence_id, retrieved_atprovenance
statedfor a converted quantity, the value and unit exactly as the source stated them before conversion ({"value": "5.0", "unit": "mi"} beside a stored 8.05 km); value is a decimal string, so the source's precision survives. null when nothing was converted
besiderows recorded beside the cell, newest first; empty when there are none. Each has a kind: also_stated (another value the same run stated for the cell), not_applied (a value later research found and did not apply; reason says why, in plain words) withheld (found only outside the run's allowed sources, so it has no value), non_conforming (a value that broke the column's rule) or replaced (a value the cell held until a newer one replaced it, kept as history). Each also carries its own verdict, source_hosts, note and captured_at

A contradicted or cited: false cell is a signal to surface to a human, not a fact to act on.

The citation is always the source of the value the cell holds. Its value, verdict and source_urls belong to that value. A row in beside, however new, does not replace it: read a beside row's verdict as that row's, never as the cell's. The rows are there so you see what the app's popover shows: a value the same run also stated, or a newer finding that was not applied.

Preview caps (unsubscribed accounts)

Free-preview accounts receive only the 10 preview rows per entity: the best-evidenced rows, in the order of the cross-run report (the same 10 a saved report tab previews), which can change as research re-checks them. When the cap withholds rows, the response says so explicitly — "truncated": true, "preview_row_cap": 10, plus has_more: true — and paging past the cap requires a subscription. A table that fits within the cap is returned whole, with truncated: false; exports say the same in their metadata and X-Truncated header. Never report a truncated preview as the complete dataset.

Rows can change identity after you have read them. A person may mark two rows as the same thing (a merge), and a rerun may find that a record was renamed (renamed: renamed in place, the old name kept as an alias) or replaced by another record (succeeded_by: the two are linked). GET /v1/changes lists all three, newest first, in one feed:

bash
curl -s "$BASE_URL/v1/changes?live=true" -H "Authorization: Bearer $DEEPSIEVE_API_KEY"
Response· json
{"data": [{"object": "change", "id": "…", "kind": "merge", "entity": "companies",
           "record_id": "…", "by": "user", "survivor_name": "Clover",
           "merged_names": ["Clover Network"], "undoable": true, "undo_blocked": null,
           "created_at": "…", "undone_at": null, "blueprint": {"id": "…", "name": "…"}}],
 "has_more": false, "next_cursor": null, "blueprint": {"id": "…", "name": "…"}}

Filter with entity, kind, by (user, automatic, or operator for merges DeepSieve's own dedup made) and live; page with limit and cursor. A person's rename through Report Chat is listed too, by: "user". If you sync by record id, this is how you learn that a row you hold was folded into another or renamed. The feed is ordered by when each change was made, so an UNDO does not add a new item at the top: it sets undone_at on the original one, wherever it sits. To catch undos, re-read the feed from the start rather than only its first page.

POST /v1/changes/{change_id}/undo undoes one, exactly as the app's Undo does: a merge brings the rows back with their evidence, a rename restores the old name, and a link is removed. Researched values stay. It changes your customer's data, so an agent acting for a person should confirm first. It needs data:write and the member role; a viewer is refused whatever the key carries. Send an Idempotency-Key so a retry returns the first result, not a 409.

A record's many-to-many links (for example, the people a record lists) can be wrong. POST /v1/links/remove takes one linked record out of a record's links, as a person does in the app. entity is the side whose record lists the link; to_entity is the linked entity.

bash
curl -s -X POST $BASE_URL/v1/links/remove \
  -H "Authorization: Bearer $DEEPSIEVE_API_KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"blueprint_id": "…", "entity": "<entity>", "record_id": "…", "to_entity": "<linked entity>", "target_id": "…"}'

Research does not add that link back until the removal is undone. If it was the record's primary link, the next link becomes primary. The answer is the link_removed change with its change_id, and the same change appears in GET /v1/changes with kind: "link_removed". Its link object says what was removed: record_name, to_entity, target_id, target_name, was_primary (an undo makes it primary again) and removed_via (session for a person in the app; api_key or oauth for an agent). POST /v1/changes/{change_id}/undo puts the link back, at its old position when that still fits.

The removal is reversible, so there is no confirmation step, but it changes your customer's data: tell your human what you removed. A link the record does not have (already removed, or never linked) is 409 link_not_present; an unknown entity, record or relationship is 404 not_found. It needs data:write and the member role. The MCP tool is remove_link, and the CLI is deepsieve links remove.

Cells a person set on purpose

A value a person set on purpose (in chat, or by applying newer research) wins over research. A rerun never overwrites it; what research finds since is kept beside it. When you sync cell citations from GET /v1/data, a held cell's citation carries hold:

Response· json
{"hold": {"source": "chat_edit", "set_at": "…",
          "pending_finding": {"evidence_id": "…", "value": "J. Austen",
                              "source_urls": ["https://…"], "found_at": "…",
                              "stage": "extract"}}}

GET /v1/holds?pending=true lists every held cell with a pending finding. POST /v1/cells/{cell_id}/apply with {"finding_evidence_id": "…"} takes the finding (the cell stays protected); if research found something newer since, it is 409 finding_changed and nothing is written. POST /v1/cells/{cell_id}/keep, with the same body, keeps your human's value and dismisses the finding: research does not raise that value again unless it cites a new page. POST /v1/cells/{cell_id}/release stops protecting the cell, and research updates it on its next pass. All three change your customer's data and need data:write and the member role.

Cells stated more than one way

When one research run states a cell more than one way, the cell shows the better-supported value, and the citation says which and why:

json
{"value": "Jane Austen", "evidence_id": "…",
 "selection": {"state": "conflicting",
               "reason": "the quality check supported 2 different values; …"},
 "beside": [{"kind": "also_stated", "evidence_id": "…", "value": "J. Austen",
             "verdict": "supported", "choosable": true, "refused_by": []}]}

selection.state is picked (the run chose), repicked (the quality check confirmed or moved the choice), conflicting (it supported two or more values: a person should choose) or chosen (a person chose); null when the run stated the cell one way. GET /v1/conflicts lists every conflicting cell with the values to choose from. POST /v1/cells/{cell_id}/choose with {"evidence_id": "…", "expected_value_evidence_id": "…"} shows one of them; if the cell changed since your human looked, it is 409 choice_changed and nothing is written. The cell is then held, so later research keeps a different value beside it, and the choice is a choice change in GET /v1/changes, undone with POST /v1/changes/{change_id}/undo. A sync that mirrors the citation sees the value change and selection.state become chosen.

Marking rows as duplicates

POST /v1/merges/preview shows what merging rows would do, and writes nothing. POST /v1/merges merges them, as the app's "Mark as duplicate" does: the survivor keeps its row, the merged rows' values, citations and child rows move to it, and later runs fold the merged names into it. Two rows' lists of links are combined, never a tie (links_combined in the preview).

Send the preview's preview_digest with the merge: it applies exactly the merge you previewed. If anything changed since (a conflict decided differently, a tie's values changed, a cell newly filled), it is 409 merge_changed, nothing is written, and error.details.preview is the merge as it stands now, with its own digest to send once you have checked it. To merge without a preview on purpose, send apply_without_preview: true; sending neither is 422 preview_required. The response's preview_checked says which.

bash
curl -s -X POST $BASE_URL/v1/merges -H "Authorization: Bearer $DEEPSIEVE_API_KEY" \
  -H "Content-Type: application/json" -H "Idempotency-Key: $(uuidgen)" \
  -d '{"entity": "companies", "survivor_id": "<uuid>", "loser_ids": ["<uuid>"],
       "choices": {"hq_city::<loser uuid>": "loser"},
       "preview_digest": "<preview_digest from the preview>"}'

Where the rows disagree, the better-evidenced value wins. Where the evidence is equal it is a tie, and nobody has decided it: the merge is refused 409 ties_unresolved until you send a choice for each tie (the preview lists them as unresolved_ties), or accept_survivor_on_ties: true. With three or more rows, one choice can change the conflicts after it, so preview again with your choices until unresolved_ties is empty. Every check runs inside the merge itself: a refused merge writes nothing. Rows of an entity whose names are unique per parent, sitting under different parents, are refused 409 merge_across_parents naming the parents: merge only within one parent. The response's change_id undoes the merge with POST /v1/changes/{change_id}/undo.

A tie you settle is a value set on purpose: later research does not overwrite it (it is stored beside it, as "Newer research found X"). A value a person set on purpose in either row wins over research in the merge (basis: "held"). If a cell the merge filled changes afterwards, its undo is a 409 naming the cell; re-send with {"overwrite_changes": true} to undo anyway.

Bulk export

GET /v1/export?format=ndjson|json|csv[&entity=][&blueprint_id=] — NDJSON is the agent default: line 1 is a metadata record with the Blueprint schema, then one record per line with citations. CSV is per-entity and drops citations. blueprint_id exports a specific Blueprint by id (leaves the active one unchanged); omitted, it exports the active one. The old name, workspace_id, still works until its sunset date and says so in Deprecation/Sunset headers and a warnings entry in the metadata record; see the changelog.

Each NDJSON and JSON record carries _identity: where the row came from and went, with ids, so a sync that holds rows by id can follow them.

Response· json
{"_identity": {"formerly": ["Clover Network"],
              "succeeded_by": null,
              "succeeded_from": [{"id": "…", "name": "Old Imprint"}],
              "merged_from": ["Clover Inc"]}}

formerly is the names a rerun renamed the row from, succeeded_by the record that replaced it, succeeded_from the records it replaced, and merged_from the rows a person merged into it. Every field is present on every row. Undone changes are left out, only a record's newest link counts, and a successor that was later merged away is followed to the row it was merged into. Names are as they were at the time of the change. A free-preview export references only rows it contains.

Free-preview (unsubscribed) accounts are capped to the preview rows of each table on both surfaces (the same best-evidenced rows a saved report tab previews) — flagged by truncated + preview_row_cap. A row outside the preview is 402 payment_required, and a row can move in or out of it as research re-checks it.

Dataset sync — DeepSieve API