RelayGrid Ruby Gem

Send and receive notifications through RelayGrid in one call.

Give it a user, a template name, and the attributes the template needs. Get back a push token for the recipient's device and the delivery ids you can check later.

result = RelayGrid.client.notify(
  user:       { id: "user-123", first_name: "Ada", last_name: "Lovelace" },
  template:   "order_shipped",
  attributes: { order_number: "1042", eta: "tomorrow" }
)

result.message_id   # => 87
result.delivery_ids # => [201, 202]
result.push_token   # => "eyJf..." (nil when the template has no push channel)

Installation

gem "relaygrid"

Then bundle install. Requires Ruby >= 3.1.

Configuration

# config/initializers/relaygrid.rb
RelayGrid.configure do |config|
  config.api_key  = ENV["RELAYGRID_API_KEY"]
  config.base_url = ENV.fetch("RELAYGRID_BASE_URL", "https://relaygrid.dev")
end
Option Default Purpose
api_key Account API key (sk_...). Required.
base_url https://relaygrid.dev API origin.
timeout 30 Seconds to wait for a response.
open_timeout 10 Seconds to wait for the connection to open.
max_retries 3 Retry attempts for idempotent (GET) requests.
logger nil Logs requests when set. Headers and bodies are not logged.
ca_file nil PEM bundle for a private CA.
ws_url derived from base_url Overrides the Action Cable URL.

Bad configuration raises RelayGrid::ConfigurationError when the client is built, not on the first request.

TLS. Certificates are always verified. There is no option to turn verification off: the client carries your API key and relays channel tokens, so an unverified connection is never the right default. To trust a self-signed or internal CA, point ca_file at its PEM bundle.

Multiple accounts

RelayGrid.client is the shared default. For a host app talking to several accounts, build clients with per-client overrides:

client = RelayGrid::Client.new(api_key: .relaygrid_key)

Clients are frozen once built and safe to share across threads.

Sending

user[:id] is your identifier for the recipient. RelayGrid stores it as external_user_id and never exposes its own internal ids to you.

result = RelayGrid.client.notify(
  user:       { id: "user-123", first_name: "Ada", last_name: "Lovelace" },
  template:   "order_shipped",
  attributes: { order_number: "1042" }
)

The recipient is created on first send and their names refreshed when they change, so there is no separate registration step. If the recipient already exists and you don't want to send names, pass the id on its own:

RelayGrid.client.notify(user: "user-123", template: "order_shipped")

Email recipients

Push needs nothing but the id. Email deliveries resolve who to send to from the address stored for the recipient, so include it in the send:

RelayGrid.client.notify(
  user:     { id: "user-123", first_name: "Ada", last_name: "Lovelace",
              email: "ada@example.com" },
  template: "order_shipped"
)

Without it, a send over an email channel produces a delivery that fails with "no contact on file". A value RelayGrid can't use raises ValidationError rather than failing quietly at delivery time.

Omitting the key leaves the existing address alone; passing an empty string removes it. Contacts can also be managed outside a send:

RelayGrid.client.users.contacts("user-123")
RelayGrid.client.users.set_contact("user-123", channel_type: "email", value: "ada@example.com")
RelayGrid.client.users.delete_contact("user-123", channel_type: "email")

The result

result.message_id            # => 87
result.delivery_ids          # => [201, 202]
result.deliveries            # => [#<RelayGrid::Delivery id: 201, channel_type: "push", status: "queued">, ...]
result.delivery_for("email") # => #<RelayGrid::Delivery ...>
result.rendered_subject      # => "Hi Ada"
result.push_token            # => "eyJf..." or nil
result.push_token_expires_at # => 2026-07-28 11:00:00 +0000
result.push?                 # => true when a push channel was dispatched

The push token

When the template has a live push channel, the send response carries a token that authenticates the recipient's device to the realtime channel. Your server receives it and hands it down to your client. It expires after an hour; mint a fresh one with:

RelayGrid.client.channel_tokens.create(user_id: "user-123")
# => { token: "eyJf...", expires_in: 3600, expires_at: 2026-07-28 11:00:00 +0000 }

Checking delivery status

delivery = RelayGrid.client.deliveries.get(201)
delivery.status                 # => "delivered"
delivery.delivered?             # => true
delivery.channel_type           # => "push"
delivery.friendly_error_message # => nil, or human-readable guidance when failed

RelayGrid.client.deliveries.get_all([201, 202])  # one batched request

Predicates: queued?, sent?, delivered?, failed?, bounced?, opened?, plus success? (delivered or opened). #refresh re-fetches and returns a new Delivery — the objects are immutable.

Waiting for deliveries to settle

settled = result.wait_for_deliveries(timeout: 30, interval: 2)
settled.all?(&:success?) # => true

This blocks the calling thread. Use it in a background job, a rake task, or a script — never inside a web request, where it would pin a request thread for up to timeout seconds. In a request, return delivery_ids and let the browser poll, or subscribe over the websocket.

failed is deliberately not treated as final inside the window: delivery jobs retry, so a failed delivery can still flip to sent and then delivered. The helper waits rather than reporting a transient failure as permanent.

Receiving

Polling

RelayGrid.client.messages.new_for(user_id: "user-123")  # unseen messages
RelayGrid.client.messages.mark_as_seen(message_id)
RelayGrid.client.messages.mark_as_delivered(message_id)

Realtime

ws = RelayGrid.websocket(user_id: "user-123")
ws.on_message { |message| puts message["rendered_subject"] }
ws.on_error   { |error| Rails.logger.error(error.message) }
ws.connect

A fresh channel token is minted on every connect and reconnect, so the hourly expiry is handled for you. connect(blocking: true) blocks the thread — for a standalone consumer process, not a web request.

Errors

Everything descends from RelayGrid::Error, so rescue RelayGrid::Error always suffices. Faraday exceptions never escape the gem.

Class Raised when
ConfigurationError Bad configuration, at client construction
ConnectionError The request never produced a response
TimeoutError A timeout (subclass of ConnectionError)
AuthenticationError 401/403 — key invalid, inactive, expired, or account suspended
LimitExceededError 402 — monthly notification limit spent
NotFoundError 404
TemplateNotFoundError 404 naming a template (subclass of NotFoundError)
UserNotFoundError 404 naming a recipient (subclass of NotFoundError)
ValidationError 422
ServerError 5xx
TimeoutWaitingForDeliveries wait_for_deliveries gave up (only with raise_on_timeout)

APIError subclasses carry #status, #body, and #error_message (the server's own message).

begin
  RelayGrid.client.notify(user: user, template: "order_shipped")
rescue RelayGrid::LimitExceededError
  # out of notifications this month
rescue RelayGrid::TemplateNotFoundError => e
  Rails.logger.error(e.error_message)
end

Retries and idempotency

GETs retry automatically (3 attempts by default, jittered exponential backoff) on 429, 5xx, and transport failures.

notify is never retried. A blind retry would send the recipient a second real notification. If a send fails, deciding whether to re-send is yours to make. Server-side Idempotency-Key support is planned so retries can be made safe.

Development

bin/setup
bundle exec rspec
bundle exec rubocop

Specs use WebMock; no example makes a real HTTP call or sleeps for real.

License

MIT.