Keep the evidence of a verification decision, not a copy of everything the API told you. For each call, the record that serves you best holds: who or what asked, what was checked (masked), when, the StampMitra referenceId, the outcome and the fields the decision rested on, the policy version that applied, the decision and its reasons, and the identity of any human who changed it. Keep that for the period your policy and sector rules require. Do not keep full response bodies, raw identifiers in application logs, or anything you have no stated purpose for.
The tension in this topic is real. An auditor wants to see what you knew when you decided. A privacy regime wants you to hold as little personal data as possible for as short a time as possible. A security reviewer wants nothing sensitive sitting in log aggregators. A good audit record satisfies all three by being small, structured, masked where it can be, and linked to the underlying call by an identifier rather than by a copy.
This guide sets out what to log and keep from a verification call in India-facing systems. It separates logs from audit records, defines a minimal record, shows how to mask and hash identifiers, explains where the StampMitra request logs fit and where their limits are, covers retention, access control and tamper evidence, and finishes with code for redaction at the edges of your stack. It is general guidance and not legal advice. Retention periods and lawful bases depend on your regulator, your contracts and your counsel.
Key takeaways
- Distinguish operational logs (short-lived, for debugging) from audit records (structured, retained, for evidence). They have different contents, retention and access.
- The audit record should let you answer: what was checked, when, by whom, with what result, under which policy, and what was decided.
- Link to the call with the StampMitra
referenceId(andrequestIdon failures) instead of copying the response body. - Mask identifiers for display and logs (for example the first two and last two characters of a PAN, the last four digits of an account number). Hash identifiers you only need for matching.
- Never log API keys, full PANs, full account numbers, full response bodies or request bodies by default.
- StampMitra keeps request logs for your organisation, readable through
GET /v1/logs, with retention that depends on your plan: 30 days on pay-as-you-go, 90 on Growth and 365 on Scale in the published plan data, and shorter on sandbox. They do not return the original response body, so your own record has to carry the decision. - Retention is a policy decision. Sector rules can require KYC records to be kept for years, while data protection principles push towards deleting what you no longer need. Settle the period with your compliance owner and write it down.
- Make audit records append-only, restrict who can read them, and consider tamper evidence such as a hash chain.
- Re-running a verification to answer an audit question gives you today's answer, not the one you relied on. Keep the original record.
Logs and audit records are different things
Teams often use one word for two jobs. Separate them.
| Operational log | Audit record | |
|---|---|---|
| Purpose | Debugging and monitoring | Evidence of a decision |
| Contents | Request ids, status codes, timings, errors, masked subject | Subject (masked), result, decision, reasons, policy version, actor, times |
| Format | Free-form lines or structured events | A fixed schema in a database table |
| Retention | Days to weeks | Years, as your policy and sector rules require |
| Access | Engineers | Compliance, audit, a small group of named staff |
| Mutability | Rotated and deleted | Append-only |
| Personal data | As little as possible, ideally none | Only what the decision needs, minimised and masked |
| Loss tolerance | Acceptable | Not acceptable |
Operational logs flow into aggregators, error trackers and dashboards that many people can read and that often live with third parties. That is exactly why sensitive data does not belong there. The audit record lives in a controlled store. Neither is a substitute for the other: a debugging log is not evidence, and the audit record is a poor place to dump stack traces.
The questions an audit will ask
Design the record backwards from the questions you will be asked.
- Who was verified, and how can we identify them without exposing the number?
- What exactly was checked: which identifier, which endpoint?
- When was the check made?
- Who or what made it: which service, which user, which key?
- What did the source say, at least in the fields the decision depended on?
- What was the decision, and which rules led to it?
- Which version of the policy was in force?
- Did a person override the automated outcome, who, when and why?
- Can we show the call really happened, independently of your database?
- If we are asked to delete this person's data, what happens to this record?
Each question maps to a field or a mechanism below. If you cannot answer one from the record, either add the field or accept that you cannot answer the question, and say so in your policy.
A minimal verification record
The following schema covers the questions without storing the response body.
| Field | Example | Why it is there |
|---|---|---|
id | vr_01J9... | Your own primary key |
subject_id | vendor_1042 | Link to the party in your system |
subject_kind | PAN, GSTIN, BANK_ACCOUNT, CIN | What kind of identifier was checked |
subject_masked | AB******4F | Human-readable reference without the full number |
subject_hash | salted SHA-256 | Duplicate detection and lookup without plaintext |
endpoint | POST /v1/verify/pan | What was called |
environment | production | Never mix sandbox with production evidence |
requested_at | 2026-10-29T09:41:07Z | When you made the call |
actor | svc:onboarding-worker or user:priya@... | Who or what triggered it |
reference_id | SMV-20260927-8F42K7 | The link to StampMitra's record of the call |
request_id | a1b2c3d4-... | Present on failures; what support needs |
http_status | 200 | Transport outcome |
result | valid, invalid, error, unknown | Normalised outcome |
facts | A small JSON with the fields the decision used | Evidence, minimised |
decision | PASS, REVIEW, FAIL | What you decided |
reasons | ["NAME_DIFFERS"] | Why, as codes |
policy_version | vendor-checks@7 | The rules in force |
override_by, override_at, override_reason | user:asha@..., time, text | Human changes |
An example row:
{
"id": "vr_01J9X2K8",
"subject_id": "vendor_1042",
"subject_kind": "GSTIN",
"subject_masked": "07*********Z1",
"subject_hash": "9b3f...e1",
"endpoint": "POST /v1/verify/gstin",
"environment": "production",
"requested_at": "2026-10-29T09:41:07Z",
"actor": "svc:onboarding-worker",
"reference_id": "SMV-20260927-9K21P4",
"request_id": null,
"http_status": 200,
"result": "valid",
"facts": { "registrationStatus": "Active", "embeddedPanMatches": true, "nameCompare": "COMPATIBLE" },
"decision": "PASS",
"reasons": [],
"policy_version": "vendor-checks@7"
}Look at facts. It holds the three things the decision actually depended on and nothing else: the status, whether the embedded PAN matched, and the result of the name comparison. It does not hold the legal name, the principal place of business or the director list. If a later question needs more, the reference_id and the platform's own records are the route, subject to their retention.
What to mask, hash or never store
| Data | In the audit record | In operational logs | Notes |
|---|---|---|---|
| PAN | Masked; hash for matching; full value only where a rule requires it and in an encrypted field | Masked only | Not a secret, but personal data |
| GSTIN | Masked; the state code and the embedded-PAN-match result as facts | Masked | Business data, but for proprietors it identifies a person |
| CIN | Full value is acceptable for companies, as public registry data | Full or masked | Public identifier of a company |
| Bank account number | Last four digits; hash for matching | Last four only | Sensitive financial data; never the full number in logs |
| IFSC | Full | Full | Identifies a branch, not a person |
Names (registeredName, nameAtBank, fatherName) | Prefer to store the comparison result and score, not the names | Never | If a name must be kept, store it encrypted |
| Address, principal place of business | Usually not | Never | Keep only if a rule requires it |
| Directors and DINs | Only the fact that the signatory matched | Never | Personal data of third parties |
referenceId, requestId | Always | Always | These are the keys to the platform's own record |
| API keys and secrets | Never | Never | Never in any log, ticket or screenshot |
| Full response or request bodies | No | No, by default | A temporary, access-controlled debug capture is the exception |
| Aadhaar | Not applicable: StampMitra does not offer Aadhaar-based verification | Do not collect | Avoid creating new Aadhaar data in your own systems |
The pattern in the table is the same in every row: store a pointer, a result or a masked form. Store plaintext only where a rule demands it, and then in a field that is encrypted and read by few.
Masking and hashing in code
// mask.mjs
import crypto from "node:crypto";
export const maskPan = (pan) => (pan && pan.length === 10 ? `${pan.slice(0, 2)}******${pan.slice(-2)}` : "[invalid]");
export const maskGstin = (g) => (g && g.length === 15 ? `${g.slice(0, 2)}*********${g.slice(-2)}` : "[invalid]");
export const maskAccount = (a) => (a && a.length >= 9 ? `${"*".repeat(a.length - 4)}${a.slice(-4)}` : "[invalid]");
// Salted hash for duplicate detection. Keep the salt in a secret manager, not in the database.
export function subjectHash(kind, value, salt = process.env.AUDIT_HASH_SALT) {
return crypto.createHmac("sha256", salt).update(`${kind}:${value.toUpperCase().trim()}`).digest("hex");
}The same in Python:
import hashlib, hmac, os
def mask_pan(pan: str) -> str:
return f"{pan[:2]}******{pan[-2:]}" if pan and len(pan) == 10 else "[invalid]"
def mask_account(acct: str) -> str:
return "*" * (len(acct) - 4) + acct[-4:] if acct and len(acct) >= 9 else "[invalid]"
def subject_hash(kind: str, value: str) -> str:
salt = os.environ["AUDIT_HASH_SALT"].encode()
return hmac.new(salt, f"{kind}:{value.upper().strip()}".encode(), hashlib.sha256).hexdigest()Use an HMAC with a secret key, not a bare hash. A bare SHA-256 of a PAN can be reversed by trying every possible PAN, since the space is small. A keyed hash with the key held outside the database cannot.
Where the StampMitra request logs fit
StampMitra records each of your API calls against your organisation, and you can read them with an API key. They are your independent evidence that a call happened, which answers audit question nine.
GET /v1/logs returns paginated items. Each carries an id, a createdAt time, an actorType, the service code such as pan.verify, and metadata. You can filter by from, to, service and status (success or failure), and page with limit (up to 200) and nextCursor.
curl -G "https://api-b2b.stampmitra.in/v1/logs" \
-H "Authorization: Bearer $STAMPMITRA_API_KEY" \
--data-urlencode "service=pan.verify" \
--data-urlencode "from=2026-10-29T00:00:00.000Z" \
--data-urlencode "to=2026-10-30T00:00:00.000Z" \
--data-urlencode "limit=200"Three points about what they are and are not.
They are an activity log, not a data store. The items identify the service and the time, and they do not return the original response body. That is why the decision and its facts must be in your own record.
Their retention depends on your plan. In the published plan data, log retention is 30 days on pay-as-you-go, 90 days on Growth and 365 days on Scale, and the sandbox plan has a shorter window. If your audit period is longer, export what you need on a schedule, and do not treat the dashboard as an archive.
They are keyed to time and service, not to your subject. When you need to prove a particular call happened, the referenceId stored in your own record and a time-window query on the logs together give you the proof. The errors, retries and idempotency guide shows a worked lookup.
Reconciling with the usage summary
GET /v1/usage/summary returns totals for the current billing period: totalApiRequests, callsByService, callsByPeriod and callsByDay. Compare these with the count of audit records you wrote for the same period and service. A platform count higher than yours means calls made without a record; a lower one means records for calls that were rejected before work. Monthly reconciliation is an audit control in its own right. It shows that your record is complete.
Retention: how long to keep what
There is no single right answer, and this guide will not give you a number. What it can do is lay out the forces.
| Force | Pushes towards |
|---|---|
| Sector rules for KYC and transaction records | Keeping records for a defined period, often years after the relationship ends. Typical rules refer to periods such as five years, and you should confirm the current period and its starting point with your regulator or counsel |
| Data protection principles (purpose limitation, storage limitation) | Keeping personal data no longer than the purpose needs |
| Dispute and litigation horizons | Keeping evidence for the limitation period of possible claims |
| Cost and breach exposure | Keeping less, because every stored identifier is something that can leak |
| Partner and bank contracts | Whatever the contract says |
The reconciliation is to separate what you keep. Keep the minimal audit record for the long period, since it holds masked or hashed subjects and normalised facts. Keep anything richer, such as encrypted names or uploaded documents, for the shortest period your rules allow. Delete operational logs on a short rotation.
A retention table in your policy might look like this, with your own periods filled in.
| Data class | Example contents | Retention (set by you) | Deletion trigger |
|---|---|---|---|
| Operational logs | Status codes, timings, masked subject | Short rotation | Time |
| Audit record | The minimal record above | Per sector rule and policy | End of retention period |
| Encrypted identity data | Names, full identifiers where required | The shortest period your rules allow | End of relationship plus the period |
| Documents | Uploaded certificates | Per policy | End of relationship plus the period |
| Exports and backups | Copies of the above | Same as the source, plus a backup lifecycle | Same as source |
For what StampMitra itself retains, read its developer data retention and deletion schedule, developer privacy policy and data processing addendum. They describe StampMitra's practice and do not set yours.
A retention job
from datetime import datetime, timedelta, timezone
RETENTION = { # filled in by you and your compliance owner
"ops_log": timedelta(days=30),
"audit_record": timedelta(days=365 * 5),
"identity_blob": timedelta(days=365 * 5),
}
def sweep(db, now=None):
now = now or datetime.now(timezone.utc)
for table, keep in RETENTION.items():
cutoff = now - keep
n = db.delete_older_than(table, cutoff, respecting_legal_hold=True)
db.log_event("retention_sweep", table=table, cutoff=cutoff.isoformat(), deleted=n)The figures above are placeholders to show where the numbers go. They are not recommendations. Notice respecting_legal_hold: when a dispute or investigation is open, normal deletion must pause for the affected records.
Erasure requests and records you must keep
A person may ask you to delete their data. A rule may require you to keep a verification record. Both can be true at once, and the audit record design is what lets you honour each part.
The sensible handling is: delete what you are not required to keep (rich identity data, documents that no rule needs), keep what you are required to keep (the minimal audit record, with masked and hashed subject), and tell the person what you retained and why. Because the minimal record already holds a masked subject and a keyed hash instead of the plaintext, keeping it does the least possible harm. Decide this process in advance with your counsel, write it down, and make the deletion steps a runbook rather than a scramble. For the general framework see the Digital Personal Data Protection Act, 2023, and confirm how it interacts with your sector's retention rules.
Notice and consent
The record is only the back half. The front half is telling people what you collect and why. At the point where a person enters a PAN or a bank account, say what you will check, with whom the check is made, why, and how long you keep the result. That sentence is your purpose statement, and your audit record's field set should match it: if the notice says you keep a masked identifier and the outcome, do not quietly keep names and addresses as well.
Access control and encryption
An audit store is a high-value target, so treat it as one.
- Least privilege. A small, named group reads audit records. Engineers read operational logs but not the audit store by default.
- Logging reads. Record who viewed which audit records, since access to personal data is itself something to evidence.
- Encryption at rest and in transit. Use your platform's encryption, and a separate application-level key for any field that holds plaintext identifiers or names.
- Key separation. Keep the hash salt and any field-encryption keys in a secret manager, apart from the database.
- Backups. Backups inherit the data's sensitivity and its retention. Make sure a deletion in the source is reflected, within a stated period, in the backups.
Tamper evidence
An audit record is more convincing when you can show nobody has changed it. Two simple practices help.
Append-only storage. Do not update rows. Corrections and overrides are new rows that reference the old one. Enforce it with database permissions: the application role has insert and select but not update or delete on the audit table, and only a separate retention role can delete, with its own logging.
A hash chain. Each record includes the hash of the previous one, so a changed or removed row breaks the chain.
// chain.mjs
import crypto from "node:crypto";
export function sealRecord(record, previousHash) {
const body = JSON.stringify(record, Object.keys(record).sort());
const hash = crypto.createHash("sha256").update(`${previousHash ?? ""}|${body}`).digest("hex");
return { ...record, prev_hash: previousHash ?? null, hash };
}
export function verifyChain(rows) {
let prev = null;
for (const row of rows) {
const { hash, prev_hash, ...rest } = row;
if (prev_hash !== prev) return { ok: false, brokenAt: row.id };
const expected = crypto.createHash("sha256").update(`${prev ?? ""}|${JSON.stringify(rest, Object.keys(rest).sort())}`).digest("hex");
if (expected !== hash) return { ok: false, brokenAt: row.id };
prev = hash;
}
return { ok: true };
}A chain does not stop someone with full database access from rewriting everything, but it makes quiet edits detectable, and publishing the latest hash somewhere outside the database (a signed daily note, a separate system) makes rewrites detectable too. It is a proportionate control for evidence you may need to defend.
Recording human decisions and overrides
Automated decisions are the easy part. The part that attracts questions is when a person intervened. For each override, record who, when, what the automated decision was, what the person chose, and the reason in a sentence of free text. Store it as a new row linked to the original. Require a reason, because "approved" with no reason is the record auditors dislike most.
Keep the reviewer's identity as a stable id, not only a display name, and record the role they acted in. If a second approver is required above a threshold, store both. When a policy changes, do not rewrite the history: the policy_version on each row tells the reader which rules applied at the time.
Recording failures, unknown outcomes and negative results
The record should be honest about what did not work.
- A negative result (
valid: false) is a completed lookup. Record it asresult: invalidwith itsreferenceId. It is not an error. - An error (400, 401, 402, 403, 429, 502) is recorded as
result: errorwith thehttp_status, theerror.codeand therequest_id. NoreferenceIdexists for a rejected call. - An unknown outcome (a timeout) is recorded as
result: unknown, later resolved tovalid,invalidorerroronce you have checked the logs. Keep the history. An auditor who seesunknownfollowed by a resolution learns that you handled the ambiguity. - A retry is a new attempt. Keep it linked to the first through a shared
subject_idand purpose, and never overwrite the earlier row.
Redaction at the edges of your stack
The best audit design is undone by a debugging line that prints a request body. Put redaction where logs leave your code.
// logger.mjs (pino)
import pino from "pino";
export const logger = pino({
redact: {
paths: ["req.headers.authorization", "req.headers['x-api-key']", "*.pan", "*.gstin", "*.accountNumber", "*.name", "*.registeredName", "*.nameAtBank", "*.fatherName", "body", "response"],
censor: "[redacted]",
},
});import logging, re
PAN = re.compile(r"\b[A-Z]{5}[0-9]{4}[A-Z]\b")
GSTIN = re.compile(r"\b[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][1-9A-Z]Z[0-9A-Z]\b")
KEY = re.compile(r"\bsk_(?:live|test)_[A-Za-z0-9_]+\b")
class RedactFilter(logging.Filter):
def filter(self, record):
msg = record.getMessage()
msg = KEY.sub("sk_***", msg)
msg = PAN.sub(lambda m: m.group(0)[:2] + "******" + m.group(0)[-2:], msg)
msg = GSTIN.sub(lambda m: m.group(0)[:2] + "*********" + m.group(0)[-2:], msg)
record.msg, record.args = msg, ()
return True
logging.getLogger().addFilter(RedactFilter())Also check the other places that quietly capture data: error trackers that attach request bodies, APM tools that record query strings, reverse proxies that log bodies, and browser tools that capture form fields. Configure each to drop or mask the same fields. And never let a key into a screenshot, a ticket or a chat message.
Producing evidence for an audit
When the request arrives, you want a query, not a project.
-- All verification decisions for one vendor, newest first, with overrides
SELECT r.requested_at, r.subject_kind, r.subject_masked, r.result, r.decision, r.reasons,
r.policy_version, r.reference_id, o.override_by, o.override_reason
FROM verification_records r
LEFT JOIN verification_overrides o ON o.record_id = r.id
WHERE r.subject_id = :vendor_id
ORDER BY r.requested_at DESC;Pair the output with a one-page explanation of how the record is built: what is stored, what is masked, how it links to the platform's own logs through referenceId, how long it is kept and who can read it. That page does as much for an auditor as the data does. For a specific disputed call, hand over the referenceId, the time and the requestId if it failed, and ask support to look at the platform's side.
A worked example: one vendor, from onboarding to a payment dispute
Follow a fictional vendor, Sharma Traders, through a record trail to see how the pieces fit together.
Onboarding, March. The onboarding worker verifies the vendor's PAN. The audit table gains a row: subject_kind PAN, subject_masked AB******4F, the keyed hash, endpoint POST /v1/verify/pan, reference_id SMV-...-8F42K7, result valid, facts recording panStatus: VALID, holderTypeMatches: true and nameCompare: COMPATIBLE, decision REVIEW with reason NAME_COMPATIBLE_INITIALS_OR_MIDDLE, policy_version vendor-checks@7. No name, no father's name, no full PAN. A reviewer, Asha, approves after seeing a PAN card image. A second row, linked to the first, records override_by: user:asha, a time and the reason "card image shows middle name abbreviated in application".
The GSTIN and bank account checks add two more rows in the same shape. The bank row stores the last four digits of the account, the full IFSC, the utr in facts, and a normalised nameCompare against the verified PAN name.
A change, August. The vendor emails to say their bank details have changed. The finance system treats the request as a new account: it runs a new bank account verification, writes a new row with a new reference_id, holds the next payment, and records the callback to the vendor's known number as a manual step with the caller's id. The earlier row is untouched.
A dispute, November. A payment went to the new account and the vendor says it never arrived. The finance team opens the vendor's record and sees: the bank verification row from August (account ending in a given four digits, result valid, nameCompare EXACT), the hold and its release with two approvers, and the payment reference from the payments system. They send the bank the utr from the verification row, which identifies the penny-drop transfer, and the account's last four digits. They ask StampMitra support, through support, to confirm the platform's record of the call using the referenceId and the time.
Notice what the team did not do. They did not re-run the verification to "check again", which would have produced a new answer unrelated to the August decision. They did not search email for screenshots. They did not need the full account number in a log. The record answered the questions it was designed to answer, and the platform's own logs, within their retention for the plan, corroborated that the call happened.
Schema design: indexes, partitions and queries
An audit table grows without bound, which makes a few design choices matter.
Index what you query. The common lookups are by subject_id (everything about one vendor), by reference_id (find the record for a platform call), by subject_hash (duplicates and cross-vendor matches) and by requested_at (period reports and retention). Index each, and make reference_id unique within an environment.
Partition by time. Partition the table by month. Retention then becomes dropping a partition, which is fast and does not bloat the table, and reports for a period read only the partitions they need.
Keep facts small and versioned. The facts field is a flexible JSON document, which invites drift. Give it a schema version, validate it on write, and document the keys per subject_kind. A future reader should not have to guess what nameCompare meant in 2026.
Separate environments. Add environment to every row, and refuse to write production records from a sandbox key. A sandbox result in the evidence trail is worse than a missing record, because it looks real.
Do not store derived text. Reasons are codes, decisions are enums, results are enums. Free text appears only in override reasons. Codes can be counted and queried; sentences cannot.
Write in the same transaction as the decision. If the decision and the audit row can diverge, they will. Persist both together, or write the audit row first and treat the decision as derived from it.
Who owns the record
A trail without an owner decays. Name the roles.
| Role | Responsibility |
|---|---|
| Compliance owner | Approves the schema, the retention table and the erasure process; answers auditors |
| Engineering owner | Maintains the writing code, the redaction filters, the reconciliation job and the retention job |
| Security | Reviews access, encryption, key separation and backups; owns incident response |
| Data protection contact | Handles access and erasure requests and the privacy notice wording |
| Business owner of the process | Owns the policy versions and the decisions made under them |
Review the arrangement twice a year. Check that the schema still matches the notice, the retention job has been running, the reconciliation is within tolerance, and nobody outside the named group has read access.
After an incident
If a leak occurs, the record decides how bad it is. With masked and hashed subjects, a leak of the audit store exposes patterns and outcomes but not plaintext identifiers. With plaintext identifiers in application logs, a leak exposes everyone who was ever verified. Ask these questions in advance: which stores hold plaintext, who can read them, how long they keep it, and how fast you could list the affected people. A short runbook that answers them is worth more than a long policy nobody has read. StampMitra's incident and breach notification procedure describes its own process.
Checklist
- Operational logs and audit records are separate, with different retention and access.
- The audit record uses the minimal schema, with
referenceId, masked subject, decision, reasons and policy version. - Full PANs, account numbers and response bodies are not in operational logs.
- Hashes for matching are keyed (HMAC), with the key outside the database.
- Audit rows are append-only, with overrides as new rows and a reason on each.
- Retention periods are written down, approved by the compliance owner and enforced by a job that respects legal holds.
- Erasure requests have a documented process that separates what is deleted from what must be kept.
- Access to audit records is limited and logged.
- Redaction filters are installed in loggers, error trackers and proxies.
- A monthly reconciliation compares your record count with the platform's usage summary.
- A report query exists that produces the audit evidence for one subject.
- The platform's own log retention for your plan is known, and anything needed beyond it is exported.
Common mistakes
- Using one log stream as both debugging aid and audit evidence.
- Logging full request and response bodies "temporarily" and leaving it on.
- Hashing identifiers with an unkeyed hash that can be reversed by guessing.
- Storing the whole response so as to "have everything", and then holding names, addresses and director details nobody has a purpose for.
- Overwriting a record when a decision changes instead of adding a new row.
- Re-running a verification to answer an auditor, replacing the evidence with today's answer.
- Assuming the dashboard's request logs are an archive, when retention depends on your plan.
- Copying a retention period from a blog post, including this one, instead of setting it with compliance.
- Letting an API key into a log line, a screenshot or a ticket.
- Mixing sandbox records with production evidence, so a sample result is mistaken for a real check.
What to do next
Start by writing the minimal record schema and the masking helpers, since everything else hangs on them. Then install the redaction filters before the next deployment. Decide the retention table with your compliance owner. For the checks that produce the records, read Building a vendor onboarding flow and KYC vs KYB. For what to do about stale or disputed results, read why API results can differ from the government portal. The quickstart gets you a sandbox key. A PAN lookup is ₹5 per call and a bank account check is ₹4 per call on pay-as-you-go, which is a reminder that each record corresponds to a billable call; see the pricing page. Questions go to support.
API reference at a glance
PAN
POST /v1/verify/panRequest parameters
| Field | Type | Description |
|---|---|---|
Authorizationrequired | header | Bearer <api-key> (e.g. sk_test_... in sandbox, sk_live_... in production). The x-api-key header is also accepted. |
panrequired | string | PAN, format ABCDE1234F. |
name | string | Name to match against records (max 256 chars). |
POST /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"
}POST /v1/verify/panPAN: ₹5 per successful call, pay-as-you-go (lower on Growth and Scale).
Bank Account
POST /v1/verify/bank-accountRequest parameters
| Field | Type | Description |
|---|---|---|
Authorizationrequired | header | Bearer <api-key> (e.g. sk_test_... in sandbox, sk_live_... in production). The x-api-key header is also accepted. |
accountNumberrequired | string | 9-18 digit numeric bank account number. |
ifscrequired | string | 11-character IFSC code, e.g. ICIC0001234. |
name | string | Beneficiary name to match. |
phone | string | Account holder's 10-digit Indian mobile number, when available; improves match confidence. |
POST /v1/verify/bank-account{
"referenceId": "SMV-20260927-3B71A0",
"valid": true,
"nameAtBank": "RAVI KUMAR",
"bankName": "ICICI Bank",
"utr": "017952100012345",
"city": "Mumbai",
"branch": "Andheri West",
"micr": "400229002",
"nameMatchScore": "100",
"nameMatchResult": "GOOD_MATCH",
"accountStatus": "ACCOUNT_IS_VALID",
"accountStatusCode": "1",
"ifscDetails": {
"bank": "ICICI Bank",
"ifsc": "ICIC0001234",
"micr": "400229002",
"address": "Andheri West, Mumbai",
"city": "Mumbai",
"state": "Maharashtra",
"branch": "Andheri West"
}
}POST /v1/verify/bank-accountBank Account: ₹4 per successful call, pay-as-you-go (lower on Growth and Scale).
Frequently asked questions
What should I store from a verification call?
Store a minimal audit record: masked subject, a keyed hash for matching, endpoint, environment, time, actor, the referenceId, the facts the decision depended on, the decision and reasons, the policy version and any override with who and why. Avoid copying the full response.
What is the difference between logs and an audit record?
Operational logs are short-lived debugging data with minimal personal data and broad engineer access. An audit record is structured evidence of a decision, retained for years, append-only and readable by a small named group. Use both, for different jobs.
How do I identify the call later without storing the response?
Store the StampMitra referenceId, and the requestId on failures, with the time and the endpoint. Those link your record to the platform's own request log, which you can query with GET /v1/logs within your plan's retention.
How long does StampMitra keep my request logs?
It depends on your plan. The published plan data shows 30 days on pay-as-you-go, 90 days on Growth and 365 days on Scale, with a shorter window on sandbox. The logs do not return the original response body, so keep your own audit record.
How should I mask PANs and bank account numbers?
Show the first two and last two characters of a PAN and only the last four digits of an account number. Use a keyed HMAC with a secret outside the database for matching, not a bare hash, because a plain hash of a PAN can be reversed by guessing.
How long should I retain verification records?
That is a policy decision. Sector rules for KYC can require records to be kept for years, while data protection principles favour deleting what you no longer need. Keep the minimal masked record for the long period, richer data for the shortest allowed period, and confirm periods with your compliance owner.
What do I do when someone asks me to delete their data?
Delete what no rule requires you to keep, retain the minimal audit record that a rule does require, and tell the person what you kept and why. A record holding a masked subject and a keyed hash makes that retention the least harmful it can be.
Should I store the registered name and director details from the response?
Usually not. Store the comparison result, such as COMPATIBLE or a score, and the fact that the signatory matched a director. If a name must be kept, store it in an encrypted field that few people can read, for the shortest period your rules allow.
How do I make audit records tamper evident?
Make the table append-only, with corrections and overrides as new rows, restrict delete rights to a separate logged role, and consider a hash chain where each row includes the previous row's hash. Publish the latest hash outside the database to detect full rewrites.
Can I re-run a verification to answer an auditor?
No. A re-run returns today's answer, not the one you relied on, and creates a second referenceId. Retrieve the stored record of the original decision, and re-verify only if you need the current state for a next step.
Sources
- StampMitra API reference data for
GET /v1/logsandGET /v1/usage/summary, and for thereferenceIdand error shapes, as in this repository on 2026-10-29. - StampMitra plan data: log retention by plan (30, 90 and 365 days on pay-as-you-go, Growth and Scale respectively), as in this repository's plan snapshot dated 2026-10-27.
- StampMitra legal pages on data retention and deletion, privacy, data processing and incident notification, as published on this site on 2026-10-29.
- Digital Personal Data Protection Act, 2023, on India Code: https://www.indiacode.nic.in (general framework; confirm how it interacts with your sector rules, 2026).
- Reserve Bank of India, Master Direction on Know Your Customer, and the Prevention of Money-laundering Act, 2002 and its rules, on record keeping: https://www.rbi.org.in and https://www.indiacode.nic.in (confirm current retention periods there, 2026).