If you are looking for an e-stamp paper API in India, the StampMitra B2B API gives you eight documented building blocks that chain into one order flow: discover the e-Stamp services your organisation is entitled to, read the field schema and the state and article catalogue, calculate duty through the authoritative endpoint, create an order, attach parties, pay through a payment intent, poll or listen for status, and download the issued certificate. All calls use an API key in the Authorization: Bearer header against https://api-b2b.stampmitra.in.
The most important design fact is that the flow is asynchronous. A successful HTTP response, a successful payment and an issued stamp are three different events, and StampMitra's own service terms tell developers not to treat a payment as an issued instrument until the authoritative status says so. This guide walks the flow step by step with the real paths, scopes and bodies from the e-Stamp API reference, says plainly where the reference does not publish a response shape, and shows how to build polling, webhook verification and document retrieval defensively around those gaps.
It is written for backend developers at proptech, legal-tech, NBFC and marketplace teams who will wire this into a rental-agreement or loan-document journey. It is general technical information, not legal advice. Duty, forms and procedures are state-specific, so confirm legal questions with qualified counsel or the relevant state authority.
Key takeaways
- The e-Stamp surface is a chain of eight documented products: catalog, schema and options, state/instrument/article lookup, duty calculation, parties, order creation, payment, and order status plus document access.
- Auth is one API key per environment:
sk_test_...for sandbox,sk_live_...for production, sent asAuthorization: Bearer <key>orx-api-key. Every endpoint also needs its own scope. - Order creation reuses the generic
POST /v1/orderswith an e-StampserviceCodeand a free-formdetailsobject; the e-Stampdetailsshape is documented as not finalised, so drive your form from the schema endpoint, not from hardcoded fields. - Never calculate or hardcode duty.
POST /v1/estamp/duty/calculateis the one authoritative calculation and its amount must not be recomputed client-side. This guide deliberately contains no duty figures. - Payment confirmed does not mean stamp issued. Gate your product on order status and on the document being listed, not on a payment event.
- Use
GET /v1/orders/:id/statusfor polling and signed order webhooks for push. Webhooks are verified with HMAC-SHA256 overtimestamp.payload, and self-serve webhook registration is not available yet. - Document download URLs are typically short-lived and signed, so fetch a fresh one at the moment you need it and store the certificate itself in your own secure storage.
- Sandbox is free and never debits your wallet; production needs a KYC-approved organisation and a funded API credit wallet.
What does the e-stamp order flow look like end to end?
The reference models the journey as eight products, and the same ordered list drives the timeline component on the products pages. In order, they are the catalog, schema and options, state/instrument/article lookup, duty and value calculation, parties, order creation, payment, and order status with document access. The order matters because later steps depend on identifiers and decisions from earlier ones.
Here is the whole chain in one table, with the exact paths, scope and purpose from the reference.
| Step | Product | Method and path | Scope |
|---|---|---|---|
| 1 | Catalog | GET /v1/services, GET /v1/services/:serviceCode | services.read |
| 2 | Schema and options | GET /v1/estamp/schema/:serviceCode | estamp.read |
| 3 | State, instrument, article | GET /v1/estamp/states, GET /v1/estamp/states/:stateCode/instruments, GET /v1/estamp/instruments/:instrumentCode/articles | estamp.read |
| 4 | Duty and value | POST /v1/estamp/duty/calculate | estamp.duty.calculate |
| 5 | Order creation | POST /v1/orders | orders.create |
| 6 | Parties | POST /v1/estamp/orders/:orderId/parties | estamp.parties.write |
| 7 | Payment | POST /v1/payments/intents, GET /v1/payments/intents/:id, GET /v1/orders/:id/payment | payments.write, payments.read, orders.read |
| 8 | Status and documents | GET /v1/orders/:id, GET /v1/orders/:id/status, GET /v1/orders/:id/documents, GET /v1/orders/:id/documents/:documentId/download | orders.read, documents.read |
Notice that the parties endpoint takes an orderId, so in practice parties are attached to an order that already exists, even though the timeline lists parties before order creation. The reference describes the endpoint as attaching or correcting customer and party details on an existing e-Stamp order. Build your sequence as: read and calculate first, create the order, attach parties, then pay.
A second thing to notice: all eight doc pages gate production access on one estamp service code. A single production entitlement covers the whole surface, rather than eight separate approvals.
A mental model of the states
Keep three separate questions in your head throughout: did my API call succeed, did the money move, and was the instrument issued. The first is an HTTP status. The second is the payment record. The third is the order status and the presence of a document. Most integration bugs in this area come from collapsing two of these into one.
How do I authenticate and which scopes do I need?
Every protected endpoint takes either Authorization: Bearer <key> or x-api-key: <key>. Keys are environment-scoped: sk_test_ for sandbox and sk_live_ for production, and they carry OAuth-style scopes. Sensitive scopes can additionally be restricted to an IP allowlist, and endpoints are rate limited server-side. The authentication docs cover key management, and the guide on sandbox versus production keys explains the environment split in more depth.
For a full e-Stamp integration your key needs the following scopes. Create the key with only what each service actually calls, which limits the damage if one leaks.
| Scope | Used for |
|---|---|
services.read | Listing and reading entitled services |
estamp.read | Schema, states, instruments, articles |
estamp.duty.calculate | Authoritative duty and service-charge calculation |
orders.create | Creating the order |
estamp.parties.write | Attaching or correcting parties |
payments.write | Creating payment intents |
payments.read | Reading a payment intent |
orders.read | Order, status and order-payment reads |
documents.read | Listing and downloading documents |
A good pattern is to split keys by service. Your quoting service only needs services.read, estamp.read and estamp.duty.calculate. Your order worker needs orders.create, estamp.parties.write and payments.write. Your status poller needs orders.read and payments.read. A document service needs documents.read alone.
What does a scope error look like?
Every error is normalised to { error: { code, message, requestId } }. A missing or bad key returns 401 with code UNAUTHORIZED. A valid key without the needed scope returns 403 with code FORBIDDEN and the message "API key is valid but lacks the required scope for this endpoint." Log the requestId on every failure, because it is what support will ask for. The errors and handling guide covers this envelope across products.
export STAMPMITRA_API_KEY="sk_test_xxxxxxxxxxxx" # sandbox key, never commit it
export BASE="https://api-b2b.stampmitra.in"Step 1: How do I discover which e-Stamp services I can order?
Start with the generic catalog. GET /v1/services returns every service your organisation is entitled to, verification and e-Stamp together. The reference is explicit that there is no e-Stamp-specific catalog endpoint yet: e-Stamp instruments surface as regular service entries once entitled. Requires services.read.
curl -s "$BASE/v1/services" \
-H "Authorization: Bearer $STAMPMITRA_API_KEY"The documented response example shows the shape:
{
"services": [
{ "serviceCode": "estamp.instrument.rent_agreement", "name": "Rent Agreement e-Stamp", "active": true }
]
}Filter for codes that start with estamp. and have active: true. The serviceCode is what you pass later to the schema endpoint and to order creation, so cache the list for a short period rather than hardcoding strings. For a single service, call GET /v1/services/:serviceCode, which returns the detail for one service if it is entitled and active.
// Node.js 18+ (global fetch)
const BASE = "https://api-b2b.stampmitra.in";
const headers = { Authorization: `Bearer ${process.env.STAMPMITRA_API_KEY}` };
export async function listEstampServices() {
const res = await fetch(`${BASE}/v1/services`, { headers });
const body = await res.json();
if (!res.ok) throw Object.assign(new Error(body.error?.message), { requestId: body.error?.requestId, status: res.status });
return body.services.filter((s) => s.serviceCode.startsWith("estamp.") && s.active);
}What if my list is empty?
An empty e-Stamp list means your organisation is not entitled yet, not that the call failed. Entitlement is configured per organisation and production access is gated on the estamp service code. Check the production section of your dashboard or write to developer support rather than retrying.
Step 2: How do I get the field schema and dropdown options?
Rather than hardcode a form, ask the API what to collect. GET /v1/estamp/schema/:serviceCode returns generic form-rendering metadata for the e-Stamp product and needs the estamp.read scope. Two documented details matter. First, serviceCode is accepted for forward-compatible route shape and e-Stamp has exactly one schema regardless of its value, so do not build logic that expects different schemas per code today. Second, the reference does not publish a response example, so treat the shape as something you discover from the live sandbox response.
curl -s "$BASE/v1/estamp/schema/estamp.instrument.rent_agreement" \
-H "Authorization: Bearer $STAMPMITRA_API_KEY"Save one real sandbox response as a fixture in your repository and write your form renderer against it. When the schema evolves, your fixture test fails loudly in CI instead of your users failing quietly in production.
# Python 3.9+, requests
import os, requests
BASE = "https://api-b2b.stampmitra.in"
H = {"Authorization": f"Bearer {os.environ['STAMPMITRA_API_KEY']}"}
def get_estamp_schema(service_code: str) -> dict:
r = requests.get(f"{BASE}/v1/estamp/schema/{service_code}", headers=H, timeout=15)
body = r.json()
if not r.ok:
raise RuntimeError(f"{r.status_code} {body['error']['code']} req={body['error']['requestId']}")
return bodyWhy not just hardcode the fields?
Because the reference states that the shape of details for e-Stamp orders is not finalised upstream, and that there is no first-class state, article or parties[] field in the order contract yet. Driving the form from the schema keeps you aligned when that contract settles. It also respects StampMitra's service terms, which place responsibility on you to submit accurate document details and to use structured options that correspond to the underlying transaction.
Step 3: How do I resolve state, instrument and article?
Stamp duty in India is a state matter in practice: availability, procedure and requirements vary by state or Union Territory, authority and document category. That is also how StampMitra's e-Stamp service terms put it. For the authoritative statutory position in any state, check that state's stamp department and SHCIL; I could not open those official sites while preparing this guide, so this article does not restate any rule from them. Three read-only endpoints, all under estamp.read, give you StampMitra's catalogue.
| Call | What it returns | Documented caveat |
|---|---|---|
GET /v1/estamp/states | e-Stamp-eligible states | Served from StampMitra's synced master-data catalogue |
GET /v1/estamp/states/:stateCode/instruments | Instruments (articles) for a state | There is no separate instrument entity distinct from an article |
GET /v1/estamp/instruments/:instrumentCode/articles | Articles | instrumentCode is treated as a state code; no article sub-breakdown below the state-scoped list |
The caveats are unusual enough to read twice. The instruments endpoint and the articles endpoint return the same state-scoped article catalogue. Pass a state code to either one. Do not build a UI that expects a three-level tree of state, instrument, then article, because the API currently gives you two levels.
curl -s "$BASE/v1/estamp/states" -H "Authorization: Bearer $STAMPMITRA_API_KEY"
# Replace <stateCode> with a code taken from the states response
curl -s "$BASE/v1/estamp/states/<stateCode>/instruments" \
-H "Authorization: Bearer $STAMPMITRA_API_KEY"The reference does not publish response bodies for these three calls, so use the live sandbox output to learn the field names, and take stateCode values only from the states response rather than from memory.
Choosing the right article is your responsibility
StampMitra's terms state that the developer must select or submit the correct document category, stamp article and instrument type, and must not knowingly choose a wrong classification to reduce statutory charges. Where the service offers structured options, use the one that accurately matches the transaction. If your user is unsure, route them to a human or counsel rather than guessing in code. For a rental-agreement journey specifically, see the e-Stamp for rental agreements use case.
Step 4: How do I calculate duty without hardcoding amounts?
POST /v1/estamp/duty/calculate runs what the reference calls the one authoritative pricing calculation, covering e-Stamp duty and service charges. It requires the estamp.duty.calculate scope and is metered against your fulfilment wallet. The reference is firm on one rule: the response amount is never recalculated or overridden client-side.
That rule has practical consequences for your code. Do not keep a duty table in your database. Do not compute a percentage of the transaction value in your frontend for a preview. Do not round, add or subtract the returned amount. Show the number the API returned, and nothing else. This guide intentionally contains no duty figures, because they differ by state and instrument and the API returns them for you; check the live value rather than trusting any blog post, including this one.
curl -s -X POST "$BASE/v1/estamp/duty/calculate" \
-H "Authorization: Bearer $STAMPMITRA_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "...": "fields exactly as listed by GET /v1/estamp/schema/:serviceCode" }'The request and response bodies for this endpoint are not published in the reference today, so the body above is deliberately a placeholder. Populate it from the schema response from Step 2 and read the amount field names from the sandbox response. If anything is unclear, ask developer support before you guess.
What does a calculation cost, and when should I call it?
Because the call is metered, do not call it on every keystroke. Calculate once when the user confirms the state, article and value, cache the result against a hash of those inputs, and recalculate only if an input changes. Pair this with your own timestamped record of what quote the user saw, since StampMitra's terms note that statutory charges can be revised between quotation and payment. The per-call rate for e-Stamp calls is ₹5 in production, and sandbox calls never debit your wallet.
Step 5: How do I create the e-stamp order?
Order creation uses the generic endpoint: POST /v1/orders with scope orders.create. For e-Stamp you pass an e-Stamp serviceCode from GET /v1/services and put instrument, party and value details in the free-form details object. The documented parameters are externalOrderId (your own reference, required), externalCustomerId (your reference for the customer, required), serviceCode (required), details (optional object), and an optional Idempotency-Key header.
The documented request example looks like this, and the reference itself labels the details content as illustrative only:
curl -s -X POST "$BASE/v1/orders" \
-H "Authorization: Bearer $STAMPMITRA_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-ord_ext_123-v1" \
-d '{
"externalOrderId": "ord_ext_123",
"externalCustomerId": "cust_ext_456",
"serviceCode": "estamp.instrument.rent_agreement",
"details": { "state": "MH", "stampValue": 500, "note": "Shape not finalized - illustrative only" }
}'Do not copy that details into production. It shows the envelope, not a contract. Build details from the schema in Step 2. The order response example is not published in the reference, but the webhook payload and the status endpoints refer to two identifiers: orderId (the B2B-assigned id used in /v1/orders/:id) and your own externalOrderId. Store both.
export async function createEstampOrder({ externalOrderId, externalCustomerId, serviceCode, details }) {
const res = await fetch(`${BASE}/v1/orders`, {
method: "POST",
headers: {
...headers,
"Content-Type": "application/json",
"Idempotency-Key": `order-${externalOrderId}-v1`,
},
body: JSON.stringify({ externalOrderId, externalCustomerId, serviceCode, details }),
});
const body = await res.json();
if (!res.ok) throw Object.assign(new Error(body.error?.message), { status: res.status, requestId: body.error?.requestId });
return body; // persist the returned order id next to externalOrderId
}Why send an Idempotency-Key?
The header is optional in the reference, but for an order that eventually spends money it is cheap insurance against a network timeout that leaves you unsure whether the order was created. Derive the key from your own externalOrderId so a retry of the same logical order reuses the key. The reference does not describe the server's replay behaviour in detail, so also keep externalOrderId unique on your side and reconcile by listing against it if a retry result is ambiguous. StampMitra's terms also tell developers to investigate duplicate transactions immediately, which is the legal-side reason to be strict here.
Step 6: How do I attach parties to the order?
POST /v1/estamp/orders/:orderId/parties attaches or corrects the customer and party details on an existing e-Stamp order. It requires estamp.parties.write and is metered against your fulfilment wallet. The product description mentions first party, second party and witness details; the reference does not publish the request body, so the field names come from the schema and sandbox.
curl -s -X POST "$BASE/v1/estamp/orders/<orderId>/parties" \
-H "Authorization: Bearer $STAMPMITRA_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "...": "party fields as defined by the schema endpoint" }'Because the endpoint is documented as "attach or correct", you can call it again to fix details before the order moves forward. Design your UI around that: show a review screen, let the user edit, and re-post. After payment and issuance, correction may not be possible: StampMitra's terms say that whether an e-Stamp can be corrected depends on applicable law and the issuing process, and that a new transaction may be needed.
Party data is personal data
Names, addresses and identifiers of the people signing a rent or loan agreement are personal data. As general good practice under the principles of India's Digital Personal Data Protection Act 2023 (which I could not re-open on the official site to quote today, so I do not cite section numbers), collect only what the schema requires, tell users why, and avoid copying party details into logs, analytics and support tickets. Never paste real identifiers into tests: use obviously fake sandbox data. The StampMitra developer privacy policy and data processing addendum describe how the platform treats data on its side.
If you also need to confirm that a party's PAN or business registration is genuine before you stamp, the verification APIs such as PAN and GSTIN sit under separate scopes. StampMitra's terms are explicit that it does not determine legal identity or the contractual relationship between parties unless a verification service expressly does so.
Step 7: How does payment work and what do I do with the intent?
Payment is a separate resource. POST /v1/payments/intents creates a payment intent for an order; the reference says it is Razorpay-backed, requires payments.write, and accepts the optional Idempotency-Key header. GET /v1/payments/intents/:id fetches the current state of an intent (payments.read), and GET /v1/orders/:id/payment fetches the payment associated with an order (orders.read).
curl -s -X POST "$BASE/v1/payments/intents" \
-H "Authorization: Bearer $STAMPMITRA_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: pay-ord_ext_123-attempt-1" \
-d '{ "...": "fields per /docs/estamp/estamp-payment" }'
curl -s "$BASE/v1/payments/intents/<intentId>" \
-H "Authorization: Bearer $STAMPMITRA_API_KEY"
curl -s "$BASE/v1/orders/<orderId>/payment" \
-H "Authorization: Bearer $STAMPMITRA_API_KEY"Again, the reference lists the paths, scopes and headers but not the body or response fields, so open the payment doc page and your sandbox response for the current contract. Do not invent fields like clientSecret or checkoutUrl because you have seen them on another gateway.
Who talks to the payment provider?
The webhooks page is explicit: StampMitra's own payment provider delivers its webhooks to POST /v1/payments/webhooks/:connectionId, which is server-to-server, signature-verified by StampMitra, and not called by your frontend or your backend. You never implement or call that route. What you do is read the resulting payment state through the two GET endpoints above, and listen for the order-level events described later.
Payment is not issuance
The Service Terms say a payment confirmation does not necessarily mean the underlying e-Stamp instrument has been issued, that a transaction may remain pending after payment where external confirmation or issuance is required, and that you must not represent a payment as an issued instrument until the applicable issuance status is confirmed. Translate that into code: after the payment turns successful, move your own record to something like paid_awaiting_issuance, never to stamped. Only the status endpoint and a listed document justify the final state. Also keep wallet billing and customer payment distinct in your head; the API credit wallet that meters your calls is not the same thing as the payment intent for the instrument.
Step 8: How do I poll order status and what do statuses mean?
GET /v1/orders/:id/status (scope orders.read) polls the lifecycle status of an order, and GET /v1/orders/:id fetches the order itself. The reference does not enumerate status values. The one concrete status value in the published materials is FULFILMENT_COMPLETED, which appears in the webhook example payload for the order.fulfilment_completed event. The legal Service Terms list statuses that a transaction "may have, without limitation", such as initiated, pending, payment pending, payment successful, processing, issued, failed, rejected, cancelled, expired and reversed, and say another status may be defined by the applicable API. Treat that as a conceptual vocabulary, not as the literal API strings.
The right engineering response is a tolerant state machine: map the strings you have actually observed to your internal states, and send anything unknown to a "needs attention" bucket rather than throwing.
const TERMINAL_OK = new Set(["FULFILMENT_COMPLETED"]); // observed in the webhook example
// Add failure strings only after you see them in sandbox responses or event payloads.
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
export async function waitForOrder(orderId, { maxMs = 15 * 60_000 } = {}) {
const started = Date.now();
let delay = 3_000;
while (Date.now() - started < maxMs) {
const res = await fetch(`${BASE}/v1/orders/${orderId}/status`, { headers });
if (res.status === 429) { await sleep(delay * 2); delay = Math.min(delay * 2, 60_000); continue; }
const body = await res.json();
if (!res.ok) throw Object.assign(new Error(body.error?.message), { status: res.status, requestId: body.error?.requestId });
if (TERMINAL_OK.has(body.status)) return body;
// log the raw body so unknown statuses are discoverable
console.log("order status", orderId, JSON.stringify(body));
await sleep(delay);
delay = Math.min(Math.round(delay * 1.5), 60_000);
}
return { timedOut: true };
}The exact name of the status field in the status response is not published; the code above reads body.status because that is the field name in the webhook payload, so verify it against your sandbox output. Python is equally simple:
import time, requests
def wait_for_order(order_id: str, max_s: int = 900) -> dict:
deadline, delay = time.time() + max_s, 3.0
while time.time() < deadline:
r = requests.get(f"{BASE}/v1/orders/{order_id}/status", headers=H, timeout=15)
if r.status_code == 429:
time.sleep(delay * 2); delay = min(delay * 2, 60); continue
body = r.json()
r.raise_for_status()
if body.get("status") == "FULFILMENT_COMPLETED":
return body
print("order status", order_id, body) # keep raw output while learning the values
time.sleep(delay); delay = min(delay * 1.5, 60)
return {"timed_out": True}How often should I poll?
The documented rate-limit example is "60 requests per 60 seconds for this endpoint", so one request per second per endpoint is the ceiling, and you should sit far below it. Back off progressively, stop after a sensible deadline, and move the order to a manual-review queue on timeout rather than polling forever. Better still, poll only as a backstop and rely on events as the primary signal.
How do webhooks work for e-stamp orders and how do I verify them?
The webhooks page documents outbound webhooks for order lifecycle events. The event types are order.created, order.payment_confirmed, order.fulfilment_started, order.fulfilment_completed, order.failed, order.cancelled, order.refund_completed and order.document_ready. The last one is the e-Stamp trigger you care about: a generated document, such as an e-Stamp certificate, is ready to download.
The example payload for order.fulfilment_completed is:
{
"coreOrderId": "core_order_9f3a1c2b8e4d4a5f",
"coreEventType": "FULFILMENT_COMPLETED",
"occurredAt": "2026-09-25T09:05:00.000Z",
"orderId": "ord_4f5a6b7c8d9e0f1a",
"externalOrderId": "ord_ext_123",
"status": "FULFILMENT_COMPLETED"
}Deliveries are signed as HMAC-SHA256(secret, ${timestamp}.${JSON.stringify(payload)}), hex-encoded, and sent alongside a timestamp header. Failed deliveries are retried with exponential backoff, up to 6 attempts over roughly 13 hours, before being dead-lettered. Your endpoint should verify, then respond 2xx quickly.
Two honesty notes. First, the page marks self-serve webhook registration as coming soon: today, webhook endpoints for an organisation are configured internally by StampMitra, and there is no public route to register or rotate secrets. Ask developer support when you need one set up. Second, the page does not name the signature or timestamp header, so confirm those names during onboarding rather than guessing; the code below takes them as configuration.
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.STAMPMITRA_WEBHOOK_SECRET;
const SIG_HEADER = process.env.WEBHOOK_SIGNATURE_HEADER; // confirm name with StampMitra
const TS_HEADER = process.env.WEBHOOK_TIMESTAMP_HEADER; // confirm name with StampMitra
const app = express();
app.post("/webhooks/stampmitra", express.json({ type: "application/json" }), (req, res) => {
const ts = req.get(TS_HEADER);
const sig = req.get(SIG_HEADER) ?? "";
const expected = crypto.createHmac("sha256", SECRET)
.update(`${ts}.${JSON.stringify(req.body)}`).digest("hex");
const a = Buffer.from(sig, "hex"), b = Buffer.from(expected, "hex");
if (!ts || a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(401);
// idempotent handling: dedupe on orderId + coreEventType + occurredAt
enqueue(req.body); // do real work off the request path
res.sendStatus(200);
});A caveat on the construction: the documented signing input uses JSON.stringify(payload), so re-serialising the parsed body can differ from the bytes on the wire if key order or spacing differs. Verify against the sandbox deliveries, and if your framework gives you the raw body, compare both forms during testing. Also reject deliveries whose timestamp is far from your clock; the exact tolerance is your policy, since none is documented.
Treat events as hints, status as truth
Events can arrive late, twice, or out of order, and retries span hours. When an event arrives, dedupe it, then call GET /v1/orders/:id/status and act on the authoritative state. That follows the Service Terms guidance to rely on the authoritative API status rather than a frontend or payment event, and to implement reconciliation for pending and asynchronous transactions.
How do I fetch the issued e-stamp certificate?
Once the order has completed, or when you receive order.document_ready, list the order's documents with GET /v1/orders/:id/documents (scope documents.read). The reference says this lists generated documents, for example the e-Stamp certificate PDF, for a completed order. To download one, call GET /v1/orders/:id/documents/:documentId/download, which returns a typically short-lived, signed URL for that specific document.
curl -s "$BASE/v1/orders/<orderId>/documents" \
-H "Authorization: Bearer $STAMPMITRA_API_KEY"
curl -s "$BASE/v1/orders/<orderId>/documents/<documentId>/download" \
-H "Authorization: Bearer $STAMPMITRA_API_KEY"The response fields are not published, so read the live output for the id field of each document and the field holding the URL.
def fetch_certificate(order_id: str, out_path: str):
docs = requests.get(f"{BASE}/v1/orders/{order_id}/documents", headers=H, timeout=15).json()
# pick the document id using the field names you observed in sandbox
document_id = pick_certificate_id(docs)
link = requests.get(f"{BASE}/v1/orders/{order_id}/documents/{document_id}/download",
headers=H, timeout=15).json()
url = extract_url(link) # field name per sandbox output
pdf = requests.get(url, timeout=60) # signed URL: do NOT add your API key header here
pdf.raise_for_status()
open(out_path, "wb").write(pdf.content)Two practices follow from the "short-lived, signed" wording. Do not store the URL; store the file, in your own encrypted storage, because the URL will expire. And do not forward the URL to end users as a permanent link: generate a fresh download link each time and serve the file from your own authenticated route. Do not send your API key to the signed URL host. The Service Terms add that developers must preserve the original issued instrument or electronic representation as required by law and business needs, and that sensitive e-Stamp outputs must be stored securely and not cached insecurely.
Verifying authenticity
The issued certificate is the object that matters. To confirm that a certificate is genuine, use the verification channel published by the issuing authority; for SHCIL-issued e-stamp certificates that is the SHCIL e-Stamping site. I could not open the SHCIL site or the India Code text of the Indian Stamp Act while writing this, so I am not describing its verification fields or any section of the Act here. Read the official instructions directly before you document them for your own users.
What are the common failure modes and how should I handle them?
Every failure uses the same envelope, { error: { code, message, requestId } }, and the e-Stamp surface shares the generic error set. The documented ones relevant here are in the table.
| HTTP | Code | Documented message | What to do |
|---|---|---|---|
| 400 | BAD_REQUEST | Validation errors, e.g. a format message | Fix the input; never retry unchanged |
| 401 | UNAUTHORIZED | Missing or invalid API key. | Check key and environment (sk_test_ vs sk_live_) |
| 402 | PAYMENT_REQUIRED | Insufficient fulfilment wallet balance to cover this call. | Top up the API credit wallet, then retry |
| 403 | FORBIDDEN | Valid key but lacks the required scope | Add the missing scope to the key |
| 404 | NOT_FOUND | The requested resource was not found. | Wrong id or wrong environment |
| 429 | TOO_MANY_REQUESTS | Rate limit exceeded: 60 requests per 60 seconds for this endpoint. | Back off with jitter |
| 502 | INTERNAL_ERROR | Unable to reach the order/customer service right now | Retry with backoff; reconcile before re-creating |
The 402 case deserves attention in this flow, because the metered calls (duty calculation and parties, per the reference) debit your wallet. Monitor the balance and alert before it runs out, or your users will hit a wall mid-journey. A 404 on an order you just created often means the id came from another environment: sandbox and production data are separate.
Which errors are safe to retry?
Retry 429 and 502 with exponential backoff and jitter. Do not blindly retry a 502 on order creation: first look the order up by your externalOrderId, or reuse the same Idempotency-Key, so you do not create a duplicate. Do not retry 400, 401, 403 or 402 without changing something. For the broader strategy across the platform see the errors and handling guide.
Failure causes that are not HTTP errors
An order can fail or be rejected after you receive a 2xx, via the order.failed event or a failed status. The Service Terms say StampMitra may reject, suspend or require correction where submitted information appears inconsistent, incomplete, fraudulent or materially incorrect, and that issuance depends on external systems and authorities. Your product needs a visible failed state, a message to the user, and a refund and support path. Refund eligibility is governed by StampMitra's refund policy and applicable rules, and statutory amounts may follow separate authority-controlled rules, so do not promise refunds in your UI copy.
When should I use this API, and when should I not?
Use the API when e-stamping is a step inside a flow you own: a rental agreement journey, a loan-document pack, a marketplace contract, an onboarding bundle. The value is that duty, order, payment and certificate retrieval all live behind one key and one error envelope, and your own users never leave your product.
| Situation | Fit | Why |
|---|---|---|
| Proptech generating rent agreements at volume | Good | Programmatic order, payment and document retrieval |
| NBFC stamping loan documents inside its LOS | Good | Status events and documents can feed your workflow |
| One-off personal stamp purchase | Weak | A consumer flow on stampmitra.in is simpler than an integration |
| You need a guaranteed instant certificate | Careful | Issuance depends on external systems and authorities, so design for pending |
| You want to compute duty yourself | Do not | The calculation is authoritative server-side and must not be overridden |
| You need to self-register webhook endpoints | Not yet | Registration is configured by StampMitra today |
Questions to settle first
Which states and instruments will you stamp? Entitlement and availability depend on jurisdiction. Who is the legal purchaser, and have you captured the right party details? Who owns reconciliation when an order is stuck in an in-between state? And who in your organisation reviews classification choices? Answering these with your legal counsel before coding saves rework.
How do I test the whole flow in sandbox?
Sandbox keys (sk_test_) come from self-signup with no credit card, and the pricing page states that sandbox calls run against sandbox data and never debit your wallet. Production keys need a KYC-approved organisation. Test with sandbox data only: the sample values you see in the docs, never real people's details.
Build a test plan around the questions the reference leaves open, because the sandbox is where you learn the shapes:
- Call
GET /v1/servicesand record the exact e-Stamp codes. - Save the schema response as a fixture and generate your form from it.
- Walk states, instruments and articles for two or three states and save the outputs.
- Run the duty calculation and confirm that your UI displays the returned amount unmodified.
- Create an order twice with the same
Idempotency-Keyand confirm you do not end up with two. - Attach parties, then re-post a correction.
- Create a payment intent and read it back through both GET routes.
- Poll status until a terminal state, logging raw bodies.
- Fetch documents and download one, then confirm your code never persists the signed URL.
- Trigger 401, 403 and 404 on purpose by using a wrong key, a key without a scope and an unknown id.
Test the unhappy paths for your own side too: kill the worker between order creation and parties, return 500 from your webhook endpoint to watch retries, and replay a webhook twice to check your dedupe. Plan a simulated 402 by running a production-like wallet at zero in a staging organisation if you have one. The Postman collection and the SDK page can shorten the first run.
What sandbox cannot tell you
Do not read sandbox success as proof that a state will issue in production. State availability and external system behaviour are production realities, so confirm them with a small, supervised production pilot after entitlement.
What should I check before going to production?
Production access is gated per organisation. Apply for it from the dashboard; once approved, you can create an sk_live_ key. All e-Stamp doc pages share the single estamp service code, so one entitlement covers the surface.
A short go-live checklist:
- Create separate live keys per service with only the scopes in the scope table, and restrict sensitive scopes to an IP allowlist where available.
- Fund the API credit wallet and add a low-balance alert, since 402 stops the flow. The e-Stamp per-call rate is ₹5; see the pricing page for the current rate card.
- Store keys and the webhook secret in a secrets manager. Rotate if they ever appear in logs.
- Confirm the webhook endpoint, secret and header names with StampMitra and replay a sample event end to end.
- Persist
requestId, yourexternalOrderId, the order id and every status transition for audit. The Service Terms ask developers to preserve audit trails and e-Stamp references where legally or operationally required. - Build a reconciliation job that sweeps orders stuck in non-terminal states.
- Write the user-facing copy for pending, failed and refunded cases with your counsel.
- Read the e-Stamp service terms, the API acceptable use policy and the API security policy.
Security habits that matter here
Keep keys server-side. Never log certificates or party details. Verify every webhook signature. Check that your own users are authorised to see an order before you serve its document. And do not use e-Stamp services for sham or fraudulent transactions: the terms prohibit fabricating, altering or fraudulently duplicating instruments and misrepresenting issuance status.
How do I troubleshoot a stuck or failing order?
Work from the outside in. Start with the HTTP layer, then the order, then the payment, then the document.
| Symptom | Likely cause | Check |
|---|---|---|
| 401 on every call | Wrong key, wrong header, wrong environment | Key prefix, Authorization: Bearer spelling |
| 403 on one step only | Missing scope for that step | Compare the call with the scope table |
| 402 on duty or parties | Wallet empty | Dashboard billing, top up |
| Empty e-Stamp service list | Not entitled yet | Production access status, support |
| Order exists but never completes | Pending payment or external issuance delay | GET /v1/orders/:id/payment, then status |
| Paid but no certificate | Issuance still pending; payment is not issuance | Status endpoint, order.document_ready event |
| Documents list is empty | Order not completed yet | Wait for completion; do not treat as an error |
| Download link fails later | Signed URL expired | Request a fresh download URL |
| Webhook signature mismatch | Re-serialised body, wrong secret, wrong timestamp header | Compare raw and re-stringified body |
| 404 on an order id | Id from the other environment, or externalOrderId used instead of orderId | Use the B2B-assigned id in paths |
If you still cannot explain it, collect the requestId values from the failing calls, your externalOrderId and the order id, and write to developer support at [email protected]. Do not email certificates or party documents; the identifiers are enough.
A reconciliation pattern that scales
Run a scheduled job that selects orders in non-terminal states older than a threshold you choose, calls the status endpoint for each at a polite rate, and updates your records. Combine it with webhook handling and you get both low latency and eventual correctness. When an order has been pending beyond your tolerance, surface it to an operator instead of silently retrying or re-creating it, because a duplicate stamp is a worse outcome than a delayed one.
How StampMitra Helps
StampMitra is a private LegalTech platform operated by Bani Global Industries LLP (LLPIN ACI-6373). It is not a government agency or a law firm, and nothing here is legal advice. What the API does today is concrete: one API key and one error envelope cover eight documented e-Stamp building blocks, from entitled-service discovery and the state and article catalogue, through authoritative duty calculation, order creation, parties, a Razorpay-backed payment intent, and status polling, to listing and downloading the issued certificate.
Several points are designed to keep your integration honest. Duty comes from one authoritative endpoint and is never overridden client-side. Every response carries a requestId that support can trace. Sandbox is free, never debits your wallet and needs no credit card, so you can learn the real response shapes before committing. Production is gated by KYC-approved onboarding, and per-key scopes plus optional IP allowlisting limit what a leaked key can do. The e-Stamp per-call rate is ₹5 and is listed on the pricing page.
StampMitra is also open about the edges. The details shape for e-Stamp orders is documented as not finalised, several request and response bodies are not yet published in the reference, and self-serve webhook registration is not available today: webhook endpoints are configured by StampMitra on request. If any of this blocks you, say so at developer support and we will tell you what is available now.
What's next is plans, not promises. We intend to publish fuller request and response contracts for each e-Stamp endpoint, add first-class state, article and parties fields to the order contract, and offer self-serve webhook endpoint management once the backend routes are confirmed. To start, create a sandbox account, follow the quickstart, and keep the e-Stamp reference open next to this guide. If your first use case is rentals, the rental agreements use case walks through the product side, and the PAN verification in Node.js and Python guide shows the same coding patterns for the verification endpoints.
API reference at a glance
POST /v1/ordersRequest 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. |
Idempotency-Key | header | Optional idempotency key. |
externalOrderIdrequired | string | Your own reference for this order. |
externalCustomerIdrequired | string | Your own reference for the customer. |
serviceCoderequired | string | The entitled service to order (e.g. an estamp.* code). |
details | object | Free-form service-specific payload (e-Stamp field shape is not finalized). |
POST /v1/orders{
"externalOrderId": "ord_ext_123",
"createdAt": "2026-10-15T09:41:07.000Z",
"serviceCode": "estamp.instrument.rent_agreement"
}POST /v1/orderse-Stamp Order Creation: ₹5 API fee per successful call, pay-as-you-go (lower on Growth and Scale).
POST /v1/payments/intentsRequest 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. |
Idempotency-Key | header | Optional idempotency key. |
externalOrderIdrequired | string | Your reference for the order being paid. |
amountMinorrequired | integer | Amount in minor units (paise); positive integer. |
currencyrequired | string | Currency code, at least 3 characters, e.g. INR. |
paymentConnectionIdrequired | string | Id of one of your active payment connections. |
POST /v1/payments/intents{
"id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"externalOrderId": "ord_ext_123",
"coreOrderId": null,
"amountMinor": 50000,
"currency": "INR",
"status": "CREATED",
"providerIntentId": "order_Nf3k2m9QxPz1Ab",
"createdAt": "2026-10-15T09:41:07.000Z"
}POST /v1/payments/intentsPayment: ₹5 API fee per successful call, pay-as-you-go (lower on Growth and Scale).
GET /v1/orders/:id/documentsRequest 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. |
GET /v1/orders/:id/documentsOrder Status & Document Access: ₹5 API fee per successful call, pay-as-you-go (lower on Growth and Scale).
Frequently asked questions
What is an e-stamp paper API?
It is an API that lets your software order electronic stamp paper programmatically instead of buying it manually. With StampMitra you discover entitled services, calculate duty, create an order, pay, and download the issued e-Stamp certificate using one API key.
Which endpoints make up the StampMitra e-Stamp flow?
GET /v1/services, GET /v1/estamp/schema/:serviceCode, the three state, instrument and article lookups, POST /v1/estamp/duty/calculate, POST /v1/orders, POST /v1/estamp/orders/:orderId/parties, the payment intent routes, order status, and the documents list and download routes.
Can I calculate stamp duty myself and send the amount?
No. The duty calculate endpoint is the one authoritative calculation and its amount must not be recalculated or overridden client-side. Duty varies by state and instrument, so always show the value the API returns and check the live figure.
Does a successful payment mean the e-stamp is issued?
No. StampMitra's service terms say payment confirmation does not necessarily mean the instrument has been issued and that a transaction may stay pending afterwards. Rely on the order status and on the certificate appearing in the documents list before telling users it is stamped.
How do I know when my e-stamp certificate is ready?
Poll GET /v1/orders/:id/status with backoff, or listen for the order.document_ready webhook event. Then call GET /v1/orders/:id/documents to list documents and request a download URL for the certificate PDF.
How are StampMitra webhooks verified?
Deliveries are signed with HMAC-SHA256 over the timestamp, a dot, and the JSON payload, hex-encoded, and sent with a timestamp header. Recompute the signature with your secret, compare in constant time, and respond with a 2xx. Retries run up to six attempts over roughly 13 hours.
Can I register my own webhook endpoint through the API?
Not yet. The webhooks page says self-serve registration and secret rotation are coming soon, and today StampMitra configures webhook endpoints for an organisation internally. Contact developer support to have one set up for your account.
Why does the document download URL stop working?
The download endpoint returns a typically short-lived, signed URL. Do not store or share it. Download the certificate file promptly into your own secure storage and request a fresh URL whenever you need the file again.
Is the e-Stamp API free to test?
Yes. Sandbox keys (sk_test_) are free with no credit card, run against sandbox data and never debit your wallet. Production requires a KYC-approved organisation, a live key and a funded API credit wallet.
What happens if my wallet runs out during the flow?
Metered calls such as duty calculation and parties return HTTP 402 with code PAYMENT_REQUIRED and the message about insufficient fulfilment wallet balance. Top up the API credit wallet and retry, and add a low-balance alert so users never hit it mid-journey.
Sources
Verified 2026-10-16 against the StampMitra developer repository and pages:
- StampMitra API product definitions,
src/data/api-products.ts(e-Stamp products, paths, scopes, error envelope): e-Stamp API reference. - StampMitra webhooks page (event types, example payload, HMAC-SHA256 signing, retry policy, registration status).
- StampMitra pricing page (sandbox and per-call billing wording) and authentication docs (key prefixes, scopes, production KYC).
- StampMitra Verification, e-Stamp and e-Sign Service Terms, sections 11 to 27, and the Refund policy section on stamp duty and e-stamp transactions.
Official bodies to consult directly: SHCIL e-Stamping (shcilestamp.com), your state stamp department, and the text of the Indian Stamp Act, 1899 on India Code. On 2026-10-16 these sites returned errors or blocked automated access from my environment, so no statement in this guide relies on them; read them yourself before describing duty rules, verification steps or section-level legal effect to your users.