Proof of residence
This endpoint takes a proof of residence — an electricity, water, phone or internet bill — reads the address printed on it and checks that address against the database of the Brazilian Postal Service (Correios).
The response separates the two things clearly:
extraction— the address as it was written on the proof.postOfficeData— the address the Correios have on record for that ZIP code, plus amatchessaying, field by field, whether the two agree.
It is that comparison that gives the response its value. A ZIP code read correctly but with a diverging street address is a different signal from an address that checks out entirely, and matches is where that difference shows up.
This endpoint does not verify that the file sent really is a proof of residence: it assumes it is, and tries to extract an address from it. It also does not check the account holder against the Brazilian Federal Revenue Service, and does not read identity documents — that is what Full OCR is for.
An original PDF gives more than a photo of a printed bill
For a list of known issuers, a PDF with embedded text — the file you download from the utility company's website — is read straight from the PDF text, with no OCR involved. That path is more accurate, and it is the only one where the number and the complement of the address are actually filled in.
A photo or a screenshot of the bill falls into the OCR path, which is more prone to reading errors. Whenever your flow allows it, ask for the original PDF.
Request
POST/proof-of-residence/v3Headers
Authorization: ApiKey <your-api-key>This endpoint also accepts the JWT token, in the format Authorization: Bearer <accessToken>. See JWT Token.
Parameters
This endpoint has no query string parameters. Any you send are ignored, without error.
Accepted files
| Item | Value |
|---|---|
| Formats | application/pdf, image/png, image/jpeg |
| Files per call | 1 file |
| Maximum size (multipart) | 15 MB |
The form field name is up to you — it comes back in the response, in data[].fieldname and in metadata.filesInfo[].fieldname. Sending more than one file is rejected with 422.
Besides multipart/form-data, the file can be sent as JSON, in the base64 field, as described in Sending files.
Example request
curl -i -X POST 'https://api-homolog.nxcd.app/proof-of-residence/v3' \
--header 'Authorization: ApiKey YOUR_API_KEY' \
--form 'comprovante=@./conta-de-luz.pdf'Response
This section describes v3, the recommended version. v2 and v4 return a data in a different format — see Versions.
The envelope is the API standard:
| Field | Description | Type |
|---|---|---|
| id | Unique identifier of the request | String |
| version | API version that served the call | String |
| data | List with one entry per address recognized. Comes back empty when no address was validated. | Object[] |
| metadata | Metadata of the request | Object |
‼️ An empty data is a normal response, not an error
On v3, in the OCR path, an address whose ZIP code was not found in the Correios database is dropped from the response. If the proof yielded no valid ZIP code, you get 200 with "data": [] and a normally populated metadata.filesInfo. In the direct PDF reading path this dropping does not happen: the result comes back with matches set to false.
Check the size of data before reading data[0] — code that assumes data[0].extraction gets an error instead of an empty result.
Each item of data carries:
| Field | Description | Type |
|---|---|---|
| page | Page of the file where the address was found. Starts at 0. | Number |
| fieldname | Name of the field the file was sent in | String |
plus the three blocks below.
data[].extraction
The address as it was written on the proof, with no correction at all.
| Field | Description | Type |
|---|---|---|
| extraction.zipCode | ZIP code read, without formatting | String |
| extraction.address | Street address read | String |
| extraction.number | Number read | String |
| extraction.district | District read | String |
| extraction.city | City read | String |
| extraction.state | State read | String |
| extraction.addressComplement | Address complement read | String |
🚧 number and addressComplement depend on the reading path
In the OCR path — images, and PDFs from issuers that are not recognized — these two fields are not extracted yet and come back empty.
They are only filled in on the direct PDF reading path described at the top of this page. Do not build a business rule that depends on them without handling the empty case.
{
"extraction": {
"zipCode": "84145000",
"address": "R RIO SOLIMOES",
"number": "169",
"district": "CENTRO",
"city": "CARAMBEI",
"state": "PR",
"addressComplement": "CASA 01"
}
}data[].postOfficeData
The address the Correios have on record for the ZIP code read, and the result of comparing it with what was on the proof.
| Field | Description | Type |
|---|---|---|
| postOfficeData.zipCode | ZIP code according to the Correios | String |
| postOfficeData.address | Street address according to the Correios | String |
| postOfficeData.district | District according to the Correios | String |
| postOfficeData.city | City according to the Correios | String |
| postOfficeData.state | State according to the Correios | String |
| postOfficeData.matches.zipCode | true when the ZIP code read was found in the Correios database | Boolean |
| postOfficeData.matches.address | true when the street address read matches the Correios' | Boolean |
| postOfficeData.matches.district | true when the district matches | Boolean |
| postOfficeData.matches.city | true when the city matches | Boolean |
| postOfficeData.matches.state | true when the state matches | Boolean |
How to read the matches
matches.zipCode is the most important one: it says whether the ZIP code exists. The other four compare text — the OCR reading against the Correios record — and a false in them means "does not check out", which can be either a diverging address or a reading error.
postOfficeData carries the Correios' address, not the one on the proof. When you want to show the address to the user and the matches check out, the Correios' is the more reliable of the two; the number and the complement, however, the Correios do not have — those exist only in extraction.
{
"postOfficeData": {
"zipCode": "84145000",
"address": "Rua Rio Solimoes",
"district": "Centro",
"city": "Carambei",
"state": "PR",
"matches": {
"zipCode": true,
"address": true,
"district": true,
"city": true,
"state": true
}
}
}data[].classification
| Field | Description | Type |
|---|---|---|
| classification.type | Type of the document | String |
| classification.subtype | Recognized issuer of the proof, when there is one | String |
| classification.country | Issuing country | String |
| classification.sides | List with one entry per page analyzed | Object[] |
| classification.sides[].side | Side of the document. On the OCR path, always null. | String/null |
| classification.sides[].page | Page analyzed, starting at 0 | Number |
| classification.sides[].fieldname | Name of the field the file was sent in | String |
| classification.sides[].confidence | Confidence of the classification | Number |
🚧 This block is not a classifier
On this endpoint, classification is largely a fixed label, not the result of an analysis. In the OCR path it always comes back with type: "ProofOfResidence", subtype: null, country: "BRA" and confidence: 0 — including when the file sent is not a proof of residence at all.
subtype only carries the recognized issuer, and confidence only rises above zero, on the direct PDF reading path.
Do not use this block to decide whether the file sent really was a proof of residence. If you need that check, do it beforehand, with the Classifier.
metadata
| Field | Description | Type |
|---|---|---|
| metadata.filesInfo | List with one entry per file sent | Object[] |
| metadata.filesInfo[].fieldname | Name of the field used to send the file | String |
| metadata.filesInfo[].name | Name of the file sent | String |
| metadata.filesInfo[].size | Size of the file, in bytes | Number |
| metadata.filesInfo[].pages | Number of pages of the file. For images, 1. | Number |
| metadata.filesInfo[].mimetype | Type of the file sent | String |
| metadata.filesInfo[].encoding | Encoding of the file as sent | String |
| metadata.filesInfo[].sha256 | SHA-256 hash of the file sent | String |
| metadata.timeSpent | Processing time of the request, in milliseconds | Number |
Full example
Status Code: 200Proof read and address checked against the Correios:
{
"id": "f9ba65a4-394e-4bbc-8748-7789a1bae137",
"version": "v3",
"data": [
{
"page": 0,
"fieldname": "comprovante",
"extraction": {
"zipCode": "84145000",
"address": "R RIO SOLIMOES",
"number": "169",
"district": "CENTRO",
"city": "CARAMBEI",
"state": "PR",
"addressComplement": "CASA 01"
},
"postOfficeData": {
"zipCode": "84145000",
"address": "Rua Rio Solimoes",
"district": "Centro",
"city": "Carambei",
"state": "PR",
"matches": {
"zipCode": true,
"address": true,
"district": true,
"city": true,
"state": true
}
},
"classification": {
"type": "ProofOfResidence",
"subtype": null,
"country": "BRA",
"sides": [
{
"side": null,
"page": 0,
"fieldname": "comprovante",
"confidence": 0
}
]
}
}
],
"metadata": {
"filesInfo": [
{
"fieldname": "comprovante",
"name": "conta-de-luz.pdf",
"size": 82611,
"pages": 1,
"mimetype": "application/pdf",
"encoding": "7bit",
"sha256": "e844439599eb440b031d4ce4ebe2cfe3c37d483fb76d56b622bc77da6c435bed"
}
],
"timeSpent": 4199
}
}Address partly checked
Status Code: 200ZIP code found, but the street address read does not match the Correios record:
{
"id": "6a1d1e8c-4a7d-4a5e-9f2c-1b0f2a9c7e11",
"version": "v3",
"data": [
{
"page": 0,
"fieldname": "comprovante",
"extraction": {
"zipCode": "84145000",
"address": "R RI0 S0LIM0ES",
"number": "",
"district": "CENTR0",
"city": "CARAMBEI",
"state": "PR",
"addressComplement": ""
},
"postOfficeData": {
"zipCode": "84145000",
"address": "Rua Rio Solimoes",
"district": "Centro",
"city": "Carambei",
"state": "PR",
"matches": {
"zipCode": true,
"address": false,
"district": false,
"city": true,
"state": true
}
},
"classification": {
"type": "ProofOfResidence",
"subtype": null,
"country": "BRA",
"sides": [
{ "side": null, "page": 0, "fieldname": "comprovante", "confidence": 0 }
]
}
}
],
"metadata": {
"filesInfo": [
{
"fieldname": "comprovante",
"name": "conta.jpg",
"size": 421308,
"pages": 1,
"mimetype": "image/jpeg",
"encoding": "7bit",
"sha256": "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae"
}
],
"timeSpent": 6210
}
}No address validated
Status Code: 200{
"id": "a7f0c3d2-9e51-4b8a-8f31-2d4c6b9e0a77",
"version": "v3",
"data": [],
"metadata": {
"filesInfo": [
{
"fieldname": "comprovante",
"name": "foto-borrada.jpg",
"size": 152064,
"pages": 1,
"mimetype": "image/jpeg",
"encoding": "7bit",
"sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
}
],
"timeSpent": 5030
}
}Response headers
| Header | When it appears |
|---|---|
Nextid-ReqId | On every response. Carries the request identifier, the same one as the id field in the body. |
Errors
| Code | When it happens |
|---|---|
| 401 Unauthorized | API key or JWT token missing, invalid, or without permission for this endpoint. |
| 404 Not Found | The version given in the URL does not exist. Only v2, v3 and v4 are accepted. |
| 406 Not Acceptable | The body was sent as JSON, but without the base64 field. |
| 413 Payload Too Large | The file sent as multipart/form-data is larger than 15 MB. |
| 415 Unsupported Media Type | The Content-Type header is missing or is not supported. |
| 422 Unprocessable Entity | The file format is not accepted, or more than one file was sent in the same call. |
| 500 Internal Server Error | Unexpected failure during processing. |
The error response format is described in HTTP response codes.
Versions
The recommended version is v3, called at /proof-of-residence/v3.
‼️ Omitting the version hands the call to v2
POST /proof-of-residence, with no version in the path, is served by v2, which has a different response format. Always state the version in the URL.
| Version | Status | What changes |
|---|---|---|
| v3 | Recommended | matches as plain booleans, classification with type/subtype/country/sides. Drops addresses whose ZIP code was not found. |
| v4 | Available | A different format: extraction stops being the address and starts carrying the account holder and the account identifiers, and an enhanced appears. See below. |
| v2 | Legacy | matches as objects with matched, confidence and strategy. classification in a different vocabulary. Drops nothing. This is what answers when the version is omitted from the URL. |
What changes in v4
v4 changes the purpose of extraction. Instead of the address, it starts carrying who the account holder is and what the account identifiers are — it is the version for those who need to match the proof to a customer, not just validate the address.
| Field | Description | Type |
|---|---|---|
| extraction.name | Name of the account holder, as read on the bill | String |
| extraction.address | Address block, as read on the bill | String |
| extraction.taxNumber | CPF or CNPJ (the Brazilian individual and company taxpayer numbers) of the holder, as read on the bill | String |
| extraction.clientNumber | Customer number at the utility company | String |
| extraction.installation | Installation or metering unit number | String |
| enhanced | The same four identifiers, already normalized, plus the address fields split out (zipCode, address, number, district, city, state, complement) | Object |
| postOfficeData | Same format as v3 | Object |
| classification | The recognized issuer of the proof, with name, uf and version — not the type/subtype/country of v3 | Object |
🚧 On v4 the complement is called complement, not addressComplement
In the address fields of v2 and v3, the complement comes in addressComplement. In v4's enhanced, the same data comes in complement. This is what the API really returns — both names coexist, each in its own version.
🚧 v4 does not have a single format
What v4 returns depends on the path the analysis took. On the direct PDF reading path, extraction comes back with the address fields of v3, and enhanced and classification do not appear. On the OCR path, the table above applies.
Beyond that, v4 does not drop addresses whose ZIP code was not found, unlike v3.
If what you need is to validate an address, stay on v3. Consider v4 when you need the account holder and the account identifiers, and handle the missing fields.
‼️ Switching versions breaks whoever reads the response
The result of the ZIP code check is in data[].postOfficeData.matches.zipCode.matched on v2 and in data[].postOfficeData.matches.zipCode on v3 — a plain boolean. And the address read from the proof, which on v2 and v3 is in data[].extraction, changes meaning on v4.
Code written for one version does not work on the other without changes.
What changes in v2
Two differences from v3:
postOfficeData.matchesis more detailed. Each field becomes an object withmatched,confidence(0 to 1) andstrategy(char-to-charor empty), instead of a boolean. The path to the same piece of data stops beingmatches.cityand becomesmatches.city.matched.classificationuses the old vocabulary. It carries onlyconfidenceandtype: "PROOF-OF-RESIDENCE"— there is nosubtype,countryorsides.
v2 also does not drop entries whose ZIP code was not found: they stay in data, with matches set to false.
{
"id": "13cfec7c-b238-4820-a4d1-5173e4c1418e",
"version": "v2",
"data": [
{
"page": 0,
"fieldname": "comprovante",
"extraction": {
"zipCode": "84145000",
"address": "R RIO SOLIMOES",
"number": "",
"district": "CENTRO",
"city": "CARAMBEI",
"state": "PR",
"addressComplement": ""
},
"postOfficeData": {
"zipCode": "84145000",
"address": "Rua Rio Solimoes",
"district": "Centro",
"city": "Carambei",
"state": "PR",
"matches": {
"zipCode": { "matched": true, "confidence": 1, "strategy": "" },
"address": { "matched": true, "confidence": 0.94, "strategy": "char-to-char" },
"district": { "matched": true, "confidence": 1, "strategy": "char-to-char" },
"city": { "matched": true, "confidence": 1, "strategy": "char-to-char" },
"state": { "matched": true, "confidence": 1, "strategy": "char-to-char" }
}
},
"classification": {
"confidence": 0,
"type": "PROOF-OF-RESIDENCE"
}
}
],
"metadata": {
"filesInfo": [
{
"fieldname": "comprovante",
"name": "conta-de-luz.pdf",
"size": 82611,
"pages": 1,
"mimetype": "application/pdf",
"encoding": "7bit",
"sha256": "e844439599eb440b031d4ce4ebe2cfe3c37d483fb76d56b622bc77da6c435bed"
}
],
"timeSpent": 4199
}
}