Aadhaar Masking API · Python

Aadhaar masking API in Python

A working client in four lines, and everything you need around it to run this against real KYC volume: retries that fire on the right failures, the header that tells you whether the document was actually masked, and Django and FastAPI patterns that do not tie up a web worker.

The four-line version

The endpoint takes one file and returns one file. There is no job to poll, no webhook to register and no SDK to install — requests is enough.

# pip install requests
import os, requests

r = requests.post(
    "https://api.maskaadhaar.com/api/v1/mask-aadhaar",
    headers={"X-API-Key": os.environ["MASKAADHAAR_KEY"]},
    files={"file": open("aadhaar.jpg", "rb")},
    timeout=60,
)
open("aadhaar_masked.jpg", "wb").write(r.content)
print("redacted", r.headers["X-Masked-Count"], "region(s)")

That is the whole happy path. The rest of this page is about the parts that are not the happy path, because those are what decide whether an unmasked Aadhaar number ever reaches a place it should not.

The three things every integration must get right

The endpoint is simple enough that a working call is four lines in any language. What separates a demo from something you can put in front of a KYC queue is the handling around it, and it comes down to three things that are identical whatever you write it in.

1. A 200 does not mean the document was masked

This is the one that bites. If the Aadhaar number cannot be read — a bad scan, glare across the digits, a photograph taken at an angle — the API returns the original document, unchanged, with status 200 and the header X-Masked-Count: 0.

That design is deliberate: silently substituting a blank page or an error would be worse, because a pipeline that expects a document back would either break or, far more dangerously, write an empty file where a redacted one should be. But it means a status check alone is not a masking check. Branch on X-Masked-Count, and treat zero as a document needing human review rather than as a success.

The failure this prevents. An unmasked Aadhaar number sitting in a folder named redacted, passed downstream to a partner, an auditor or a storage bucket with looser access rules than the original. Nobody looks again at a file that has already been marked done.

2. Retry the right failures, and only those

Two of the error codes are worth retrying and the rest are not. A 429 rate_limited means you are ahead of your per-minute allowance and should back off exponentially. A 500 processing_failed is safe to retry once. Everything else — an invalid key, an oversized file, an unsupported type, an exhausted monthly quota — will return exactly the same answer however many times you ask, and retrying only delays the moment somebody finds out.

The distinction matters most for 429, which carries two different meanings on the same status code. rate_limited clears in seconds. quota_exceeded does not clear until the month resets, and a retry loop that cannot tell them apart will spin until it gives up. Read the error field, not the status.

3. Never log the document, and never log the key

The reason to mask a document is that its contents are sensitive. A debug log that writes the request body, or an error handler that dumps the full request on failure, puts the unmasked Aadhaar number into your log aggregator — typically a system with broader access and longer retention than the document store you were protecting. The same applies to the API key: it belongs in an environment variable or a secrets manager, never in source, never in a log line, never in a URL.

A client you can ship

This is the four-line version with the three rules above applied: it separates retryable failures from permanent ones, refuses to call a zero-count response a success, and keeps the document and the key out of anything that gets logged.

# masking.py — requires MASKAADHAAR_KEY in the environment.
import os, time, logging
import requests

API = "https://api.maskaadhaar.com/api/v1"
log = logging.getLogger(__name__)

# 429 rate_limited clears in seconds and 500 is safe to retry once. Everything else
# returns the same answer however many times you ask — including 429 quota_exceeded,
# which shares a status code with rate_limited but will not clear until the month does.
RETRYABLE = {"rate_limited", "processing_failed"}


class MaskingError(Exception):
    def __init__(self, code, message):
        self.code, self.message = code, message
        super().__init__(f"{code}: {message}")


class NotMasked(MaskingError):
    # 200 OK, but no Aadhaar number was found: the original came back unchanged.
    pass


def mask_aadhaar(path, *, attempts=4, timeout=60):
    # Returns (masked_bytes, regions_redacted). Raises NotMasked on a zero count.
    key = os.environ["MASKAADHAAR_KEY"]
    delay = 2.0

    for attempt in range(attempts):
        with open(path, "rb") as fh:
            # Reopened every attempt. A retry on a spent handle uploads zero bytes
            # and fails in a way that looks like a server problem, not a client bug.
            res = requests.post(
                f"{API}/mask-aadhaar",
                headers={"X-API-Key": key},
                files={"file": fh},
                timeout=timeout,
            )

        if res.status_code == 200:
            break

        try:
            err = res.json()
        except ValueError:
            err = {"error": "unknown", "message": res.text[:200]}
        code = err.get("error", "unknown")

        if code not in RETRYABLE or attempt == attempts - 1:
            raise MaskingError(code, err.get("message", ""))

        # Log what went wrong, never the document and never the key.
        log.warning("masking retry %d/%d after %s", attempt + 1, attempts, code)
        time.sleep(delay)
        delay *= 2

    regions = int(res.headers.get("X-Masked-Count", 0))
    if regions == 0:
        # The document came back untouched. Treating this as success is how an
        # unmasked Aadhaar number ends up in a folder marked redacted.
        raise NotMasked("no_number_found",
                        "No Aadhaar number detected; document needs review")

    return res.content, regions

Two details there are worth calling out, because both are mistakes that surface under load rather than in testing.

The file handle is reopened on every attempt. A handle that has already been read sits at end-of-file, so a retry reusing it uploads nothing. The request succeeds at the transport level and fails at the API, which looks like an intermittent server problem rather than a client bug — and it only appears once you start hitting rate limits, which is to say in production.

The quota headers are worth recording. Every successful response carries X-Quota-Used and X-Quota-Remaining. Feeding them into your metrics is the cheapest way to learn you are near your monthly limit before the batch that hits it.

Multi-page PDFs

An e-Aadhaar download is a PDF, often more than one page. Every page is scanned, and X-Masked-Count reports the total across all of them.

masked, regions = mask_aadhaar("e-aadhaar.pdf")
open("e-aadhaar_masked.pdf", "wb").write(masked)

Two things change for PDFs. Processing time scales with page count, so raise the timeout for long documents rather than letting a legitimate request time out and get retried. And the optional output_format field accepts pdf, jpg or same — the default keeps the input format, which is usually what a document pipeline wants.

files = {"file": open("e-aadhaar.pdf", "rb")}
data  = {"output_format": "jpg"}   # flatten to an image instead
res = requests.post(f"{API}/mask-aadhaar", headers=hdr, files=files,
                    data=data, timeout=180)

A scanned PDF — a photocopy pushed through a document scanner — behaves like an image and depends on scan quality. A digitally generated e-Aadhaar is cleaner and reads more reliably. If you accept both, expect a higher zero-count rate on the scanned pile and route those to review rather than tightening the timeout.

Django

The call takes as long as OCR takes, which is not something to do inside a request-response cycle. Under real concurrency an inline call ties up a worker for the duration and the request times out. Push it to Celery.

# tasks.py
from celery import shared_task
from django.core.files.base import ContentFile
from .masking import mask_aadhaar, NotMasked, MaskingError
from .models import KycDocument


@shared_task(bind=True, max_retries=3)
def mask_document(self, doc_id):
    doc = KycDocument.objects.get(pk=doc_id)
    try:
        masked, regions = mask_aadhaar(doc.original.path)
    except NotMasked:
        # Not worth retrying — the document is unreadable, not the service. Park it
        # for a human instead of burning quota on the same file three more times.
        doc.status = KycDocument.NEEDS_REVIEW
        doc.save(update_fields=["status"])
        return
    except MaskingError as exc:
        if exc.code == "quota_exceeded":
            # Will not clear until the month resets. Alert; do not loop.
            doc.status = KycDocument.BLOCKED
            doc.save(update_fields=["status"])
            return
        raise self.retry(exc=exc, countdown=60)

    doc.masked.save(f"masked_{doc.pk}.jpg", ContentFile(masked), save=False)
    doc.regions_redacted = regions
    doc.status = KycDocument.MASKED
    doc.save()

    # Delete the unmasked original once the masked copy is stored. Keeping both
    # means the masking bought nothing: the plain number is still on disk.
    doc.original.delete(save=True)

That last step is the one most pipelines skip. Masking a document and keeping the original beside it does not reduce your exposure — it adds a file. If your retention policy requires the original, it belongs somewhere with stricter access than the masked copy, not in the same bucket under the same permissions.

FastAPI

Same reasoning, different mechanism. httpx gives an async client so the call does not block the event loop, and BackgroundTasks returns to the caller at once.

import os, httpx
from fastapi import FastAPI, UploadFile, BackgroundTasks, HTTPException

app = FastAPI()
API = "https://api.maskaadhaar.com/api/v1"


async def mask_and_store(content: bytes, filename: str, doc_id: str):
    async with httpx.AsyncClient(timeout=60) as client:
        res = await client.post(
            f"{API}/mask-aadhaar",
            headers={"X-API-Key": os.environ["MASKAADHAAR_KEY"]},
            files={"file": (filename, content)},
        )
    res.raise_for_status()

    if int(res.headers.get("X-Masked-Count", 0)) == 0:
        await flag_for_review(doc_id)
        return

    await store_masked(doc_id, res.content)


@app.post("/kyc/upload")
async def upload(file: UploadFile, background: BackgroundTasks):
    if file.content_type not in {"image/jpeg", "image/png", "application/pdf"}:
        # Reject locally rather than spending a call to be told 422.
        raise HTTPException(415, "Upload a JPEG, PNG or PDF")

    content = await file.read()
    doc_id = new_document_id()
    background.add_task(mask_and_store, content, file.filename, doc_id)
    return {"document_id": doc_id, "status": "processing"}

Validating the content type before calling is worth the three lines. An unsupported file costs a round trip to be told 422, and rejecting it locally gives the user a faster, more specific answer.

Masking a directory in parallel

For a backlog — a folder of documents collected before masking was in place — concurrency is bounded by your plan's requests-per-minute, not by your machine. A thread pool sized to the rate limit keeps you just under it without needing a scheduler.

from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path

# Sized to the plan's requests-per-minute, not to the CPU. The work happens on our
# side, so more local threads only buy more 429s.
WORKERS = 8


def mask_directory(src: Path, dst: Path):
    dst.mkdir(parents=True, exist_ok=True)
    files = [p for p in src.iterdir()
             if p.suffix.lower() in {".jpg", ".jpeg", ".png", ".pdf"}]
    review, failed = [], []

    with ThreadPoolExecutor(max_workers=WORKERS) as pool:
        futures = {pool.submit(mask_aadhaar, str(p)): p for p in files}
        for fut in as_completed(futures):
            path = futures[fut]
            try:
                masked, _ = fut.result()
            except NotMasked:
                review.append(path.name)
                continue
            except MaskingError as exc:
                failed.append((path.name, exc.code))
                continue
            (dst / f"masked_{path.name}").write_bytes(masked)

    # Both lists matter. `review` is documents that came back unmasked and must not
    # be filed as done; `failed` never got processed at all.
    return review, failed

Print both lists at the end and act on them. A batch job that reports only how many files it wrote is indistinguishable from one that quietly left a hundred Aadhaar numbers in the clear. For larger backlogs and the arithmetic behind sizing a run, see bulk Aadhaar masking.

Masking Aadhaar at volume from Python?

API access is provisioned per organisation, priced by volume. Tell us your expected monthly documents and peak throughput and we will issue a key with a test quota.

Request API access Read the API docs

Error codes worth handling

HTTPerrorWhat it meansRetry?
401missing_api_keyNo X-API-Key header sentNo — fix the request
401invalid_api_keyKey not recognised or revokedNo
403key_disabledKey exists but is disabledNo
413file_too_largeAbove the size ceilingNo — downscale first
422unsupported_file_typeNot a JPEG, PNG or PDFNo — convert first
429rate_limitedPer-minute rate exceededYes — exponential back-off
429quota_exceededMonthly document quota reachedNo — retrying will not help
500processing_failedMasking failed internallyYes — once

Errors return JSON with a stable machine-readable error field. Branch on that rather than on the human-readable message, which may be reworded at any time.

Questions

How do I mask an Aadhaar number in Python?

Post the document to https://api.maskaadhaar.com/api/v1/mask-aadhaar as multipart/form-data with your key in the X-API-Key header, using requests or httpx. The response body is the masked document itself, so write response.content to a file. Check the X-Masked-Count response header before treating the result as masked: zero means no Aadhaar number was found and the original document was returned unchanged.

Is there a Python SDK for Aadhaar masking?

No SDK is needed. The API is a single synchronous multipart POST that returns the document in the response body, so requests or httpx covers it in a few lines. Avoiding an SDK also means no dependency to keep in step with your Python version or your security review.

How do I handle rate limits in Python?

Retry only on 429 rate_limited and 500 processing_failed, with exponential back-off starting around two seconds. Do not retry 429 quota_exceeded, which shares the status code but means the monthly quota is gone and will not clear until the month resets. Branch on the JSON error field rather than on the status code.

Can I mask Aadhaar numbers in a Django or FastAPI application?

Yes. Because the call is synchronous and takes as long as OCR takes, run it off the request thread: a Celery task in Django, or a background task in FastAPI. Calling it inline inside a web request ties up a worker for the duration and times out under load.

Does the Python client need to store the document?

No. The masked document arrives in the response body and can be streamed straight to its destination. Nothing is stored on our side either: documents are processed in memory and streamed back, with no copy written to a database or object store.

Related