Retrying a failed verification call is safe in two situations and risky in the rest. Retry a 429 after a pause, because the request was turned away before any work happened. Retry a 502 a couple of times, because the upstream service was unreachable. For everything else, the useful move is not another attempt but a decision: fix the input, fix the key, fund the wallet, or ask a person. The case that deserves most of your care is the one the status codes do not cover at all: the request that timed out and left you not knowing whether it ran.
That case matters because a blind retry after a timeout can run the check twice, and you pay for both. The referenceId returned by a verification identifies that call and is explicitly not an idempotency key. The protection is the Idempotency-Key header, which every metered verification route honours: send the same key on the retry and you get the stored response instead of a second check. Without a key, a retry is a new call. For a bank account verification, which involves a transfer through a bank, a repeat can do more than cost a second fee.
This guide is about retry design for verification workloads. It builds on the status-code basics in API errors and how to handle them rather than repeating them, and focuses on the parts that decide whether your integration is safe under load: what outcomes a call can have, what the platform does and does not offer for idempotency, how to build your own attempt ledger, how to resolve an unknown outcome from the request logs, backoff and rate-limit control, queue design, and what to do when the wallet runs out.
Key takeaways
- Every call ends in one of five states: success, a negative result that is also a success, a rejection before any work, a failure after work started, or an unknown outcome. The retry rule differs for each.
- Only 429 and 502 are candidates for automatic retry, with exponential backoff, jitter and a hard attempt cap. Do not retry 400, 401, 402, 403 or 404.
- A
valid: falseresult is a completed lookup, not an error. It is billed in production and must not enter your retry path. - Send an
Idempotency-Keyon every verification call and reuse it on the retries of one call. ThereferenceIdis a call identifier, not an idempotency key. Keep your own attempt ledger as well, for the cases a key cannot cover (a key older than 24 hours, a job restarted with a new key). - Build an attempt ledger: record intent before the call and the outcome after it, keyed by what you are verifying. Check it before every call.
- To resolve an unknown outcome, look in the request logs (
GET /v1/logs) for a completed call before sending another. - Bank account verification is the endpoint where a blind retry is least safe, because it involves a bank transfer.
- Control concurrency with a token bucket or semaphore sized below your plan's limit, and treat 429 as a signal to slow down for everyone, not just the failing request.
- An empty wallet (402) should pause the queue and alert a person. Spinning on retries will not refill it.
Idempotency-Keyfollows one rule everywhere: the same key and the same request replays the stored response; the same key with a different request is 422IDEMPOTENCY_KEY_REUSED; the same key while the first request is still running is 409IDEMPOTENCY_IN_PROGRESSwithRetry-After. It is required onPOST /v1/estamp/ordersandPOST /v1/batches(400IDEMPOTENCY_KEY_REQUIREDwithout it).
The five outcomes of a single call
Think of each call as ending in one of five places. Naming them makes the retry rule almost mechanical.
| Outcome | How it shows up | Did work happen? | Billed in production? | Safe to repeat? |
|---|---|---|---|---|
| Success | HTTP 200 with valid: true | Yes | Yes | Not needed |
| Negative success | HTTP 200 with valid: false or found: false | Yes | Yes, it is a completed lookup | Only if your policy wants a fresh check later |
| Rejected before work | 400, 401, 402, 403, 404, 429 | No | No | Only 429, after a pause. Others need a fix, not a retry |
| Failed after work began | 502 INTERNAL_ERROR, a connection reset with a body | Possibly | Documented as billed only after a successful response | Yes, a limited number of times |
| Unknown | Timeout, dropped connection, no response | Unknown | Unknown | No, until you have checked |
The fifth row is where most production incidents begin. A timeout is not a failure. It is the absence of information, and the right response to missing information is to go and find it.
The second row is where many integrations quietly misbehave. A negative result arrives as a 200. Code that treats only valid: true as success may route negatives into the error handler, retry them, and pay for each retry. Handle a negative as a normal, billed, final answer.
What billing tells you about retry safety
The reference gives a few billing rules that shape the design.
- Usage is metered after a successful response. For priced services the wallet is debited then, not before.
- Both positive and negative determinations are completed, billable verifications.
- A malformed input fails fast with a 400 before any lookup.
- Sandbox calls are logged but never debit the wallet.
Two things follow. First, a request rejected before work (400, 401, 402, 403, 429) is not billed, so there is no money at stake in repeating it, only a pointless call. Second, for a 502 or an unknown outcome, the documentation says debit happens after a successful response, which suggests a failed call is not billed, but it does not state what happens when a response is lost on its way back. Do not build a financial control on a guess about that edge. Use the logs to see what the platform recorded, and use the usage summary to reconcile.
Idempotency: what is offered
| Endpoint family | The Idempotency-Key header |
|---|---|
Verify endpoints (/v1/verify/*) | Optional, honoured on every metered route. The referenceId is not a key |
POST /v1/estamp/duty/calculate, POST /v1/estamp/orders/:orderId/parties | Optional, honoured |
POST /v1/orders, POST /v1/customers, POST /v1/payments/intents | Optional, honoured |
POST /v1/estamp/orders, POST /v1/batches | Required: 400 IDEMPOTENCY_KEY_REQUIRED without it |
POST /v1/webhooks/events/:id/replay | Replay re-fires the same delivery with the same payload through the same path |
The rules are the same on every route that takes the header:
| Situation | Result |
|---|---|
| First request with a new key | It runs once and its response is stored against the key |
| Same key, same request | The stored response again, with the same status and body. No second call, no second charge |
| Same key, a different request | 422 IDEMPOTENCY_KEY_REUSED. Nothing runs. Use a new key for a different request |
| Same key while the first request is still running | 409 IDEMPOTENCY_IN_PROGRESS with a Retry-After header. Wait, then resend the identical request |
| A failed first request | The key is released, so the same key can be retried |
| Key scope | Your organisation, the route and the environment. A sandbox key never matches a live one |
| Retention | 24 hours. After that the same key runs as a new request |
| Key shape | 1 to 255 printable characters. Anything else is 400 IDEMPOTENCY_KEY_INVALID |
Use one key per logical operation, derived from something stable you own (a payee id plus a purpose, an agreement id plus a version), and keep it across every retry of that operation. A random key per attempt defeats the purpose.
A key does not replace the ledger below. It stops a repeat inside 24 hours; the ledger also covers a job that restarts with a brand-new key, and it is where you record the outcome you will reconcile later.
Building your own idempotency: the attempt ledger
The pattern is simple and does most of the work. Before you call, write down that you intend to. After you hear back, write down what happened. Before any call, read the ledger.
A minimal table:
CREATE TABLE verification_attempts (
id BIGSERIAL PRIMARY KEY,
subject_key TEXT NOT NULL, -- what is being verified, e.g. 'gstin:07AAAAA1111A1Z1'
purpose TEXT NOT NULL, -- 'ONBOARD', 'RECHECK', 'PAYMENT'
window_start TIMESTAMPTZ NOT NULL, -- bucket that makes repeats within a window the same attempt
state TEXT NOT NULL, -- 'STARTED', 'DONE', 'UNKNOWN', 'FAILED'
reference_id TEXT,
outcome JSONB,
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
finished_at TIMESTAMPTZ,
UNIQUE (subject_key, purpose, window_start)
);The unique constraint is the idempotency. Two workers that both try to verify the same GSTIN for the same purpose in the same window collide on insert, and only one proceeds. The window (say, the current hour or day, depending on how often you want a fresh result for that purpose) defines what "the same attempt" means. A re-check next quarter falls in a different window and is allowed.
The call wrapper:
// ledger-call.mjs
export async function verifyOnce({ db, subjectKey, purpose, window, doCall, findInLogs }) {
const inserted = await db.tryInsertAttempt({ subjectKey, purpose, window, state: "STARTED" });
if (!inserted) {
const prior = await db.getAttempt({ subjectKey, purpose, window });
if (prior.state === "DONE") return prior.outcome; // reuse: no second call, no second charge
if (prior.state === "STARTED" || prior.state === "UNKNOWN") {
const found = await findInLogs(prior.started_at); // resolve before repeating
if (found) { await db.finishAttempt(prior.id, "DONE", found); return found; }
throw Object.assign(new Error("OUTCOME_UNRESOLVED"), { retryable: false });
}
}
try {
const outcome = await doCall(); // one HTTP request, a bounded number of 429/502 retries inside
await db.finishAttempt(inserted.id, "DONE", outcome);
return outcome;
} catch (err) {
const state = err.kind === "TIMEOUT" ? "UNKNOWN" : "FAILED"; // a timeout is not a failure
await db.finishAttempt(inserted.id, state, { error: err.kind, status: err.status, requestId: err.requestId });
throw err;
}
}Two details deserve attention. The wrapper writes the attempt before the network call, so a crash between the call and the write still leaves a STARTED row that a later run will notice. And it maps a timeout to UNKNOWN, not FAILED, which is what prevents the next run from firing again without looking.
Resolving an unknown outcome from the logs
When a call timed out, the platform may or may not have run it. The request logs can tell you. GET /v1/logs lists your organisation's calls with createdAt, service (for example pan.verify, gstin.verify or bank-account.verify) and status (success or failure), and accepts from, to, service, status, limit (up to 200) and a cursor.
curl -G "https://api-b2b.stampmitra.in/v1/logs" \
-H "Authorization: Bearer $STAMPMITRA_API_KEY" \
--data-urlencode "service=bank-account.verify" \
--data-urlencode "from=2026-10-29T09:40:00.000Z" \
--data-urlencode "to=2026-10-29T09:45:00.000Z" \
--data-urlencode "status=success"Narrow the window to the minute around your attempt and filter by service. If a successful entry exists, the call ran and you should treat it as done. The log entry gives you the time and the service; it does not return the original verification response body, so your own ledger is where the result belongs. That is a reason to persist the outcome the moment you receive it, and why unresolved calls need a policy: if the log shows a success but your system lost the response, you may have to re-verify to recover the data, and that is a deliberate, recorded second call, not an accident.
// find-in-logs.mjs
export async function findInLogs({ service, startedAt, key = process.env.STAMPMITRA_API_KEY }) {
const from = new Date(Date.parse(startedAt) - 5_000).toISOString();
const to = new Date(Date.parse(startedAt) + 90_000).toISOString();
const url = new URL("https://api-b2b.stampmitra.in/v1/logs");
Object.entries({ service, from, to, status: "success", limit: "10" }).forEach(([k, v]) => url.searchParams.set(k, v));
const res = await fetch(url, { headers: { Authorization: `Bearer ${key}` }, signal: AbortSignal.timeout(10_000) });
if (!res.ok) return null;
const body = await res.json();
return body.items?.length ? { loggedAt: body.items[0].createdAt, source: "logs" } : null;
}The match is by time and service, so if you run many parallel verifications you can have more than one entry in the window. That is why the ledger is keyed per subject and why the log lookup is a tiebreaker, not a primary key. If the evidence is ambiguous, mark the attempt for a human and do not fire again.
Retry policy by status
| Status | code | Retry? | How | Why |
|---|---|---|---|---|
| 400 | BAD_REQUEST | No | Fix input, validate earlier | The same body fails the same way |
| 401 | UNAUTHORIZED | No | Fix the key or header; alert | Retrying cannot create a key |
| 402 | PAYMENT_REQUIRED | No | Pause the queue; recharge; alert a person | Balance changes only through a recharge |
| 403 | FORBIDDEN | No | Fix scopes or allowlist; alert | Permissions do not heal |
| 404 | NOT_FOUND | No | Fix the id or the organisation | A stale or foreign id stays missing |
| 409 | IDEMPOTENCY_IN_PROGRESS | Yes, after Retry-After | Resend the identical request with the same key | The first request is still running |
| 409 | CONFLICT | No | Treat as already exists | State, not transience |
| 422 | IDEMPOTENCY_KEY_REUSED | No | Use a new key for a new request | The key already means another request |
| 429 | TOO_MANY_REQUESTS | Yes | Backoff with jitter; slow the whole client | The window passes |
| 502 | INTERNAL_ERROR | Yes, a few times | Backoff with jitter; cap attempts | The upstream may recover |
| Timeout or reset | none | Not blindly | Resolve first (ledger, logs), then maybe retry | The call may have run |
| Other 5xx | unknown | Like 502, with a cap | Log the raw status and a truncated body | Not documented; do not invent codes |
Every 429 and 503, and a 409 IDEMPOTENCY_IN_PROGRESS, carries a Retry-After header in seconds. Use it as the minimum wait; the backoff below is your policy on top of it, and it applies when no header is present.
A 429 deserves one extra thought. It may be the per-endpoint rate limit (the documented example is 60 requests per 60 seconds for an endpoint) or, on a sandbox key, a sandbox limit (10 requests a minute or 200 a day per organisation, 50 a day per IP address). The first and the per-minute sandbox limit clear within a minute. The daily and IP limits do not clear until midnight IST. If a 429 persists for many minutes on a sandbox key, stop retrying, because you are probably out of requests for the day; the error code (SANDBOX_DAILY_LIMIT or SANDBOX_IP_LIMIT) and details.resetAt say so.
Backoff with jitter, in code
Exponential backoff with full jitter is the standard shape: wait a random time between zero and an exponentially growing cap. The randomness keeps a crowd of clients from retrying in lockstep.
// backoff.mjs
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
export async function withRetry(fn, { retryOn = (e) => e.status === 429 || e.status === 502, max = 4, base = 500, cap = 15_000, deadlineMs = 60_000 } = {}) {
const started = Date.now();
for (let attempt = 0; ; attempt++) {
try {
return await fn();
} catch (err) {
const out = attempt + 1 >= max || !retryOn(err) || Date.now() - started > deadlineMs;
if (out) throw err;
const wait = Math.random() * Math.min(cap, base * 2 ** attempt); // full jitter
await sleep(wait);
}
}
}The same in Python:
import random, time
def with_retry(fn, retry_on=lambda e: getattr(e, "status", None) in (429, 502),
max_attempts=4, base=0.5, cap=15.0, deadline=60.0):
started = time.monotonic()
attempt = 0
while True:
try:
return fn()
except Exception as err:
attempt += 1
if attempt >= max_attempts or not retry_on(err) or time.monotonic() - started > deadline:
raise
time.sleep(random.uniform(0, min(cap, base * 2 ** (attempt - 1))))Both versions have three limits on purpose: a maximum number of attempts, a cap on any single wait, and an overall deadline. A retry loop without a deadline turns a short outage into a pile of stuck workers. A timeout is deliberately absent from retryOn, for the reasons above; add it only for endpoints and call types where a repeat is harmless, and only after the ledger says no earlier attempt could have succeeded.
Concurrency control and the rate limit
Backoff handles a 429 after it happens. Concurrency control prevents most of them. Your plan has a limit on requests per minute, and the sandbox plan, pay-as-you-go and the higher plans differ; read your own number from the dashboard rather than hard-coding the documented example.
A token bucket that all workers share keeps the aggregate rate under that number:
import threading, time
class TokenBucket:
def __init__(self, per_minute: int, burst: int = 5):
self.capacity = burst
self.tokens = float(burst)
self.rate = per_minute / 60.0
self.updated = time.monotonic()
self.lock = threading.Lock()
def take(self):
while True:
with self.lock:
now = time.monotonic()
self.tokens = min(self.capacity, self.tokens + (now - self.updated) * self.rate)
self.updated = now
if self.tokens >= 1:
self.tokens -= 1
return
wait = (1 - self.tokens) / self.rate
time.sleep(wait)
bucket = TokenBucket(per_minute=45) # set below your plan's limit to leave headroom
def call_api(...):
bucket.take()
...Set the rate below the plan limit to leave headroom for other services sharing the key and for retries. When a 429 does arrive, treat it as information about the whole client: reduce the bucket's rate temporarily, not just the failing request's schedule. A client that backs off one request while ten others hammer the endpoint is not backing off.
Circuit breakers and bulkheads
When the upstream is struggling, the kindest thing your client can do is stop asking for a while. A circuit breaker counts recent failures (502s and timeouts, not 400s) and opens after a threshold, failing calls instantly for a cool-down period, then lets a single probe call through. During the open state your onboarding flow should queue work or show "we will verify shortly", not pile up timeouts.
Bulkheads separate workloads so one cannot starve another. Give bulk jobs such as a backfill their own limiter, below the interactive limiter, so a re-check sweep never makes a user wait. If you share one key between services, the shared rate limit couples them; separate keys per service, as recommended in sandbox vs production API keys, also separate their usage in the logs.
Interactive, background and bulk calls need different policies
One retry policy for everything is a convenient mistake. The right numbers depend on who is waiting.
| Context | Attempt cap | Total deadline | On exhaustion |
|---|---|---|---|
| Interactive (a user is watching a form) | Low: two attempts | Short, a few seconds beyond one call | Show "we are still checking" and continue in the background |
| Background (a worker processes an application) | Moderate: four attempts | Minutes | Mark the attempt, alert if it persists, retry the whole job later |
| Bulk (a backfill or a re-check sweep) | Low per item, with slow overall pacing | Hours for the whole run | Record the failure, continue with the rest, report at the end |
For interactive calls the aim is to never leave the user staring at a spinner, so cap the work and move the rest to the background. For background work the aim is completeness, so a larger budget is fine. For bulk work the aim is to be a good neighbour: a low per-item cap keeps one bad record from consuming the run, and slow pacing keeps your sweep from degrading the rest of your traffic. In all three, the ledger is shared, so a job that begins in the foreground and finishes in the background never repeats a call that already ran.
Reconciling your counters with the platform's
Retry bugs show up as numbers that do not agree. Reconcile regularly, and the first duplicate announces itself while it is still small.
GET /v1/usage/summary returns the totals for the current billing period: totalApiRequests, callsByService (for example pan.verify and estamp), callsByPeriod and callsByDay. Compare callsByDay for each service with your own ledger's count of attempts in the DONE state for the same day.
| Observation | Likely cause |
|---|---|
Platform count is higher than your DONE count | Calls you made but did not record (a crash after the call), retries that fired without a ledger row, or another service using the same key |
| Platform count is lower than your attempts | Your attempts include failures that were rejected before work, which are not billed |
| Counts match within a few percent | Healthy. The residual is usually timeouts that did run |
| A step change on one day | A deploy, a queue replay or a backfill. Look at that day's logs |
Reconcile daily at first. Write the difference to a dashboard so a drift is visible without anyone having to run a query. A growing gap between platform and ledger is the signature of unrecorded retries, and it is cheaper to find that by counting than by reading an invoice. The usage summary and logs endpoints use the same API key authentication as the rest, and the logs retention depends on your plan, so keep your own ledger as the long-term record.
A worked incident: timeouts during a backfill
A fictional finance team starts re-verifying 6,000 vendors' GSTINs overnight. Here is how the design above behaves, and how the version without it behaves.
Without a ledger or pacing, the job opens fifty parallel connections. Around the two-thousandth vendor, the upstream slows and a wave of timeouts appears. The naive retry wrapper treats a timeout like a 502 and retries immediately, three times. The fifty workers now issue up to two hundred requests in the same minute. The rate limit answers with 429, which the wrapper also retries, three times, each without backoff. The queue backs up, workers hold connections, and the retries multiply. By morning the team has paid for several hundred duplicate lookups, the usage summary is far above the vendor count, and nobody can say which vendors were verified.
With the design above, the same night looks different. The token bucket holds the aggregate rate under the plan limit, so there is no 429 wave. When the upstream slows, timeouts are recorded as UNKNOWN, not retried. The circuit breaker opens after a run of timeouts and the workers pause, then probe, then resume. For each unknown attempt, the next run checks the logs for a completed gstin.verify entry in the right minute and either closes the attempt as done or flags it for a person. The 402 never appears because the low-balance alert fired the previous afternoon. In the morning the reconciliation shows platform and ledger counts agreeing within a handful of calls, and a list of about forty vendors needing a second look.
The difference was not cleverness. It was four ordinary controls: a ledger, a bucket, a breaker and a log lookup.
When the wallet runs out: 402
A 402 PAYMENT_REQUIRED with the message Insufficient fulfilment wallet balance to cover this call. is the one status that no code can fix. Only a recharge changes it. Retrying wastes the rate limit, floods logs and hides the real problem.
Design it as an operational state, not an error branch.
- On the first 402, mark the queue
PAUSED_WALLET, stop dequeuing paid work and keep accepting new work into the queue. - Alert a person with a message that says what is blocked and how much is waiting.
- Show users a truthful message ("we are not able to complete verification right now") rather than a verification failure.
- When the wallet is recharged, resume the queue in order, with the token bucket still limiting the rate.
Do not wait for a 402 to learn that the balance is low. Run your own low-balance alert from the usage summary and balance you can see in the dashboard, with a threshold in days of normal consumption, so that the 402 is rare. Recharges carry GST and a minimum amount, and a GST invoice follows payment, which is another reason to plan recharges rather than react to them.
Queues: at-least-once delivery and idempotent workers
Most verification at scale runs through a queue. Queues deliver messages at least once, which means a worker may see the same job twice, either because a worker crashed after finishing but before acknowledging, or because a visibility timeout expired during a slow call. Your worker must be idempotent, and the ledger above is what makes it so.
def handle(job):
key = f"gstin:{job['gstin']}"
window = hour_bucket(job["enqueued_at"]) # same job re-delivered lands in the same window
outcome = verify_once(subject_key=key, purpose="ONBOARD", window=window,
do_call=lambda: verify_gstin(job["gstin"]),
find_in_logs=lambda started: find_in_logs("gstin.verify", started))
save_result(job["vendor_id"], outcome)Three queue settings matter. Set the visibility timeout longer than your longest call plus retries, so a slow call is not handed to a second worker. Route jobs that fail non-retryably (400, 401, 403) to a dead-letter queue with the error code and requestId attached, so someone can look at them. And cap the redelivery count, so a poisoned job does not circulate forever and pay for itself on every lap.
Webhook consumers need the same discipline
Verification responses arrive in the HTTP response, but order and payment events arrive as webhooks, and deliveries can repeat. The platform sends X-Webhook-Signature, X-Webhook-Timestamp and X-Webhook-Event-Id headers. The signature is the hexadecimal HMAC-SHA256 of the timestamp, a dot and the raw body, using your signing secret. Verify it against the raw body, not re-serialised JSON, and use the event id as your deduplication key.
// webhook-handler.mjs (Express-style; use the raw body)
import crypto from "node:crypto";
export async function handleWebhook(req, res, { secret, seen }) {
const ts = req.header("X-Webhook-Timestamp");
const sig = req.header("X-Webhook-Signature");
const id = req.header("X-Webhook-Event-Id");
const expected = crypto.createHmac("sha256", secret).update(`${ts}.${req.rawBody}`).digest("hex");
const ok = sig && expected.length === sig.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
if (!ok || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.status(401).end();
if (await seen.has(id)) return res.status(200).end(); // duplicate delivery: acknowledge and do nothing
await seen.add(id); // record before processing, or in the same transaction
await processEvent(JSON.parse(req.rawBody));
res.status(200).end();
}The 300-second window is the SDK default and a sensible replay guard; the platform itself defines no tolerance. Record the event id in the same transaction as the work it triggers, so a crash cannot leave work done and the id unrecorded. If you miss events, GET /v1/webhooks/events lists delivery attempts and POST /v1/webhooks/events/:id/replay re-fires one, so your handler must tolerate the same event arriving again. The webhooks page has verification snippets for more languages.
Choosing timeouts
Pick timeouts from your own user experience rather than from a number in this guide, since the reference does not publish response-time figures. A few principles.
- Set a connect timeout shorter than the total timeout, so a dead route fails quickly.
- Set a total timeout per call that suits the context: shorter for an interactive form, longer for a background job.
- Keep the total timeout below the queue's visibility timeout and below any gateway timeout in front of your service.
- Measure the real latency of each endpoint on your network over a week, then set the timeout near the slow tail plus margin.
- Treat a timeout as unknown, and send it down the ledger path, not the retry path.
A timeout that is too short creates unknown outcomes for calls that were about to succeed. A timeout that is too long ties up workers during an outage. Review the number after your first month of production traffic.
What to log for every call
Retry logic is debuggable only if each attempt leaves a trail. Log, per attempt: the subject key (masked), the endpoint, the attempt number, the HTTP status, the error.code and requestId on failures, the referenceId on success, the elapsed time and the decision taken (accept, retry in N ms, stop). Never log the API key or full personal data. The audit trail guide lists what to keep and for how long.
Testing your retry behaviour
You can test most of this without spending money.
- Unit tests with a fake client. Return 429, 429, then success, and assert two waits and one result. Return 400 and assert no retry. Return a timeout and assert the attempt is
UNKNOWN. - Ledger tests. Run two workers on the same subject and assert one call. Simulate a crash between the call and the write and assert that the next run consults the logs.
- Sandbox for the real HTTP path. The documented sandbox values give you a valid and an invalid outcome on demand, and a malformed value gives you a 400. A made-up key gives a 401 and a key without a scope gives a 403. Sandbox never debits the wallet, so it cannot give you a 402, and it cannot produce a 502 on demand; use a local server that returns those bodies. The sandbox testing guide covers what sandbox does and does not prove.
- A chaos run. Point a staging client at a proxy that drops one in ten responses after forwarding them, and confirm that your ledger and logs lookup keep the duplicate count at zero.
Common mistakes
- Treating
valid: falseas an error and retrying it. - Retrying a timeout without checking whether the first call ran.
- Retrying on 400, 401, 402 or 403 in a loop.
- Using the
referenceIdas an idempotency key. - Keeping no record of intent, so a crash leaves no trace of a call that may have been billed.
- Backing off one request while the rest of the client keeps sending at full speed.
- Retrying without a maximum attempt count or deadline.
- Spinning on 402 instead of pausing the queue and alerting someone.
- Setting a queue visibility timeout shorter than the call, so two workers process one job.
- Assuming the optional
Idempotency-Keybehaves a certain way without testing it.
What to do next
Add the attempt ledger first, since it protects you from the most expensive mistake. Then wrap your calls in the retry function above with the status table as its policy, put a token bucket in front, and make your workers idempotent. For the status codes themselves, read API errors and how to handle them. For where this fits in a whole flow, read Building a vendor onboarding flow. What each endpoint returns is in the verification API reference, and the quickstart gets you a sandbox key. A PAN lookup is ₹5 per call on pay-as-you-go, so a duplicate is a real cost; 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).
Frequently asked questions
Which StampMitra errors should I retry automatically?
Only 429 TOO_MANY_REQUESTS after a pause and 502 INTERNAL_ERROR a few times, with exponential backoff, jitter and a hard cap on attempts and total time. Do not retry 400, 401, 402, 403 or 404, because the same request fails the same way.
Are verification calls idempotent?
Not as documented. The reference says the referenceId identifies a call and is not an idempotency key, and no idempotency header is documented for verify endpoints. A repeat is a second call with a second referenceId and, in production, possibly a second charge.
What should I do when a verification call times out?
Treat it as unknown, not failed. Record the attempt before calling, mark it UNKNOWN on timeout, and look in GET /v1/logs for a completed call for that service in the right minute before repeating. Bank account verification is the least safe to retry blindly.
Does the Idempotency-Key header work for verification?
No. It is documented only as an optional header on POST /v1/orders and POST /v1/payments/intents, described as an optional idempotency key. How long keys are remembered and what a duplicate returns are not specified, so test it in sandbox and confirm with support.
Is valid false an error that I should retry?
No. A valid false or found false result arrives as an HTTP 200 and is a completed lookup that is billed in production. Handle it as a final answer on your normal result path, not in the error handler or the retry loop.
How do I build my own idempotency for verification?
Keep an attempt ledger keyed by subject, purpose and a time window with a unique constraint. Insert a STARTED row before the call, finish it with the outcome after, and reuse a DONE row instead of calling again. Map timeouts to UNKNOWN so the next run checks the logs first.
How should I handle a 402 insufficient wallet balance?
Pause the paid queue, alert a person and show users a truthful message rather than a verification failure. Only a recharge fixes a 402, so retrying wastes the rate limit. Resume in order after the recharge, and run your own low-balance alert so a 402 is rare.
How do I avoid hitting the rate limit?
Share a token bucket across workers and set its rate below your plan's limit, which you should read from the dashboard. When a 429 arrives, slow the whole client temporarily, not only the failing request, and add jittered backoff to the retry.
How do I make queue workers safe against duplicate delivery?
Queues deliver at least once, so make the worker idempotent with the attempt ledger. Set the visibility timeout above the longest call plus retries, send non-retryable failures to a dead-letter queue with the requestId, and cap redeliveries.
How do I deduplicate webhook deliveries?
Verify the HMAC-SHA256 signature of the timestamp, a dot and the raw body, then use the X-Webhook-Event-Id header as the deduplication key, recorded in the same transaction as the work. Replayed deliveries from the events endpoint carry the same payload, so handlers must tolerate repeats.
Sources
- StampMitra API reference data: error shape and codes, the note that
referenceIdis not an idempotency key, theIdempotency-Keyheader and its rules, and the logs and webhook events endpoints, as in this repository on 2026-10-29. - StampMitra webhook signing description (headers
X-Webhook-Signature,X-Webhook-Timestamp,X-Webhook-Event-Idand the HMAC-SHA256 scheme) as in this repository on 2026-10-29. - StampMitra sandbox behaviour, copied from the sandbox responder, 2026-10-29.
- General engineering practice on retries with jitter and idempotent consumers, as described in public documentation of widely used queueing and API platforms (2026). No platform-specific numbers are quoted.