Java SDK
The official Java client for the StampMitra Developer API: identity verification and e-Stamp with a fluent builder, immutable typed models, automatic retries and signed-webhook helpers.
Pre-release 0.1.0 - package registry publishing in progress
- Package
in.stampmitra:stampmitra-java- Version
- 0.1.0
- Requires
- Java 11 or newer (built with --release 11; tested on JDK 11, 17 and 21). The only runtime dependencies are Jackson (jackson-databind, jackson-datatype-jsr310). HTTP uses the JDK's built-in java.net.http.HttpClient, and you can plug in another one. No Lombok, no annotation processors.
- License
- MIT
- Changelog
- CHANGELOG.md
- Immutable typed models; fields the SDK does not know about are never dropped (getRaw(), get("field")).
- Every method has an ...Async twin returning a CompletableFuture; cancellation and Thread.interrupt() are honoured.
- Retries with jittered backoff and an automatic Idempotency-Key on every POST except webhook replay.
- Lazy log pagination (iterate, stream, pages), pluggable HTTP transport and redacted debug logging.
Install the package
<dependency>
<groupId>in.stampmitra</groupId>
<artifactId>stampmitra-java</artifactId>
<version>0.1.0</version>
</dependency>implementation("in.stampmitra:stampmitra-java:0.1.0")Available once published. Not yet published to Maven Central. This command works once the package is released; until then follow the GitHub repository for the release.
Verify a PAN in five lines
Use a sandbox key (sk_test_...) while you build. It is free, never debits your wallet and runs against sandbox data.
import in.stampmitra.sdk.StampMitra;
import in.stampmitra.sdk.models.PanVerification;
StampMitra client = StampMitra.builder().apiKey(System.getenv("STAMPMITRA_API_KEY")).build();
PanVerification pan = client.verify().pan("ABCDE1234F", "Rahul Kumar");
System.out.println((pan.isValid() ? "valid " : "invalid ") + pan.getNameMatchResult() + " " + pan.getReferenceId());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.
The key is read from the STAMPMITRA_API_KEY environment variable unless you call apiKey(...). client.environment() is "test" or "live". Keys that do not match ^sk_(test|live)_[A-Za-z0-9_-]{8,}$ are rejected unless you call allowCustomKey(true). baseUrl must be https:// (plain http:// only for localhost, 127.0.0.1 and ::1, for example a mock server) and must not contain credentials.
StampMitra client = StampMitra.builder()
.apiKey(apiKey)
.baseUrl("https://api-b2b.stampmitra.in")
.timeout(Duration.ofSeconds(30)) // per attempt
.maxRetries(2)
.appInfo("my-app", "1.0.0", "https://example.com")
.logger(line -> System.err.println("[stampmitra] " + line))
.validateInput(true)
.allowCustomKey(false)
.build();
// Also available: transport, defaultHeader, debug, executor (for the async methods), sleeper (tests).
// Every method also accepts per-request RequestOptions:
// timeout, maxRetries, idempotencyKey, header(name, value)Every resource in one place
Orders are identified by the externalOrderId that create returns (there is no id). Methods that take a request class also accept a plain Map<String, Object> for fields the SDK does not model yet. Endpoints whose shape the API does not fix (e-Stamp reference data, duty, parties, webhook events) return GenericResponse, and GET /v1/services returns an iterable ServiceList. Response toString() masks personal data.
// Verification (POST /v1/verify/*). Every call, including valid=false results, is billed.
client.verify().pan("ABCDE1234F", "Rahul Kumar");
client.verify().gstin("27AABCT1332L1ZZ");
client.verify().rc("MH12AB1234");
client.verify().voterId("ABC1234567");
client.verify().drivingLicense("MH1420110012345", "1990-01-31"); // IP-allowlisted key required
client.verify().passport("PA1079341954215", "1990-01-31"); // IP-allowlisted key required
client.verify().cin("U74999DL2015PTC284009");
client.verify().udyam("UDYAM-MH-12-1234567");
client.verify().panToGstin("ABCDE1234F");
client.verify().panToUdyam("ABCDE1234F");
client.verify().bankAccount("000123456789", "ICIC0001234", "Rahul Kumar", null);
// Services and e-Stamp reference data
client.services().list();
client.services().get("estamp.instrument.rent_agreement");
client.estamp().getSchema("estamp.instrument.rent_agreement");
client.estamp().listStates();
client.estamp().listInstruments("MH");
client.estamp().listArticles("rent_agreement");
client.estamp().calculateDuty(EstampDutyCalculateRequest.builder()
.stateCode("MH").articleCode("5").stampDutyAmount(new BigDecimal("500")).build());
client.estamp().addParties("o1", EstampPartiesRequest.builder().customerName("A").partyName("B").build());
// Orders and payments (orders are addressed by the externalOrderId you supplied)
client.orders().create(CreateOrderRequest.builder()
.externalOrderId("o1").externalCustomerId("c1").serviceCode("estamp.instrument.rent_agreement").build());
client.orders().get("o1");
client.orders().getStatus("o1");
client.orders().getPayment("o1");
client.orders().listDocuments("o1");
client.orders().getDocumentDownload("o1", "doc_1"); // short-lived signed URL
client.payments().intents().create(CreatePaymentIntentRequest.builder()
.externalOrderId("o1").amountMinor(50000).currency("INR").paymentConnectionId("pc_1").build());
client.payments().intents().get("pi_1");
// Usage, logs, webhook deliveries
client.usage().summary();
client.logs().list(LogsParams.builder().service("pan.verify").status("success").limit(50).build());
client.webhooks().events().list(20, null);
client.webhooks().events().replay("evt_1");
// Every method has an Async twin returning a CompletableFuture
CompletableFuture<PanVerification> pan = client.verify().panAsync("ABCDE1234F");Pagination
iterate returns an Iterable that follows nextCursor lazily, stream returns a java.util.stream.Stream and pages iterates whole pages; each call starts again from the first page. list returns a single page.
LogsParams params = LogsParams.builder().service("pan.verify").limit(100).build();
for (LogEntry entry : client.logs().iterate(params)) {
System.out.println(entry.getId() + " " + entry.getCreatedAt());
}
LogPage page = client.logs().list(params); // page.getItems(), page.getNextCursor()
long firstFive = client.logs().stream(params).limit(5).count();Error classes
All errors are unchecked and extend in.stampmitra.sdk.errors.StampMitraException, which exposes getStatus(), getErrorCode(), getMessage(), getRequestId(), getField(), getBody() and getHeaders(). RateLimitException adds getRetryAfterSeconds() (null when the header is absent). Other statuses, such as an unexpected 3xx, raise the base StampMitraException, and non-JSON error bodies still map by status. Exceptions never contain the API key.
| HTTP status | Class | Notes |
|---|---|---|
| 400 / local validation | ValidationException | Bad input. Also raised locally, before any request, by the validation helpers. |
| 401 | AuthenticationException | Missing, malformed or revoked API key. |
| 402 | PaymentRequiredException | Insufficient wallet balance or credit. |
| 403 | PermissionException | The server message is passed through verbatim, e.g. Passport and Driving Licence need an IP-allowlisted key. |
| 404 | NotFoundException | Unknown order, service or event id. |
| 409 | ConflictException | Idempotency-Key reused with a different body, or a state conflict. |
| 429 | RateLimitException | Back off and retry; the server's Retry-After value is exposed when it is sent. |
| 5xx | UpstreamException | A server-side or upstream failure. Retried automatically for 502, 503 and 504. |
| network / timeout | ConnectionException / RequestTimeoutException | No usable response: connection failure, timeout or cancellation. |
| webhook verification | SignatureVerificationException | Bad or stale webhook signature, an empty secret, or a malformed timestamp. Reject the delivery with a 400. |
try {
client.verify().gstin("27AABCT1332L1ZZ");
} catch (RateLimitException e) {
// back off: e.getRetryAfterSeconds() may be null
} catch (StampMitraException e) {
System.err.println(e.getRequestId() + ": " + e.getMessage());
}Retries and idempotency
- Default maxRetries is 2 (at most 3 attempts), with exponential backoff and full jitter (0.5 s base, 8 s cap). Retry-After is honoured when the server sends it, capped at 60 s. The timeout (default 30 s) applies per attempt.
- Retried: 429, 502, 503, 504, connection errors and timeouts. Any other 4xx is never retried. GET is always retry-safe.
- The SDK generates a random UUIDv4 Idempotency-Key for every POST except webhook replay and reuses the same key across automatic retries. The API honours the header on every metered call, so a retried verification replays the original result instead of billing twice. Pass your own with RequestOptions.
- A POST without a key (webhook replay) is never retried unless you supply one.
Client-side validation
Inputs are checked before sending (PAN, GSTIN, IFSC, CIN, Udyam, vehicle number, 9 to 18 digit account number, passport file number, driving licence, voter ID, phone, ISO dates, names up to 256 characters), trimmed and upper-cased (the account number is only trimmed). A failure is a local ValidationException (getField() names the input) with no network call and no billing. Separators are not stripped, so MH-12 AB 1234 is rejected, like the API does. The validators are public (Validators.pan(...)) and can be disabled with validateInput(false).
Verify webhook signatures
Deliveries carry X-Webhook-Signature, X-Webhook-Timestamp (Unix seconds) and X-Webhook-Event-Id. The signature is the hex HMAC-SHA256 of the timestamp, a dot and the raw body, keyed with your endpoint secret. Always verify against the raw body, never re-serialised JSON.
Webhook.constructEvent verifies the signature over the body exactly as received, compares in constant time, rejects timestamps more than 300 s (configurable) from now and rejects an empty secret. Failures raise SignatureVerificationException. Webhook.verifySignature returns a boolean, and Webhook.constructEventFromHeaders reads the three headers from any Map<String, ? extends Collection<String>> such as com.sun.net.httpserver.Headers or a servlet header map.
import in.stampmitra.sdk.Webhook;
import in.stampmitra.sdk.models.WebhookEvent;
import in.stampmitra.sdk.models.WebhookEventType;
// rawBody: the request body as received (do not parse and re-serialise it)
WebhookEvent event = Webhook.constructEvent(rawBody, timestamp, signature, secret);
if (event.getType() == WebhookEventType.ORDER_PAYMENT_CONFIRMED) {
System.out.println(event.getCoreOrderId() + " paid at " + event.getOccurredAt());
}
boolean authentic = Webhook.verifySignature(rawBody, timestamp, signature, secret); // tolerance 300 sWebhookEvent is typed for the eight order.* events (getType(), getCoreOrderId(), getCoreEventType(), getOccurredAt(), getEventId()); the event's own extra fields are preserved (event.get("..."), event.getRaw()). Deliveries are retried, so de-duplicate on the event id from X-Webhook-Event-Id.
Framework recipes
import in.stampmitra.sdk.Webhook;
import in.stampmitra.sdk.errors.SignatureVerificationException;
import in.stampmitra.sdk.models.WebhookEvent;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;
// Take the body as byte[]: the signature covers the raw bytes, so never let Spring parse the JSON first.
@RestController
public class WebhookController {
private final String secret;
public WebhookController(@Value("${stampmitra.webhook-secret}") String secret) {
this.secret = secret;
}
@PostMapping("/webhooks/stampmitra")
public ResponseEntity<String> receive(
@RequestBody byte[] rawBody,
@RequestHeader(Webhook.HEADER_TIMESTAMP) String timestamp,
@RequestHeader(Webhook.HEADER_SIGNATURE) String signature,
@RequestHeader(value = Webhook.HEADER_EVENT_ID, required = false) String eventId) {
try {
WebhookEvent event = Webhook.constructEvent(rawBody, timestamp, signature, secret,
Webhook.DEFAULT_TOLERANCE_SECONDS, eventId, System.currentTimeMillis() / 1000L);
System.out.println("verified " + event.getType() + " order=" + event.getCoreOrderId());
return ResponseEntity.ok("ok");
} catch (SignatureVerificationException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("invalid signature: " + e.getErrorCode());
}
}
}Webhooks: Event types, payloads and retry policy are on the Webhooks page.
Safe logging
Nothing is logged by default. Pass logger(line -> ...) or debug(true) (writes to the System.Logger named in.stampmitra.sdk at DEBUG) to get one line per request, response and retry. Authorization and x-api-key are redacted, identifiers keep their last four characters and names, dates of birth and addresses are removed. The API key never appears in log lines, exception messages, stack traces or toString() output.
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.