StampMitraDevelopers
Official SDK

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
Install

Install the package

gem install
gem install stampmitra
Gemfile
gem '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.

Gemfile (from GitHub)
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.

Quickstart

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.

Ruby
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}"
Authentication

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.

Server-side only. Never ship sk_ keys in browser or mobile apps, or commit them to a repository. Keep them in environment variables or a secrets manager. Authentication
Client options
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: }
Resources

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.

All resources
# 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

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:.

Ruby
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).first
Errors

Error 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 statusClassNotes
400 / local validationStampMitra::ValidationErrorBad input. Also raised locally, before any request, by the validation helpers.
401StampMitra::AuthenticationErrorMissing, malformed or revoked API key.
402StampMitra::PaymentRequiredErrorInsufficient wallet balance or credit.
403StampMitra::PermissionDeniedErrorThe server message is passed through verbatim, e.g. Passport and Driving Licence need an IP-allowlisted key.
404StampMitra::NotFoundErrorUnknown order, service or event id.
409StampMitra::ConflictErrorIdempotency-Key reused with a different body, or a state conflict.
429StampMitra::RateLimitErrorBack off and retry; the server's Retry-After value is exposed when it is sent.
5xxStampMitra::UpstreamErrorA server-side or upstream failure. Retried automatically for 502, 503 and 504.
network / timeoutStampMitra::ConnectionError / StampMitra::TimeoutErrorNo usable response: connection failure, timeout or cancellation.
webhook verificationStampMitra::SignatureVerificationErrorBad or stale webhook signature, an empty secret, or a malformed timestamp. Reject the delivery with a 400.
Ruby
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}"
end
Retries and idempotency

Retries and idempotency

Validation

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.

Webhooks

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.

Ruby
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
end

The 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

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
end

Webhooks: Event types, payloads and retry policy are on the Webhooks page.

Safe logging

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.

SDKs

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.