simple_connect-client
Dependency-free Ruby client for the SimpleWaConnect API.
Two independent entry points:
SimpleConnect::Client— the integration client. Ships domain events (POST), fetches event details (GET), and verifies integration health — all signed with HMAC-SHA256. For integration providers holding a signingkey_id/secret.SimpleConnect::MessagesClient— the messages client. Sends WhatsApp template messages (POST) and looks up their delivery status (GET) — authenticated with a Bearer account API key. For any caller with an API key, including third parties with no integration. See docs/adr/0001-messages-use-separate-bearer-client.md for why these are separate clients.
Both share the same transport: built-in retries on 5xx / network errors and
the same Result contract.
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",
recipient: {
mobile_no: "+919812345678", # required — routing target
name: "Ramesh Kumar" # optional
},
event: {
event_id: "pp_payment_#{payment.id}", # idempotency key
customer_name: "Ramesh Kumar",
agency_name: "Acme Dairy",
payment_date: "2026-04-16",
payment_mode: "UPI",
payment_amount: "450.00",
customer_total_due_amount: "0.00"
}
)
The two hashes mirror the wire body 1:1:
recipientis used by SimpleConnect to route the outbound WhatsApp message.mobile_nois required,nameis optional. Fields here are not accessible to template variable mappings — to show the customer's name in a message body, duplicate it underevent:(e.g.customer_name:).eventcarries the envelope plus all template-variable fields. Pass an explicitevent_id(unique per domain event) for safe retries — duplicateevent_ids are treated as no-ops by the server. If omitted, the client generatesevt_<hex>.occurred_atdefaults to now,languageto"en". Anyname:you pass insideeventis ignored — it's always set to the positionalevent_key.
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
Sending template messages
MessagesClient is a separate client from the HMAC Client above. It
authenticates with a Bearer account API key (sk_live_…) and sends
template messages only — free-form / session messages are out of scope
(they require an open 24-hour window and aren't exposed over the JSON API).
Setup
MESSAGES = SimpleConnect::MessagesClient.new(
base_url: ENV.fetch("SIMPLECONNECT_BASE_URL"), # e.g. https://app.simplewaconnect.com
api_key: ENV.fetch("SIMPLECONNECT_API_KEY"), # account API key, "sk_live_…"
logger: Rails.logger # optional
)
The instance is immutable and thread-safe — share one per api_key. If you
send on behalf of multiple accounts (each with its own number / API key),
construct one MessagesClient per api_key and memoize them.
Deliver a template message
result = MESSAGES..deliver(
template_name: "order_update",
language_code: "en_US", # optional → template default
recipients: [
{ mobile_no: "919999999999", name: "John Doe" } # 1..N recipients; mobile_no required
],
sender_phone_number: "919000000001", # optional → account's first active number
header: { text: ["Order Update"] }, # forwarded verbatim (see below)
body: ["John", "ORDER-101"], # positional array OR named hash
buttons: [
{ index: 2, sub_type: "url", parameters: [{ type: "text", text: "ORDER-101" }] }
],
message_group: "order-101" # optional correlation id
)
if result.success?
response = result.data # MessageDeliverResponse
response. # => [{ "message_id" =>, "recipient" =>, "status" => }]
response.errors # => per-recipient rejections, if any
response.any_queued? # => true when at least one recipient was enqueued
else
Rails.logger.warn("send rejected: #{result.data&. || result.error}")
end
template_name and recipients are validated client-side (blank/empty raise
ArgumentError before any HTTP call); each recipient needs mobile_no.
Everything else — template existence, the 24-hour window, opt-out, sender
validity — is server-authoritative.
Components are forwarded verbatim in the shape the message API documents.
Pick the header / body / buttons shape based on the approved template:
header:— one of{ text: [...] }/{ text: { name: val } }/{ image: { link:, caption: } }/{ video: {...} }/{ document: { link:, filename: } }/{ audio: { link: } }/{ location: { latitude:, longitude:, name:, address: } }. Omit if the template has no header (or a text header with no variables).body:— positional["v1", "v2"]or named{ customer_name: "John" }, matching the template'sparameter_format. Omit if no body variables.buttons:— only for buttons carrying runtime values (dynamic URL suffix, copy-code). Each entry needsindex(0-based) andsub_type.
Any argument left nil is omitted from the request entirely.
Look up a message's status
result = MESSAGES..detail()
if result.success?
msg = result.data # MessageDetailResponse
msg.status # => "queued" / "sent" / "delivered" / "read" / "failed" / ...
msg.recipient # => "919999999999"
msg. # => "order-101" (or nil)
if msg.failed?
msg.error # => { "message" => "...", "data" => { ... } }
end
end
Like the Events client, message-sending HTTP is synchronous and may retry —
wrap it in a background job and pass max_attempts: 1 to avoid stacking the
queue's retries on the library's (see "Run in a background job" below; the
same guidance applies to MessagesClient).
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",
recipient: { mobile_no: "+919812345678", name: "Ramesh" },
event: 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",
recipient: { mobile_no: "+919812345678", name: "Ramesh" },
event: fields.merge(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",
recipient: { mobile_no: "+919812345678" },
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, recipient, event)
SIMPLECONNECT.events.deliver(event_key, recipient: recipient, event: event)
end
end
DeliverSimpleConnectEventJob.perform_later(
"customer_payment_received",
{ mobile_no: "+919812345678", name: "Ramesh" },
{ customer_name: "Ramesh", agency_name: "Acme", 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.