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?, retrying?, failed?, bounced?,
opened?, plus success? (delivered or opened) and terminal? (the server will
not move it on its own). #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
It returns once every delivery is terminal — successful or not — so check
success? yourself rather than treating a return as a win. A delivery the server
is still retrying reads retrying, which is why failed means final.
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.
Webhooks
Registering an endpoint is what replaces polling for delivery status: RelayGrid
posts delivery.* events to your app as they happen.
endpoint = RelayGrid.client.webhook_endpoints.create(url: "https://app.example.com/relaygrid/webhooks")
endpoint["secret"] # => "whsec_..." — returned here and by rotate_secret only, so store it now
An account has one endpoint. current returns it (or nil), update(id, url:)
moves it, test(id) queues a signed ping, and attempts(id) is the dispatch
log — response codes, errors, durations — for answering "why didn't my webhook
arrive?". rotate_secret(id) issues a new secret and invalidates the old one
immediately, so deploy the new one to your receiver first.
Verifying
class RelayGridWebhooksController < ApplicationController
skip_before_action :verify_authenticity_token
def create
event = RelayGrid::Webhooks.construct_event(
request.raw_post,
request.headers["X-RelayGrid-Signature"],
ENV.fetch("RELAYGRID_WEBHOOK_SECRET")
)
case event.type
when "delivery.delivered" then mark_delivered(event.delivery.id)
when "delivery.failed" then alert(event.delivery.)
end
head :ok
rescue RelayGrid::SignatureVerificationError
head :bad_request
end
end
Verify before you parse — it is the raw bytes that were signed, so
re-serializing the JSON first will not verify. The HMAC proves the body came
from RelayGrid unaltered, and the timestamp signed alongside it bounds how long
a captured request stays replayable (five minutes; change it with tolerance:,
or pass nil to disable the window).
Two properties of the transport shape your handler:
- At-least-once. The same
event.idcan arrive twice — a receiver that times out after doing its work still gets retried. Key side-effects offid. - Unordered.
delivery.deliveredcan arrive beforedelivery.sent. Branch onevent.delivery.status, which is the state at emission time, not on the order events show up in.
event.delivery is a Delivery, in exactly the shape deliveries.get returns,
so webhook-driven and polling code can share handlers. It is nil for a ping.
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.