Overview
The API masks Aadhaar numbers and QR codes in images, PDFs, ZIP archives and Excel workbooks. Send a document and receive a masked copy: short documents are answered at once, long PDFs and archives are processed in the background and followed with a task ID.
| Base URL | https://mask-api.habilelabs.io |
|---|---|
| Authentication | Your key in the aadhaar-masking-key header on every request
(x-api-key and api-key are accepted too) |
| Browser calls | Not supported: the API sends no CORS headers, so call it from your server |
| Route | What it does |
|---|---|
POST /aadhaar-masking | Mask a document or a ZIP (multipart file), or a file in your own S3 (JSON body of presigned links) |
POST /aadhaar-masking-excel | Mask Aadhaar numbers in Excel cells |
GET /status/{taskId} | Follow a background task and get its download link |
GET /, GET /health | {"status": "ok"} while the service is up |
Quick start
Mask a document in one call:
curl -X POST "https://mask-api.habilelabs.io/aadhaar-masking" \
-H "aadhaar-masking-key: <your-api-key>" \
-F "file=@aadhaar.jpg"
For an image or short PDF the masked file's download URL comes straight back:
{ "status": 200, "statusMessage": "Success", "message": "File processed successfully.",
"fileName": "<uuid>.jpg",
"downloadUrl": "https://files.habilelabs.io/masked-output/.../<uuid>.jpg?...",
"logs": { "Page_1": { "is_number_masked": 1, "is_QR_masked": 1, ... } },
"totalPages": 1, "processedImages": 1, "maskedPages": 1, "unmaskedPages": 0,
"maskingStatus": true }
Download from downloadUrl promptly; the link expires (an hour on the live service).
What gets masked: the first 8 digits of each Aadhaar number (the last 4 stay visible) and the Aadhaar QR code in full. When the service cannot establish which end of a number comes first, all 12 digits are covered instead — for example a number that is detected but too small or faint to read, or a sideways number whose direction could not be confirmed. A number found by reading the page rather than by the detector — a number on a UIDAI or Income-Tax screenshot, the small second copy under the ghost photo on newer cards, or one whose own crop would not read — is covered the same 8 digits where it begins or ends its line of text. Where it sits inside a longer line, or its digits cannot be told apart on the page (a small, blurred copy), a 9th digit is covered as well (3 stay visible), so the eighth is never left partly showing. Name, photo, DOB, address and VID are not masked. Detection is automatic and not guaranteed — spot-check outputs for high-stakes use. Masked PDFs are rebuilt from page images, so they are not text-searchable.
Supported files: pdf, jpg, jpeg,
png, tif, tiff, and iPhone heic /
heif photos (masked and returned as a JPEG) · the type is what the file
contains: a PDF or an image whose name says something else
(scan.jfif, report.crdownload) is masked as what it is, and
anything else is refused (422) · up to 100 MB ·
password-protected PDFs (including e-Aadhaar downloads) are not supported ·
multi-page TIFFs: every frame is masked and returned (one Page_N
per frame in logs; a long one may be processed in the background).
Processing modes
POST /aadhaar-masking with a file accepts an optional mode query parameter that
controls how your document is processed. It is the most important choice when
integrating.
mode=auto — default, recommended for most callers
The API picks the fastest safe path for each file:
- Images & short PDFs are masked
synchronously — the download URL is in the same
200 OKresponse. - PDFs at or above the page threshold (set on the
server) are masked in the background. The request is
still held for up to about a minute, and a document that finishes inside
that wait is answered
200with its download URL, exactly as a short one is. One still running when the wait runs out gets a202with ataskIdto poll. The202message states the threshold that applied. - If the server is busy, or a file takes longer than ~60 s, it is moved to
background processing and you get
202instead of200.
You never have to check the page count yourself, but always handle
both 200 and 202. Omitting mode
gives you this behavior.
Use it when you want the quickest answer for everyday documents (a scan, a photo, a 1–2 page KYC PDF) and only occasionally handle a large file. Ideal for interactive apps and customer uploads.
mode=batch — force background processing for every file
Every file, even a single small image, goes to background
processing. You always get a 202 + taskId and always
poll /status. Nothing is masked synchronously.
Use it when:
- Bulk ingestion — submitting many files in a loop; fire-and-forget, collect results later. No open connections while masking runs.
- Uniform client code — handle one response shape (always a task + poll) instead of branching on result-vs-task.
- Server-to-server / offline jobs — result isn't needed in real time, so you avoid any client-side request timeouts.
- Predictability — a file's size never changes whether the call blocks.
Trade-off: even a tiny image takes the background path (tens of seconds) instead of returning instantly.
mode=async — never hold a long PDF
As auto, except that a PDF at or above the page threshold is
answered 202 the moment its task is submitted, without the wait.
Images and short PDFs are still masked synchronously and answered
200.
Use it when your client would rather poll than keep a connection open for up to a minute.
Decision table
| Your situation | Mode |
|---|---|
| User uploads a scan/photo and waits for the result | auto |
| Single KYC document, a few pages | auto |
| Mixed sizes, want simplest handling of small files | auto |
| Submitting many documents in bulk | batch |
| Background/queue worker, result not needed immediately | batch |
| Want one response shape and polling for everything | batch |
| Very large PDFs are common in your traffic | either (auto already masks them in the background) |
| Long PDFs, and you would rather poll than hold a connection | async |
mode applies to POST /aadhaar-masking with a file. Any invalid value returns 400.Endpoints
POST /aadhaar-masking with a file
Body is multipart/form-data with a single file field;
mode as above. The response matches the legacy
aadhaar-masking-api-ai service: success returns {status,
statusMessage, message, logs, totalPages, processedImages, fileName, downloadUrl,
maskedPages, unmaskedPages, maskingStatus}, and the file is renamed to
<uuid>.<ext>. A long PDF returns the same 200
when it finishes inside the wait; otherwise, and for every ZIP, you get
202 Accepted:
{ "taskId": "9922ed7c-...", "status": "SUBMITTED", "pages": 162,
"statusUrl": "https://mask-api.habilelabs.io/status/9922ed7c-...",
"message": "... being masked asynchronously. Poll statusUrl ..." }
/status includes the same page fields when the task is complete.
Errors use reference bodies {status, statusMessage, message}:
401 (key missing) / 403 (key wrong) / 400 (more than one file, no file, invalid
mode) / 422 (type, size, unreadable PDF or ZIP) / 423 (password-protected
PDF or ZIP) / 500 (masking failed).
maskingStatus is currently always true
for images and PDFs. A document is masked whole or not at all:
if any page cannot be masked, a synchronous request fails with 500
and no file; a background task (the 202 + taskId path) ends
FAILED with error: "page N of M could not be masked" and
no download URL; inside a ZIP, that entry is flagged error and the
original is included unchanged. Every page of a document returned as masked was
processed.POST /aadhaar-masking-excel
Masks Aadhaar numbers in .xls/.xlsx cells. Multipart
file; returns {status, statusMessage, message, fileName,
downloadUrl, maskedCount, totalSheets, maskingStatus}. The rule is the one
used for scanned documents: a cell holds an Aadhaar number when it contains twelve
digits — one run (a numeric cell included) or groups adding up to twelve
(2345 6789 0124, 2345-6789-0124, with text around them) —
not opening with 0 or 1; twelve digits picked out of a longer run must also pass the
Verhoeff checksum, and a mobile number with its country code beside a phone label is
refused. Only the number is masked, in place: the first eight digits become
X, the last four stay (Aadhaar: XXXX XXXX 0124). Every
sheet and cell is checked in both formats; formulas and styles are kept; a workbook
with nothing to mask is a success with maskedCount: 0.
maskingStatus means at least one number was masked. If the synchronous
window closes first you get 202 with a taskId and
fileType: "excel"; poll /status.
POST /aadhaar-masking with S3 links
Send a JSON body (Content-Type: application/json) to the same route
instead of a file: { "getUrl": "<presigned GET>", "uploadUrl":
"<presigned PUT>", "fileName": "doc.pdf" }. The service downloads from
getUrl, masks, and PUTs the result to uploadUrl, then returns
{status: 200, statusMessage: "Success", message: "File masked successfully."}.
Images and short PDFs are masked synchronously; a PDF at or past the page
threshold is handed to background processing and answered 202 with a
taskId and statusUrl — when that task is
COMPLETED the masked file has been PUT to your uploadUrl
(and /status says deliveredToUploadUrl: true, or
false with a deliveryError; the download link still works).
Keep the presigned uploadUrl valid for a few hours for a long document.
Both URLs must be https S3 presigned URLs — a host on an
amazonaws.com S3 endpoint (regional, dual-stack, accelerate and
VPC-endpoint forms included); any other host is refused, and redirects from
either URL are never followed.
Errors: key missing → 401; key wrong, or a URL that is not an https S3 URL → 403;
a missing field or a body that is not JSON → 403 (body status: 404);
too large → 422; password-protected PDF → 422 (body status: 423);
download/upload failure → 500; unsupported type → 501.
POST /aadhaar-masking with a ZIP archive
Send a .zip to the same route to mask many documents in one
request. ZIP archives are always processed in the background
and return one masked zip whose folder structure mirrors the input. You always
get 202:
{ "taskId": "9f1c...", "status": "SUBMITTED", "fileType": "zip",
"files": 12, "statusUrl": ".../status/9f1c...",
"message": "ZIP archives are masked asynchronously. Poll statusUrl..." }
skipped; a
failing entry (corrupt image, encrypted inner PDF) is copied through unmasked and
flagged with an error; entries beyond the 2,000-page budget per
archive are included unchanged and flagged skipped. A HEIC photo
comes back as a masked JPEG beside where it was (IMG_1.HEIC →
IMG_1.jpg, marked convertedFrom), without the original;
one that cannot be read is removed and flagged with an
error.Hidden entries — files or folders whose name starts with
. (and everything inside such a folder), plus anything under
__MACOSX/ — are removed from the returned zip and
listed in the report as removed (summary.filesRemoved); they do not
affect maskingStatus. The page budget applies to every entry: one that
would take the archive past 2,000 pages is included unchanged and flagged, and later
entries that fit are still masked. Check the
archive's maskingStatus and files report before treating
the result as safe.Limits checked up front (over any → 422/423): zip
≤ 500 MB, uncompressed ≤ 3 GB, ≤ 1500 entries, compression ratio ≤ 100× (every
entry, hidden ones included). Page counts are not checked up front — split archives
larger than ~2,000 pages.
GET /status/{taskId}
Check a background task (needs your key; a missing or wrong one is answered
403 {"error": "Forbidden"}). Status moves
SUBMITTED → RUNNING → COMPLETED (or
FAILED). If the background job is killed before it can report — Spot
capacity reclaimed, the queue's time limit, an out-of-memory kill — a poll checks the
job with Batch and reports FAILED with Batch's reason (this needs the job
id recorded at submission; without it the record is left as it is and expires with the
task). A shorter document may be masked by the service's own function instead of a
Batch job; if that run is cut short and its retries with it, a poll reports
FAILED once the task has sat unchanged for 50 minutes while
RUNNING, or an hour and 50 minutes while SUBMITTED.
{ "taskId": "...", "status": "COMPLETED",
"maskedUrl": "https://files.habilelabs.io/masked-output/<id>/<name>?...",
"key": "...", "expiresInSeconds": 3600, "summary": { ... } }
The maskedUrl is freshly signed on every poll and valid for
expiresInSeconds from when you receive it. Unknown IDs return
404. Files are kept for about a day; once the file has been deleted
a poll returns status: "EXPIRED" with no link — resubmit the
document.
ZIP tasks also carry fileType: "zip" and a live
progress object on every poll
({ filesTotal, filesDone, filesMasked, filesSkipped, filesFailed }),
plus a per-file files report when complete. For a very large archive
(roughly 1,500 entries or more) the report is too big to embed: files is
omitted and reportUrl — a signed link to the same JSON, valid for
expiresInSeconds like the download — is returned instead.
Tasks from S3 links (a long PDF sent as getUrl/uploadUrl) also carry
deliveredToUploadUrl — whether the masked file was PUT to the
uploadUrl given — and, when it was not, a deliveryError; the
download link works either way.
Excel tasks (a spreadsheet handed to the background after the
sync window closed) carry fileType: "excel" and, when
COMPLETED, the Excel route's fields instead of page counts:
message, maskedCount, totalSheets,
maskingStatus, with downloadUrl.
GET / and GET /health
{"status": "ok"} with 200 while the service is up; no key
needed.
Retired routes
Removed on 2026-10-06; they now answer 404.
| Was | Use instead |
|---|---|
POST /mask | POST /aadhaar-masking (same file, same mode; reference-shaped answer) |
GET /upload-url, POST /mask-by-key | POST /aadhaar-masking with the file |
POST /aadhaar-masking-s3 | POST /aadhaar-masking with the same JSON body |
The summary object
Present on every completed image or PDF task (GET /status/{taskId}).
| Field | Meaning |
|---|---|
masked | true if any region was masked anywhere |
pagesTotal | Pages processed (1 for images) |
pagesWithAadhaar | Pages where an Aadhaar number was masked (QR-only pages count 0) |
pagesMasked | Pages with at least one region masked |
regionsMasked | Total number/QR regions blacked out |
alreadyMaskedRegions | Currently always 0 (already-masked regions are skipped silently) |
pagesWithErrors | Always 0 on success — if any page cannot be masked the request fails (500, or FAILED for background tasks) and no file is produced |
Other errors
| HTTP | When |
|---|---|
403 | Rate-limited by the firewall (no JSON body) |
404 | Unknown route ({"error": "Not found"}) or unknown taskId |
405 | Wrong method on a route ({"error": "Method not allowed"}) |
502 | Background task could not be submitted |
Integration recipes
Synchronous (auto mode, small files)
POST /aadhaar-masking (multipart file)
200 → download downloadUrl before it expires
202 → file was large or slow; fall through to polling below
Always handle both 200 and 202 in auto mode — a caller
can't always know a PDF's page count in advance.
Polling (async / batch)
POST /aadhaar-masking?mode=batch (multipart file) → 202 { taskId, statusUrl }
loop:
GET /status/{taskId}
SUBMITTED | RUNNING → wait 3–5s, repeat
COMPLETED → download downloadUrl, read summary
FAILED → read error
Production notes
- Download links expire after
expiresInSeconds; poll/statusagain for a fresh one. - Budget about 10 seconds per page, plus a minute or two for a background task to start.
- The first request after a quiet period is slower while the service warms up.
- Your upload is deleted as soon as the masked copy exists, and masked files after about a day.
- Your file name never reaches storage or the download link; uploads are renamed to a random ID.
- Keep your API key secret, and ask us to rotate it if it is ever exposed.