Aadhaar Masking API · Java

Aadhaar masking API in Java

Multipart from java.net.http.HttpClient with no third-party HTTP library, then the parts that matter for a KYC pipeline: retry policy, the header that tells you whether the document was actually masked, and Spring Boot wiring that keeps the call off the request thread.

Multipart without a library

The JDK's HttpClient has no multipart body publisher, which is why most Java examples reach for Apache HttpClient or OkHttp. It is about twenty lines to build the body yourself, and it removes a dependency from a service that handles identity documents — worth doing once and keeping.

import java.io.ByteArrayOutputStream;
import java.net.URI;
import java.net.http.*;
import java.nio.file.*;

final class Multipart {
    private static final String BOUNDARY = "----MaskAadhaar" + System.nanoTime();

    // Builds a single-file multipart/form-data body. The boundary must not appear
    // inside the payload; a nanoTime suffix makes that collision impossible in practice.
    static HttpRequest.BodyPublisher fileBody(Path path) throws Exception {
        var out = new ByteArrayOutputStream();
        var header = "--" + BOUNDARY + "\r\n"
            + "Content-Disposition: form-data; name=\"file\"; filename=\""
            + path.getFileName() + "\"\r\n"
            + "Content-Type: application/octet-stream\r\n\r\n";

        out.write(header.getBytes());
        out.write(Files.readAllBytes(path));
        out.write(("\r\n--" + BOUNDARY + "--\r\n").getBytes());

        return HttpRequest.BodyPublishers.ofByteArray(out.toByteArray());
    }

    static String contentType() {
        return "multipart/form-data; boundary=" + BOUNDARY;
    }
}

With that in place the call itself is short:

var client = HttpClient.newHttpClient();

var request = HttpRequest.newBuilder()
    .uri(URI.create("https://api.maskaadhaar.com/api/v1/mask-aadhaar"))
    .header("X-API-Key", System.getenv("MASKAADHAAR_KEY"))
    .header("Content-Type", Multipart.contentType())
    .POST(Multipart.fileBody(Path.of("aadhaar.jpg")))
    .build();

var res = client.send(request, HttpResponse.BodyHandlers.ofByteArray());
Files.write(Path.of("aadhaar_masked.jpg"), res.body());

System.out.println("redacted "
    + res.headers().firstValue("X-Masked-Count").orElse("0") + " region(s)");

firstValue returns an Optional, and the default matters. Writing .orElse("1") — or ignoring the header — turns a document that was never masked into one your pipeline records as masked. Default to "0" and treat zero as needing review.

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

The same call with the three rules applied: a retry policy that distinguishes transient failures from permanent ones, a checked outcome rather than a bare byte array, and no path by which the document or the key reaches a log.

import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.*;
import java.nio.file.Path;
import java.time.Duration;
import java.util.Set;

public class AadhaarMasker {

    private static final String API = "https://api.maskaadhaar.com/api/v1";

    // rate_limited clears in seconds and processing_failed is safe to retry once.
    // quota_exceeded shares status 429 but will not clear until the month resets,
    // so retrying it only delays the moment somebody finds out.
    private static final Set<String> RETRYABLE = Set.of("rate_limited", "processing_failed");

    private final HttpClient http = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(10))
            .build();
    private final ObjectMapper json = new ObjectMapper();
    private final String apiKey = System.getenv("MASKAADHAAR_KEY");

    public record Result(byte[] masked, int regions, int quotaRemaining) {}

    public static class MaskingException extends Exception {
        public final String code;
        MaskingException(String code, String message) {
            super(code + ": " + message);
            this.code = code;
        }
    }

    // 200 OK, but no Aadhaar number was found: the original came back unchanged.
    public static class NotMaskedException extends MaskingException {
        NotMaskedException() {
            super("no_number_found", "No Aadhaar number detected; document needs review");
        }
    }

    public Result mask(Path path) throws Exception {
        long delayMs = 2_000;
        HttpResponse<byte[]> res = null;

        for (int attempt = 0; attempt < 4; attempt++) {
            // The body is rebuilt each attempt. A BodyPublisher is not reusable once
            // subscribed, and a reused one sends nothing.
            var req = HttpRequest.newBuilder()
                    .uri(URI.create(API + "/mask-aadhaar"))
                    .header("X-API-Key", apiKey)
                    .header("Content-Type", Multipart.contentType())
                    .timeout(Duration.ofSeconds(60))
                    .POST(Multipart.fileBody(path))
                    .build();

            res = http.send(req, HttpResponse.BodyHandlers.ofByteArray());
            if (res.statusCode() == 200) break;

            // Errors are JSON with a stable machine-readable `error` field.
            var node = json.readTree(res.body());
            var code = node.path("error").asText("unknown");

            if (!RETRYABLE.contains(code) || attempt == 3) {
                throw new MaskingException(code, node.path("message").asText(""));
            }

            Thread.sleep(delayMs);
            delayMs *= 2;
        }

        int regions = Integer.parseInt(
                res.headers().firstValue("X-Masked-Count").orElse("0"));

        // Calling a zero count a success is how an unmasked Aadhaar number ends up in
        // a folder marked redacted.
        if (regions == 0) throw new NotMaskedException();

        int remaining = Integer.parseInt(
                res.headers().firstValue("X-Quota-Remaining").orElse("-1"));

        return new Result(res.body(), regions, remaining);
    }
}

Both exception types are checked rather than runtime, deliberately. A caller that forgets to handle NotMaskedException will not compile, which is the whole point: the compiler enforces the one rule that this API most needs callers to follow.

Spring Boot

The call takes as long as OCR takes, so it does not belong on a servlet thread. Register the masker as a bean and run the work on a task executor.

import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

@Service
public class KycDocumentService {

    private final AadhaarMasker masker;
    private final DocumentRepository repo;

    public KycDocumentService(AadhaarMasker masker, DocumentRepository repo) {
        this.masker = masker;
        this.repo = repo;
    }

    @Async("maskingExecutor")
    public void maskDocument(Long id) {
        var doc = repo.findById(id).orElseThrow();
        try {
            var result = masker.mask(doc.originalPath());
            repo.storeMasked(id, result.masked(), result.regions());

            // Delete the unmasked original once the masked copy is safely stored.
            // Keeping both means the masking bought nothing.
            repo.deleteOriginal(id);

            if (result.quotaRemaining() >= 0 && result.quotaRemaining() < 100) {
                // Cheapest possible early warning before a batch hits the limit.
                alerts.quotaLow(result.quotaRemaining());
            }
        } catch (AadhaarMasker.NotMaskedException e) {
            // Unreadable document, not a service failure. A retry spends quota to
            // get the same answer, so park it for a human.
            repo.markNeedsReview(id);
        } catch (AadhaarMasker.MaskingException e) {
            if ("quota_exceeded".equals(e.code)) {
                repo.markBlocked(id);
                alerts.quotaExhausted();
            } else {
                repo.markFailed(id, e.code);
            }
        } catch (Exception e) {
            repo.markFailed(id, "transport");
        }
    }
}

Size the executor to your plan's requests-per-minute, not to your core count. The work happens on our side, so extra threads buy rate-limit errors rather than throughput.

@Bean("maskingExecutor")
public Executor maskingExecutor() {
    var executor = new ThreadPoolTaskExecutor();
    // Matches the plan's requests-per-minute. Raising this does not raise throughput.
    executor.setCorePoolSize(8);
    executor.setMaxPoolSize(8);
    executor.setQueueCapacity(500);
    executor.setThreadNamePrefix("masking-");
    executor.initialize();
    return executor;
}

Async and batch

For a backlog, sendAsync plus a bounded semaphore keeps you under the rate limit without a scheduler or a queue.

import java.util.concurrent.*;
import java.util.*;

// Bounded by the plan's rate limit rather than by available cores.
private static final Semaphore GATE = new Semaphore(8);

public BatchReport maskAll(List<Path> paths) throws InterruptedException {
    var review = Collections.synchronizedList(new ArrayList<Path>());
    var failed = Collections.synchronizedList(new ArrayList<Path>());
    var done   = Collections.synchronizedList(new ArrayList<Path>());

    try (var pool = Executors.newVirtualThreadPerTaskExecutor()) {
        for (Path p : paths) {
            pool.submit(() -> {
                GATE.acquire();
                try {
                    var r = masker.mask(p);
                    Files.write(outputFor(p), r.masked());
                    done.add(p);
                } catch (AadhaarMasker.NotMaskedException e) {
                    review.add(p);
                } catch (Exception e) {
                    failed.add(p);
                } finally {
                    GATE.release();
                }
                return null;
            });
        }
    }

    // All three lists matter. A report that gives only `done` is indistinguishable
    // from one that quietly left a hundred Aadhaar numbers in the clear.
    return new BatchReport(done, review, failed);
}

Virtual threads (Java 21 and later) suit this well: the tasks are almost entirely blocked on network I/O, so platform threads would sit idle at a much higher cost. The semaphore, not the thread count, is what keeps you inside the rate limit. For the arithmetic behind sizing a large run, see bulk Aadhaar masking.

Masking Aadhaar at volume from Java?

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 Java?

Build a multipart/form-data body and POST it to https://api.maskaadhaar.com/api/v1/mask-aadhaar with your key in the X-API-Key header. The JDK's java.net.http.HttpClient can do this without any third-party HTTP library once you write a small multipart body publisher. The response body is the masked document; read the X-Masked-Count header to confirm it was actually masked.

Does java.net.http.HttpClient support multipart uploads?

Not directly — the JDK ships no multipart BodyPublisher. Building one is about twenty lines: write the boundary, a Content-Disposition header naming the part 'file', the file bytes, and the closing boundary, then wrap it in BodyPublishers.ofByteArray. That removes a dependency from a service handling identity documents.

Do I need Apache HttpClient or OkHttp?

No. They are convenient, but the only thing they add here is the multipart body, which is short enough to own. Fewer dependencies in a service that processes Aadhaar documents is worth a few lines of code.

How should Spring Boot call the masking API?

Register the client as a bean and run the call on an @Async task executor sized to your plan's requests-per-minute. Calling it on a servlet thread ties up the thread for as long as OCR takes and times out under load.

Why should NotMasked be a checked exception?

Because the single most damaging mistake with this API is treating a 200 response with X-Masked-Count of zero as a success. Making it checked means a caller that forgets to handle it will not compile, so the compiler enforces the rule rather than a code reviewer having to catch it.

Related