simple_connect-client
Dependency-free Ruby client for the SimpleWaConnect integration endpoints. Ships domain events (POST), fetches event details (GET), and verifies integration health — all signed with HMAC-SHA256, with built-in retries on 5xx / network errors.
Stdlib-only at runtime: no Rails, no Faraday, no gems.
Installation
# Gemfile
gem "simple_connect-client", require: "simple_connect"
Then bundle install.
Setup
Create one client per app (typically in an initializer):
# config/initializers/simple_connect.rb
SIMPLECONNECT = SimpleConnect::Client.new(
endpoint_url: ENV.fetch("SIMPLECONNECT_ENDPOINT_URL"), # e.g. https://app.simplewaconnect.com/api/v1/integrations/purepani/events
key_id: ENV.fetch("SIMPLECONNECT_KEY_ID"), # the "wa_sec_…" prefix shown on the integration Security card
secret: ENV.fetch("SIMPLECONNECT_SECRET"), # the raw signing secret shown once at connect / rotation
logger: Rails.logger # optional
)
One Client instance is safe to share across threads.
Optional: client-side event-key whitelist
The gem ships no hardcoded event list — each provider owns its own taxonomy.
By default, events.deliver accepts any non-empty event_key and the server
validates it (returning 422 on unknown keys).
If you'd rather catch typos before any HTTP call, pass event_keys::
SIMPLECONNECT = SimpleConnect::Client.new(
endpoint_url: "...",
key_id: "...",
secret: "...",
event_keys: %w[
customer_payment_received
customer_invoice_ready
customer_invoice_payment_reminder
customer_today_order_delivered
customer_app_invitation
]
)
SIMPLECONNECT.events.deliver("customer_paymnet_received", ...) # typo
# => ArgumentError: Unknown event_key 'customer_paymnet_received'.
# Must be one of: customer_payment_received, customer_invoice_ready, ...
Leave event_keys: unset when your provider's event taxonomy changes often,
or when you'd rather rely on a single source of truth (the server).
Usage
The client groups calls into two resource objects — events and integrations.
Deliver a domain event
SIMPLECONNECT.events.deliver(
"customer_payment_received",
customer_name: "Ramesh Kumar",
customer_mobile_no: "+919812345678",
agency_name: "Acme Dairy",
payment_date: "2026-04-16",
payment_mode: "UPI",
payment_amount: "450.00",
customer_total_due_amount: "0.00",
event_id: "pp_payment_#{payment.id}" # idempotency key
)
Pass an explicit event_id: (unique per domain event) for safe retries —
duplicate event_ids are treated as no-ops by the server.
Fetch a previously-ingested event
result = SIMPLECONNECT.events.detail("pp_payment_42")
# result.response_body is JSON (see server docs for shape)
Verify integration health
result = SIMPLECONNECT.integrations.verify
# result.response_body contains the per-event-flow snapshot
Return value — SimpleConnect::Result
Every resource call returns a Result struct with:
| Field | Type | Notes |
|---|---|---|
success? |
Boolean | true on 2xx |
status_code |
Integer | HTTP status, or 0 on network error |
response_body |
String? | Raw body string (nil on network error) |
error |
String? | Short error description on failure |
attempts |
Integer | HTTP attempts made (1..MAX_ATTEMPTS) |
data |
Response? | Typed response object (see below) when the body was parseable; nil otherwise |
result = SIMPLECONNECT.events.deliver("customer_payment_received", fields)
if result.success?
Rails.logger.info("delivered in #{result.attempts} attempt(s); id=#{result.data.event_id}")
else
Rails.logger.error("deliver failed: #{result.error}")
end
Working with responses
result.data is polymorphic by outcome — typed to whatever the server sent:
| Outcome | result.data |
|---|---|
| 2xx + valid JSON | endpoint-specific success class (see below) |
| 4xx / 5xx + valid JSON | SimpleConnect::Responses::ErrorResponse |
| 4xx / 5xx + non-JSON body | nil (fall back to result.error + result.response_body) |
| Network error (timeout, DNS, refused) | nil |
| 2xx + unparseable JSON (server bug) | raises SimpleConnect::MalformedResponseError |
Always check result.success? first, then read result.data.
events.deliver → DeliverResponse
result = SIMPLECONNECT.events.deliver(
"customer_payment_received", fields, event_id: "pp_pay_42"
)
if result.success?
response = result.data
response.event_id # => "pp_pay_42"
response.log_id # => 42
response.duplicate? # => false on fresh POST, true on idempotent replay
response.used_previous_secret? # => true during the 24h grace window after rotation
end
events.detail(event_id) → EventResponse
result = SIMPLECONNECT.events.detail("pp_pay_42")
if result.success?
event = result.data
event.event_key # => "customer_payment_received"
event.dispatched? # => true / false
event.failed? # => true / false
event.skipped? # => true when status starts with "skipped_"
event.occurred_at # => Time, or nil if unparseable
event.payload # => original envelope hash (minus top-level metadata)
event.error_text # => nil on success statuses; populated when status == "failed"
if event.
msg = event. # => MessageResponse (see below)
msg.incoming? # => true for user-initiated inbound messages
msg.status_callback? # => true for message.status callbacks
msg. # => "wamid.HBgL..."
msg.status # => "delivered" / "read" / ...
msg. # => Time (or nil if unparseable)
end
end
integrations.verify → VerifyResponse
result = SIMPLECONNECT.integrations.verify
if result.success?
verify = result.data
verify.connected? # => true
verify.provider # => "purepani"
verify.event_flows.each do |flow|
flow.event_key # => "customer_payment_received"
flow.state # => "enabled" / "not_configured" / "needs_attention" / ...
flow.enabled? # => true / false
flow.needs_attention? # => true / false
flow.configured? # => true when a template is linked
if flow.configured?
flow.template.name # => "pay_rcvd"
flow.template.language # => "en"
flow.template.approved? # => true / false
end
end
# Lookup by event_key:
flow = verify.event_flow("customer_payment_received")
end
Error path → ErrorResponse
result = SIMPLECONNECT.events.deliver("some_event", fields)
unless result.success?
if result.data # ErrorResponse (or nil for non-JSON / network errors)
Rails.logger.warn("deliver rejected: #{result.data.}")
# result.data.to_h → full parsed error body for any un-surfaced fields
else
# Network error or non-JSON body from a proxy.
Rails.logger.error("deliver transport-failed: #{result.error}")
end
end
Unsurfaced fields — #to_h escape hatch
Every response class exposes #to_h returning a duplicate of the raw parsed
JSON. If the server adds a new field we haven't surfaced as a method yet,
reach for it via response.to_h["new_field"] instead of waiting for a gem
release.
Run in a background job
HTTP delivery is synchronous and may retry (up to 3 attempts, linear backoff).
Wrap events.deliver in ActiveJob / Sidekiq so the calling request isn't
blocked on endpoint latency.
Recommended — disable library retries when wrapping in a job queue. The
queue has its own retry layer; keeping both means one 500 becomes dozens of
HTTP calls (job retries × library retries). Pass max_attempts: 1 to the
Client so each job invocation does a single POST:
# config/initializers/simple_connect.rb
SIMPLECONNECT = SimpleConnect::Client.new(
endpoint_url: ENV.fetch("SIMPLECONNECT_ENDPOINT_URL"),
key_id: ENV.fetch("SIMPLECONNECT_KEY_ID"),
secret: ENV.fetch("SIMPLECONNECT_SECRET"),
logger: Rails.logger,
max_attempts: 1 # Sidekiq / ActiveJob will retry for us
)
class DeliverSimpleConnectEventJob < ApplicationJob
def perform(event_key, fields, event_id:)
SIMPLECONNECT.events.deliver(event_key, fields, event_id: event_id)
end
end
DeliverSimpleConnectEventJob.perform_later(
"customer_payment_received",
{ customer_name: "...", ... },
event_id: "pp_payment_#{payment.id}"
)
If you're not wrapping in a queue — e.g., calling from a one-off script or
a synchronous backend — leave the default (max_attempts: 3, linear 1s/2s
backoff, retries on 5xx and network errors).
Custom retry policy
For exponential backoff, jitter, or a longer window, pass a Retryable
instance instead. max_attempts: and retryable: are mutually exclusive.
SimpleConnect::Client.new(
endpoint_url: ..., key_id: ..., secret: ...,
retryable: SimpleConnect::Retryable.new(
max_attempts: 5,
delay: ->(n) { (2**n) + rand } # exponential + jitter
)
)
Error hierarchy
All gem-specific errors inherit from SimpleConnect::Error < StandardError:
| Error class | Raised when |
|---|---|
SimpleConnect::ConfigurationError |
Client.new is given blank/conflicting config. |
SimpleConnect::UnknownEventError |
events.deliver called with a key outside the configured event_keys: list. |
SimpleConnect::MalformedResponseError |
A 2xx response body wasn't valid JSON (server bug — not retryable). |
ArgumentError (stdlib, not in hierarchy) |
Programmer misuse: empty event_key, empty event_id, bad HTTP method, etc. |
Signing scheme
The client signs every request:
X-SimpleConnect-Key-Id— thekey_idpassed to the clientX-SimpleConnect-Timestamp— current unix time in secondsX-SimpleConnect-Signature—sha256=<hex>where<hex>is HMAC-SHA256 of"{timestamp}.{raw_body}"using the secret.
For GET requests (no body), the signed string is "{timestamp}." (timestamp
- dot + empty body).
Development
bundle install
bundle exec rspec # run the spec suite
bundle exec rubocop --config .rubocop.yml # lint (the --config flag is needed when the gem is developed inside a parent Rails app with its own .rubocop.yml)
bundle exec rake build # build the .gem into pkg/
bin/console # IRB with the gem preloaded
Contributing
Bug reports and pull requests welcome on GitHub at the repo URL in the gemspec. Please:
- Fork and create a feature branch.
- Add or update specs for anything you change in
lib/. The spec suite should stay green across Ruby 3.2, 3.3, and 3.4 — CI runs the matrix. - Run
bundle exec rubocop --config .rubocop.ymland fix offenses before opening the PR. - Update
CHANGELOG.mdunder[Unreleased]. - Keep the gem dependency-free (stdlib only). New runtime deps need a compelling reason and a discussion on the issue tracker first.
Don't include unrelated refactors in the same PR — separate concerns land more predictably.
License
MIT — see LICENSE.txt.