.NET SDK
The official .NET client for the StampMitra Developer API: async-first identity verification and e-Stamp with typed records, IAsyncEnumerable pagination, automatic retries, signed-webhook helpers and ASP.NET Core dependency injection.
Pre-release 0.1.0 - package registry publishing in progress
- Package
StampMitra- Version
- 0.1.0
- Requires
- net6.0 or net8.0 (and therefore every later runtime). There is no netstandard2.1 or .NET Framework build. The core package uses only System.Net.Http.HttpClient and System.Text.Json, with no third-party runtime dependencies. C# 9 or newer is recommended (records and init-only properties). .NET 6 is past its end of support and is kept as a target for existing applications.
- License
- MIT
- Changelog
- CHANGELOG.md
- Async-first with a CancellationToken on every call; cancellation is never wrapped.
- Immutable records; unknown fields are kept in AdditionalProperties.
- Retries with jittered backoff and an automatic Idempotency-Key on every POST except webhook replay.
- services.AddStampMitra(...) in StampMitra.Extensions.DependencyInjection; no Microsoft.Extensions dependency in the core package.
Install the package
dotnet add package StampMitra
# optional: services.AddStampMitra(...) for ASP.NET Core / generic host
dotnet add package StampMitra.Extensions.DependencyInjectionAvailable once published. Not yet published to NuGet. 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.
using StampMitra;
using StampMitra.Models;
using var client = new StampMitraClient(); // reads STAMPMITRA_API_KEY
var pan = await client.Verify.PanAsync(new PanVerifyRequest { Pan = "ABCDE1234F", Name = "Rahul Kumar" }, ct);
Console.WriteLine($"{(pan.Valid == true ? "valid" : "invalid")} {pan.NameMatchResult} {pan.ReferenceId}");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 client reads STAMPMITRA_API_KEY unless you set StampMitraClientOptions.ApiKey. Keys that do not match ^sk_(test|live)_[A-Za-z0-9_-]{8,}$ are rejected unless AllowCustomKey is set. client.Environment is Test, Live or Custom. BaseUrl must be https (loopback http only, for mock servers) with no credentials in the URL. The API key never appears in ToString(), exceptions or logs. ct in the samples is a CancellationToken; use CancellationToken.None if you do not need cancellation.
using var client = new StampMitraClient(new StampMitraClientOptions
{
ApiKey = Environment.GetEnvironmentVariable("STAMPMITRA_API_KEY"),
BaseUrl = "https://api-b2b.stampmitra.in",
Timeout = TimeSpan.FromSeconds(20), // per attempt, default 30 s
MaxRetries = 3, // default 2, range 0 to 10
AppInfo = new AppInfo("my-app", "1.4.0", "https://example.com"),
DebugLog = line => Console.Error.WriteLine(line),
ValidateInput = true,
AllowCustomKey = false,
});
// Also available: HttpClient (yours is never disposed), DefaultHeaders.
// Every method also takes a per-call RequestOptions (Timeout, MaxRetries, IdempotencyKey, Headers) and a CancellationToken.Every resource in one place
Responses are immutable records (pan.RegisteredName). Fields the SDK does not know are kept in AdditionalProperties (name to JsonElement), and HTTP metadata (status, headers, request id, attempts) is on model.Response. Responses whose shape the API does not fix (e-Stamp reference data, duty, parties, webhook events) are GenericResponse (Data, Get(name), Items, NextCursor, RawJson), and GET /v1/services returns a bare array exposed as ServiceList.
using StampMitra.Models; // models and request types
using StampMitra.Resources; // LogsListParams
// Verification: every call is billed, including valid = false results
var pan = await client.Verify.PanAsync(new PanVerifyRequest { Pan = "ABCDE1234F", Name = "Rahul Kumar" }, ct);
var gst = await client.Verify.GstinAsync(new GstinVerifyRequest { Gstin = "27ABCDE1234F1Z5" }, ct);
var rc = await client.Verify.RcAsync(new VehicleRcVerifyRequest { VehicleNumber = "MH12AB1234" }, ct);
var bank = await client.Verify.BankAccountAsync(new BankAccountVerifyRequest { AccountNumber = "000123456789", Ifsc = "ICIC0001234", Name = "Rahul Kumar" }, ct);
// Also: VoterIdAsync, DrivingLicenseAsync* , PassportAsync* , CinAsync, UdyamAsync, PanToGstinAsync, PanToUdyamAsync
// * needs an IP-allowlisted key; otherwise PermissionDeniedException carries the server message
// Services and e-Stamp reference data
ServiceList services = await client.Services.ListAsync(ct);
GenericResponse states = await client.Estamp.ListStatesAsync(ct);
GenericResponse duty = await client.Estamp.CalculateDutyAsync(new EstampDutyCalculateRequest { StateCode = "MH", ArticleCode = "5", StampDutyAmount = 500 }, ct);
// Also: Services.GetAsync, Estamp.GetSchemaAsync, ListInstrumentsAsync(stateCode), ListArticlesAsync(instrumentCode), AddPartiesAsync(orderId, ...)
// Orders and payments (addressed by the externalOrderId you supplied)
var order = await client.Orders.CreateAsync(
new CreateOrderRequest { ExternalOrderId = "o1", ExternalCustomerId = "c1", ServiceCode = "estamp.instrument.rent_agreement" },
new RequestOptions { IdempotencyKey = "order-o1", Timeout = TimeSpan.FromSeconds(10) },
ct);
OrderStatusResponse status = await client.Orders.GetStatusAsync(order.ExternalOrderId!, ct);
// Also: Orders.GetAsync, GetPaymentAsync, ListDocumentsAsync, GetDocumentDownloadAsync(id, documentId), Payments.Intents.CreateAsync / GetAsync
// Usage, logs, webhook deliveries
UsageSummary usage = await client.Usage.SummaryAsync(ct);
LogsPage logs = await client.Logs.ListAsync(new LogsListParams { Status = "failure", Limit = 50 }, ct);
GenericResponse events = await client.Webhooks.Events.ListAsync(20, null, null, ct);
// Also: Webhooks.Events.ReplayAsyncPagination
ListAllAsync returns an IAsyncEnumerable<LogEntry> that follows nextCursor lazily; ListAsync returns a single page with Items and NextCursor.
await foreach (var entry in client.Logs.ListAllAsync(new LogsListParams { Service = "pan.verify", Limit = 100 }, null, ct))
{
Console.WriteLine($"{entry.Id} {entry.CreatedAt}"); // follows nextCursor lazily
}
LogsPage page = await client.Logs.ListAsync(new LogsListParams { Status = "failure", Limit = 50 }, ct); // page.Items, page.NextCursorError classes
All errors derive from StampMitraException, which carries Status, Code, Message, RequestId, Field and Body. RateLimitException adds RetryAfter when the header is present. Any other status raises the base StampMitraException, and non-JSON error bodies still map by status. Caller cancellation surfaces as OperationCanceledException, never wrapped.
| 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 | PermissionDeniedException | 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 | ApiConnectionException / ApiTimeoutException | 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
{
await client.Verify.GstinAsync(new GstinVerifyRequest { Gstin = "27ABCDE1234F1Z5" }, ct);
}
catch (RateLimitException ex)
{
Console.WriteLine($"back off {ex.RetryAfter}");
}
catch (StampMitraException ex)
{
Console.Error.WriteLine($"{ex.RequestId}: {ex.Message}");
}Retries and idempotency
- MaxRetries defaults to 2, with exponential backoff and full jitter (0.5 s base, 8 s cap). Retry-After is honoured when sent, capped at 60 s. Timeout (default 30 s) applies per attempt, so a timed-out attempt raises ApiTimeoutException and is retried when retry-safe.
- Retried: 429, 502, 503, 504, connection errors and timeouts. Other statuses (400, 401, 402, 403, 404, 409, 500) are never retried. GET is always retry-safe.
- A POST is retried only when it carries an Idempotency-Key. The SDK generates a random UUIDv4 (from the operating system CSPRNG) for every POST except webhook replay (verification, duty, parties, orders, payment intents) and reuses the same key across retries, so a retried metered call replays the original result instead of billing twice. Pass your own with RequestOptions.IdempotencyKey.
- Webhook replay is never retried unless you supply a key. If you pass your own HttpClient, create it with AllowAutoRedirect = false and Timeout = Timeout.InfiniteTimeSpan (the DI package does both); the SDK applies the timeout per attempt itself and never follows redirects.
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), trimmed and upper-cased where the API does so. A failure is a local ValidationException (Field is set) with no network call and no billing. Separators such as MH-12 AB 1234 are not stripped, exactly like the API. 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.
WebhookSignature.ConstructEvent returns a typed record for each of the eight events (Type is the documented name such as order.created) or an UnknownWebhookEvent; extra fields are kept in AdditionalProperties. The comparison is constant-time, timestamps further than 300 s (configurable) from now are rejected, an empty secret is rejected, and failures raise SignatureVerificationException (Code is SIGNATURE_MISMATCH, INVALID_TIMESTAMP, TIMESTAMP_OUTSIDE_TOLERANCE, INVALID_PAYLOAD, MISSING_HEADERS or MISSING_SECRET). WebhookSignature.Verify returns a bool, and ConstructEventFromHeaders reads the three headers through a lookup.
using StampMitra.Webhooks;
// rawBody: the request body bytes, untouched (never parse and re-serialise)
WebhookEvent evt = WebhookSignature.ConstructEvent(rawBody, timestamp, signature, secret, eventId: eventId);
switch (evt)
{
case OrderPaymentConfirmedEvent paid:
Console.WriteLine($"paid: {paid.CoreOrderId}");
break;
case OrderDocumentReadyEvent ready:
Console.WriteLine($"document ready: {ready.CoreOrderId}");
break;
}
bool authentic = WebhookSignature.Verify(rawBody, timestamp, signature, secret, toleranceSeconds: 300);Deliveries are retried by the platform, so de-duplicate on EventId (from X-Webhook-Event-Id) before doing any work.
Framework recipes
using StampMitra;
using StampMitra.Webhooks;
var secret = Environment.GetEnvironmentVariable("STAMPMITRA_WEBHOOK_SECRET");
if (string.IsNullOrEmpty(secret))
{
Console.Error.WriteLine("Set STAMPMITRA_WEBHOOK_SECRET to the endpoint's signing secret.");
return 1;
}
var app = WebApplication.CreateBuilder(args).Build();
app.MapPost("/webhooks/stampmitra", async (HttpRequest request, ILogger<Program> logger) =>
{
// The signature covers the raw request bytes: read the body untouched, never re-serialise it.
using var ms = new MemoryStream();
await request.Body.CopyToAsync(ms);
var raw = ms.ToArray();
WebhookEvent evt;
try
{
evt = WebhookSignature.ConstructEventFromHeaders(raw, name => request.Headers[name].FirstOrDefault(), secret);
}
catch (SignatureVerificationException ex)
{
logger.LogWarning("Rejected webhook: {Code}", ex.Code);
return Results.BadRequest(new { error = "invalid signature" });
}
// Deliveries are retried: de-duplicate on evt.EventId before doing any work.
switch (evt)
{
case OrderPaymentConfirmedEvent paid:
logger.LogInformation("Payment confirmed for order {Order}", paid.CoreOrderId);
break;
case OrderDocumentReadyEvent ready:
logger.LogInformation("Document ready for order {Order}", ready.CoreOrderId);
break;
default:
logger.LogInformation("{Type} for order {Order} (event {EventId})", evt.Type ?? evt.CoreEventType, evt.CoreOrderId, evt.EventId);
break;
}
return Results.Ok(new { received = true });
});
await app.RunAsync();
return 0;
// Makes ILogger<Program> resolvable in a top-level-statements program.
public partial class Program
{
}Webhooks: Event types, payloads and retry policy are on the Webhooks page.
Safe logging
Nothing is logged by default. Set DebugLog (an Action<string>) to receive one line per request and response. Authorization and x-api-key are redacted, identifiers (PAN, GSTIN, account number and so on) are masked to their last four characters and names and dates of birth are removed; Redactor is public if you need the same masking elsewhere. With the DI package the lines go to ILogger at Debug level. Treat logs as sensitive anyway.
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.