Quick start
Every request is a single multipart/form-data POST carrying one file. The response body is the masked document — there is no JSON envelope to unwrap and no second call to fetch a result.
# Mask an Aadhaar card and write the result to disk curl -X POST https://api.maskaadhaar.com/api/v1/mask-aadhaar \ -H "X-API-Key: your_api_key" \ -F "file=@aadhaar.jpg" \ --output aadhaar_masked.jpg
Masking is applied to the first eight digits of the Aadhaar number, leaving the last four visible — the format UIDAI describes for shared copies.
Authentication
Pass your key in the X-API-Key header on every request. Keys look like mak_live_... and are issued per organisation.
X-API-Key: mak_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
A missing key returns 401 missing_api_key; an unrecognised or revoked key returns 401 invalid_api_key.
Supported formats
| Input | Content type | Notes |
|---|---|---|
| JPEG | image/jpeg | Photographs and scans |
| PNG | image/png | Screenshots and exports |
application/pdf | Multi-page e-Aadhaar; every page is scanned |
The optional output_format field accepts pdf, jpg, or same (the default, which keeps the input format where possible).
Mask Aadhaar
POST/api/v1/mask-aadhaar
Detects Aadhaar numbers in the document and redacts the first eight digits of each. Both sides of a two-sided printout are handled, including the common layout where one card is rotated ninety degrees relative to the other.
| Field | Type | Required | Description |
|---|---|---|---|
file | File | Yes | JPEG, PNG or PDF containing the Aadhaar card |
output_format | String | No | pdf, jpg or same (default) |
Returns 200 with the masked file as a binary stream. If no Aadhaar number is found the original document is returned unchanged with X-Masked-Count: 0 — check that header rather than assuming success from the status code.
Mask PAN
POST/api/v1/mask-pan
Detects PAN numbers and redacts the middle seven characters, leaving the first two and the last one visible: ABCDE1234F becomes ABxxxxxxxF.
| Field | Type | Required | Description |
|---|---|---|---|
file | File | Yes | JPEG, PNG or PDF containing the PAN card |
output_format | String | No | pdf, jpg or same (default) |
Extract Aadhaar
POST/api/v1/extract-aadhaar
Reads an Aadhaar card and returns its fields as JSON. Same request shape as the masking endpoints — one multipart part named file — so an existing integration needs no new plumbing.
| Field | Type | Required | Description |
|---|---|---|---|
file | File | Yes | JPEG, PNG or PDF containing the Aadhaar card |
include_confidence | Boolean | No | Adds a per-field confidence object. Default false |
reveal_aadhaar_number | Boolean | No | Requests the unmasked number. Honoured only on an entitled key — see below |
curl -X POST https://api.maskaadhaar.com/api/v1/extract-aadhaar \ -H "X-API-Key: $MASKAADHAAR_KEY" \ -F "file=@aadhaar.jpg"
{
"success": true,
"document_type": "aadhaar",
"sides_detected": ["front", "back"],
"fields": {
"name": "Rahul Kumar",
"aadhaar_number": "XXXX XXXX 9012",
"aadhaar_last4": "9012",
"date_of_birth": "1990-05-12",
"year_of_birth": null,
"gender": "M",
"care_of": null,
"address": {
"full": "12 Anna Salai, Teynampet, Chennai, Tamil Nadu 600018",
"pincode": "600018"
},
"vid_detected": false
},
"checksum_valid": true,
"warnings": []
}
aadhaar_number comes back with the first eight digits covered, and aadhaar_last4 is supplied separately so you do not have to parse the mask off. The last four are what reconciliation against your own records needs; the first eight are what make the number a cross-database lookup key. If your use case genuinely requires the full number, the entitlement is granted per organisation against an attested lawful basis — talk to us. Requesting it on a key without the entitlement returns 403 reveal_not_entitled rather than silently downgrading to masked.Three fields repay reading the notes on:
| Field | What to watch |
|---|---|
date_of_birth | ISO YYYY-MM-DD, or null. Older cards carry only a year: date_of_birth is then null and year_of_birth is set. We never synthesise a January date to fill the gap |
address | Returns full and pincode. It does not split into line, locality, district and state: Aadhaar addresses are not consistently structured, and components that are wrong a fifth of the time are worse than one honest string because you cannot tell which fifth. A partial_address warning flags a read we were not confident about |
checksum_valid | false means digits were read but do not form a valid Aadhaar number. Treat that as a read failure, not as a number |
Warnings are non-fatal and returned as an array, empty when clean: low_contrast, single_side_only, rotated_input, partial_address, year_of_birth_only.
Extract PAN
POST/api/v1/extract-pan
Reads a PAN card and returns its fields as JSON.
| Field | Type | Required | Description |
|---|---|---|---|
file | File | Yes | JPEG, PNG or PDF containing the PAN card |
include_confidence | Boolean | No | Adds a per-field confidence object. Default false |
{
"success": true,
"document_type": "pan",
"fields": {
"pan": "ABCPE1234F",
"name": "RAHUL KUMAR",
"father_name": "SURESH KUMAR",
"date_of_birth": "1990-05-12",
"holder_type": "individual"
},
"format_valid": true,
"warnings": []
}
holder_type is decoded from the fourth character of the PAN itself, not from the card's QR code, which this endpoint does not read. P is an individual, C a company, H a Hindu Undivided Family, F a firm, T a trust, and so on. A character we have no label for returns null, which means "unrecognised code", not "invalid PAN" — rejecting a real PAN because our table is short would be the worse error.
PAN is returned in full. It is not governed by the Aadhaar Act, it carries no equivalent lookup-key property, and returning it is the norm for the category — so the masking default above does not apply here.
format_valid checks the structural rule only: five letters, four digits, one letter. It is a format check, not a validity check against the Income Tax department, and we do not claim otherwise — a perfectly well-formed PAN can belong to nobody.
holder_type decodes the fourth character, which encodes what kind of entity holds the PAN: individual, company, hindu_undivided_family, firm, trust and so on. It is reported, never enforced. null means we have no label for that character, not that the PAN is invalid — deliberately, because rejecting a real PAN over an unrecognised code would be a worse failure than reporting one we cannot name.
Verify document
POST/api/v1/verify-document
Checks whether an Aadhaar card's printed text agrees with the data UIDAI signed into its QR code. This is the one check on an Aadhaar card that can be proved rather than estimated: UIDAI signs the QR payload, so a card whose printed name disagrees with its signed name was altered, and a forger cannot re-sign the payload.
curl -X POST https://api.maskaadhaar.com/api/v1/verify-document \ -H "X-API-Key: $MASKAADHAAR_KEY" \ -F "file=@aadhaar.jpg"
{
"verdict": "fail",
"rationale": "The printed document disagrees with the data UIDAI signed.",
"qr": { "status": "decoded" },
"signature": { "status": "valid" },
"checks_performed": [
{ "id": "qr_decoded", "result": true },
{ "id": "qr_signature_verified", "result": true },
{ "id": "printed_vs_signed_crosscheck","result": true },
{ "id": "aadhaar_checksum", "result": true }
],
"signals": [
{ "id": "qr_name_mismatch", "severity": "high",
"detail": "The printed name does not match the name in the signed QR." }
]
}
Verdicts
| Verdict | Meaning |
|---|---|
pass | The QR decoded, UIDAI's signature verified, and every printed field matches what was signed. |
review | Nothing contradicts the document, but something could not be established — no QR, an unreadable QR, or a signature we could not verify. A human should look. |
fail | The printed text disagrees with the signed payload, or the signature did not verify. This is a finding, not a probability. |
Signals
| Signal | Severity | Meaning |
|---|---|---|
qr_name_mismatch | high | Printed name differs from the signed name |
qr_dob_mismatch | high | Printed date of birth differs from the signed one |
qr_aadhaar_last4_mismatch | high | Printed last four digits differ from the signed reference. Works on a masked card, since the last four remain visible |
qr_gender_mismatch | medium | Printed gender differs from the signed value |
qr_pincode_mismatch | medium | Printed pincode differs from the signed address |
aadhaar_checksum_invalid | medium | Twelve digits that fail the Verhoeff check. Usually a misread on a poor capture rather than a forgery, which is why it reviews rather than fails |
px_per_module against the minimum, so you can tell a customer to re-photograph rather than guessing why it failed.Values are never returned. The response says the printed name does not match the signed name; it does not return either name. Otherwise this would be an extraction endpoint by another route, handing back demographics you may have no basis to hold.
Signature verification needs UIDAI's certificate, provisioned per deployment. Without it the status is not_verified with the reason, never valid, and the verdict is capped at review however clean the cross-checks are — because a forger who replaces the whole QR with one encoding their own data produces a document that cross-checks perfectly. Talk to us about enabling it.
Redact PII
POST/api/v1/redact-pii
Covers identifiers on any document, without needing to know what the document is. The Aadhaar and PAN endpoints know what they are looking at; a KYC folder does not consist only of Aadhaar cards.
| Field | Type | Required | Description |
|---|---|---|---|
file | File | Yes | JPEG, PNG or PDF |
types | String | No | Comma-separated. Empty means the defaults below |
| Type | Shape | On by default |
|---|---|---|
aadhaar | Twelve digits, Verhoeff-validated | Yes |
vid | Sixteen-digit Virtual ID | Yes |
pan | Five letters, four digits, one letter | Yes |
passport | One letter, seven digits | Yes |
voter_epic | Three letters, seven digits | Yes |
driving_licence | State code, RTO code, eleven digits | Yes |
gstin | Fifteen characters, embeds a PAN | Yes |
ifsc | Four letters, a zero, six characters | Yes |
phone_in | Indian mobile, optional country code | Yes |
email | Anything shaped like an address | Yes |
bank_account | Nine to eighteen digits | No |
bank_account is off by default. Nine to eighteen digits with no checksum describes an account number, an invoice number and a customer ID equally well. On by default it would quietly destroy the documents it was meant to make shareable, and unlike a missed number a wrong redaction cannot be undone. Enable it where you know the document type. GET /api/v1/pii-types returns this table as JSON, unauthenticated, so you can read it before a key is issued.curl -X POST https://api.maskaadhaar.com/api/v1/redact-pii \ -H "X-API-Key: $MASKAADHAAR_KEY" \ -F "file=@document.pdf" \ -F "types=aadhaar,pan,phone_in" \ --output redacted.pdf
The response is the redacted document. X-Redacted-Count gives the number of regions covered and X-Redacted-Types a JSON object of counts by type. The values themselves are never returned in a header — a header carrying the identifier it just redacted would put that identifier into every access log between us and you.
PDFs are rasterised before redaction rather than annotated. Drawing a rectangle over a PDF leaves the text readable underneath by copy-paste or pdftotext, which is the failure this endpoint exists to prevent.
Bulk endpoints
POST/api/v1/bulk/mask-aadhaar
POST/api/v1/bulk/mask-pan
POST/api/v1/bulk/anonymise
Several documents in one request, returned as a ZIP. Attach each file as a repeated files part. Batch size is bounded per plan; ask us for yours.
curl -X POST https://api.maskaadhaar.com/api/v1/bulk/mask-aadhaar \ -H "X-API-Key: $MASKAADHAAR_KEY" \ -F "files=@a.pdf" -F "files=@b.jpg" -F "files=@c.png" \ --output masked_batch.zip
The ZIP holds the processed documents and a manifest.json:
{
"submitted": 3,
"masked": 2,
"no_number_found": 1,
"failed": 0,
"documents_charged": 2,
"results": [
{"index": 1, "filename": "a.pdf", "status": "masked",
"masked_count": 2, "output": "001_a_masked.pdf"},
{"index": 3, "filename": "c.png", "status": "no_number_found",
"masked_count": 0, "output": "003_c_masked.jpg"}
]
}
no_number_found is a finding, not a pass. That document was processed and returned unchanged, so it is still readable. A batch reporting "48 documents processed" without separating those out has told you almost nothing, and those are precisely the documents that reach an archive still carrying a number. Branch on the status rather than on the count of files returned.One document failing does not fail the batch — it gets a status and the rest are still returned. Quota is checked against the whole batch up front, so you are never left with a part-processed ZIP and a 429 halfway down the manifest. Charging follows the single-document rule: only documents that produced a redaction are billed.
Global documents
POST/api/v1/global/detect
POST/api/v1/global/redact
POST/api/v1/global/redact/text
POST/api/v1/global/batch/redact
GET/api/v1/global/entity-types
The endpoints above cover India. These cover documents from everywhere else: passports, bank statements, payslips, tax forms, medical letters and contracts from the US, UK, EU, Canada, Australia, Brazil and South Africa. You use the same key, the same quota and the same billing rule, and a call that finds nothing is not charged.
| Type | Country | Validation | On by default |
|---|---|---|---|
PASSPORT_MRZ | Any | ICAO 9303 check digits, per field and composite | Yes |
PAYMENT_CARD | Any | Luhn | Yes |
IBAN | Any | ISO 7064 mod-97 and country length | Yes |
US_SSN | US | SSA structural rules | Yes |
US_ITIN, US_EIN | US | Format | Yes |
US_ABA_ROUTING | US | 3-7-1 weighted checksum | Yes |
UK_NINO | GB | Format and issued prefixes | Yes |
UK_NHS | GB | mod-11 | Yes |
CA_SIN | CA | Luhn | Yes |
AU_TFN, AU_ABN | AU | Weighted mod-11 / mod-89 | Yes |
BR_CPF | BR | Double mod-11 | Yes |
ZA_ID | ZA | Luhn and a valid birth date | Yes |
IN_AADHAAR, IN_PAN | IN | Verhoeff / format | Yes |
EU_VAT | EU | Format | Yes |
EMAIL, PHONE, IP_ADDRESS, MAC_ADDRESS, CRYPTO_BTC, CRYPTO_ETH | Any | Format | Yes |
SWIFT_BIC, DATE_OF_BIRTH | Any | Format | No |
rejected_candidates with the reason, for example "area 666 is not issued by the SSA". That way you can see what was considered instead of wondering what was missed. GET /api/v1/global/entity-types returns this table as JSON without a key, and you can filter it with ?country=GB or ?category=financial.Redact a document
| Field | Type | Required | Description |
|---|---|---|---|
file | File | Yes | PDF, PNG, JPEG, WEBP, TIFF or BMP |
mode | String | No | box (default), blur (images only), mask, token or pseudonym |
entity_types | String | No | Comma-separated. Empty means the defaults |
countries | String | No | ISO-2 codes, e.g. US,GB. Limits country-specific types to those countries; universal types still apply |
mask_keep | Integer | No | Characters kept by mask mode, 0–8. Default 4 |
strip_metadata | Boolean | No | Remove PDF author/producer and image EXIF, including GPS. Default true |
response_mode | String | No | binary (default) streams the file. json returns it base64-encoded with the full report |
curl -X POST https://api.maskaadhaar.com/api/v1/global/redact \ -H "X-API-Key: $MASKAADHAAR_KEY" \ -F "file=@bank_statement.pdf" \ -F "countries=GB" \ --output redacted.pdf
X-Entities-Found gives the number of identifiers redacted, and X-Entity-Types lists their types separated by commas. Values never appear in a header. PDF text is removed from the content stream, not covered. The page keeps its layout and the rest of the text stays searchable, but pdftotext and copy-paste recover nothing from the redacted regions, because nothing is left there to recover.
| Mode | Output for 4111 1111 1111 1111 | Use it for |
|---|---|---|
box | Black box, text removed | Archives, sharing externally |
mask | ************1111 | Support teams who need to reconcile the last digits |
token | [PAYMENT_CARD_1] | Sending a document to an LLM, where the model still needs to know a card was there |
pseudonym | 4000 0000 0000 0002, a reserved stand-in in the same format | Test and staging data that has to keep parsing |
blur | Gaussian blur | Images only |
Tokens and pseudonyms stay consistent within one document, so the same SSN gets the same token everywhere it appears. They are never consistent across documents, so two files cannot be linked through their tokens.
Detect without changing anything
/global/detect takes the same file, entity_types and countries fields and returns a report instead of a file. Use it to triage an upload before deciding whether to accept it.
{
"entities": [
{"type": "US_SSN", "value_preview": "*******6789", "page": 1,
"confidence": 1.0, "validated": true, "validation": "ssa-structural"}
],
"rejected_candidates": [
{"type": "US_SSN", "reason": "area 666 is not issued by the SSA", "value_preview": "*******3456", "page": 2}
],
"summary": {"US_SSN": 1},
"file_type": "PDF",
"metadata_present": ["author", "producer"],
"quota": {"used": "142", "remaining": "858"}
}
Redact text
JSON in, JSON out. The default mode is token, which is the one that makes a support ticket or a chat transcript safe to send to a model.
curl -X POST https://api.maskaadhaar.com/api/v1/global/redact/text \
-H "X-API-Key: $MASKAADHAAR_KEY" \
-H "Content-Type: application/json" \
-d '{"text": "Card 4111 1111 1111 1111, NI number AB123456C"}'
# {"redacted_text": "Card [PAYMENT_CARD_1], NI number [UK_NINO_1]", ...}
Batch
/global/batch/redact takes repeated files parts plus the same options. It returns JSON with one entry per file, each holding status (redacted, nothing_found or error), the report, and the file as file_base64. One bad file does not fail the batch, and only files in which something was redacted are charged.
ocr block with the pages and confidence. That way "nothing was found" and "we could not read this page" are different answers. Up to 30 scanned pages are OCR'd per document; the response tells you if that limit was reached.Health check
GET/health
Unauthenticated. Returns {"status":"ok"} when the service is accepting traffic. Suitable for uptime monitoring.
curl -fsS https://api.maskaadhaar.com/health
# {"status":"ok"}
It answers 200 whenever the service can serve requests, and reports nothing else — no version, no configuration, no dependency status. Point a monitor at it and alert on anything that is not a 200. Note that a healthy service still returns 402 or 429 to a key whose period has lapsed or whose quota is spent; those are answers about your account, not about the service, so do not wire them into an uptime alert.
Example: cURL
# Capture the response headers alongside the masked file curl -X POST https://api.maskaadhaar.com/api/v1/mask-aadhaar \ -H "X-API-Key: $MASKAADHAAR_KEY" \ -F "file=@aadhaar_front.jpg" \ -D headers.txt \ --output aadhaar_masked.jpg # How many regions were redacted? grep -i X-Masked-Count headers.txt
Example: Python
import os
import requests
API_URL = "https://api.maskaadhaar.com/api/v1/mask-aadhaar"
with open("aadhaar.jpg", "rb") as fh:
response = requests.post(
API_URL,
headers={"X-API-Key": os.environ["MASKAADHAAR_KEY"]},
files={"file": ("aadhaar.jpg", fh, "image/jpeg")},
timeout=120,
)
response.raise_for_status()
masked_count = int(response.headers.get("X-Masked-Count", 0))
if masked_count == 0:
raise ValueError("No Aadhaar number was found — do not treat this as masked")
with open("aadhaar_masked.jpg", "wb") as out:
out.write(response.content)
print(f"Redacted {masked_count} region(s)")
print(f"Quota remaining: {response.headers.get('X-Quota-Remaining')}")
Example: Node.js
import fs from "node:fs";
const form = new FormData();
form.append(
"file",
new Blob([fs.readFileSync("aadhaar.jpg")], { type: "image/jpeg" }),
"aadhaar.jpg"
);
const res = await fetch("https://api.maskaadhaar.com/api/v1/mask-aadhaar", {
method: "POST",
headers: { "X-API-Key": process.env.MASKAADHAAR_KEY },
body: form,
});
if (!res.ok) {
const err = await res.json();
throw new Error(`${err.error}: ${err.message}`);
}
const maskedCount = Number(res.headers.get("x-masked-count") ?? 0);
if (maskedCount === 0) {
throw new Error("No Aadhaar number found — do not treat this as masked");
}
fs.writeFileSync("aadhaar_masked.jpg", Buffer.from(await res.arrayBuffer()));
console.log(`Redacted ${maskedCount} region(s)`);
Response headers
| Header | Description |
|---|---|
X-Masked-Count | Number of regions redacted. Zero means nothing was found — treat that as a failure in your pipeline, not a success. |
X-Extracted-Fields | Non-null fields returned by an extraction call. Zero means nothing was read — the extraction analogue of a masked count of zero, and worth branching on rather than assuming |
X-Entities-Found | Identifiers found by a global endpoint. Zero means nothing was found, and the call was not charged |
X-Entity-Types | Comma-separated types redacted by /global/redact, e.g. IBAN,US_SSN. Types only, never values |
X-Quota-Used | Documents consumed in the current billing month. On a subscription plan this counts API documents only; the free browser tools are not metered. The figure matches your dashboard. |
X-Quota-Remaining | Documents left in the current billing month, against the same allowance |
Content-Disposition | Suggested filename, e.g. aadhaar_masked.jpg |
Error codes
Errors return JSON with a stable machine-readable error field. Branch on that, not on the human-readable message, which may change.
{
"error": "quota_exceeded",
"message": "Monthly quota of 1000 documents reached.",
"quota": { "limit": 1000, "used": 1000, "remaining": 0 }
}
| HTTP | error | Meaning |
|---|---|---|
| 401 | missing_api_key | No X-API-Key header was sent |
| 401 | invalid_api_key | Key not recognised or revoked |
| 403 | key_disabled | Key exists but has been disabled |
| 413 | file_too_large | File exceeds the 50 MB limit |
| 422 | unsupported_file_type | Not a JPEG, PNG or PDF |
| 429 | rate_limited | Requests-per-minute limit exceeded — back off and retry |
| 403 | reveal_not_entitled | Full Aadhaar number requested on a key without the entitlement |
| 422 | no_document_detected | Nothing recognisable as the requested document type. Not billed |
| 429 | quota_exceeded | Monthly document quota reached |
| 500 | processing_failed | Masking failed internally; safe to retry once |
On 429 rate_limited, retry with exponential back-off starting around two seconds. On 429 quota_exceeded, retrying will not help until the quota resets or is raised.
Rate limits and quotas
Two independent limits apply: a per-minute request rate and a monthly document quota. Both are enforced per API key, not per IP address, so requests from behind a shared NAT do not compete with each other.
| Limit | Value | How to read it at runtime |
|---|---|---|
| Monthly documents | Provisioned per organisation | X-Quota-Used and X-Quota-Remaining on every response |
| Requests per minute | Provisioned to your peak | 429 rate_limited once exceeded; back off and retry |
| Max file size | 50 MB per document | 413 above it, and nothing is charged |
There are no fixed tiers to pick from. Both the monthly quota and the per-minute rate are set on your key when it is issued, so the numbers that apply to you are your own rather than a published band. Tell us your expected volume and peak throughput and we will provision for it, and raise it later without a key change.
What counts against your quota
Quota is consumed when a document comes back redacted. Rejected requests cost nothing, and neither does a document in which no number could be found.
| Outcome | Counts? |
|---|---|
401 invalid or missing key | No |
413 file larger than 50 MB | No |
422 unsupported file type | No |
429 rate limited or quota exhausted | No |
500 processing failed | No |
422 no document detected | No |
200 with X-Masked-Count above zero | Yes |
200 with X-Extracted-Fields above zero | Yes |
200 with X-Entities-Found above zero | Yes |
200 with X-Masked-Count: 0 | No |
Each request is recorded with its endpoint, size, processing time and status code, so a disputed month can be reconciled line by line.
Data handling
Documents are processed in memory and streamed back in the response. We do not write them to a database or object store and we retain no copy once the request completes.
One detail worth stating precisely, because it matters for your own security review: the OCR engine writes a short-lived temporary file to local disk while a request is being processed, which is removed when the request finishes. So the accurate claim is that documents are not persisted, rather than that they never touch disk. Processing runs in the AWS Mumbai (ap-south-1) region.
See our privacy policy for the full statement.