Skip to content

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 a matches saying, 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/v3

Headers

http
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

ItemValue
Formatsapplication/pdf, image/png, image/jpeg
Files per call1 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

bash
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:

FieldDescriptionType
idUnique identifier of the requestString
versionAPI version that served the callString
dataList with one entry per address recognized. Comes back empty when no address was validated.Object[]
metadataMetadata of the requestObject

‼️ 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:

FieldDescriptionType
pagePage of the file where the address was found. Starts at 0.Number
fieldnameName of the field the file was sent inString

plus the three blocks below.

data[].extraction

The address as it was written on the proof, with no correction at all.

FieldDescriptionType
extraction.zipCodeZIP code read, without formattingString
extraction.addressStreet address readString
extraction.numberNumber readString
extraction.districtDistrict readString
extraction.cityCity readString
extraction.stateState readString
extraction.addressComplementAddress complement readString

🚧 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.

json
{
  "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.

FieldDescriptionType
postOfficeData.zipCodeZIP code according to the CorreiosString
postOfficeData.addressStreet address according to the CorreiosString
postOfficeData.districtDistrict according to the CorreiosString
postOfficeData.cityCity according to the CorreiosString
postOfficeData.stateState according to the CorreiosString
postOfficeData.matches.zipCodetrue when the ZIP code read was found in the Correios databaseBoolean
postOfficeData.matches.addresstrue when the street address read matches the Correios'Boolean
postOfficeData.matches.districttrue when the district matchesBoolean
postOfficeData.matches.citytrue when the city matchesBoolean
postOfficeData.matches.statetrue when the state matchesBoolean

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.

json
{
  "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

FieldDescriptionType
classification.typeType of the documentString
classification.subtypeRecognized issuer of the proof, when there is oneString
classification.countryIssuing countryString
classification.sidesList with one entry per page analyzedObject[]
classification.sides[].sideSide of the document. On the OCR path, always null.String/null
classification.sides[].pagePage analyzed, starting at 0Number
classification.sides[].fieldnameName of the field the file was sent inString
classification.sides[].confidenceConfidence of the classificationNumber

🚧 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

FieldDescriptionType
metadata.filesInfoList with one entry per file sentObject[]
metadata.filesInfo[].fieldnameName of the field used to send the fileString
metadata.filesInfo[].nameName of the file sentString
metadata.filesInfo[].sizeSize of the file, in bytesNumber
metadata.filesInfo[].pagesNumber of pages of the file. For images, 1.Number
metadata.filesInfo[].mimetypeType of the file sentString
metadata.filesInfo[].encodingEncoding of the file as sentString
metadata.filesInfo[].sha256SHA-256 hash of the file sentString
metadata.timeSpentProcessing time of the request, in millisecondsNumber

Full example

Status Code: 200

Proof read and address checked against the Correios:

json
{
  "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: 200

ZIP code found, but the street address read does not match the Correios record:

json
{
  "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
json
{
  "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

HeaderWhen it appears
Nextid-ReqIdOn every response. Carries the request identifier, the same one as the id field in the body.

Errors

CodeWhen it happens
401 UnauthorizedAPI key or JWT token missing, invalid, or without permission for this endpoint.
404 Not FoundThe version given in the URL does not exist. Only v2, v3 and v4 are accepted.
406 Not AcceptableThe body was sent as JSON, but without the base64 field.
413 Payload Too LargeThe file sent as multipart/form-data is larger than 15 MB.
415 Unsupported Media TypeThe Content-Type header is missing or is not supported.
422 Unprocessable EntityThe file format is not accepted, or more than one file was sent in the same call.
500 Internal Server ErrorUnexpected 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.

VersionStatusWhat changes
v3Recommendedmatches as plain booleans, classification with type/subtype/country/sides. Drops addresses whose ZIP code was not found.
v4AvailableA different format: extraction stops being the address and starts carrying the account holder and the account identifiers, and an enhanced appears. See below.
v2Legacymatches 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.

FieldDescriptionType
extraction.nameName of the account holder, as read on the billString
extraction.addressAddress block, as read on the billString
extraction.taxNumberCPF or CNPJ (the Brazilian individual and company taxpayer numbers) of the holder, as read on the billString
extraction.clientNumberCustomer number at the utility companyString
extraction.installationInstallation or metering unit numberString
enhancedThe same four identifiers, already normalized, plus the address fields split out (zipCode, address, number, district, city, state, complement)Object
postOfficeDataSame format as v3Object
classificationThe recognized issuer of the proof, with name, uf and version — not the type/subtype/country of v3Object

🚧 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.matches is more detailed. Each field becomes an object with matched, confidence (0 to 1) and strategy (char-to-char or empty), instead of a boolean. The path to the same piece of data stops being matches.city and becomes matches.city.matched.
  • classification uses the old vocabulary. It carries only confidence and type: "PROOF-OF-RESIDENCE" — there is no subtype, country or sides.

v2 also does not drop entries whose ZIP code was not found: they stay in data, with matches set to false.

Status Code: 200
json
{
  "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
  }
}

Nextcode | Identity Verification Solutions