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. # => 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: account.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. # => 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. # => 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
timeoutseconds. In a request, returndelivery_idsand 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..new_for(user_id: "user-123") # unseen messages
RelayGrid.client..mark_as_seen()
RelayGrid.client..mark_as_delivered()
Realtime
ws = RelayGrid.websocket(user_id: "user-123")
ws. { || puts ["rendered_subject"] }
ws.on_error { |error| Rails.logger.error(error.) }
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.)
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.