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?, 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 timeout seconds. In a request, return delivery_ids and 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.friendly_error_message)
    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.id can arrive twice — a receiver that times out after doing its work still gets retried. Key side-effects off id.
  • Unordered. delivery.delivered can arrive before delivery.sent. Branch on event.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.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.