Ruby SDK
The official Ruby client for the StampMitra Developer API: identity verification and e-Stamp with typed immutable responses, automatic retries and signed-webhook helpers for Rails, Sinatra and Rack, using only the standard library.
Pre-release 0.1.0 - package registry publishing in progress
- Package
stampmitra- Version
- 0.1.0
- Requires
- Ruby 3.0 or newer (CI runs CRuby 3.0 to 4.0). The standard library only: zero runtime dependencies (net/http, json, openssl, securerandom). The gem name is stampmitra and the module is StampMitra.
- License
- MIT
- Changelog
- CHANGELOG.md
- Zero runtime dependencies: standard library only.
- Immutable typed responses; fields the gem does not know about are kept and reachable with [] and to_h.
- Retries with jittered backoff and an automatic Idempotency-Key on every POST except webhook replay.
- Lazy Enumerator pagination, typed webhook events, and redacted debug logging through Logger.
Install the package
gem install stampmitragem 'stampmitra', '~> 0.1.0'Available once published. Not yet published to RubyGems. This command works once the package is released; until then follow the GitHub repository for the release.
gem 'stampmitra', git: 'https://github.com/Bani-Global-Industries-LLP/stampmitra-developer-ruby', branch: 'main'Until then, install from GitHub: The gemspec sits at the repository root, so Bundler can build the gem straight from Git. This works only once the repository's main branch is published.
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.
require 'stampmitra'
client = StampMitra::Client.new(api_key: ENV['STAMPMITRA_API_KEY']) # or just StampMitra::Client.new
pan = client.verify.pan(pan: 'ABCDE1234F', name: 'Rahul Kumar')
puts "#{pan.valid? ? 'valid' : 'invalid'} #{pan.name_match_result} #{pan.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.
The key is read from the STAMPMITRA_API_KEY environment variable unless you pass api_key:. Keys that do not match ^sk_(test|live)_[A-Za-z0-9_-]{8,}$ are rejected unless you pass allow_custom_key: true. client.environment is 'test', 'live' or 'custom'. base_url must be https (plain http only for localhost, 127.0.0.1 and ::1) and must not contain credentials. The API key never appears in inspect, to_s, pp, YAML, error messages or logs.
client = StampMitra::Client.new(
api_key: key,
base_url: 'https://api-b2b.stampmitra.in',
timeout: 30, # seconds, per attempt
max_retries: 2,
default_headers: { 'X-Trace-Id' => 'abc' },
app_info: { name: 'my-app', version: '1.0.0', url: 'https://example.com' },
logger: Logger.new($stderr),
validate_input: true,
allow_custom_key: false
)
# Every resource method also takes a trailing options: hash for that call only:
client.orders.get('ord_1', options: { timeout: 5, max_retries: 0, headers: { 'X-Trace-Id' => 'abc' } })
# options: { timeout:, max_retries:, idempotency_key:, headers: }Every resource in one place
Responses are immutable value objects (StampMitra::Models::PanVerification and so on) with snake_case readers (pan.registered_name, pan.valid?). Unknown fields are never dropped: use pan[:referenceId], pan.unknown_fields or pan.to_h. inspect and to_s mask identifiers and remove names. Endpoints whose shape the API does not fix (e-Stamp reference data, duty, parties, webhook events) return Models::GenericResponse, and services.list returns an Enumerable of Models::Service. Orders are addressed by the external_order_id that create returns (there is no id). Every typed result also carries its response: order.response.status.
# Verification (every call, including valid: false results, is billed)
client.verify.pan(pan: 'ABCDE1234F', name: 'Rahul Kumar')
client.verify.gstin(gstin: '27ABCDE1234F1Z5')
client.verify.rc(vehicle_number: 'MH12AB1234')
client.verify.voter_id(voter_id: 'ABC1234567')
client.verify.driving_license(dl_number: 'MH1420110012345', dob: '1990-01-31') # IP-allowlisted key
client.verify.passport(file_number: 'PA1079341954215', dob: '1990-01-31') # IP-allowlisted key
client.verify.cin(cin: 'U74999DL2015PTC284009')
client.verify.udyam(udyam_number: 'UDYAM-MH-12-1234567')
client.verify.pan_to_gstin(pan: 'ABCDE1234F')
client.verify.pan_to_udyam(pan: 'ABCDE1234F')
client.verify.bank_account(account_number: '000123456789', ifsc: 'ICIC0001234', name: 'Rahul Kumar')
# Services and e-Stamp reference data
client.services.list.each { |service| puts service.service_code }
client.services.get('estamp.instrument.rent_agreement')
client.estamp.get_schema('estamp.instrument.rent_agreement')
client.estamp.list_states
client.estamp.list_instruments('MH')
client.estamp.list_articles('rent_agreement')
client.estamp.calculate_duty(state_code: 'MH', article_code: '5', stamp_duty_amount: 1000)
client.estamp.add_parties('ord_123', customer_name: 'Asha', party_name: 'Ravi')
# Orders and payments: orders are addressed by YOUR external_order_id
order = client.orders.create(
external_order_id: 'ord_1', external_customer_id: 'cust_1', service_code: 'estamp.instrument.rent_agreement',
options: { idempotency_key: 'order-ord_1-attempt-1' }
)
client.orders.get(order.external_order_id)
client.orders.get_status(order.external_order_id)
client.orders.get_payment(order.external_order_id)
client.orders.list_documents(order.external_order_id)
client.orders.get_document_download(order.external_order_id, 'doc_1') # the link is short-lived
client.payments.intents.create(external_order_id: 'ord_1', amount_minor: 50_000, currency: 'INR', payment_connection_id: 'pc_1')
client.payments.intents.get('pi_1')
# Usage, logs, webhook deliveries
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')
# Raw HTTP metadata
order, response = client.with_response.orders.get('ord_1')
puts "#{response.status} #{response.request_id} #{response.attempts}"Pagination
logs.each follows next_cursor lazily, so first(10) stops fetching pages as soon as it has enough. For one page at a time use logs.list or each_page. webhooks.events.list takes limit: and cursor:.
client.logs.each(service: 'pan.verify', limit: 100) do |entry|
puts "#{entry.created_at} #{entry.service} #{entry.id}"
end
# Without a block, each returns an Enumerator, so it composes with lazy, first, take, each_slice...
latest = client.logs.each(limit: 50).lazy.select { |entry| entry.service == 'pan.verify' }.first(10)
page = client.logs.list(limit: 50) # page.items, page.next_cursor
first_page = client.logs.each_page(limit: 50).firstError classes
Every error inherits from StampMitra::Error and exposes status, code, message, request_id, field and body. RateLimitError adds retry_after (seconds, when the header is present) and TimeoutError is a ConnectionError. Other statuses raise the base StampMitra::Error, and non-JSON error bodies still map by status.
| HTTP status | Class | Notes |
|---|---|---|
| 400 / local validation | StampMitra::ValidationError | Bad input. Also raised locally, before any request, by the validation helpers. |
| 401 | StampMitra::AuthenticationError | Missing, malformed or revoked API key. |
| 402 | StampMitra::PaymentRequiredError | Insufficient wallet balance or credit. |
| 403 | StampMitra::PermissionDeniedError | The server message is passed through verbatim, e.g. Passport and Driving Licence need an IP-allowlisted key. |
| 404 | StampMitra::NotFoundError | Unknown order, service or event id. |
| 409 | StampMitra::ConflictError | Idempotency-Key reused with a different body, or a state conflict. |
| 429 | StampMitra::RateLimitError | Back off and retry; the server's Retry-After value is exposed when it is sent. |
| 5xx | StampMitra::UpstreamError | A server-side or upstream failure. Retried automatically for 502, 503 and 504. |
| network / timeout | StampMitra::ConnectionError / StampMitra::TimeoutError | No usable response: connection failure, timeout or cancellation. |
| webhook verification | StampMitra::SignatureVerificationError | Bad or stale webhook signature, an empty secret, or a malformed timestamp. Reject the delivery with a 400. |
begin
client.verify.gstin(gstin: '27ABCDE1234F1Z5')
rescue StampMitra::RateLimitError => e
sleep(e.retry_after || 1) # the API sends no Retry-After today, so this is usually nil
rescue StampMitra::PermissionDeniedError => e
warn "not allowed: #{e.message}"
rescue StampMitra::Error => e
warn "#{e.class} #{e.status} #{e.code} request_id=#{e.request_id}: #{e.message}"
endRetries and idempotency
- Default max_retries is 2 (at most 3 attempts), with exponential backoff and full jitter (0.5 s base, 8 s cap). A Retry-After header (seconds or an HTTP date, capped at 60 s) is honoured when sent. The timeout (default 30 s) applies per attempt, not as a total deadline.
- Retried: 429, 502, 503, 504, connection errors and timeouts. Not retried: other 4xx and 5xx statuses, and redirects (which are never followed). GET is always retry-safe.
- The gem generates a random UUIDv4 Idempotency-Key (from a CSPRNG) for every POST the API accepts one on (verification, duty calculation, e-Stamp parties, orders and payment intents) and reuses the same key on every attempt. A retried verification, order or payment intent therefore replays the original result instead of being billed or created twice. Pass your own with options: { idempotency_key: '...' }.
- Webhook replay (webhooks.events.replay) is never auto-keyed and is not retried unless you pass a key. Set max_retries: 0 on the client or on one call to turn retries off. Each request uses a fresh connection and Net::HTTP's own transparent retry is disabled, so a request is never silently replayed.
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). Values are trimmed and upper-cased, but separators are not stripped: "MH 12 AB 1234" is rejected, exactly as the API does. A failure raises StampMitra::ValidationError with field set, with no network call and no billing. The validators are public (StampMitra::Validators.pan(' abcde1234f ') returns "ABCDE1234F"); turn all of it off with 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.
Verification lives in StampMitra::Webhook: construct_event_from_headers (accepts a Rack env, any Hash with any header casing, or Rails' request.headers), construct_event(payload:, timestamp:, signature:, secret:) and the boolean verify_signature. The comparison is constant-time, timestamps more than 300 s (tolerance:) from now are rejected, an empty secret is refused, and failures raise SignatureVerificationError (check e.code), which never contains the secret.
payload = request.body.read # Rack/Sinatra; in Rails use request.raw_post
begin
event = StampMitra::Webhook.construct_event_from_headers(
payload: payload, headers: request.env, secret: ENV.fetch('STAMPMITRA_WEBHOOK_SECRET')
)
puts "#{event.type} #{event.core_order_id} delivery=#{event.event_id}"
rescue StampMitra::SignatureVerificationError => e
warn "rejected: #{e.code}" # respond 400
endThe eight order.* events decode to typed classes (Webhook::OrderCreated, OrderPaymentConfirmed, OrderFulfilmentStarted, OrderFulfilmentCompleted, OrderFailed, OrderCancelled, OrderRefundCompleted, OrderDocumentReady), each with core_order_id, core_event_type, occurred_at and event_id; extra fields stay available through event[:field] and event.to_h. An unrecognised coreEventType yields a plain Webhook::Event, never an error. Pass the raw body exactly as received, never re-serialised JSON. Deliveries are retried, so de-duplicate on the event id from X-Webhook-Event-Id.
Framework recipes
# app/controllers/stampmitra_webhooks_controller.rb (Rails 6.1+)
# config/routes.rb: post '/webhooks/stampmitra', to: 'stampmitra_webhooks#create'
class StampmitraWebhooksController < ApplicationController
skip_forgery_protection # server-to-server call; the signature is the only authentication
def create
event = StampMitra::Webhook.construct_event_from_headers(
payload: request.raw_post, # the bytes as received, never params
headers: request.headers,
secret: Rails.application.credentials.dig(:stampmitra, :webhook_secret)
)
case event
when StampMitra::Webhook::OrderDocumentReady
FetchDocumentsJob.perform_later(event.core_order_id, event.event_id)
when StampMitra::Webhook::OrderFailed, StampMitra::Webhook::OrderCancelled
Rails.logger.warn("StampMitra #{event.type} for #{event.core_order_id}")
end
head :ok
rescue StampMitra::SignatureVerificationError => e
render json: { error: e.code }, status: :bad_request
end
endWebhooks: Event types, payloads and retry policy are on the Webhooks page.
Safe logging
Nothing is logged by default. Pass a standard Logger as logger: to get debug logs of requests and responses (any object responding to debug works). Authorization and x-api-key are redacted, and identifiers (PAN, GSTIN, account numbers) and names in bodies are masked. On Ruby 3.5 and newer, logger is a bundled gem: add gem 'logger' to your Gemfile if you pass a Logger.
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.