Aadhaar Masking API · Node.js

Aadhaar masking API in Node.js

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.

The zero-dependency version

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 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, 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.

TypeScript types

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.

Express

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.

NestJS

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),
    };
  }
}

Concurrency without a queue

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.

Masking Aadhaar at volume from Node.js?

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 Node.js?

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.

Do I need axios or form-data for the Aadhaar masking API?

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.

Why is my X-Masked-Count header undefined in Node?

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.

Does fetch time out on its own in Node?

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.

How do I mask many documents concurrently in Node?

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.

Related