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

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.message?
    msg = event.message     # => MessageResponse (see below)
    msg.incoming?           # => true for user-initiated inbound messages
    msg.status_callback?    # => true for message.status callbacks
    msg.message_id          # => "wamid.HBgL..."
    msg.status              # => "delivered" / "read" / ...
    msg.timestamp           # => Time (or nil if unparseable)
  end
end

integrations.verifyVerifyResponse

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.message}")
    # 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 — the key_id passed to the client
  • X-SimpleConnect-Timestamp — current unix time in seconds
  • X-SimpleConnect-Signaturesha256=<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:

  1. Fork and create a feature branch.
  2. 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.
  3. Run bundle exec rubocop --config .rubocop.yml and fix offenses before opening the PR.
  4. Update CHANGELOG.md under [Unreleased].
  5. 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.