A working call with built-in fetch and no dependencies, then the
parts that matter in production: retries on the right failures, the header that tells you
whether the document was actually masked, and Express and NestJS patterns that stream instead
of buffering.
Node 18 and later ship fetch, FormData and Blob as
globals, so a working call needs nothing from npm — no axios, no
form-data, no SDK.
import { readFile, writeFile } from "node:fs/promises"; const form = new FormData(); form.append("file", new Blob([await readFile("aadhaar.jpg")]), "aadhaar.jpg"); const res = await fetch("https://api.maskaadhaar.com/api/v1/mask-aadhaar", { method: "POST", headers: { "X-API-Key": process.env.MASKAADHAAR_KEY }, body: form, }); await writeFile("aadhaar_masked.jpg", Buffer.from(await res.arrayBuffer())); console.log("redacted", res.headers.get("x-masked-count"), "region(s)");
Header names are lower-cased. The fetch
Headers object normalises keys, so res.headers.get("X-Masked-Count") and
res.headers.get("x-masked-count") both work — but
res.headers["X-Masked-Count"] is undefined, silently. Reading a
header as a property rather than through .get() is the most common way this
integration ends up ignoring the masked count entirely.
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, plus a timeout — fetch has no
default timeout, so without an AbortSignal a stalled request hangs until the
socket dies.
// masking.mjs — requires MASKAADHAAR_KEY in the environment. import { readFile } from "node:fs/promises"; import { basename } from "node:path"; const API = "https://api.maskaadhaar.com/api/v1"; // rate_limited clears in seconds and processing_failed is safe to retry once. // Everything else returns the same answer however many times you ask — including // quota_exceeded, which shares status 429 but does not clear until the month does. const RETRYABLE = new Set(["rate_limited", "processing_failed"]); export class MaskingError extends Error { constructor(code, message) { super(`${code}: ${message}`); this.code = code; } } // 200 OK, but no Aadhaar number was found: the original came back unchanged. export class NotMasked extends MaskingError {} const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); export async function maskAadhaar(path, { attempts = 4, timeoutMs = 60_000 } = {}) { // Read once, outside the retry loop: the bytes do not change between attempts. const bytes = await readFile(path); let res, delay = 2_000; for (let attempt = 0; attempt < attempts; attempt++) { // A fresh FormData each attempt. Reusing one whose stream has been consumed // sends an empty body, which fails in a way that looks like a server problem. const form = new FormData(); form.append("file", new Blob([bytes]), basename(path)); // fetch has no default timeout. Without this a stalled request hangs forever. res = await fetch(`${API}/mask-aadhaar`, { method: "POST", headers: { "X-API-Key": process.env.MASKAADHAAR_KEY }, body: form, signal: AbortSignal.timeout(timeoutMs), }); if (res.ok) break; // Errors are JSON with a stable `error` field. Branch on that, never on // `message`, which is prose and may be reworded at any time. const err = await res.json().catch(() => ({ error: "unknown" })); if (!RETRYABLE.has(err.error) || attempt === attempts - 1) { throw new MaskingError(err.error ?? "unknown", err.message ?? ""); } // Log what went wrong — never the document, never the key. console.warn(`masking retry ${attempt + 1}/${attempts} after ${err.error}`); await sleep(delay); delay *= 2; } const regions = Number(res.headers.get("x-masked-count") ?? 0); if (regions === 0) { // The document came back untouched. Calling this a success is how an unmasked // Aadhaar number ends up in a folder marked redacted. throw new NotMasked("no_number_found", "No Aadhaar number detected; document needs review"); } return { masked: Buffer.from(await res.arrayBuffer()), regions }; }
Note ?? 0 rather than || 0 on the header. They behave identically
here, but the habit matters: || treats a legitimate "0" as falsy in
other contexts, and this is exactly the value you must not accidentally coerce.
There is no SDK to import types from, so declare the response shape yourself. It is small.
export interface MaskResult { masked: Buffer; /** Regions redacted. Zero is returned as a NotMasked error, never here. */ regions: number; quotaUsed: number | null; quotaRemaining: number | null; } export type MaskingErrorCode = | "missing_api_key" | "invalid_api_key" | "key_disabled" | "file_too_large" | "unsupported_file_type" | "rate_limited" | "quota_exceeded" | "processing_failed"; export interface ApiError { error: MaskingErrorCode; message: string; quota?: { limit: number; used: number; remaining: number }; }
Typing error as a union rather than string is what makes the
compiler tell you that your switch has stopped being exhaustive if a new code
appears.
Masking takes as long as OCR takes. Doing it inline inside a request handler holds the connection open for seconds, so accept the upload, respond immediately, and mask in the background.
import express from "express"; import multer from "multer"; import { maskAadhaar, NotMasked, MaskingError } from "./masking.mjs"; const app = express(); // memoryStorage keeps the unmasked document out of the filesystem entirely. If you // use diskStorage instead, delete the original the moment the masked copy is stored. const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 50 * 1024 * 1024 }, fileFilter(_req, file, cb) { // Reject locally instead of spending a call to be told 422. const ok = ["image/jpeg", "image/png", "application/pdf"]; cb(null, ok.includes(file.mimetype)); }, }); app.post("/kyc/upload", upload.single("document"), async (req, res) => { if (!req.file) return res.status(415).json({ error: "Upload a JPEG, PNG or PDF" }); const id = newDocumentId(); res.status(202).json({ documentId: id, status: "processing" }); try { const { masked, regions } = await maskFromBuffer(req.file.buffer, req.file.originalname); await storeMasked(id, masked, regions); } catch (err) { if (err instanceof NotMasked) return flagForReview(id); if (err instanceof MaskingError && err.code === "quota_exceeded") { // Will not clear until the month resets. Alert; do not requeue. return alertQuotaExhausted(id); } await requeue(id); } });
Responding 202 before the work finishes is the point. The client gets an id it
can poll, and a slow document never becomes a timed-out HTTP request.
Same shape, wrapped in a provider so the key and the retry policy live in one injectable place rather than being repeated at every call site.
import { Injectable, Logger } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; @Injectable() export class MaskingService { private readonly log = new Logger(MaskingService.name); private readonly key: string; constructor(config: ConfigService) { // Read once at construction. A missing key should fail at boot, loudly, // rather than at 2am on the first document of a batch. this.key = config.getOrThrow<string>("MASKAADHAAR_KEY"); } async mask(buffer: Buffer, filename: string): Promise<MaskResult> { const form = new FormData(); form.append("file", new Blob([buffer]), filename); const res = await fetch("https://api.maskaadhaar.com/api/v1/mask-aadhaar", { method: "POST", headers: { "X-API-Key": this.key }, body: form, signal: AbortSignal.timeout(60_000), }); if (!res.ok) { const err: ApiError = await res.json(); throw new MaskingError(err.error, err.message); } const regions = Number(res.headers.get("x-masked-count") ?? 0); if (regions === 0) { throw new NotMasked("no_number_found", "Document needs review"); } this.log.log(`masked ${regions} region(s); quota remaining ` + res.headers.get("x-quota-remaining")); return { masked: Buffer.from(await res.arrayBuffer()), regions, quotaUsed: Number(res.headers.get("x-quota-used") ?? 0), quotaRemaining: Number(res.headers.get("x-quota-remaining") ?? 0), }; } }
For a backlog, the limit is your plan's requests-per-minute, not your event loop. Node will
happily fire ten thousand concurrent requests and collect ten thousand
429s. Bound it.
// A worker pool sized to the plan's rate limit. No queue library needed: the // constraint is our rate limit, not local scheduling. const CONCURRENCY = 8; export async function maskAll(paths) { const queue = [...paths]; const review = [], failed = [], done = []; async function worker() { let path; while ((path = queue.shift()) !== undefined) { try { const { masked } = await maskAadhaar(path); await writeFile(`masked/${basename(path)}`, masked); done.push(path); } catch (err) { if (err instanceof NotMasked) review.push(path); else failed.push([path, err.code]); } } } await Promise.all(Array.from({ length: CONCURRENCY }, worker)); // Report all three. A job that prints only `done.length` is indistinguishable // from one that quietly left a hundred Aadhaar numbers in the clear. return { done, review, failed }; }
Promise.all over a fixed set of workers pulling from a shared array gives you a
bounded pool in eight lines. 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.
Node 18 and later ship fetch, FormData and Blob as globals, so no npm package is needed. Append the file to a FormData, POST it to https://api.maskaadhaar.com/api/v1/mask-aadhaar with your key in the X-API-Key header, and write the response arrayBuffer to disk. Read res.headers.get('x-masked-count') to confirm the document was actually masked.
No. Built-in fetch handles multipart uploads natively from Node 18 onward, and Blob plus FormData are globals. Fewer dependencies also means fewer things to patch when a security advisory lands.
Because fetch returns a Headers object, not a plain object. res.headers['X-Masked-Count'] is always undefined; you must call res.headers.get('x-masked-count'). Header names are normalised to lower case, so either casing works inside .get(). This is the most common reason an integration silently ignores the masked count.
No. fetch has no default timeout, so a stalled request hangs until the socket dies. Pass signal: AbortSignal.timeout(60_000) on every call, and raise it for multi-page PDFs where processing time scales with page count.
Bound concurrency to your plan's requests-per-minute rather than firing everything at once. A fixed number of workers pulling from a shared array gives you a bounded pool in a few lines, with no queue library. Unbounded Promise.all over thousands of files just collects rate-limit errors.