PHP SDK
The official PHP client for the StampMitra Developer API: identity verification and e-Stamp with typed immutable models, PSR-18 transport and signed-webhook helpers for Laravel and plain PHP.
Pre-release 0.1.0 - package registry publishing in progress
- Package
stampmitra/stampmitra-php- Version
- 0.1.0
- Requires
- PHP 8.1 or newer. The SDK talks PSR-18 and PSR-17, so any compatible HTTP client works: Guzzle is auto-discovered, or pass your own httpClient.
- License
- MIT
- Changelog
- CHANGELOG.md
- Typed, immutable response models; raw() and get() expose every field the SDK does not model.
- Bring your own PSR-18 HTTP client and any PSR-3 logger.
- Retries with jittered backoff and an automatic Idempotency-Key on every POST the API documents the header for.
- Webhook helpers with Laravel and plain PHP examples; logs paginate with a lazy Generator.
Install the package
composer require stampmitra/stampmitra-php guzzlehttp/guzzleAvailable once published. Not yet published to Packagist. This command works once the package is released; until then follow the GitHub repository for the release.
composer config repositories.stampmitra vcs https://github.com/Bani-Global-Industries-LLP/stampmitra-developer-php
composer require stampmitra/stampmitra-php:dev-main guzzlehttp/guzzleUntil then, install from GitHub: composer.json sits at the repository root with the package name stampmitra/stampmitra-php, so Composer can read it as a VCS repository.
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.
use StampMitra\StampMitra;
$client = new StampMitra(apiKey: getenv('STAMPMITRA_API_KEY') ?: null); // or just new StampMitra()
$pan = $client->verify->pan('ABCDE1234F', 'Rahul Kumar');
echo $pan->valid ? 'valid' : 'invalid', ' ', $pan->nameMatchResult, ' ', $pan->referenceId, PHP_EOL;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 pass apiKey:. $client->environment is 'test' or 'live'. Keys that do not match ^sk_(test|live)_[A-Za-z0-9_-]{8,}$ are rejected unless you pass allowCustomKey: true.
$client = new StampMitra(
apiKey: 'sk_test_...',
baseUrl: 'https://api-b2b.stampmitra.in',
timeout: 30,
maxRetries: 2,
httpClient: null, // any PSR-18 client; Guzzle is auto-discovered
defaultHeaders: [],
appInfo: ['name' => 'my-app', 'version' => '1.0.0', 'url' => 'https://example.com'],
logger: null, // any PSR-3 logger
validateInput: true,
allowCustomKey: false,
);
// Every method also takes per-request options (a RequestOptions or an array):
$client->orders->create($body, ['idempotencyKey' => 'order-123']);Every resource in one place
Responses are typed, immutable models ($pan->registeredName). Fields the SDK does not know about are never dropped: $pan->raw() returns the full decoded body and $pan->get('field') reads any field. HTTP metadata (status, headers, request id, attempts) is on $pan->response(). Endpoints whose schema is not fixed yet return GenericResponse.
// Verification (every call is billed, including valid=false results)
$client->verify->pan('ABCDE1234F', 'Rahul Kumar');
$client->verify->gstin('27ABCDE1234F1Z5');
$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', name: 'Rahul Kumar');
// 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(['stateCode' => 'MH', 'articleCode' => '5']);
$client->estamp->addParties('ord_123', ['customerName' => '...', 'partyName' => '...']);
// Orders and payments
$order = $client->orders->create(['externalOrderId' => 'o1', 'externalCustomerId' => 'c1', 'serviceCode' => 'estamp....']);
$client->orders->get('o1');
$client->orders->getStatus('o1');
$client->orders->getPayment('o1');
$client->orders->listDocuments('o1');
$client->orders->getDocumentDownload('o1', 'doc_1');
$client->payments->intents->create(['externalOrderId' => 'o1', 'amountMinor' => 50000, 'currency' => 'INR', 'paymentConnectionId' => '...']);
$client->payments->intents->get('pi_1');
// Usage, logs, webhook events
$client->usage->summary();
$client->logs->list(['service' => 'pan.verify', 'status' => 'success', 'limit' => 50]);
$client->webhooks->events->list(limit: 20);
$client->webhooks->events->replay('evt_1');Pagination
iterate() returns a lazy Generator that follows nextCursor; list() returns a single page.
foreach ($client->logs->iterate(['service' => 'pan.verify', 'limit' => 100]) as $entry) {
echo $entry->id, ' ', $entry->createdAt, PHP_EOL; // follows nextCursor lazily (a Generator)
}
$page = $client->logs->list(['limit' => 50]); // $page->items, $page->nextCursorError classes
All errors live in StampMitra\Exception and extend StampMitraError (getStatus(), getErrorCode(), getMessage(), getRequestId(), getField(), getBody()). RateLimitError adds getRetryAfter() when the header is present. Other statuses raise the base StampMitraError, and non-JSON error bodies still map by status.
| HTTP status | Class | Notes |
|---|---|---|
| 400 (and local validation) | 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 | PermissionError | 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 | ConnectionError / TimeoutError | No usable response: connection failure, timeout or cancellation. |
try {
$client->verify->gstin('27ABCDE1234F1Z5');
} catch (\StampMitra\Exception\RateLimitError $e) {
// back off: $e->getRetryAfter()
} catch (\StampMitra\Exception\StampMitraError $e) {
error_log($e->getRequestId() . ': ' . $e->getMessage());
}Retries and idempotency
- Default maxRetries is 2, with exponential backoff and full jitter (base 0.5 s, cap 8 s). Retry-After is honoured when the server sends it.
- Retried: 429, 502, 503, 504, connection errors and timeouts. GET is always retry-safe; a POST is retried only when it carries an Idempotency-Key.
- The SDK generates a UUIDv4 Idempotency-Key for every POST the API documents the header for (verification, duty calculation, e-Stamp parties, orders, payment intents) and reuses the same key across retries. Pass your own with ['idempotencyKey' => '...'].
- Other POSTs (webhook replay) are never retried unless you supply a key. With Guzzle the timeout option is applied per request; with another PSR-18 client, configure the timeout on that client.
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) and normalised to upper case. A failure is a local ValidationError with no network call and no billing. Turn it off 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 and rejects timestamps more than 300 s (configurable) from now. Failures raise SignatureVerificationError. Webhook::verifySignature returns a bool and Webhook::constructEventFromHeaders reads the headers for you.
use StampMitra\Webhook;
$payload = file_get_contents('php://input'); // raw bytes, do not re-encode
$event = Webhook::constructEvent(
$payload,
$_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'],
$_SERVER['HTTP_X_WEBHOOK_SIGNATURE'],
$secret,
);
// or: Webhook::verifySignature($payload, $timestamp, $signature, $secret, toleranceSeconds: 300): bool
// or: Webhook::constructEventFromHeaders($payload, $headers, $secret)Deliveries are retried, so handle events idempotently and de-duplicate on the event id from X-Webhook-Event-Id.
Framework recipes
<?php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use StampMitra\Exception\SignatureVerificationError;
use StampMitra\Webhook;
$secret = getenv('STAMPMITRA_WEBHOOK_SECRET') ?: '';
// The signature covers the raw bytes: read the body untouched, never re-encode it.
$payload = (string) file_get_contents('php://input');
try {
$event = Webhook::constructEvent(
$payload,
$_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '',
$_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '',
$secret,
eventId: $_SERVER['HTTP_X_WEBHOOK_EVENT_ID'] ?? null,
);
} catch (SignatureVerificationError $e) {
http_response_code(400);
echo 'invalid signature';
exit;
}
// Handle idempotently: deliveries are retried, so de-duplicate on $event->eventId.
http_response_code(200);
echo 'ok';Webhooks: Event types, payloads and retry policy are on the Webhooks page.
Safe logging
Nothing is logged by default. Pass any PSR-3 logger as logger: to get debug logs of requests and responses, with Authorization and x-api-key redacted and identifiers and names in bodies masked.
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.