Documentation

Field Review

Confirm or correct individual extracted fields, and optionally hand one invoice to an external reviewer through a time-limited hosted link.


Field review records one decision per extracted field — confirmed, edited, or dismissed — next to the value the extractor originally produced. Nothing is overwritten: the correction is stored alongside the original, and the extracted invoice itself is left unchanged.

There are two ways in. Field reviews on an invoice are part of the public API and work with any active API key. Hosted review sessions hand a single invoice to someone who has no Parsurai account through a tokenised link, and are disabled by default.

Endpoints

MethodPathCredentialHosted-review flag
GET/v1/invoices/{invoice_id}/field-reviewsAPI key—
PATCH/v1/invoices/{invoice_id}/field-reviewsAPI key—
POST/v1/invoices/{invoice_id}/review-sessionsAPI keyRequired
GET/v1/review-sessions/{token}Session tokenRequired
GET/v1/review-sessions/{token}/field-reviewsSession tokenRequired
PATCH/v1/review-sessions/{token}/field-reviewsSession tokenRequired

The two /v1/invoices/{invoice_id}/field-reviews routes are not plan-gated and not flag-gated. Everything under Hosted Review Sessions below is.

Reading Field Reviews

curl "https://api.parsurai.com/v1/invoices/2f7d7f8a-32fb-4a7c-a3b1-53b61b64252f/field-reviews" \
  -H "X-API-Key: $PARSURAI_API_KEY"
{
  "invoice_id": "2f7d7f8a-32fb-4a7c-a3b1-53b61b64252f",
  "reviews": [
    {
      "field_path": "vendor.iban",
      "original_value": "DE44500105175407324931",
      "corrected_value": "DE02120300000000202051",
      "review_state": "edited",
      "confidence": null,
      "reason_codes": ["low_confidence", "manual_edit"],
      "reviewed_by_user_id": "8b2a1e4c-5f31-4d0b-9a77-6c3e2d1f4a90",
      "reviewed_at": "2026-06-14T09:12:44Z",
      "version": 1
    }
  ],
  "summary": {
    "needs_review": 7,
    "edited": 1,
    "confirmed": 0,
    "dismissed": 0,
    "review_completion": 0.125
  }
}
FieldTypeDescription
field_pathstringThe reviewed field, in dotted path notation
original_valueanyThe value read off the document, resolved when the review was written
corrected_valueanyThe value the reviewer supplied, or null
review_statestringneeds_review, confirmed, edited, or dismissed
confidencenumber | nullReserved. Reviews written through this API always carry null
reason_codesstring[]Why the field was flagged or changed
reviewed_by_user_idUUID | nullThe account the decision was recorded against
reviewed_attimestamp | nullWhen the decision was recorded
versionintegerOptimistic-concurrency counter, incremented on every write

reviews contains only fields that have actually been reviewed. An invoice nobody has touched returns an empty array.

Summary

summary is computed over a fixed set of eight core fields — vendor.name, vendor.iban, buyer.name, invoice_number, issue_date, due_date, total_amount.amount, currency — plus any other field that already has a review. A core field with no review counts as needs_review.

FieldMeaning
needs_reviewFields still awaiting a decision
editedFields whose value was corrected
confirmedFields accepted as extracted
dismissedFields the reviewer decided not to act on
review_completion(confirmed + edited + dismissed) / total, from 0.0 to 1.0

So an invoice with no reviews at all reports needs_review: 8 and review_completion: 0.0.

Submitting Decisions

PATCH /v1/invoices/{invoice_id}/field-reviews applies a batch of decisions in one transaction. Either every update in the body lands, or none does.

curl -X PATCH "https://api.parsurai.com/v1/invoices/2f7d7f8a-32fb-4a7c-a3b1-53b61b64252f/field-reviews" \
  -H "X-API-Key: $PARSURAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "updates": [
      {
        "field_path": "vendor.iban",
        "review_state": "edited",
        "corrected_value": "DE02120300000000202051",
        "reason_codes": ["manual_edit"],
        "expected_version": 0
      },
      {
        "field_path": "invoice_number",
        "review_state": "confirmed",
        "expected_version": 1
      }
    ]
  }'
FieldTypeRequiredDescription
field_pathstringYesMust be a reviewable path (see below)
review_statestringYesneeds_review, confirmed, edited, or dismissed
corrected_valueanyNoDefaults to null
reason_codesstring[]NoDefaults to an empty list
expected_versionintegerYesThe version you last read, 0 or greater

The response is the same batch payload the GET returns, reflecting every review on the invoice after the write.

Optimistic Concurrency

A field that has never been reviewed has no row, and that absence is version 0. Send expected_version: 0 for the first decision on a field; the row it creates has version: 1. Every later write must send the version you last read, and bumps it by one.

If the stored version differs, the whole batch is rejected with 409 field_review_conflict. The error carries the current server-side review for the field that conflicted, so you can show the reviewer what changed underneath them and retry with the fresh version:

{
  "code": "field_review_conflict",
  "message": "Field review for path 'vendor.iban' has version conflict. DB: 2, expected: 1.",
  "current_review": {
    "field_path": "vendor.iban",
    "review_state": "confirmed",
    "version": 2
  }
}

Reason Codes

CodeMeaning
low_confidenceThe extractor was unsure of the value
validator_warningA validation rule flagged the value
missing_valueThe field was absent from the document
manual_editA person changed the value
source_mismatchThe value disagrees with another source
stale_updateThe value was written against outdated data

Reviewable Fields

Only scalar fields on the invoice can be reviewed. Submitting anything else returns 422 invalid_field_review.

  • Party fields on vendor. and buyer.: name, address, email, phone, tax_id, iban, bank_name
  • invoice_number, currency, payment_instructions
  • issue_date, due_date, billing_period_start, billing_period_end
  • subtotal.amount, discount_total.amount, tax_total.amount, total_amount.amount
  • reference_numbers[n].value
  • line_items[n].: description, quantity, unit, unit_price, discount, total, tax_rate
  • tax_breakdown[n].: rate, taxable_amount, tax_amount

A path that resolves to an object or an array is refused as well — only leaf values are reviewable.

Error Handling

StatusCodeTypical cause
404—The invoice is not in your workspace
409field_review_conflictexpected_version does not match the stored version
422invalid_field_reviewThe path is not reviewable, or resolves to a non-scalar

404 covers a missing invoice and an invoice belonging to another workspace identically, so the API cannot be used to confirm that someone else's invoice ID exists.

Hosted Review Sessions

Hosted review sessions are enabled per deployment and are off by default.

The gate is server-side and fails closed: hosted_review_sessions_enabled defaults to false, so an unset environment variable means disabled. There is no bypass header, query parameter, or internal route. While the surface is off, every route in this section answers 404 with the code hosted_review_disabled — identically, whether or not the token in the URL was ever real.

Check with the operator of your deployment before building against it, and assume the surface is off unless it has been turned on deliberately.

A review session hands one invoice to a reviewer who has no Parsurai account. The session token is the only credential: it authenticates on its own, with no login behind it.

Creating a Session

POST /v1/invoices/{invoice_id}/review-sessions takes the origin allowed to embed the reviewer page.

curl -X POST "https://api.parsurai.com/v1/invoices/2f7d7f8a-32fb-4a7c-a3b1-53b61b64252f/review-sessions" \
  -H "X-API-Key: $PARSURAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"allowed_origin": "https://ap.example.com"}'

allowed_origin is a serialized origin — scheme, host, and optional port, with no path and no trailing slash — or * to opt out of the check.

{
  "token": "0m3Zx1qKQ8w7nS2vB4tR9pL6dY5hJ0cA1fG3eU8iO2s",
  "expires_at": "2026-06-14T10:12:44Z",
  "allowed_origin": "https://ap.example.com"
}

The token is returned exactly once and stored only as a hash, so it cannot be read back later. It is valid for one hour from creation.

Using a Session

The reviewer opens https://www.parsurai.com/{locale}/review/{token}, which renders Parsurai's hosted review page against the session. Your own front end can call the session routes directly instead:

MethodPathReturns
GET/v1/review-sessions/{token}invoice_id, allowed_origin, expires_at, invoice_json
GET/v1/review-sessions/{token}/field-reviewsThe same batch payload as the invoice route
PATCH/v1/review-sessions/{token}/field-reviewsThe same batch payload, after the write

invoice_json is the raw extraction result for the invoice behind the token.

The token travels in the URL path, so these responses are sent with Cache-Control: no-store, private and Referrer-Policy: no-referrer. Treat the link itself as a credential: anything that logs, caches, or forwards the URL leaks it.

StatusCause
401The token is unknown, or the session has expired — an expired session is deleted on the spot
401The account the link was issued against no longer exists
428That account has an outstanding legal re-acceptance, so the link is re-gated exactly as a signed-in user would be
404Hosted review sessions are not enabled on this deployment

Webhook Events

Field review emits two of the events listed in the webhooks overview:

  • review.required when a review session is created, so it only ever fires where hosted review sessions are enabled. Its payload carries invoice_id, review_session_id, and fields_flagged, the number of fields whose extraction confidence was below 0.85.
  • review.completed after a batch of decisions leaves summary.needs_review at zero. Its payload carries invoice_id and review_session_id. This one fires for decisions submitted through the invoice routes as well; a batch that did not come from a session gets a generated review_session_id.

Read the current batch before every write. expected_version is what stops two reviewers from silently overwriting each other, and it is only useful if the version you send is the one you just read.