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
| Method | Path | Credential | Hosted-review flag |
|---|---|---|---|
GET | /v1/invoices/{invoice_id}/field-reviews | API key | — |
PATCH | /v1/invoices/{invoice_id}/field-reviews | API key | — |
POST | /v1/invoices/{invoice_id}/review-sessions | API key | Required |
GET | /v1/review-sessions/{token} | Session token | Required |
GET | /v1/review-sessions/{token}/field-reviews | Session token | Required |
PATCH | /v1/review-sessions/{token}/field-reviews | Session token | Required |
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
}
}
| Field | Type | Description |
|---|---|---|
field_path | string | The reviewed field, in dotted path notation |
original_value | any | The value read off the document, resolved when the review was written |
corrected_value | any | The value the reviewer supplied, or null |
review_state | string | needs_review, confirmed, edited, or dismissed |
confidence | number | null | Reserved. Reviews written through this API always carry null |
reason_codes | string[] | Why the field was flagged or changed |
reviewed_by_user_id | UUID | null | The account the decision was recorded against |
reviewed_at | timestamp | null | When the decision was recorded |
version | integer | Optimistic-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.
| Field | Meaning |
|---|---|
needs_review | Fields still awaiting a decision |
edited | Fields whose value was corrected |
confirmed | Fields accepted as extracted |
dismissed | Fields 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
}
]
}'
| Field | Type | Required | Description |
|---|---|---|---|
field_path | string | Yes | Must be a reviewable path (see below) |
review_state | string | Yes | needs_review, confirmed, edited, or dismissed |
corrected_value | any | No | Defaults to null |
reason_codes | string[] | No | Defaults to an empty list |
expected_version | integer | Yes | The 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
| Code | Meaning |
|---|---|
low_confidence | The extractor was unsure of the value |
validator_warning | A validation rule flagged the value |
missing_value | The field was absent from the document |
manual_edit | A person changed the value |
source_mismatch | The value disagrees with another source |
stale_update | The 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.andbuyer.:name,address,email,phone,tax_id,iban,bank_name invoice_number,currency,payment_instructionsissue_date,due_date,billing_period_start,billing_period_endsubtotal.amount,discount_total.amount,tax_total.amount,total_amount.amountreference_numbers[n].valueline_items[n].:description,quantity,unit,unit_price,discount,total,tax_ratetax_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
| Status | Code | Typical cause |
|---|---|---|
404 | — | The invoice is not in your workspace |
409 | field_review_conflict | expected_version does not match the stored version |
422 | invalid_field_review | The 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:
| Method | Path | Returns |
|---|---|---|
GET | /v1/review-sessions/{token} | invoice_id, allowed_origin, expires_at, invoice_json |
GET | /v1/review-sessions/{token}/field-reviews | The same batch payload as the invoice route |
PATCH | /v1/review-sessions/{token}/field-reviews | The 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.
When a Link Stops Working
| Status | Cause |
|---|---|
401 | The token is unknown, or the session has expired — an expired session is deleted on the spot |
401 | The account the link was issued against no longer exists |
428 | That account has an outstanding legal re-acceptance, so the link is re-gated exactly as a signed-in user would be |
404 | Hosted review sessions are not enabled on this deployment |
Webhook Events
Field review emits two of the events listed in the webhooks overview:
review.requiredwhen a review session is created, so it only ever fires where hosted review sessions are enabled. Its payload carriesinvoice_id,review_session_id, andfields_flagged, the number of fields whose extraction confidence was below0.85.review.completedafter a batch of decisions leavessummary.needs_reviewat zero. Its payload carriesinvoice_idandreview_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 generatedreview_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.