HabileLabs Aadhaar Masking API

API documentation

How to send documents to the Aadhaar Masking API, what comes back, and how to follow the ones processed in the background.

Try it in the console

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 URLhttps://mask-api.habilelabs.io
AuthenticationYour key in the aadhaar-masking-key header on every request (x-api-key and api-key are accepted too)
Browser callsNot supported: the API sends no CORS headers, so call it from your server
RouteWhat it does
POST /aadhaar-maskingMask a document or a ZIP (multipart file), or a file in your own S3 (JSON body of presigned links)
POST /aadhaar-masking-excelMask 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 OK response.
  • 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 200 with its download URL, exactly as a short one is. One still running when the wait runs out gets a 202 with a taskId to poll. The 202 message 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 202 instead of 200.

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 situationMode
User uploads a scan/photo and waits for the resultauto
Single KYC document, a few pagesauto
Mixed sizes, want simplest handling of small filesauto
Submitting many documents in bulkbatch
Background/queue worker, result not needed immediatelybatch
Want one response shape and polling for everythingbatch
Very large PDFs are common in your trafficeither (auto already masks them in the background)
Long PDFs, and you would rather poll than hold a connectionasync
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..." }
Not every file in the returned zip is masked. Entries are taken by content, as single files are. Non-maskable entries (docx, txt, nested zips, …) are included unchanged and flagged 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.

WasUse instead
POST /maskPOST /aadhaar-masking (same file, same mode; reference-shaped answer)
GET /upload-url, POST /mask-by-keyPOST /aadhaar-masking with the file
POST /aadhaar-masking-s3POST /aadhaar-masking with the same JSON body

The summary object

Present on every completed image or PDF task (GET /status/{taskId}).

FieldMeaning
maskedtrue if any region was masked anywhere
pagesTotalPages processed (1 for images)
pagesWithAadhaarPages where an Aadhaar number was masked (QR-only pages count 0)
pagesMaskedPages with at least one region masked
regionsMaskedTotal number/QR regions blacked out
alreadyMaskedRegionsCurrently always 0 (already-masked regions are skipped silently)
pagesWithErrorsAlways 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

HTTPWhen
403Rate-limited by the firewall (no JSON body)
404Unknown route ({"error": "Not found"}) or unknown taskId
405Wrong method on a route ({"error": "Method not allowed"})
502Background 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