Node.js / TypeScript SDK
The official Node.js and TypeScript client for the StampMitra Developer API: identity verification and e-Stamp with typed responses, automatic retries and signed-webhook helpers.
Pre-release 0.1.0 - package registry publishing in progress
- Package
@stampmitra/sdk- Version
- 0.1.0
- Requires
- Node.js 18 or newer (uses the global fetch). The core also runs on Bun, Deno and Cloudflare Workers. Zero runtime dependencies, dual ESM/CJS, fully typed.
- License
- MIT
- Changelog
- CHANGELOG.md
- Typed responses; fields the SDK does not know about are preserved.
- Retries with jittered backoff and an automatic Idempotency-Key on every POST except webhook replay.
- Webhook verification for Node, Next.js and edge runtimes (WebCrypto).
- Cursor auto-pagination, raw-response access and redacted debug logging.
Install the package
npm install @stampmitra/sdkAvailable once published. Not yet published to npm. This command works once the package is released; until then follow the GitHub repository for the release.
Verify a PAN in five lines
Use a sandbox key (sk_test_...) while you build. It is free, never debits your wallet and runs against sandbox data.
import { StampMitra } from '@stampmitra/sdk';
const client = new StampMitra(); // reads STAMPMITRA_API_KEY
const res = await client.verify.pan({ pan: 'ABCDE1234F', name: 'Rahul Kumar' });
console.log(res.referenceId, res.valid, res.nameMatchResult);API keys and environments
Sandbox
Sandbox keys start with sk_test_. They are free and return sandbox data.
Live
Live keys start with sk_live_. They need an approved production access request and are metered per successful call.
Pass apiKey or set the STAMPMITRA_API_KEY environment variable. client.environment is 'test' or 'live'. Keys that do not match ^sk_(test|live)_[A-Za-z0-9_-]{8,}$ are rejected unless you pass allowCustomKey: true. In a browser runtime the constructor refuses sk_live_ keys and warns for test keys.
new StampMitra({
apiKey,
baseUrl: 'https://api-b2b.stampmitra.in',
timeout: 30_000,
maxRetries: 2,
fetch,
defaultHeaders,
appInfo: { name: 'my-app', version: '1.0.0', url: 'https://example.com' },
logger,
validateInput: true,
allowCustomKey: false,
});
// Every method also takes per-request options as its last argument:
// { timeout, maxRetries, idempotencyKey, headers, signal }Every resource in one place
Response bodies are typed from the API examples. Payloads whose shape the API has not finalised (e-Stamp details, duty, parties, payment intent) are typed as open objects.
// Verification (POST /v1/verify/*). Every call, including negative results, is billed.
await client.verify.pan({ pan: 'ABCDE1234F' });
await client.verify.gstin({ gstin: '07AAAAA1111A1Z1', businessName: 'Jane Doe Enterprises' });
await client.verify.rc({ vehicleNumber: 'MH12AB1234' });
await client.verify.voterId({ voterId: 'ABC1234567' });
await client.verify.drivingLicense({ dlNumber: 'MH1420110012345', dob: '1990-01-01' }); // IP-allowlisted key
await client.verify.passport({ fileNumber: 'PA1079341954215', dob: '1990-01-01' }); // IP-allowlisted key
await client.verify.cin({ cin: 'U74999DL2015PTC284009' });
await client.verify.udyam({ udyamNumber: 'UDYAM-MH-12-1234567' });
await client.verify.panToGstin({ pan: 'ABCDE1234F' });
await client.verify.panToUdyam({ pan: 'ABCDE1234F' });
await client.verify.bankAccount({ accountNumber: '000123456789', ifsc: 'ICIC0001234', name: 'Ravi Kumar' });
// Services and e-Stamp reference data
await client.services.list();
await client.services.get('estamp.instrument.rent_agreement');
await client.estamp.getSchema('estamp.instrument.rent_agreement');
await client.estamp.listStates();
await client.estamp.listInstruments('MH');
await client.estamp.listArticles('INSTRUMENT_CODE');
await client.estamp.calculateDuty({ /* see e-Stamp docs */ });
// Orders and payments
const order = await client.orders.create({
externalOrderId: 'o1',
externalCustomerId: 'c1',
serviceCode: 'estamp.instrument.rent_agreement',
details: {},
});
await client.estamp.addParties('ORDER_ID', { /* parties */ });
await client.orders.get('ORDER_ID');
await client.orders.getStatus('ORDER_ID');
await client.orders.getPayment('ORDER_ID');
await client.orders.listDocuments('ORDER_ID');
await client.orders.getDocumentDownload('ORDER_ID', 'DOCUMENT_ID'); // short-lived signed URL
await client.payments.intents.create({ /* see payment docs */ });
await client.payments.intents.get('INTENT_ID');
// Usage, logs, webhook deliveries
await client.usage.summary();
await client.logs.list({ service: 'estamp', status: 'failure', limit: 50 });
await client.webhooks.events.list({ limit: 20 });
await client.webhooks.events.replay('EVENT_ID');
// Raw response access
const { data, response } = await client.verify.pan({ pan: 'ABCDE1234F' }).withResponse();
console.log(response.status, response.requestId, response.attempts);Pagination
listAll follows nextCursor for you; list returns a single page with items and nextCursor.
for await (const entry of client.logs.listAll({ service: 'estamp' })) console.log(entry.id);
const page = await client.logs.list({ limit: 50 }); // page.items, page.nextCursor
for await (const delivery of client.webhooks.events.listAll()) {
// ...
}Error classes
Every error extends StampMitraError and carries status, code, message, requestId and raw. ValidationError adds field and RateLimitError adds retryAfter (seconds). Errors are mapped by HTTP status only, so non-JSON error bodies still produce the right class.
| HTTP status | Class | Notes |
|---|---|---|
| 400 / 422 | ValidationError | Bad input. Also raised locally, before any request, by the validation helpers. |
| 401 | AuthenticationError | Missing, malformed or revoked API key. |
| 402 | PaymentRequiredError | Insufficient wallet balance or credit. |
| 403 | PermissionError | The server message is passed through verbatim, e.g. Passport and Driving Licence need an IP-allowlisted key. |
| 404 | NotFoundError | Unknown order, service or event id. |
| 409 | ConflictError | Idempotency-Key reused with a different body, or a state conflict. |
| 429 | RateLimitError | Back off and retry; the server's Retry-After value is exposed when it is sent. |
| 5xx | UpstreamError | A server-side or upstream failure. Retried automatically for 502, 503 and 504. |
| network / timeout / abort | ConnectionError / TimeoutError / RequestAbortedError | No usable response: connection failure, timeout or cancellation. |
import { ValidationError, RateLimitError } from '@stampmitra/sdk';
try {
await client.verify.pan({ pan: 'ABCDE1234F' });
} catch (e) {
if (e instanceof ValidationError) console.error('Bad input:', e.field, e.message);
else if (e instanceof RateLimitError) console.log(e.retryAfter, e.requestId);
else throw e;
}Retries and idempotency
- Default maxRetries is 2, with exponential backoff and full jitter (base 0.5 s, cap 8 s). Retry-After is honoured when the server sends it.
- Retried: 429, 502, 503, 504, connection errors and timeouts. GET is always retry-safe.
- Every POST except webhook replay (all verification calls, estamp.calculateDuty, estamp.addParties, orders.create and payments.intents.create) automatically sends an Idempotency-Key (a random UUIDv4, or your own idempotencyKey) and reuses the same key across automatic retries. The API honours the key on every metered call, so a retried verification is replayed rather than billed twice.
- webhooks.events.replay is never auto-keyed and is not retried after a response or a timeout; it is retried only after a connection failure that happened before any response. Passing an explicit idempotencyKey enables full retries for it.
Client-side validation
Inputs are trimmed, upper-cased and checked locally against the API formats (PAN, GSTIN, IFSC, CIN, Udyam, vehicle number, 9 to 18 digit bank account, passport file number, driving licence, voter ID, YYYY-MM-DD dates, name length). Invalid input throws ValidationError (local: true) with no network call and no billing. Turn it off with validateInput: false.
Verify webhook signatures
Deliveries carry X-Webhook-Signature, X-Webhook-Timestamp (Unix seconds) and X-Webhook-Event-Id. The signature is the hex HMAC-SHA256 of the timestamp, a dot and the raw body, keyed with your endpoint secret. Always verify against the raw body, never re-serialised JSON.
Two entry points: @stampmitra/sdk/webhooks is async and uses WebCrypto (Next.js, Cloudflare Workers, Node 18+), and @stampmitra/sdk/webhooks-node is the synchronous Node variant. Stale timestamps (default tolerance 300 s) are rejected and comparison is constant-time.
// Edge runtimes, Next.js, Node 18+ (async, WebCrypto)
import { constructEventFromHeaders } from '@stampmitra/sdk/webhooks';
const event = await constructEventFromHeaders(rawBody, request.headers, secret);
// Synchronous Node variant
import { verifySignature, constructEvent } from '@stampmitra/sdk/webhooks-node';
const ok = verifySignature(rawBody, timestampHeader, signatureHeader, secret); // booleanverifySignature returns a boolean and never throws on bad input. constructEvent throws WebhookSignatureError, whose reason is malformed, stale or mismatch.
Framework recipes
import express, { type Request, type Response } from 'express';
import { constructEvent, WebhookSignatureError } from '@stampmitra/sdk/webhooks-node';
const app = express();
// IMPORTANT: verify against the RAW body, not re-serialised JSON.
app.post(
'/webhooks/stampmitra',
express.raw({ type: 'application/json' }),
(req: Request, res: Response) => {
try {
const event = constructEvent(
req.body as Buffer,
String(req.header('X-Webhook-Timestamp')),
String(req.header('X-Webhook-Signature')),
process.env.STAMPMITRA_WEBHOOK_SECRET!,
);
console.log('event', req.header('X-Webhook-Event-Id'), event);
res.sendStatus(200);
} catch (err) {
if (err instanceof WebhookSignatureError) return void res.status(400).send(err.reason);
throw err;
}
},
);
app.listen(3000);Webhooks: Event types, payloads and retry policy are on the Webhooks page.
Safe logging
Nothing is logged by default. Pass logger: { debug(msg, meta) {} } to receive request and response records with Authorization and x-api-key redacted and PAN, GSTIN, account numbers and names masked.
Other ways to integrate
The REST API works from any language, and the other SDKs share the same contract.
Ready to call the API?
Get a free sandbox key and run the quickstart above in about 5 minutes.