Skip to content

OCR + biometrics flow

A document-based onboarding answers four questions, always in this order:

  1. What document is this?
  2. What is written on it — and does it match the Federal Revenue Service?
  3. Is the person in the selfie the same one on the document?
  4. Does that CPF correspond to a registered person?

Each question has an endpoint, and the reference describes all of them in detail. What it does not describe is the order, what to do when a step does not return what you expect and how the result of one step feeds the next — which is the subject of this page.

The first question is the only optional one: Full OCR already classifies the document internally. When it is worth calling the classifier first is the first decision of the flow.

Overview

StepEndpointQuestion it answersWhat you carry forward
1
(optional)
POST /classify/v3What document is this, and which side did I get?the decision to move on, ask for the other side, or route
2POST /full-ocr/v4What is written, and does it match the Revenue Service?enhanced.person.taxId and the image of the document
3POST /face-match/v2Is the selfie of the same person as the document?matched and confidence
4GET /bureau/v2/natural-person/{CPF}Does that CPF correspond to a registered person?official name, mother's name and date of birth
  the document files
  (front + back)

          │  optional — only when there is a decision to make before extracting
          ├──▶  POST /classify/v3
          │       → data[0].classification.type / .subtype / .sides[].side


    POST /full-ocr/v4?returnsFaceInfo=true
      → data[0].enhanced.person ........ the data read, already normalized
      → data[0].taxData and .matches ... cross-check against the Federal Revenue Service
      → data[0].classification ......... what document it was, after all

          │  + the user's selfie

    POST /face-match/v2
      → data[0].matched and data[0].confidence

          │  with the enhanced.person.taxId

    GET /bureau/v2/natural-person/{taxId}
      → data.name, data.mothersName, data.birthdate

‼️ Inform the version in the Full OCR URL

POST /full-ocr, with no version in the path, is served by v2 — not by v4. v2 has a different response format, does not separate extraction from enhanced and ignores every query parameter. Always call /full-ocr/v4.

Step 1 — classify, and when to skip it

The Classifier answers what document is this and nothing else: type, subtype, country and side, with the confidence of the classification. It does not read the content of the document.

Full OCR does the same classification internally — it comes back in data[].classification of every response — and extracts the fields and cross-checks against the Federal Revenue Service, all in one call. That is why most flows do not need step 1.

The question that decides is a simple one: is there any decision to make before extracting?

Call /classify/v3 first when…Go straight to /full-ocr/v4 when…
You need to validate the capture while the user is still on the screen — finding out that only the back came in and asking for the front before paying for the extraction.You already know you are going to extract anyway. The classification comes along, at no extra cost.
You need to route by type: proof of residence has a dedicated endpoint, and other types may simply not serve the flow.Your flow accepts a closed set of documents and treats everything unrecognized the same way.
You want to reject early a document outside the accepted list, without paying for the full analysis.You receive front and back together and you trust the capture.

🚧 Step 1 is one more call, and it is billed

Every 2xx response generates a charge, including a classification that found no document at all — see Billing. Classifying "just in case", before a /full-ocr that was going to happen anyway, doubles the number of calls needed to get the same information.

On top of that, /classify/v3 accepts one file per call, while /full-ocr accepts up to seven: classifying front and back is two calls, and the OCR one still comes after that.

The classifier's output is a list ordered from the best to the worst result. An image with no recognizable document returns 200 with "data": [] — that is not an error, it is an answer. Only read data[0] after confirming that the list is not empty.

Step 2 — extracting the data and cross-checking against the Revenue Service

Full OCR returns, for each document recognized, three blocks that matter to the flow:

  • extraction — the text as it came out of the OCR, raw.
  • enhanced — the same data normalized and corrected. This is the one you persist, and it is where the CPF of step 4 comes from, in enhanced.person.taxId, without the mask.
  • taxData and matches — what the Federal Revenue Service returned and the field-by-field result of the comparison against what was written on the document.
bash
curl -i -X POST 'https://api-homolog.nxcd.app/full-ocr/v4?returnsFaceInfo=true' \
  --header 'Authorization: ApiKey YOUR_API_KEY' \
  --form 'frente=@./cnh-frente.jpg' \
  --form 'verso=@./cnh-verso.jpg'

Send the whole document — the cross-check depends on the side

The CPF is not printed on both sides of every document, so the lookup at the Federal Revenue Service depends on the side sent. A driver's license (CNH) with the back only, for instance, does not trigger the lookup: taxData and matches are still in the response, but empty and with every matches set to false.

That is the main practical reason to validate the capture beforehand — whether in step 1 or in your own interface. The rules per document type are in taxData and matches.

Two readings that often go wrong:

  • data is a list, ordered from the best to the worst result. If you expect a single document, read data[0] — but confirm first that the list did not come back empty. An unreadable file returns 200 with "data": [].
  • matches.name: false does not always mean "different name". A name read with fewer than 8 characters — the typical symptom of a failed OCR — is treated as unchecked, and the cross-check stops there. In that case, the way out is improving the capture, not rejecting the person.

Do you also need the official biometrics?

Full OCR + Datavalid adds the validation against SERPRO's Datavalid to the same call. It replaces this step; it does not stack with it.

Step 3 — comparing the face on the document with the selfie

So far, the flow has proven that the document exists and that its data matches the Revenue Service. What is left is proving that whoever is submitting it is the person on the document.

Face Match takes two images in the same request — the one of the document and the selfie — and answers whether the faces belong to the same person:

bash
curl -i -X POST 'https://api-homolog.nxcd.app/face-match/v2' \
  --header 'Authorization: ApiKey YOUR_API_KEY' \
  --form 'documento=@./cnh-frente.jpg' \
  --form 'selfie=@./selfie.jpg'

The field names are up to you and come back in the response, inside data[].resources[].fieldname — they are how you tell which image is which.

🚧 "data": [] is not an error, and data[0] may not exist

The comparison only happens when both images have some detectable face. If a face is missing, the response is 200 with an empty list, and code that reads data[0].matched straight away breaks instead of getting a negative result. Check the size of data first.

Three decisions in this step:

  • Did the selfie come from a liveness session? Then the endpoint is a different one: Face Match for Liveness reuses the selfie already captured and you send only the document. The full chaining is in the Liveness flow — and it is what adds liveness, which /face-match alone does not do: comparing faces does not tell a present person apart from a photo of a photo.
  • Which image of the document should you send? The original does the job. If you want to display or audit the crop of the face that was on the document, /full-ocr/v4 with returnsFaceInfo=true already returned that crop in data[].face.croppedBase64 — but the comparison itself is always a call to /face-match.
  • Do you need the government's biometrics? Face Match + Datavalid checks the face against the official database from a CPF.

A wrong query parameter behaves differently on each endpoint

On /face-match, an unknown parameter takes the request down with 422. On /full-ocr, the same mistake is ignored silently: the response comes back 200, without the field you were expecting. A returnFaceInfo written without the s warns you about nothing — it just returns a response with no face.

Step 4 — cross-checking against the bureau

The Individual Bureau looks up the Federal Revenue Service data from the CPF:

GET /bureau/v2/natural-person/12345678909

The CPF comes from data[0].enhanced.person.taxId of step 2, already without the mask — although the endpoint accepts both forms, with or without.

Not every flow needs this step. When /full-ocr/v4 managed to cross-check, taxData already brings the official name, mother's name and date of birth, and matches already says what matched. The Individual Bureau is the direct route when:

  • the document does not carry a CPF, or the cross-check did not happen because the right side was missing — the blocks came back empty;
  • the CPF you want to verify was typed by the user, rather than read from the document, and you need to check it against what the document said;
  • you need the official data at a point in the flow where there is no document at hand at all.

🚧 A CPF that is not found returns 200 with "data": {}

It is not a 404. Test whether the object came back empty before reading data.name. And the call is billed all the same, because processing did take place.

Careful when comparing dates between the two endpoints

taxData.birthdate, in Full OCR, is a string in the "1990-02-01" format. data.birthdate, in the Individual Bureau, is a number in the 19990201 format. Normalize before comparing — comparing the two directly never comes out equal.

When a step does not return what you expect

SymptomLikely causeWhat to do
Response in an unexpected format, without enhancedPOST /full-ocr called with no version: the call was served by v2.Use /full-ocr/v4. Check the version field of the response.
A query parameter with no effect on Full OCRName misspelled, or a parameter that is only valid on v4 used on another version. Neither one raises an error.Check the spelling and the version. Booleans are only turned on by the exact string true.
"data": [] on the classifier or on Full OCRNo document recognized — blurred, cropped or dark image, or a type outside the accepted list.It is neither an error nor a failure: it is a billed 200. Ask for a new capture.
taxData and matches empty, every matches set to falseThe side sent does not allow the lookup at the Revenue Service, or the document has no CPF to cross-check (CRLV, ANTT — Brazilian vehicle documents).Send it again with front and back. The rules per type are in the reference.
matches.name: false with a visibly correct nameName read with fewer than 8 characters, treated as unchecked; or a noisy OCR.Compare against extraction to see what was actually read, and improve the capture.
"data": [] on Face MatchOne of the images has no detectable face — a cropped document, a dark selfie, a PDF whose first page with a face is not the expected one.Check the size of data before reading data[0], and send the images again.
422 on Face MatchUnknown query parameter or one with an invalid value, a rejected file format, or more than two files./face-match takes exactly two images.
422 on Full OCR right at the doorA federalRevenueNumber that fails the check-digit validation, or more than seven files.The CPF is validated before any processing and takes the whole request down.
"data": {} on the Individual BureauCPF not located.Treat it as "not found", not as an error. The response is 200.

The full list of codes and the format of the error responses are in HTTP response codes. The two ways of sending files — multipart and base64 — and the size limits are in Sending files.

What chains into what

ValueBorn inUsed in
classification.type / .sides[].sidePOST /classify/v3your decision to route, ask for the other side or move on
enhanced.person.taxIdPOST /full-ocr/v4the path of GET /bureau/v2/natural-person/{CPF}
the image of the documentthe user's capturePOST /full-ocr/v4 and POST /face-match/v2
Nextid-ReqIdthe header of every responseyour logs — it is how support locates a call

Next steps

  • Classifier — the full list of recognized types, subtypes and sides
  • Full OCR — every block of the response, the query parameters and the differences between v2, v3 and v4
  • Face Match — the full response, the query parameters and the errors
  • Individual Bureau — the fields returned by the lookup
  • Liveness flow — how to add liveness to this flow

Nextcode | Identity Verification Solutions