StampMitraDevelopers

Guides

How to Verify PAN in Node.js and Python (Tested Tutorial)

To verify a PAN in Node.js or Python, POST {"pan": "ABCDE1234F"} (optional "name") to https://api-b2b.stampmitra.in/v1/verify/pan with an Authorization: Bearer header, using a key from the STAMPMITRA_API_KEY environment variable. Validate the format first, set a timeout, retry only 429 and 5xx with backoff, and log referenceId, never the PAN.

By StampMitra Developer TeamLast updated: Read this in Hindi

To verify a PAN in Node.js or Python, send a POST request to https://api-b2b.stampmitra.in/v1/verify/pan with an Authorization: Bearer <key> header and a JSON body such as {"pan": "ABCDE1234F", "name": "Rahul Kumar"}. The name field is optional. The response contains referenceId, valid, registeredName, panStatus and nameMatchResult. Read the key from an environment variable and call the API only from your server.

This tutorial builds the whole thing in both languages: a bare fetch and requests call first, then a typed Node.js wrapper and an httpx Python client with timeouts, bounded retries, input validation, error mapping and logging that never records a PAN. You then get unit tests with mocked responses and an Express and a FastAPI endpoint to put in front of your own app. Every example uses the real PAN verification endpoint of the StampMitra API and the sample values published in its docs.

All code was executed while writing this page: the Node.js project passes tsc and 16 tests on Node 22.22, and the Python project passes 16 tests on Python 3.13. You can copy each file as it appears.

Key takeaways

  • The endpoint is POST /v1/verify/pan on https://api-b2b.stampmitra.in, gated by the verification.pan.read scope. The key goes in Authorization: Bearer <key> (or x-api-key).
  • Keep the key in the STAMPMITRA_API_KEY environment variable, keep .env out of git, and never call the API from a browser.
  • Validate the PAN as ^[A-Z]{5}[0-9]{4}[A-Z]$ after trimming and upper-casing. A malformed PAN is a 400 on the server, so catching it locally saves a round trip.
  • valid: false is a completed verification returned with HTTP 200. Treat it as a business outcome, not an exception.
  • Set explicit timeouts. Retry only 429 and 5xx, with capped exponential backoff and jitter, and a small attempt limit. Never retry 400, 401, 402 or 403.
  • Do not auto-retry timeouts: the verify endpoints have no idempotency key, so a request that timed out may still have completed.
  • Log referenceId, requestId, status and latency. Never log the PAN, the name or the key.
  • Mock the HTTP layer in tests. Assert the request shape, every error branch and that logs stay clean.
  • Show your own users generic error messages. A 401, 403 or 402 is your problem to fix, not theirs.

What does a PAN verification request and response look like?

Start with the contract, because every line of code below is a thin layer over it. The endpoint, scope and fields come from the StampMitra product data that also drives the PAN verification docs page.

ItemValue
Method and pathPOST /v1/verify/pan
Base URLhttps://api-b2b.stampmitra.in
Auth headerAuthorization: Bearer <api-key> (the x-api-key: <key> header is also accepted)
Required scopeverification.pan.read
Body: panRequired string, format ABCDE1234F
Body: nameOptional string, up to 256 characters, matched against the PAN records
MeteringRecorded as pan.verify only after a successful response

Here is the same call in cURL. The key is read from the environment, which is a habit worth forming from the first command.

cURL
curl -X POST "https://api-b2b.stampmitra.in/v1/verify/pan" \
  -H "Authorization: Bearer $STAMPMITRA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"pan":"ABCDE1234F","name":"Rahul Kumar"}'

A successful call returns this body (the docs' sample, not a real person):

JSON
{
  "referenceId": "SMV-20260927-8F42K7",
  "pan": "ABCDE1234F",
  "type": "Individual",
  "valid": true,
  "registeredName": "RAHUL KUMAR",
  "fatherName": "SURESH KUMAR",
  "panStatus": "VALID",
  "panStatusCode": "E",
  "panStatusDescription": "Existing and valid PAN",
  "nameMatchResult": "MATCH",
  "nameMatchScore": "100",
  "aadhaarSeedingStatus": "y",
  "aadhaarSeedingStatusDescription": "Aadhaar linked",
  "lastUpdatedAt": "2023-04-01",
  "message": "PAN is Valid"
}

Two details matter for your code. First, referenceId (format SMV-YYYYMMDD-XXXXXXX) is StampMitra's own transaction id and is always present, including on a negative result. Store it with your user record for audit. The docs are explicit that it is not an idempotency key. Second, both a positive and a negative determination are completed, billable verifications, so a PAN that is not valid still comes back as a normal response rather than an error.

Every error from the API uses one envelope, { "error": { "code", "message", "requestId" } }. We map each documented case later in this tutorial. For the wider context of what the PAN API does and which fields to trust, see the companion PAN verification API guide for India.

Worked examples: three failing calls and what your code does

Seeing the failures next to the code that handles them is the fastest way to internalise the policy. These bodies are the examples from the StampMitra docs for this endpoint.

A malformed PAN. You send {"pan": "ABCDE1234"} (nine characters). Locally, normalisePanInput rejects it and nothing is sent. If it somehow reached the API, you would receive HTTP 400:

JSON
{
  "error": {
    "code": "BAD_REQUEST",
    "message": "pan must match the format ABCDE1234F",
    "requestId": "a1b2c3d4-e5f6-4a1b-8c2d-9f0e1a2b3c4d"
  }
}

The client raises a bad_request error with that requestId and does not retry, because the same input will fail the same way.

A rate-limited call. You send a burst and the 61st request inside a minute gets HTTP 429:

JSON
{
  "error": {
    "code": "TOO_MANY_REQUESTS",
    "message": "Rate limit exceeded: 60 requests per 60 seconds for this endpoint.",
    "requestId": "a1b2c3d4-e5f6-4a1b-8c2d-9f0e1a2b3c4d"
  }
}

The client waits a random time up to 500 ms, tries again, and if the window is still closed it waits up to one second and tries a third time. After that it raises rate_limited, and your endpoint tells the user to retry shortly.

An unreachable upstream. HTTP 502:

JSON
{
  "error": {
    "code": "INTERNAL_ERROR",
    "message": "Unable to reach the order/customer service right now",
    "requestId": "5c1c1e9e-2f7b-4b7a-9b0e-0f1a2b3c4d5e"
  }
}

This is the textbook retryable case: the problem is on the far side, and a short wait often clears it. If all three attempts fail, you raise upstream and map it to your own 502.

The fourth outcome is not an error at all. A PAN that the records do not confirm comes back with HTTP 200 and valid: false. The docs guarantee referenceId on that response, so your code should read valid first and treat the other fields as optional.

What do you need before you write any code?

You need four things: a sandbox key, the right scope on it, a server to run the code, and a clear reason to be calling the API at all.

A sandbox key with the PAN scope

StampMitra sandbox keys start with sk_test_ and production keys with sk_live_. You get a sandbox key through self-signup at the signup page with no card. Production keys require a KYC-approved organisation. The authentication docs explain that keys carry OAuth-style scopes and that this endpoint needs verification.pan.read. If your key lacks the scope you will see a 403, covered in the error table below.

A runtime

The Node.js code is TypeScript written so that Node can run it directly. I tested it on Node 22.22; older versions may need the --experimental-strip-types flag or a build step. The Python code targets Python 3.10 or newer syntax (it uses X | None annotations) and was tested on 3.13.

Should you call this API at all?

PAN verification is a regulated-data lookup, so decide deliberately.

Use the PAN API whenThink again when
You must confirm a PAN is valid and matches a stated name during onboarding, vendor setup or payoutsYou only need to check that a string looks like a PAN. A regex does that for free
You hold a lawful purpose and the person's consent for the checkYou want to look up details of someone who has not provided the PAN to you
The call can run on your server with the key in a secret storeThe only place you could call from is browser or mobile code. The key would leak
You need an audit trail: the referenceId per decisionYou plan to re-verify the same PAN on every page load. Cache your own decision instead

If you are building a full onboarding flow rather than a single check, the KYC API use case for fintechs and NBFCs shows where PAN sits next to the other checks.

What does a call cost?

Sandbox calls are free and never debit your wallet. In production each successful call is deducted from your API credit wallet at ₹5. See pricing for the live figure. Because usage is recorded only once verification completes successfully, a failed call does not count, which is one of the reasons the retry rules later in this tutorial are safe for 429 and 5xx.

How do you set up the project and keep the API key out of your code?

The key is a password. The rule is one sentence: it lives in the process environment, it is injected at runtime, and nothing in your repository, logs, client bundle or error messages contains it. The API security policy is worth reading for how StampMitra treats keys on its side.

Node.js project

cURL
mkdir pan-verify && cd pan-verify
npm init -y
npm install express
npm install -D typescript @types/node @types/express

Use these package.json fields (the scripts and "type": "module" matter):

JSON
{
  "name": "pan-verify-demo",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "engines": { "node": ">=22.18" },
  "scripts": {
    "start": "node src/server.ts",
    "test": "node --test \"test/*.test.ts\"",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": { "express": "^5.1.0" },
  "devDependencies": {
    "@types/express": "^5.0.0",
    "@types/node": "^22.0.0",
    "typescript": "^5.6.0"
  }
}

Add a tsconfig.json that checks types without emitting files:

JSON
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "noEmit": true,
    "allowImportingTsExtensions": true,
    "erasableSyntaxOnly": true,
    "skipLibCheck": true
  },
  "include": ["src", "test"]
}

erasableSyntaxOnly keeps you to TypeScript that Node can strip types from, which is why the classes below declare fields explicitly instead of using constructor parameter properties.

Python project

cURL
mkdir pan-verify-py && cd pan-verify-py
python -m venv .venv && source .venv/bin/activate
pip install httpx requests fastapi uvicorn pytest

Environment variables

Create a .env.example that you commit, and a real .env that you never commit:

cURL
# file: .env.example
# Copy to .env (which is git-ignored) and fill in your sandbox key.
STAMPMITRA_API_KEY=sk_test_replace_me
# Optional. Defaults to https://api-b2b.stampmitra.in
# STAMPMITRA_BASE_URL=https://api-b2b.stampmitra.in
cURL
# file: .gitignore
.env
node_modules

Node 20.6 and later can load a file with node --env-file=.env, so you need no extra dependency. In Python you can export the variables in your shell, use your process manager's environment settings, or add python-dotenv if you prefer file loading.

Where the key should live in each environment

EnvironmentWhere to put STAMPMITRA_API_KEY
LaptopA git-ignored .env, or your shell profile
CIThe CI provider's encrypted secret, exposed only to the test job that needs it. Unit tests here need no key at all
Staging and productionYour platform's secret manager or encrypted environment settings, with a sk_test_ key in staging and a sk_live_ key only in production

Rotate the key if it ever appears in a log, a ticket or a screenshot. Never put it in a front-end bundle, a mobile app or a URL, and keep separate keys per environment so that revoking one does not break the others. The sandbox-versus-production story is covered in more depth in sandbox vs production API keys.

How do you verify a PAN in Node.js with plain fetch?

Node 18 and later ship a global fetch, so a first working call needs no packages. This script is deliberately small: it checks the key exists, sets a timeout, branches on res.ok, and prints only non-sensitive fields.

JavaScript
// file: src/verify-basic.mjs
// Smallest possible call: node --env-file=.env src/verify-basic.mjs
const apiKey = process.env.STAMPMITRA_API_KEY;
if (!apiKey) {
  console.error("STAMPMITRA_API_KEY is not set");
  process.exit(1);
}

const res = await fetch("https://api-b2b.stampmitra.in/v1/verify/pan", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${apiKey}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ pan: "ABCDE1234F", name: "Rahul Kumar" }),
  signal: AbortSignal.timeout(10_000),
});

const body = await res.json();
if (!res.ok) {
  console.error(res.status, body.error?.code, body.error?.requestId);
  process.exit(1);
}
console.log(body.referenceId, body.valid, body.nameMatchResult);

Run it with node --env-file=.env src/verify-basic.mjs. Three choices in that file carry over into the production code.

Why the explicit timeout?

fetch has no default timeout of its own that you should rely on. AbortSignal.timeout(ms) returns a signal that aborts after the given number of milliseconds, and the resulting error has the name TimeoutError (documented on MDN). Without it, a stalled connection can hold a request handler open indefinitely.

Why check res.ok before reading the body?

On failure the API returns the error envelope, so body.error.code and body.error.requestId exist, while body.referenceId does not. Reading the success fields from an error response gives you undefined and a confusing bug later.

Why print requestId?

When you contact StampMitra support, the requestId from the error envelope lets them find the exact call. Log it, and you will never need to ask a customer to reproduce a failure.

This version is fine for a script. It does not validate input, retry, distinguish error types or hide failures from your end users, so the next section turns it into a reusable wrapper.

How do you build a small typed Node.js wrapper?

A wrapper gives you one place for the key, the timeout, the retry policy and the error model. The pieces below are separate files so each can be tested alone. They are plain TypeScript with no runtime dependencies.

Errors

One error class with a kind lets callers branch with a switch instead of parsing messages. The kind values line up with the documented status codes.

TypeScript
// file: src/errors.ts
export type ErrorKind =
  | "validation" // rejected locally, nothing was sent
  | "bad_request" // 400 from the API
  | "auth" // 401 or 403
  | "wallet" // 402
  | "rate_limited" // 429
  | "upstream" // 5xx
  | "timeout" // our timeout fired
  | "network" // DNS, TLS, connection reset
  | "unexpected"; // anything else, including a non-JSON body

export class StampMitraError extends Error {
  readonly kind: ErrorKind;
  readonly status: number | null;
  readonly code: string | null;
  readonly requestId: string | null;

  constructor(
    kind: ErrorKind,
    message: string,
    details: { status?: number; code?: string; requestId?: string; cause?: unknown } = {},
  ) {
    super(message, { cause: details.cause });
    this.name = "StampMitraError";
    this.kind = kind;
    this.status = details.status ?? null;
    this.code = details.code ?? null;
    this.requestId = details.requestId ?? null;
  }
}

export function kindForStatus(status: number): ErrorKind {
  if (status === 400) return "bad_request";
  if (status === 401 || status === 403) return "auth";
  if (status === 402) return "wallet";
  if (status === 429) return "rate_limited";
  if (status >= 500) return "upstream";
  return "unexpected";
}

/** The only statuses we retry: rate limiting and server-side failures. */
export function isRetryableStatus(status: number): boolean {
  return status === 429 || status >= 500;
}

Input validation

TypeScript
// file: src/validate.ts
import { StampMitraError } from "./errors.ts";

// API docs: pan must match the format ABCDE1234F (5 letters, 4 digits, 1 letter).
const PAN_RE = /^[A-Z]{5}[0-9]{4}[A-Z]$/;
const NAME_MAX = 256; // API docs: name, max 256 chars

export interface PanInput {
  pan: string;
  name?: string;
}

/** Normalises and validates. Throws a "validation" error without echoing the PAN. */
export function normalisePanInput(input: PanInput): PanInput {
  const pan = typeof input.pan === "string" ? input.pan.trim().toUpperCase() : "";
  if (!PAN_RE.test(pan)) {
    throw new StampMitraError("validation", "pan must match the format ABCDE1234F");
  }
  if (input.name === undefined) return { pan };

  const name = typeof input.name === "string" ? input.name.trim() : "";
  if (name.length === 0) return { pan }; // treat blank as "not provided"
  if (name.length > NAME_MAX) {
    throw new StampMitraError("validation", `name must be at most ${NAME_MAX} characters`);
  }
  return { pan, name };
}

Note that the thrown message never contains the PAN the caller supplied. The message text matches the API's own 400 message for this case, pan must match the format ABCDE1234F.

The client

TypeScript
// file: src/client.ts
import { StampMitraError, isRetryableStatus, kindForStatus } from "./errors.ts";
import { type PanInput, normalisePanInput } from "./validate.ts";
import { type Logger, noopLogger } from "./logger.ts";

export const DEFAULT_BASE_URL = "https://api-b2b.stampmitra.in";

/** Fields shown in the docs example. Everything except referenceId and valid is
 *  typed optional so a sparse negative result does not break your code. */
export interface PanVerifyResult {
  referenceId: string;
  valid: boolean;
  pan?: string;
  type?: string;
  registeredName?: string;
  fatherName?: string;
  panStatus?: string;
  panStatusCode?: string;
  panStatusDescription?: string;
  nameMatchResult?: string;
  nameMatchScore?: string;
  aadhaarSeedingStatus?: string;
  aadhaarSeedingStatusDescription?: string;
  lastUpdatedAt?: string;
  message?: string;
}

export interface ClientOptions {
  apiKey: string;
  baseUrl?: string;
  timeoutMs?: number;
  maxAttempts?: number; // total tries, including the first
  baseDelayMs?: number;
  maxDelayMs?: number;
  logger?: Logger;
  // Injectable for tests.
  fetchImpl?: typeof fetch;
  sleep?: (ms: number) => Promise<void>;
  random?: () => number;
}

const realSleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));

export class StampMitraClient {
  private readonly apiKey: string;
  private readonly baseUrl: string;
  private readonly timeoutMs: number;
  private readonly maxAttempts: number;
  private readonly baseDelayMs: number;
  private readonly maxDelayMs: number;
  private readonly log: Logger;
  private readonly fetchImpl: typeof fetch;
  private readonly sleep: (ms: number) => Promise<void>;
  private readonly random: () => number;

  constructor(opts: ClientOptions) {
    if (!opts.apiKey) throw new Error("StampMitra apiKey is required");
    this.apiKey = opts.apiKey;
    this.baseUrl = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
    this.timeoutMs = opts.timeoutMs ?? 10_000;
    this.maxAttempts = opts.maxAttempts ?? 3;
    this.baseDelayMs = opts.baseDelayMs ?? 500;
    this.maxDelayMs = opts.maxDelayMs ?? 5_000;
    this.log = opts.logger ?? noopLogger;
    this.fetchImpl = opts.fetchImpl ?? fetch;
    this.sleep = opts.sleep ?? realSleep;
    this.random = opts.random ?? Math.random;
  }

  /** Reads STAMPMITRA_API_KEY (and optional STAMPMITRA_BASE_URL) from the environment. */
  static fromEnv(overrides: Partial<ClientOptions> = {}): StampMitraClient {
    const apiKey = process.env.STAMPMITRA_API_KEY;
    if (!apiKey) throw new Error("STAMPMITRA_API_KEY is not set");
    return new StampMitraClient({
      apiKey,
      baseUrl: process.env.STAMPMITRA_BASE_URL,
      ...overrides,
    });
  }

  async verifyPan(input: PanInput): Promise<PanVerifyResult> {
    const body = normalisePanInput(input); // throws before any network call
    return this.post<PanVerifyResult>("/v1/verify/pan", body);
  }

  private backoff(attempt: number): number {
    // Full jitter: random between 0 and min(cap, base * 2^(attempt-1)).
    const ceiling = Math.min(this.maxDelayMs, this.baseDelayMs * 2 ** (attempt - 1));
    return Math.round(this.random() * ceiling);
  }

  private async post<T>(path: string, payload: object): Promise<T> {
    for (let attempt = 1; ; attempt++) {
      const started = Date.now();
      let res: Response;
      try {
        res = await this.fetchImpl(`${this.baseUrl}${path}`, {
          method: "POST",
          headers: {
            Authorization: `Bearer ${this.apiKey}`,
            "Content-Type": "application/json",
          },
          body: JSON.stringify(payload),
          signal: AbortSignal.timeout(this.timeoutMs),
        });
      } catch (err) {
        // Not retried: a request that timed out may still have completed (and been
        // billed) on the server, and the verify endpoints have no idempotency key.
        const timedOut = err instanceof Error && err.name === "TimeoutError";
        this.log({ event: "stampmitra.request_failed", path, attempt, kind: timedOut ? "timeout" : "network", ms: Date.now() - started });
        throw new StampMitraError(timedOut ? "timeout" : "network", timedOut ? "Request timed out" : "Network error", { cause: err });
      }

      const text = await res.text();
      let json: any = null;
      try {
        json = text ? JSON.parse(text) : null;
      } catch {
        json = null; // e.g. an HTML error page from a proxy
      }

      if (res.ok && json) {
        this.log({ event: "stampmitra.request_ok", path, attempt, status: res.status, referenceId: json.referenceId, ms: Date.now() - started });
        return json as T;
      }

      const requestId: string | undefined = json?.error?.requestId;
      const code: string | undefined = json?.error?.code;
      this.log({ event: "stampmitra.request_error", path, attempt, status: res.status, code, requestId, ms: Date.now() - started });

      if (isRetryableStatus(res.status) && attempt < this.maxAttempts) {
        await this.sleep(this.backoff(attempt));
        continue;
      }
      throw new StampMitraError(kindForStatus(res.status), json?.error?.message ?? `HTTP ${res.status}`, {
        status: res.status,
        code,
        requestId,
      });
    }
  }
}

A few design decisions deserve a sentence each. fromEnv fails at startup if the key is missing, which is better than failing on the first user request. fetchImpl, sleep and random are injectable, which is what makes the tests deterministic and fast. The result type marks everything except referenceId and valid as optional because the docs only guarantee referenceId, and your code should not crash on a sparse negative result. The retry loop and its rationale are explained in the retries section below.

Usage looks like this:

TypeScript
import { StampMitraClient } from "./client.ts";
import { jsonLogger } from "./logger.ts";

const client = StampMitraClient.fromEnv({ logger: jsonLogger });
const result = await client.verifyPan({ pan: "abcde1234f", name: "Rahul Kumar" });

if (result.valid && result.nameMatchResult === "MATCH") {
  // proceed, and persist result.referenceId against your user record
}

How do you verify a PAN in Python with requests and httpx?

Python gives you two common choices. requests is ubiquitous and fine for scripts. httpx has the same friendly API, finer timeout control and an easy way to inject a fake transport for tests, so the production client below uses it.

The minimal requests version

Python
# file: verify_basic.py
"""Smallest possible call. Run: python verify_basic.py  (key comes from the environment)."""
import os
import sys

import requests

api_key = os.environ.get("STAMPMITRA_API_KEY")
if not api_key:
    sys.exit("STAMPMITRA_API_KEY is not set")

res = requests.post(
    "https://api-b2b.stampmitra.in/v1/verify/pan",
    headers={"Authorization": f"Bearer {api_key}"},
    json={"pan": "ABCDE1234F", "name": "Rahul Kumar"},
    timeout=(3.05, 10),  # (connect, read) seconds
)
body = res.json()
if not res.ok:
    err = body.get("error", {})
    sys.exit(f"{res.status_code} {err.get('code')} {err.get('requestId')}")
print(body["referenceId"], body["valid"], body.get("nameMatchResult"))

The timeout=(3.05, 10) tuple is connect and read timeouts in seconds. With requests, omitting timeout means the call can wait forever, so treat it as mandatory. Note that requests accepts the key in the Authorization header and encodes the body for you when you pass json=.

Why httpx for the real client?

httpx has four timeout types: connect, read, write and pool, and it raises ConnectTimeout, ReadTimeout, WriteTimeout or PoolTimeout, all of which are subclasses of httpx.TimeoutException (httpx timeout docs). Its default is five seconds of network inactivity, which is a sensible floor but not something you should leave implicit. It also lets you pass transport=httpx.MockTransport(handler) so tests never touch the network.

Errors, validation and the client

Python
# file: pan_demo/errors.py
from __future__ import annotations


class StampMitraError(Exception):
    """kind is one of: validation, bad_request, auth, wallet, rate_limited,
    upstream, timeout, network, unexpected."""

    def __init__(self, kind: str, message: str, *, status: int | None = None,
                 code: str | None = None, request_id: str | None = None) -> None:
        super().__init__(message)
        self.kind = kind
        self.status = status
        self.code = code
        self.request_id = request_id


def kind_for_status(status: int) -> str:
    if status == 400:
        return "bad_request"
    if status in (401, 403):
        return "auth"
    if status == 402:
        return "wallet"
    if status == 429:
        return "rate_limited"
    if status >= 500:
        return "upstream"
    return "unexpected"


def is_retryable_status(status: int) -> bool:
    """The only statuses we retry: rate limiting and server-side failures."""
    return status == 429 or status >= 500
Python
# file: pan_demo/validation.py
from __future__ import annotations

import re

from .errors import StampMitraError

# API docs: pan must match the format ABCDE1234F (5 letters, 4 digits, 1 letter).
_PAN_RE = re.compile(r"[A-Z]{5}[0-9]{4}[A-Z]")
NAME_MAX = 256  # API docs: name, max 256 chars


def normalise_pan_input(pan: object, name: object = None) -> dict[str, str]:
    """Return the request body, or raise a 'validation' error without echoing the PAN."""
    clean = pan.strip().upper() if isinstance(pan, str) else ""
    if not _PAN_RE.fullmatch(clean):
        raise StampMitraError("validation", "pan must match the format ABCDE1234F")
    body = {"pan": clean}
    if isinstance(name, str) and name.strip():
        if len(name.strip()) > NAME_MAX:
            raise StampMitraError("validation", f"name must be at most {NAME_MAX} characters")
        body["name"] = name.strip()
    return body
Python
# file: pan_demo/client.py
from __future__ import annotations

import logging
import os
import random
import time
from typing import Any, Callable

import httpx

from .errors import StampMitraError, is_retryable_status, kind_for_status
from .validation import normalise_pan_input

DEFAULT_BASE_URL = "https://api-b2b.stampmitra.in"
log = logging.getLogger("stampmitra")


class StampMitraClient:
    def __init__(
        self,
        api_key: str,
        *,
        base_url: str = DEFAULT_BASE_URL,
        timeout: httpx.Timeout | float = httpx.Timeout(10.0, connect=3.0),
        max_attempts: int = 3,
        base_delay: float = 0.5,
        max_delay: float = 5.0,
        transport: httpx.BaseTransport | None = None,  # injectable for tests
        sleep: Callable[[float], None] = time.sleep,
        rand: Callable[[], float] = random.random,
    ) -> None:
        if not api_key:
            raise ValueError("StampMitra api_key is required")
        self._http = httpx.Client(
            base_url=base_url.rstrip("/"),
            headers={"Authorization": f"Bearer {api_key}"},
            timeout=timeout,
            transport=transport,
        )
        self._max_attempts = max_attempts
        self._base_delay = base_delay
        self._max_delay = max_delay
        self._sleep = sleep
        self._rand = rand

    @classmethod
    def from_env(cls, **kwargs: Any) -> "StampMitraClient":
        key = os.environ.get("STAMPMITRA_API_KEY")
        if not key:
            raise RuntimeError("STAMPMITRA_API_KEY is not set")
        base = os.environ.get("STAMPMITRA_BASE_URL")
        if base:
            kwargs.setdefault("base_url", base)
        return cls(key, **kwargs)

    def close(self) -> None:
        self._http.close()

    def verify_pan(self, pan: str, name: str | None = None) -> dict[str, Any]:
        body = normalise_pan_input(pan, name)  # raises before any network call
        return self._post("/v1/verify/pan", body)

    def _backoff(self, attempt: int) -> float:
        # Full jitter: random between 0 and min(cap, base * 2^(attempt-1)).
        return self._rand() * min(self._max_delay, self._base_delay * 2 ** (attempt - 1))

    def _post(self, path: str, payload: dict[str, str]) -> dict[str, Any]:
        attempt = 0
        while True:
            attempt += 1
            started = time.monotonic()
            try:
                res = self._http.post(path, json=payload)
            except httpx.TimeoutException as exc:
                # Not retried: the request may still have completed (and been billed)
                # server-side, and verify endpoints have no idempotency key.
                log.warning("stampmitra request failed path=%s attempt=%d kind=timeout", path, attempt)
                raise StampMitraError("timeout", "Request timed out") from exc
            except httpx.TransportError as exc:
                log.warning("stampmitra request failed path=%s attempt=%d kind=network", path, attempt)
                raise StampMitraError("network", "Network error") from exc

            ms = int((time.monotonic() - started) * 1000)
            try:
                data = res.json()
            except ValueError:
                data = None  # e.g. an HTML error page from a proxy

            if res.is_success and isinstance(data, dict):
                log.info("stampmitra ok path=%s attempt=%d status=%d referenceId=%s ms=%d",
                         path, attempt, res.status_code, data.get("referenceId"), ms)
                return data

            err = data.get("error", {}) if isinstance(data, dict) else {}
            request_id = err.get("requestId")
            log.warning("stampmitra error path=%s attempt=%d status=%d code=%s requestId=%s ms=%d",
                        path, attempt, res.status_code, err.get("code"), request_id, ms)

            if is_retryable_status(res.status_code) and attempt < self._max_attempts:
                self._sleep(self._backoff(attempt))
                continue
            raise StampMitraError(
                kind_for_status(res.status_code),
                err.get("message") or f"HTTP {res.status_code}",
                status=res.status_code,
                code=err.get("code"),
                request_id=request_id,
            )

The structure mirrors the Node.js wrapper on purpose, so the policy (what is retried, what is logged, which errors exist) is identical across the two stacks. The client keeps one httpx.Client, which reuses connections, and sets the Authorization header once at construction so it is not rebuilt per call. Usage:

Python
from pan_demo.client import StampMitraClient

client = StampMitraClient.from_env()
result = client.verify_pan("abcde1234f", "Rahul Kumar")
if result["valid"] and result.get("nameMatchResult") == "MATCH":
    ...  # persist result["referenceId"] with the user record

Why should you validate the PAN before calling the API?

Because the API validates input format up front, a malformed PAN fails fast with a 400. That protects the upstream service, but it still costs you a network round trip, a log line and an error to handle. Validating locally gives instant feedback to your user and keeps noise out of your error rates.

What exactly should you check?

CheckRuleSource
Presencepan is a non-empty stringRequired field in the API docs
NormalisationTrim whitespace, then upper-caseUsers paste lower case and trailing spaces; the documented format is upper case
Format^[A-Z]{5}[0-9]{4}[A-Z]$API docs: format ABCDE1234F
Name lengthAt most 256 characters after trimmingAPI docs: name, max 256 chars
Blank nameTreat as not providedAvoids sending an empty optional field

What should you not check?

Do not invent extra rules such as which letter must sit in the fourth position, or try to infer the holder type from the PAN yourself. The API documents the format ABCDE1234F and nothing stricter, and the verification response already returns a type field. If your local rule is stricter than the server's, you will reject real PANs. If you want a deeper explanation of PAN structure, rely on the Income Tax Department's own material rather than a blog, since I could not verify a structure reference page to cite here.

Validate on both sides

Validate in the browser for a nicer form, but never trust it. Your server must validate again, because anyone can call your endpoint directly. In the Express and FastAPI examples later, the endpoint relies on the same normalisePanInput function the client uses, so there is exactly one definition of "valid".

A note on the name

The optional name is matched against records and surfaces as nameMatchResult and nameMatchScore. Pass the name exactly as your user typed it and let the API do the matching. Do not pre-process it into initials or strip punctuation unless your own product rules require it, and do not use the match result as the only basis for a high-stakes decision. What score or result you accept is a business policy for you and your compliance team to set.

What timeouts and retries should a PAN client use?

Timeouts decide how long you wait. Retries decide whether to try again. Both exist to turn transient trouble into a successful answer without turning a small incident into a large one.

Timeouts

SettingValue used hereWhy
Total request timeout (Node)10 seconds via AbortSignal.timeoutLonger than a typical interactive tolerance, short enough to free the handler
Connect timeout (Python)3 secondsA host that will not accept a connection fast is not going to answer
Read timeout (Python)10 secondsVerification depends on an upstream data source, so allow headroom

These are starting points, not figures from the StampMitra docs, which do not state a response-time guarantee. Measure your own p99 latency in sandbox, then set the timeout comfortably above it. Remember that your caller has a timeout too. If your front end gives up at eight seconds, a ten second server timeout plus retries only wastes work.

Which responses are worth retrying?

The docs give three facts that decide this. Rate limiting returns 429 with the message Rate limit exceeded: 60 requests per 60 seconds for this endpoint. An unreachable upstream returns 502 with code INTERNAL_ERROR. And usage is recorded and billed only after a successful response. Because a failed attempt is not billed, retrying 429 and 5xx cannot double-charge you.

StatusRetry?Reason
200 with valid: falseNoA completed, billable determination. Retrying returns the same answer
400NoYour input is wrong. Fix it
401, 403NoMissing key, bad key or missing scope. Retrying cannot grant access
402NoWallet balance is insufficient. Top up first
429Yes, a few timesThe window may reopen. Bounded, see the caveat below
5xx (including 502)Yes, a few timesTransient upstream failure; unbilled
Timeout or network errorNo (by default)The request may have completed server-side; no idempotency key exists

Backoff with jitter

Retry immediately and every client that failed together retries together. The standard remedy, described in the AWS Builders' Library article on timeouts, retries and backoff with jitter, is to wait longer after each attempt, cap the wait, add randomness, and limit the number of attempts. The same article warns that retries at several layers multiply, so retry at one layer only. If your own gateway also retries, set the attempt count here to 1.

The code above uses "full jitter": a random wait between zero and min(cap, base * 2^(attempt-1)), with a 500 ms base, a 5 second cap and three total attempts. With those numbers the longest possible waits are 500 ms and then 1 second.

How do you stay under the rate limit when verifying many PANs?

A retry loop is a reaction. For a batch job, such as checking a CSV of vendor PANs, prevent the 429 instead of recovering from it. The documented limit is 60 requests per 60 seconds for this endpoint, so spacing calls one second apart keeps a single process at the limit. The helper below serialises calls and leaves a minimum gap between their start times.

TypeScript
// file: src/limiter.ts
/** Serialises calls and leaves at least `minIntervalMs` between their starts.
 *  1000 ms keeps one process at or under 60 calls per 60 seconds. */
export function createSpacer(minIntervalMs: number) {
  let nextSlot = 0;
  return async function schedule<T>(task: () => Promise<T>): Promise<T> {
    const now = Date.now();
    const start = Math.max(now, nextSlot);
    nextSlot = start + minIntervalMs;
    if (start > now) await new Promise<void>((r) => setTimeout(r, start - now));
    return task();
  };
}
TypeScript
const schedule = createSpacer(1000);
for (const row of rows) {
  const result = await schedule(() => client.verifyPan({ pan: row.pan, name: row.name }));
  // persist result.referenceId against row.id
}

The Python version uses a lock and a monotonic clock, so it is safe across threads:

Python
# file: pan_demo/limiter.py
import threading
import time


class Spacer:
    """Blocks callers so successive calls start at least `min_interval` seconds apart.
    1.0 keeps one process at or under 60 calls per 60 seconds."""

    def __init__(self, min_interval: float) -> None:
        self._min = min_interval
        self._lock = threading.Lock()
        self._next = 0.0

    def wait(self) -> None:
        with self._lock:
            now = time.monotonic()
            start = max(now, self._next)
            self._next = start + self._min
        if start > now:
            time.sleep(start - now)
Python
spacer = Spacer(1.0)
for row in rows:
    spacer.wait()
    result = client.verify_pan(row["pan"], row.get("name"))

Two cautions. The spacer limits one process: if you run four workers, give each a four second gap or share a limiter through a queue. And the limit is described per endpoint, so a limiter for /v1/verify/pan does not need to slow down calls to other endpoints. Because the client also retries on 429, a batch that occasionally collides with someone else's burst still finishes. Both helpers have tests (limiter.test.ts and test_limiter.py) that you can add to the suites above.

The 429 caveat

A 429 can mean you exceeded the documented 60 requests per 60 seconds, or that a sandbox quota has been reached. The product pages describe the sandbox quota in different words, so check pricing for the current wording. A short backoff will not clear a 60 second window or an exhausted quota. Treat the three attempts as a courtesy for brief spikes, and fix sustained 429s at the source: put a client-side limiter or queue in front of the client so you stay under 60 calls a minute per endpoint, and surface a clear "try again shortly" to the user. The docs do not mention a Retry-After header, so the code does not depend on one.

Why not retry timeouts?

The verify endpoints are not idempotent by design: referenceId identifies a transaction but is not an idempotency key. A call that timed out on your side may well have finished on the server and been billed. Retrying could verify, and bill, twice. If you decide the cost is acceptable for your flow, add a single retry deliberately, in the one place where you accept the trade-off.

How should you map StampMitra errors to your own API?

Mapping means translating what the upstream said into what your caller should hear. The two are different audiences. The upstream message may be right for your logs and wrong for an end user.

The documented error cases

These bodies are the real examples from the StampMitra docs for this endpoint. Every one has the shape { "error": { "code", "message", "requestId" } }.

HTTPcodemessage in the docs
400BAD_REQUESTpan must match the format ABCDE1234F
401UNAUTHORIZEDMissing or invalid API key.
402PAYMENT_REQUIREDInsufficient fulfilment wallet balance to cover this call.
403FORBIDDENAPI key is valid but lacks the required scope for this endpoint.
429TOO_MANY_REQUESTSRate limit exceeded: 60 requests per 60 seconds for this endpoint.
502INTERNAL_ERRORUnable to reach the order/customer service right now

A 400 on this endpoint is also described in the docs as covering "validation error / not entitled", so do not assume every 400 is a typo in the PAN. Log the requestId and the code and look at the message.

What your callers should see

Upstream situationYour response to your callerWho must act
Local validation failure or upstream 400400 with a friendly "enter a PAN like ABCDE1234F"The end user
401 or 403500 generic, plus an alertYou: key, scope or environment is wrong
402500 generic, plus an alertYou: top up the wallet
429 after retries503 "busy, retry shortly"The user retries; you add throttling
5xx, timeout, network502 "temporarily unavailable"The user retries; you watch for an incident
200 with valid: false200 with valid: falseYour business logic

The rule behind the table: never forward upstream message text to end users, and never tell a customer that your wallet is empty. The mapError function in the Express example and map_error in the FastAPI one implement this table exactly.

Keep a stable code vocabulary

Give your own API a small set of codes (INVALID_INPUT, TRY_AGAIN_LATER, VERIFICATION_UNAVAILABLE, INTERNAL_ERROR) that do not change when the upstream changes. For a broader catalogue of how these errors behave across StampMitra endpoints, see API errors and handling.

How do you log PAN verification without leaking the PAN?

A PAN is personal data, and a name next to it is more so. Logs are copied to vendors, searched by many people and retained for a long time, which makes them the most common accidental leak. Decide up front what is allowed in a log line and make the logger incapable of more.

What is safe to log

Log itDo not log
referenceId (StampMitra's transaction id)The PAN, in full or in part
requestId from error responsesThe name, registeredName or fatherName
HTTP status, error kind, error codeThe API key, or any Authorization header
Attempt number and latency in msThe raw request or response body
Your own internal user or case idA hash of the PAN. The PAN space is small enough to brute-force an unsalted hash

The Node Logger type accepts only scalar fields, and the client only ever passes ids, statuses and timings. Python's logging calls do the same. The tests include one that runs a call with a recognisable PAN, name and key and asserts that none of them appears in the captured logs, so a future change that starts logging the body fails the build.

Other places a PAN can leak

  • Exceptions. Our validation errors never include the input. Do not wrap them with a message like `bad pan ${pan}`.
  • Framework request logging. Many web frameworks and reverse proxies can log request bodies on error. Turn that off for this route.
  • Error trackers. Configure the tracker to scrub request bodies, or send it only the error kind and requestId.
  • Analytics and URLs. Never put a PAN in a query string. It ends up in access logs and browser history. Keep it in a POST body.
  • Your database. Store the decision (valid, nameMatchResult, referenceId, a timestamp) and, only if you have a defined purpose and retention period, the PAN itself, ideally encrypted.

Consent and purpose, at a general level

India's Digital Personal Data Protection Act, 2023 sets out duties around consent, purpose and retention for personal data. I could not open the official text while preparing this page, so I am not citing specific sections. As general good practice: tell the user why you are checking their PAN, collect it only for that purpose, keep it no longer than you need, and protect it with reasonable security. Confirm your obligations with qualified counsel. StampMitra's developer privacy policy and data processing addendum describe its side of that relationship.

How do you unit test the client with mocked responses?

Unit tests must not call the real API. They would be slow, they would depend on quota, and they cannot reproduce a 502 on demand. Instead, replace the HTTP layer and feed the client the documented responses.

What to test

  1. Request shape. URL, Authorization header, JSON body and normalisation (abcde1234f becomes ABCDE1234F).
  2. The happy path and valid: false. Both return normally.
  3. Local validation. A bad PAN raises and makes zero HTTP calls.
  4. Non-retryable errors. 400, 401, 402 and 403 make exactly one call and keep the requestId.
  5. Retryable errors. 429 followed by 200 succeeds after one retry; three 502s give up with the exact backoff delays; an HTML error page from a proxy becomes a clean upstream error.
  6. Timeouts. One call, no retry, kind timeout.
  7. Logging. No PAN, name or key in the captured log output.

Node.js: fixtures and client tests

The fixtures reuse the docs' sample bodies, so your tests double as a check that your types match the documented shape.

TypeScript
// file: test/fixtures.ts
export const OK_BODY = {
  referenceId: "SMV-20260927-8F42K7",
  pan: "ABCDE1234F",
  type: "Individual",
  valid: true,
  registeredName: "RAHUL KUMAR",
  fatherName: "SURESH KUMAR",
  panStatus: "VALID",
  panStatusCode: "E",
  panStatusDescription: "Existing and valid PAN",
  nameMatchResult: "MATCH",
  nameMatchScore: "100",
  aadhaarSeedingStatus: "y",
  aadhaarSeedingStatusDescription: "Aadhaar linked",
  lastUpdatedAt: "2023-04-01",
  message: "PAN is Valid",
};

export function errorBody(code: string, message: string) {
  return { error: { code, message, requestId: "a1b2c3d4-e5f6-4a1b-8c2d-9f0e1a2b3c4d" } };
}

export function jsonResponse(status: number, body: unknown): Response {
  return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
}
TypeScript
// file: test/client.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { StampMitraClient } from "../src/client.ts";
import { StampMitraError } from "../src/errors.ts";
import type { LogFields } from "../src/logger.ts";
import { OK_BODY, errorBody, jsonResponse } from "./fixtures.ts";

type Call = { url: string; init: RequestInit };

function makeClient(responses: Array<Response | Error>, extra: { logs?: LogFields[] } = {}) {
  const calls: Call[] = [];
  const delays: number[] = [];
  const queue = [...responses];
  const fetchImpl = (async (url: string | URL | Request, init?: RequestInit) => {
    calls.push({ url: String(url), init: init ?? {} });
    const next = queue.shift();
    if (!next) throw new Error("no more mocked responses");
    if (next instanceof Error) throw next;
    return next;
  }) as typeof fetch;
  const client = new StampMitraClient({
    apiKey: "sk_test_dummy",
    fetchImpl,
    sleep: async (ms) => void delays.push(ms),
    random: () => 1, // always the full ceiling, so delays are predictable
    logger: (f) => extra.logs?.push(f),
  });
  return { client, calls, delays };
}

test("sends the documented request shape", async () => {
  const { client, calls } = makeClient([jsonResponse(200, OK_BODY)]);
  const r = await client.verifyPan({ pan: " abcde1234f ", name: "Rahul Kumar" });
  assert.equal(r.referenceId, "SMV-20260927-8F42K7");
  assert.equal(calls.length, 1);
  assert.equal(calls[0].url, "https://api-b2b.stampmitra.in/v1/verify/pan");
  const headers = calls[0].init.headers as Record<string, string>;
  assert.equal(headers.Authorization, "Bearer sk_test_dummy");
  assert.deepEqual(JSON.parse(calls[0].init.body as string), { pan: "ABCDE1234F", name: "Rahul Kumar" });
});

test("a valid:false result is a normal return, not an error", async () => {
  const { client } = makeClient([jsonResponse(200, { referenceId: "SMV-20260927-0000000", valid: false })]);
  const r = await client.verifyPan({ pan: "ABCDE1234F" });
  assert.equal(r.valid, false);
});

test("invalid PAN is rejected locally with zero network calls", async () => {
  const { client, calls } = makeClient([]);
  await assert.rejects(() => client.verifyPan({ pan: "1234567890" }), (e: unknown) => e instanceof StampMitraError && e.kind === "validation");
  assert.equal(calls.length, 0);
});

test("400 is not retried and keeps the requestId", async () => {
  const { client, calls } = makeClient([jsonResponse(400, errorBody("BAD_REQUEST", "pan must match the format ABCDE1234F"))]);
  await assert.rejects(
    () => client.verifyPan({ pan: "ABCDE1234F" }),
    (e: unknown) => e instanceof StampMitraError && e.kind === "bad_request" && e.requestId === "a1b2c3d4-e5f6-4a1b-8c2d-9f0e1a2b3c4d",
  );
  assert.equal(calls.length, 1);
});

for (const [status, kind, code] of [
  [401, "auth", "UNAUTHORIZED"],
  [403, "auth", "FORBIDDEN"],
  [402, "wallet", "PAYMENT_REQUIRED"],
] as const) {
  test(`${status} is not retried`, async () => {
    const { client, calls } = makeClient([jsonResponse(status, errorBody(code, "x"))]);
    await assert.rejects(() => client.verifyPan({ pan: "ABCDE1234F" }), (e: unknown) => e instanceof StampMitraError && e.kind === kind);
    assert.equal(calls.length, 1);
  });
}

test("429 then success: retried once with backoff", async () => {
  const { client, calls, delays } = makeClient([
    jsonResponse(429, errorBody("TOO_MANY_REQUESTS", "Rate limit exceeded: 60 requests per 60 seconds for this endpoint.")),
    jsonResponse(200, OK_BODY),
  ]);
  const r = await client.verifyPan({ pan: "ABCDE1234F" });
  assert.equal(r.valid, true);
  assert.equal(calls.length, 2);
  assert.deepEqual(delays, [500]);
});

test("502 three times: gives up after maxAttempts with exponential delays", async () => {
  const bad = () => jsonResponse(502, errorBody("INTERNAL_ERROR", "Unable to reach the order/customer service right now"));
  const { client, calls, delays } = makeClient([bad(), bad(), bad()]);
  await assert.rejects(() => client.verifyPan({ pan: "ABCDE1234F" }), (e: unknown) => e instanceof StampMitraError && e.kind === "upstream" && e.status === 502);
  assert.equal(calls.length, 3);
  assert.deepEqual(delays, [500, 1000]);
});

test("timeout is surfaced and not retried", async () => {
  const timeout = Object.assign(new Error("timed out"), { name: "TimeoutError" });
  const { client, calls } = makeClient([timeout]);
  await assert.rejects(() => client.verifyPan({ pan: "ABCDE1234F" }), (e: unknown) => e instanceof StampMitraError && e.kind === "timeout");
  assert.equal(calls.length, 1);
});

test("an HTML error page from a proxy becomes a clean upstream error", async () => {
  const html = () => new Response("<html>Bad gateway</html>", { status: 502 });
  const { client } = makeClient([html(), html(), html()]);
  await assert.rejects(() => client.verifyPan({ pan: "ABCDE1234F" }), (e: unknown) => e instanceof StampMitraError && e.kind === "upstream");
});

test("logs never contain the PAN, the name or the API key", async () => {
  const logs: LogFields[] = [];
  const { client } = makeClient([jsonResponse(200, OK_BODY)], { logs });
  await client.verifyPan({ pan: "ABCDE1234F", name: "Rahul Kumar" });
  const dump = JSON.stringify(logs);
  assert.ok(logs.length > 0);
  assert.ok(!dump.includes("ABCDE1234F"));
  assert.ok(!dump.includes("Rahul"));
  assert.ok(!dump.includes("sk_test_dummy"));
  assert.ok(dump.includes("SMV-20260927-8F42K7"));
});

Run them with npm test. Passing random: () => 1 makes the jitter hit its ceiling, so the test can assert exact delays of 500 and 1000 ms without sleeping for real.

Node.js: endpoint tests

TypeScript
// file: test/app.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import type { AddressInfo } from "node:net";
import { createApp } from "../src/app.ts";
import { StampMitraClient } from "../src/client.ts";
import { OK_BODY, errorBody, jsonResponse } from "./fixtures.ts";

async function withServer(upstream: Response[], run: (base: string) => Promise<void>) {
  const queue = [...upstream];
  const client = new StampMitraClient({
    apiKey: "sk_test_dummy",
    fetchImpl: (async () => queue.shift()!) as typeof fetch,
    sleep: async () => {},
  });
  const server = createApp(client).listen(0);
  try {
    await run(`http://127.0.0.1:${(server.address() as AddressInfo).port}`);
  } finally {
    server.close();
  }
}

const post = (base: string, body: unknown) =>
  fetch(`${base}/api/pan/verify`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });

test("200 returns only the fields the UI needs", async () => {
  await withServer([jsonResponse(200, OK_BODY)], async (base) => {
    const res = await post(base, { pan: "ABCDE1234F", name: "Rahul Kumar" });
    assert.equal(res.status, 200);
    assert.deepEqual(await res.json(), { referenceId: "SMV-20260927-8F42K7", valid: true, nameMatchResult: "MATCH", message: "PAN is Valid" });
  });
});

test("bad PAN returns 400 without touching upstream", async () => {
  await withServer([], async (base) => {
    const res = await post(base, { pan: "nope" });
    assert.equal(res.status, 400);
  });
});

test("upstream 402 is hidden from the caller as a generic 500", async () => {
  await withServer([jsonResponse(402, errorBody("PAYMENT_REQUIRED", "Insufficient fulfilment wallet balance to cover this call."))], async (base) => {
    const res = await post(base, { pan: "ABCDE1234F" });
    assert.equal(res.status, 500);
    assert.ok(!JSON.stringify(await res.json()).toLowerCase().includes("wallet"));
  });
});

Python: fixtures and client tests

Python
# file: tests/conftest.py
import httpx
import pytest

from pan_demo.client import StampMitraClient

OK_BODY = {
    "referenceId": "SMV-20260927-8F42K7", "pan": "ABCDE1234F", "type": "Individual", "valid": True,
    "registeredName": "RAHUL KUMAR", "fatherName": "SURESH KUMAR", "panStatus": "VALID",
    "panStatusCode": "E", "panStatusDescription": "Existing and valid PAN",
    "nameMatchResult": "MATCH", "nameMatchScore": "100", "aadhaarSeedingStatus": "y",
    "aadhaarSeedingStatusDescription": "Aadhaar linked", "lastUpdatedAt": "2023-04-01",
    "message": "PAN is Valid",
}


def error_body(code: str, message: str) -> dict:
    return {"error": {"code": code, "message": message, "requestId": "a1b2c3d4-e5f6-4a1b-8c2d-9f0e1a2b3c4d"}}


@pytest.fixture
def make_client():
    """Build a client whose HTTP layer replays `responses` in order and records requests."""

    def factory(responses):
        queue = list(responses)
        requests: list[httpx.Request] = []
        delays: list[float] = []

        def handler(request: httpx.Request) -> httpx.Response:
            requests.append(request)
            item = queue.pop(0)
            if isinstance(item, Exception):
                raise item
            return item

        client = StampMitraClient(
            "sk_test_dummy",
            transport=httpx.MockTransport(handler),
            sleep=delays.append,
            rand=lambda: 1.0,  # always the full ceiling, so delays are predictable
        )
        return client, requests, delays

    return factory
Python
# file: tests/test_client.py
import json
import logging

import httpx
import pytest

from pan_demo.errors import StampMitraError
from .conftest import OK_BODY, error_body


def test_sends_documented_request_shape(make_client):
    client, requests, _ = make_client([httpx.Response(200, json=OK_BODY)])
    r = client.verify_pan(" abcde1234f ", "Rahul Kumar")
    assert r["referenceId"] == "SMV-20260927-8F42K7"
    req = requests[0]
    assert str(req.url) == "https://api-b2b.stampmitra.in/v1/verify/pan"
    assert req.headers["authorization"] == "Bearer sk_test_dummy"
    assert json.loads(req.content) == {"pan": "ABCDE1234F", "name": "Rahul Kumar"}


def test_valid_false_is_a_normal_return(make_client):
    client, _, _ = make_client([httpx.Response(200, json={"referenceId": "SMV-20260927-0000000", "valid": False})])
    assert client.verify_pan("ABCDE1234F")["valid"] is False


def test_invalid_pan_makes_no_request(make_client):
    client, requests, _ = make_client([])
    with pytest.raises(StampMitraError) as exc:
        client.verify_pan("1234567890")
    assert exc.value.kind == "validation"
    assert requests == []


@pytest.mark.parametrize("status,kind,code", [
    (400, "bad_request", "BAD_REQUEST"),
    (401, "auth", "UNAUTHORIZED"),
    (402, "wallet", "PAYMENT_REQUIRED"),
    (403, "auth", "FORBIDDEN"),
])
def test_client_errors_are_not_retried(make_client, status, kind, code):
    client, requests, _ = make_client([httpx.Response(status, json=error_body(code, "x"))])
    with pytest.raises(StampMitraError) as exc:
        client.verify_pan("ABCDE1234F")
    assert exc.value.kind == kind
    assert exc.value.request_id == "a1b2c3d4-e5f6-4a1b-8c2d-9f0e1a2b3c4d"
    assert len(requests) == 1


def test_429_then_success_retries_once(make_client):
    limited = httpx.Response(429, json=error_body("TOO_MANY_REQUESTS", "Rate limit exceeded: 60 requests per 60 seconds for this endpoint."))
    client, requests, delays = make_client([limited, httpx.Response(200, json=OK_BODY)])
    assert client.verify_pan("ABCDE1234F")["valid"] is True
    assert len(requests) == 2
    assert delays == [0.5]


def test_502_exhausts_attempts_with_exponential_delays(make_client):
    bad = lambda: httpx.Response(502, json=error_body("INTERNAL_ERROR", "Unable to reach the order/customer service right now"))
    client, requests, delays = make_client([bad(), bad(), bad()])
    with pytest.raises(StampMitraError) as exc:
        client.verify_pan("ABCDE1234F")
    assert (exc.value.kind, exc.value.status) == ("upstream", 502)
    assert len(requests) == 3
    assert delays == [0.5, 1.0]


def test_timeout_is_surfaced_not_retried(make_client):
    client, requests, _ = make_client([httpx.ReadTimeout("slow")])
    with pytest.raises(StampMitraError) as exc:
        client.verify_pan("ABCDE1234F")
    assert exc.value.kind == "timeout"
    assert len(requests) == 1


def test_html_error_page_becomes_upstream_error(make_client):
    page = lambda: httpx.Response(502, text="<html>Bad gateway</html>")
    client, _, _ = make_client([page(), page(), page()])
    with pytest.raises(StampMitraError) as exc:
        client.verify_pan("ABCDE1234F")
    assert exc.value.kind == "upstream"


def test_logs_never_contain_pan_name_or_key(make_client, caplog):
    caplog.set_level(logging.DEBUG, logger="stampmitra")
    client, _, _ = make_client([httpx.Response(200, json=OK_BODY)])
    client.verify_pan("ABCDE1234F", "Rahul Kumar")
    text = caplog.text
    assert "SMV-20260927-8F42K7" in text
    for secret in ("ABCDE1234F", "Rahul", "sk_test_dummy"):
        assert secret not in text

Run python -m pytest -q from the project root (the tests/__init__.py file is empty and lets the relative imports resolve).

Python: endpoint tests

Python
# file: tests/test_app.py
import httpx
import pytest
from fastapi.testclient import TestClient

from pan_demo.app import app, get_client
from .conftest import OK_BODY, error_body


@pytest.fixture
def api(make_client):
    def build(responses):
        client, requests, _ = make_client(responses)
        app.dependency_overrides[get_client] = lambda: client
        return TestClient(app), requests

    yield build
    app.dependency_overrides.clear()


def test_200_returns_only_ui_fields(api):
    tc, _ = api([httpx.Response(200, json=OK_BODY)])
    res = tc.post("/api/pan/verify", json={"pan": "ABCDE1234F", "name": "Rahul Kumar"})
    assert res.status_code == 200
    assert res.json() == {"referenceId": "SMV-20260927-8F42K7", "valid": True,
                          "nameMatchResult": "MATCH", "message": "PAN is Valid"}


def test_bad_pan_is_400_without_upstream_call(api):
    tc, requests = api([])
    res = tc.post("/api/pan/verify", json={"pan": "nope"})
    assert res.status_code == 400
    assert requests == []


def test_upstream_402_is_hidden_as_generic_500(api):
    tc, _ = api([httpx.Response(402, json=error_body("PAYMENT_REQUIRED", "Insufficient fulfilment wallet balance to cover this call."))])
    res = tc.post("/api/pan/verify", json={"pan": "ABCDE1234F"})
    assert res.status_code == 500
    assert "wallet" not in res.text.lower()

What mocks cannot tell you

Mocks prove your code handles the documented responses. They do not prove the live API still behaves that way. Add one small integration test that runs only when a sandbox key is present, marked so CI skips it by default, and run it before releases.

How do you expose PAN verification from Express and FastAPI?

Your front end should call your backend, and your backend should call StampMitra. That keeps the key on the server and lets you add your own authentication, rate limiting and audit. The endpoints below accept { "pan", "name" }, call the wrapper and return only the fields your UI needs.

Express (Node.js)

TypeScript
// file: src/app.ts
import express from "express";
import type { Request, Response } from "express";
import { StampMitraClient } from "./client.ts";
import { StampMitraError } from "./errors.ts";

type Mapped = { status: number; code: string; message: string };

/** Translate a library error into OUR API's response. Upstream messages are never
 *  forwarded, and credential or wallet problems are not the caller's to fix. */
export function mapError(err: unknown): Mapped {
  if (!(err instanceof StampMitraError)) {
    return { status: 500, code: "INTERNAL_ERROR", message: "Something went wrong." };
  }
  switch (err.kind) {
    case "validation":
    case "bad_request":
      return { status: 400, code: "INVALID_INPUT", message: "Enter a PAN in the format ABCDE1234F." };
    case "rate_limited":
      return { status: 503, code: "TRY_AGAIN_LATER", message: "Verification is busy. Please retry shortly." };
    case "timeout":
    case "network":
    case "upstream":
      return { status: 502, code: "VERIFICATION_UNAVAILABLE", message: "Verification is temporarily unavailable." };
    case "auth":
    case "wallet":
    default:
      // Our configuration or balance problem: page someone, show the user nothing specific.
      return { status: 500, code: "INTERNAL_ERROR", message: "Something went wrong." };
  }
}

export function createApp(client: StampMitraClient) {
  const app = express();
  app.disable("x-powered-by");
  app.use(express.json({ limit: "4kb" }));

  app.post("/api/pan/verify", async (req: Request, res: Response) => {
    try {
      const { pan, name } = req.body ?? {};
      const r = await client.verifyPan({ pan, name });
      // Return only what the UI needs. Store r.referenceId with your user record.
      res.json({
        referenceId: r.referenceId,
        valid: r.valid,
        nameMatchResult: r.nameMatchResult ?? null,
        message: r.message ?? null,
      });
    } catch (err) {
      const m = mapError(err);
      const e = err instanceof StampMitraError ? err : null;
      console.error(JSON.stringify({ event: "pan_verify_failed", kind: e?.kind, status: e?.status, requestId: e?.requestId }));
      res.status(m.status).json({ error: { code: m.code, message: m.message } });
    }
  });

  return app;
}
TypeScript
// file: src/server.ts
import { StampMitraClient } from "./client.ts";
import { createApp } from "./app.ts";
import { jsonLogger } from "./logger.ts";

const client = StampMitraClient.fromEnv({ logger: jsonLogger }); // fails fast if the key is missing
const port = Number(process.env.PORT ?? 3000);
createApp(client).listen(port, () => console.log(`listening on :${port}`));

Start it with node --env-file=.env src/server.ts, then call it:

cURL
curl -X POST http://localhost:3000/api/pan/verify \
  -H "Content-Type: application/json" \
  -d '{"pan":"ABCDE1234F","name":"Rahul Kumar"}'
JSON
{
  "referenceId": "SMV-20260927-8F42K7",
  "valid": true,
  "nameMatchResult": "MATCH",
  "message": "PAN is Valid"
}

Note the 4kb body limit, the disabled x-powered-by header, and that the handler returns a subset of the upstream response. The registered name and father's name are not forwarded unless your product genuinely needs to show them.

FastAPI (Python)

Python
# file: pan_demo/app.py
from __future__ import annotations

import logging
from typing import Annotated

from fastapi import Depends, FastAPI, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field

from .client import StampMitraClient
from .errors import StampMitraError

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("pan_demo")

app = FastAPI()
_client: StampMitraClient | None = None


def get_client() -> StampMitraClient:
    global _client
    if _client is None:
        _client = StampMitraClient.from_env()  # fails fast if the key is missing
    return _client


class PanRequest(BaseModel):
    pan: str = Field(max_length=32)  # cheap size cap; strict format check is in the client
    name: str | None = Field(default=None, max_length=300)


def map_error(err: StampMitraError) -> tuple[int, str, str]:
    """Translate a library error into OUR API's response. Upstream messages are never forwarded."""
    if err.kind in ("validation", "bad_request"):
        return 400, "INVALID_INPUT", "Enter a PAN in the format ABCDE1234F."
    if err.kind == "rate_limited":
        return 503, "TRY_AGAIN_LATER", "Verification is busy. Please retry shortly."
    if err.kind in ("timeout", "network", "upstream"):
        return 502, "VERIFICATION_UNAVAILABLE", "Verification is temporarily unavailable."
    # auth, wallet, unexpected: our configuration or balance problem, not the caller's.
    return 500, "INTERNAL_ERROR", "Something went wrong."


@app.exception_handler(StampMitraError)
async def stampmitra_error_handler(_: Request, err: StampMitraError) -> JSONResponse:
    status, code, message = map_error(err)
    log.error("pan_verify_failed kind=%s status=%s requestId=%s", err.kind, err.status, err.request_id)
    return JSONResponse({"error": {"code": code, "message": message}}, status_code=status)


# A plain `def` endpoint runs in FastAPI's threadpool, so the blocking client is safe here.
@app.post("/api/pan/verify")
def verify_pan(body: PanRequest, client: Annotated[StampMitraClient, Depends(get_client)]) -> dict:
    r = client.verify_pan(body.pan, body.name)
    return {
        "referenceId": r["referenceId"],
        "valid": r["valid"],
        "nameMatchResult": r.get("nameMatchResult"),
        "message": r.get("message"),
    }

Run it with uvicorn pan_demo.app:app --port 8000. The endpoint is a plain def, so FastAPI runs it in a threadpool and the blocking httpx.Client does not stall the event loop. If you prefer async def, switch to httpx.AsyncClient and await the calls rather than calling the sync client from async code.

What a production endpoint adds

These examples are intentionally bare. Before shipping, put your own authentication in front (anyone who can reach an open PAN endpoint can spend your wallet), add per-user rate limiting well below the upstream limit of 60 requests per 60 seconds, record the referenceId against the user, and make sure the route's request body is excluded from logging. For event-driven follow-ups after verification, see the webhooks page.

How do you handle double clicks and repeated submissions?

Users double-click, mobile networks retry, and front ends resubmit forms. Because the verify endpoint has no idempotency key, two identical submissions become two billable calls. Handle it in your own layer. The simplest approach is to disable the submit button while a request is in flight. The more robust approach is to keep a short-lived record keyed by your own user or case id: if a verification for that case finished a moment ago, return the stored result with its original referenceId instead of calling again.

Be careful about what you use as the cache key. A key built from the raw PAN means the PAN sits in your cache store, so prefer your internal case id, and give entries a short expiry. Cache only completed determinations. A 502 or a timeout is not a result and must never be stored as one. Whether and how long you may keep a verification result is a policy question for your team, so set it deliberately instead of letting a cache default decide.

How do you check the setup before the first real call?

Add a startup check that proves the plumbing without spending anything meaningful. Confirm the environment variable is present, that it starts with sk_test_ in non-production environments, and that your process can resolve and reach api-b2b.stampmitra.in. The check on the prefix is a cheap guard against the most expensive configuration mistake, a live key in a development shell. Print which environment the key belongs to, never the key itself, for example stampmitra: sandbox key loaded. If the prefix and the environment disagree, refuse to start.

How do you move from sandbox to production?

Nothing in the request changes. The same URL, header and body work in both environments, and the environment is resolved from the key you send: a sk_test_ key hits sandbox data and a sk_live_ key is live. The quickstart says the same: the sandbox uses the request shape production uses.

A go-live checklist

ItemCheck
KeyA sk_live_ key issued after your organisation's KYC approval, stored in the production secret manager. See the onboarding verification policy
ScopeThe production key carries verification.pan.read
WalletThe API credit wallet has balance. A 402 in production means this
AlertsYour 500-class mapping for 401, 403 and 402 pages a human
LogsAudit one day of logs for any PAN, name or key. There should be none
LimitsYour own limiter keeps each endpoint below 60 requests per 60 seconds
RetentionA written rule for how long you keep PAN results and referenceIds
Use policyYour use matches the API acceptable use policy

Test with sandbox data first

Run the real script (verify-basic) with a sandbox key and the documented sample PAN ABCDE1234F. Confirm that you get a referenceId in the documented format and that your parsing handles every field. I have not described what sandbox returns for any particular PAN beyond the docs' sample, so run your own cases and record the outputs rather than assuming. Sandbox calls are free and do not debit your wallet, though a quota may apply, as noted in the retries section.

Roll out gradually

Flip one low-risk flow first, compare the production valid and nameMatchResult distribution to your sandbox expectations, then widen. Keep the sandbox key configured in staging so you always have a safe place to reproduce a bug.

What goes wrong most often, and how do you fix it?

Most failures fall into a short list. Match the symptom to the row, then fix the cause.

SymptomLikely causeFix
401 UNAUTHORIZEDKey missing, mistyped, revoked, or the environment variable is not set in this processPrint whether the variable exists (never its value). Check for a trailing newline or quotes copied from .env
403 FORBIDDENThe key is valid but lacks verification.pan.readIssue or edit a key with that scope in the dashboard
400 BAD_REQUEST with a PAN messageLowercase, spaces, wrong length or a non-PAN stringNormalise and validate before sending
402 PAYMENT_REQUIREDProduction wallet is emptyTop up the credit wallet, then alert on low balance
429 TOO_MANY_REQUESTSMore than 60 requests in 60 seconds on this endpoint, or a sandbox quotaThrottle on your side; see pricing for sandbox wording
502 INTERNAL_ERRORUpstream verification service unreachableLet bounded retries run; check the status page; contact support with the requestId
Fetch throws TimeoutErrorYour timeout firedCheck latency; do not blindly retry
Unexpected token < when parsing JSONA proxy returned HTMLParse defensively as the client does; inspect the status
Works locally, fails in CIThe test job reads the real key instead of mocksUnit tests should inject a fake transport and need no key

Common mistakes

  • Hardcoding the key "just for the demo". Demos get committed. Use the environment from the first line.
  • Calling from the browser. The key is then public. Always proxy through your server.
  • Treating valid: false as an exception. It is a successful, billable answer. Show "PAN not valid" and continue.
  • Retrying everything. Retrying 400 or 401 only wastes time, and retrying timeouts can double-verify.
  • Stacking retries. An SDK retry inside a gateway retry inside a client retry multiplies load. Retry in one layer.
  • Logging the request. The body is the PAN. Log ids, not payloads.
  • Echoing upstream messages to end users, which leaks internals such as wallet state.
  • No timeout. Both fetch and requests can wait indefinitely by default.

If you are stuck, gather the requestId, the UTC time, the status and the endpoint, then write to developer support at [email protected]. Do not include the PAN or the key in the message.

How StampMitra Helps

StampMitra is a private LegalTech platform operated by Bani Global Industries LLP. It is not a government agency or a law firm, and nothing in this tutorial is legal advice. What it provides today is a developer API for Indian identity verification and e-stamp workflows, with a PAN endpoint that matches the request and response shown on this page.

Concretely, the StampMitra PAN API gives you a single POST /v1/verify/pan call, a scoped API key (verification.pan.read) in sandbox and production variants, a normalised response with a referenceId for every call (including negative results), a documented error envelope with a requestId, and server-side rate limits stated in the error message. Usage is metered only on a successful response, and sandbox use is free. The same key model and the same envelope apply to the other verification endpoints, so the wrapper you built here extends to GSTIN, vehicle RC, CIN, Udyam and bank account checks by changing the path, the scope and the body fields. The products overview lists them, and the Postman collection lets you try every endpoint without writing code.

If you work with an AI coding assistant, the Build with AI page provides a ready prompt for the PAN flow that uses the same base URL and key rules as this tutorial, and the MCP server page explains how to give an assistant the API as tools for sandbox testing. Questions that this page does not answer can go to developer support or the contact page. Companion reading: the PAN verification API guide for the product itself, API errors and handling for cross-endpoint error behaviour, and the KYC use case for where PAN fits in onboarding.

What is planned

The SDKs page lists client libraries for Node.js and TypeScript, Python, PHP, Java and Go as planned, to be generated from the same API contract as the docs. Until they exist, the wrappers in this tutorial are a reasonable starting point, but they are your code, not an official SDK. Plans change, so check the SDKs page before depending on any timeline. Pricing is on the pricing page, and you can start by creating a free sandbox key on the signup page.

API reference at a glance

PAN: paramsPOST /v1/verify/pan

Request parameters

Request parameters
FieldTypeDescription
AuthorizationrequiredheaderBearer <api-key> (e.g. sk_test_... in sandbox, sk_live_... in production). The x-api-key header is also accepted.
panrequiredstringPAN, format ABCDE1234F.
namestringName to match against records (max 256 chars).
PAN: responsePOST /v1/verify/pan
{
  "referenceId": "SMV-20260927-8F42K7",
  "pan": "ABCDE1234F",
  "type": "Individual",
  "valid": true,
  "registeredName": "RAHUL KUMAR",
  "fatherName": "SURESH KUMAR",
  "panStatus": "VALID",
  "panStatusCode": "E",
  "panStatusDescription": "Existing and valid PAN",
  "nameMatchResult": "MATCH",
  "nameMatchScore": "100",
  "aadhaarSeedingStatus": "y",
  "aadhaarSeedingStatusDescription": "Aadhaar linked",
  "lastUpdatedAt": "2023-04-01",
  "message": "PAN is Valid"
}
PAN: pricePOST /v1/verify/pan

PAN: ₹5 per successful call, pay-as-you-go (lower on Growth and Scale).

Frequently asked questions

What is the endpoint to verify a PAN with StampMitra?

Send a POST request to https://api-b2b.stampmitra.in/v1/verify/pan with a JSON body containing pan (required, format ABCDE1234F) and an optional name, plus an Authorization: Bearer header. The key needs the verification.pan.read scope.

Where should I store my StampMitra API key in Node.js or Python?

Keep it in the STAMPMITRA_API_KEY environment variable, loaded from a git-ignored .env file locally and from your platform's secret manager in production. Never hardcode it, commit it, log it, or send it to browser or mobile code.

What regex should I use to validate a PAN before calling the API?

After trimming whitespace and upper-casing, use ^[A-Z]{5}[0-9]{4}[A-Z]$, which matches the documented ABCDE1234F format. Do not add stricter rules than the API documents, or you may reject real PANs. The server still validates and returns 400 for malformed input.

Which HTTP errors should I retry when verifying a PAN?

Retry only 429 and 5xx responses, using capped exponential backoff with jitter and a small attempt limit such as three. Do not retry 400, 401, 402 or 403, because the same request will fail again until you fix the input, key, scope or wallet balance.

Is it safe to retry a PAN verification that timed out?

Not automatically. The verify endpoints have no idempotency key, and referenceId is not one, so a request that timed out may have completed and been billed on the server. Retrying could verify and bill twice, so surface the timeout or retry deliberately.

What does valid: false mean in the PAN verification response?

It means the verification completed and the PAN was not confirmed as valid. It arrives as a normal HTTP 200 with a referenceId and is a billable determination, so handle it as a business outcome in your code and not as an exception or error.

How do I log PAN verification without leaking personal data?

Log only referenceId, the error requestId, HTTP status, error kind, attempt number and latency. Never log the PAN, name, API key or raw request and response bodies, and avoid hashing the PAN because the input space is small enough to brute-force.

How do I unit test PAN verification code without calling the real API?

Inject a fake HTTP layer: a custom fetch function in Node.js or httpx.MockTransport in Python. Replay the documented responses for 200, 400, 401, 402, 403, 429 and 502, then assert the request shape, retry counts, error kinds and that logs contain no PAN.

Can I call the StampMitra PAN API directly from my front end?

No. The API key would be exposed to every user. Call StampMitra only from server-side code, and have your web or mobile app call your own backend endpoint, where you can add authentication, rate limiting and audit logging of the referenceId.

How do I avoid hitting the 60 requests per 60 seconds rate limit?

The documented limit is 60 requests per 60 seconds for the endpoint. For batch work, space calls about one second apart with a client-side limiter or queue, share that limiter across workers, and keep retries as a backstop rather than the main control.

Sources

All sources were checked on 2026-10-16.

  • StampMitra Developers, PAN verification API reference: endpoint, scope, request and response fields, error examples. Derived from the platform's product data.
  • StampMitra Developers, Authentication: bearer and x-api-key headers, sk_test_ and sk_live_ keys, scopes.
  • StampMitra Developers, Quickstart and Pricing: environment variable name, sandbox behaviour, per-call billing.
  • MDN Web Docs, AbortSignal.timeout(): TimeoutError on timeout.
  • HTTPX documentation, Timeouts: connect, read, write and pool timeouts and exceptions.
  • AWS Builders' Library, Timeouts, retries and backoff with jitter: retry, backoff, jitter and retry-amplification guidance.
  • Digital Personal Data Protection Act, 2023 (Ministry of Electronics and Information Technology): the official PDF could not be opened (HTTP 403) on the verification date, so no section numbers are cited. Read the Act directly and consult qualified counsel.