Python SDK
The official Python client for the StampMitra Developer API: identity verification and e-Stamp with sync and async clients, typed pydantic models and signed-webhook helpers.
Pre-release 0.1.0 - package registry publishing in progress
- Package
stampmitra- Version
- 0.1.0
- Requires
- Python 3.9 or newer. Runtime dependencies: httpx and pydantic 2. Sync and async clients, typed (py.typed, passes mypy --strict).
- License
- MIT
- Changelog
- CHANGELOG.md
- StampMitra and AsyncStampMitra clients, usable as context managers.
- Typed pydantic v2 models: camelCase JSON, snake_case attributes, unknown fields preserved.
- Retries with jittered backoff and an automatic Idempotency-Key on every POST except webhook replay.
- Flask and FastAPI webhook examples, plus iterator-based pagination.
Install the package
pip install stampmitraAvailable once published. Not yet published to PyPI. This command works once the package is released; until then follow the GitHub repository for the release.
pip install "git+https://github.com/Bani-Global-Industries-LLP/stampmitra-developer-python.git"Until then, install from GitHub: The repository is a standard pyproject (hatchling, src layout), so pip can build it straight from Git.
Verify a PAN in five lines
Use a sandbox key (sk_test_...) while you build. It is free, never debits your wallet and runs against sandbox data.
from stampmitra import StampMitra
client = StampMitra() # reads STAMPMITRA_API_KEY
result = client.verify.pan("ABCDE1234F", name="Rahul Kumar")
print(result.valid, result.registered_name, result.reference_id)API keys and environments
Sandbox
Sandbox keys start with sk_test_. They are free and return sandbox data.
Live
Live keys start with sk_live_. They need an approved production access request and are metered per successful call.
Pass api_key="sk_test_..." or set the STAMPMITRA_API_KEY environment variable. client.environment is "test" or "live". Keys that do not match ^sk_(test|live)_[A-Za-z0-9_-]{8,}$ are rejected unless you pass allow_custom_key=True.
from stampmitra import StampMitra, RequestOptions
client = StampMitra(
api_key="sk_test_...",
base_url="https://api-b2b.stampmitra.in",
timeout=30,
max_retries=2,
default_headers={},
app_info={"name": "my-app", "version": "1.0.0", "url": "https://example.com"},
debug=False,
validate_input=True,
allow_custom_key=False,
)
# Every method also takes per-request options:
client.verify.pan(
"ABCDE1234F",
options=RequestOptions(timeout=10, max_retries=1, idempotency_key="...", headers={}),
)Every resource in one place
JSON field names are camelCase and model attributes are snake_case (registered_name). Unknown fields are preserved (model.model_extra, model.to_dict()). Request bodies for e-Stamp and orders are plain dicts exactly as the API documents them. Every returned model has .response with status_code, headers, request_id and content. Endpoints the spec leaves unconstrained return an ApiObject.
# verify (every call is billed, including negative results; HTTP 200 with valid=False)
client.verify.pan("ABCDE1234F", name="Rahul Kumar")
client.verify.gstin("07AAAAA1111A1Z1", business_name="Acme")
client.verify.rc("MH12AB1234")
client.verify.voter_id("ABC1234567")
client.verify.driving_license("MH1420110012345", "1990-01-01") # IP-allowlisted key
client.verify.passport("PA1079341954215", "1990-01-01") # IP-allowlisted key
client.verify.cin("U74999DL2015PTC284009")
client.verify.udyam("UDYAM-MH-12-1234567")
client.verify.pan_to_gstin("ABCDE1234F")
client.verify.pan_to_udyam("ABCDE1234F")
client.verify.bank_account("000123456789", "ICIC0001234", name="Jane Doe")
# services and e-Stamp
client.services.list()
client.services.get("estamp.instrument.rent_agreement")
client.estamp.get_schema(code)
client.estamp.list_states()
client.estamp.list_instruments("MH")
client.estamp.list_articles(instrument_code)
client.estamp.calculate_duty({...})
client.estamp.add_parties(order_id, {...})
# orders and payments
order = client.orders.create({"externalOrderId": "o1", "externalCustomerId": "c1", "serviceCode": code, "details": {}})
client.orders.get(id)
client.orders.get_status(id)
client.orders.get_payment(id)
client.orders.list_documents(id)
client.orders.get_document_download(id, document_id)
client.payments.intents.create({...})
client.payments.intents.get(intent_id)
# usage, logs, webhook events
client.usage.summary()
client.logs.list(from_="2026-10-01", to="2026-10-16", service="pan.verify", status="200", limit=50)
client.webhooks.events.list(limit=20)
client.webhooks.events.replay(event_id)Pagination
client.webhooks.events.iter_all() works the same way (it assumes the items / nextCursor shape, like /v1/logs).
for entry in client.logs.iter_all(service="pan.verify", limit=100): # follows nextCursor
print(entry.id)
async for entry in async_client.logs.iter_all(limit=100):
...Error classes
Every error extends StampMitraError (status, code, message, request_id, body). ValidationError has .field and RateLimitError has .retry_after (seconds). Non-JSON error bodies still map by status. The API error body is {"error": {"code", "message", "requestId", "field?"}}.
| HTTP status | Class | Notes |
|---|---|---|
| 400 / 422 | ValidationError | Bad input. Also raised locally, before any request, by the validation helpers. |
| 401 | AuthenticationError | Missing, malformed or revoked API key. |
| 402 | PaymentRequiredError | Insufficient wallet balance or credit. |
| 403 | PermissionDeniedError | The server message is passed through verbatim, e.g. Passport and Driving Licence need an IP-allowlisted key. |
| 404 | NotFoundError | Unknown order, service or event id. |
| 409 | ConflictError | Idempotency-Key reused with a different body, or a state conflict. |
| 429 | RateLimitError | Back off and retry; the server's Retry-After value is exposed when it is sent. |
| 5xx | UpstreamError | A server-side or upstream failure. Retried automatically for 502, 503 and 504. |
| network / timeout | APIConnectionError / APITimeoutError | No usable response: connection failure, timeout or cancellation. |
from stampmitra import StampMitraError, ValidationError, RateLimitError, PermissionDeniedError
try:
client.verify.pan("ABCDE1234F")
except ValidationError as e: # 400 (or local validation, e.status is None)
print(e.field, e.message)
except RateLimitError as e: # 429
print(e.retry_after)
except StampMitraError as e: # base class
print(e.status, e.code, e.request_id)Retries and idempotency
- Default max_retries is 2, with exponential backoff and full jitter (base 0.5 s, cap 8 s). Retry-After is honoured, capped at 60 s.
- Retried: 429, 502, 503, 504, connection errors and timeouts, for GET and for POST requests that carry an Idempotency-Key (which the SDK adds to every POST except webhook replay).
- Every POST except webhook replay (all verify.* calls, estamp.calculate_duty, estamp.add_parties, orders.create and payments.intents.create) gets a random UUIDv4 Idempotency-Key, reused across automatic retries, unless you pass options=RequestOptions(idempotency_key=...), which always wins. A retried verification is replayed by the server instead of being billed twice. Each separate call gets a fresh key.
- webhooks.events.replay is never auto-keyed and is never retried after a response was received; it is retried only if the TCP connection could not be established. Supply an explicit idempotency_key if you want it keyed.
Client-side validation
Inputs are normalised (trimmed, upper-cased) and checked against the same patterns as the backend before sending. Failures raise ValidationError locally, with no network call and no billing. Turn it off with StampMitra(validate_input=False).
Verify webhook signatures
Deliveries carry X-Webhook-Signature, X-Webhook-Timestamp (Unix seconds) and X-Webhook-Event-Id. The signature is the hex HMAC-SHA256 of the timestamp, a dot and the raw body, keyed with your endpoint secret. Always verify against the raw body, never re-serialised JSON.
The webhooks module verifies the signature in constant time and rejects timestamps more than tolerance_seconds (default 300) from now. The same helpers are on the client as client.webhooks.verify_signature and construct_event.
from stampmitra import webhooks, WebhookSignatureError
event = webhooks.construct_event(
raw_body,
headers["X-Webhook-Timestamp"],
headers["X-Webhook-Signature"],
secret,
)
# webhooks.verify_signature(...) returns True or raises WebhookSignatureErrorWebhookSignatureError exposes a code: signature_mismatch, timestamp_outside_tolerance, invalid_timestamp or missing_secret.
Framework recipes
import os
from flask import Flask, request
from stampmitra import WebhookSignatureError, webhooks
app = Flask(__name__)
SECRET = os.environ["STAMPMITRA_WEBHOOK_SECRET"]
@app.post("/webhooks/stampmitra")
def receive() -> "tuple[str, int]":
try:
event = webhooks.construct_event(
request.get_data(), # the RAW body, not request.json
request.headers.get(webhooks.TIMESTAMP_HEADER, ""),
request.headers.get(webhooks.SIGNATURE_HEADER, ""),
SECRET,
)
except WebhookSignatureError as err:
return str(err), 400
print("event", event.core_order_id, event.core_event_type, event.occurred_at)
return "ok", 200Webhooks: Event types, payloads and retry policy are on the Webhooks page.
Safe logging
Off by default. StampMitra(debug=True) logs to the stampmitra logger (or pass logger=). Authorization and x-api-key are redacted and PAN, GSTIN, account numbers and names are masked in logged bodies.
Other ways to integrate
The REST API works from any language, and the other SDKs share the same contract.
Ready to call the API?
Get a free sandbox key and run the quickstart above in about 5 minutes.