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.
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 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.
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.
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.
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.
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.
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; }
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.
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| HTTP | error | What it means | Retry? |
|---|---|---|---|
| 401 | missing_api_key | No X-API-Key header sent | No — fix the request |
| 401 | invalid_api_key | Key not recognised or revoked | No |
| 403 | key_disabled | Key exists but is disabled | No |
| 413 | file_too_large | Above the size ceiling | No — downscale first |
| 422 | unsupported_file_type | Not a JPEG, PNG or PDF | No — convert first |
| 429 | rate_limited | Per-minute rate exceeded | Yes — exponential back-off |
| 429 | quota_exceeded | Monthly document quota reached | No — retrying will not help |
| 500 | processing_failed | Masking failed internally | Yes — 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.
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.
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.
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.
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.
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.