Ruby Gem Documentation
Integrate RelayGrid into your Ruby or Rails applications using our official client library.
One call to send
Hand the gem a user, a template name, and the template's attributes. You get back the delivery ids to track and, when the template has a push channel, the token that subscribes the recipient's device to realtime updates. There is no separate step to register the recipient first.
Installation
# Add to your Gemfile
gem 'relaygrid'
# Then run
bundle install
Requires Ruby 3.1 or newer.
Configuration
Create an initializer at config/initializers/relaygrid.rb:
RelayGrid.configure do |config| config.api_key = ENV['RELAYGRID_API_KEY'] config.base_url = 'https://relaygrid.dev' # Optional: override the WebSocket URL (defaults to base_url with a wss:// scheme) # config.ws_url = ENV['RELAYGRID_WS_URL'] end
The account is resolved from your API key, so there is nothing else to configure. A blank api_key or a malformed base_url raises RelayGrid::ConfigurationError when the client is built, so a misconfigured deploy fails at boot rather than on your first send.
Sending
Sending a Message
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..." when the template has a live push channeluser[:id] is your identifier for the recipient — RelayGrid stores it and never exposes its own internal ids to you. The recipient is created on first send and their names refreshed when they change.
Scheduling a Send
result = RelayGrid.client.notify(
user: { id: 'user-123' },
template: 'order_shipped',
scheduled_at: Time.now + 3.days
)
result.message_id # => 87 — deliveries exist now, with status "scheduled"
# Cancel before it dispatches
RelayGrid.client.messages.cancel_schedule(result.message_id)scheduled_at accepts a Time, DateTime, or ActiveSupport::TimeWithZone, which the gem serializes with an explicit UTC offset — the server rejects anything without one, so hand-building the string yourself is a common way to hit a 422. A bare Date raises ArgumentError in the gem, since a date has no instant. Requires the scheduled_deliveries feature on the account; without it the send raises RelayGrid::LimitExceededError (402).
cancel_schedule(id) withdraws a message that has not dispatched yet. It raises RelayGrid::ValidationError (422) if the message already dispatched or was already canceled — there is no window to cancel a send once it has gone out.
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". Omitting the key leaves the existing address alone; passing an empty string removes it.
Traits
traits is a free-form hash of targeting data on the recipient — what segment rules (traits.<key>) match against. Set it on a send, or independently through users.upsert:
# On a send: shallow-merged into the recipient's existing traits.
RelayGrid.client.notify(
user: { id: 'user-123', first_name: 'Ada', traits: { plan: 'pro', seats: 5 } },
template: 'order_shipped'
)
# Also on a send: a nil value deletes just that key.
RelayGrid.client.notify(
user: { id: 'user-123', traits: { seats: nil } },
template: 'order_shipped'
)
# Through users.upsert: replaces the recipient's whole traits document.
RelayGrid.client.users.upsert(id: 'user-123', first_name: 'Ada', last_name: 'Lovelace',
traits: { plan: 'pro' })The two paths differ deliberately: a send is a partial update of a recipient it may be creating on the fly, so it shallow-merges; users.upsert writes the whole record you hand it. Traits are targeting data, not a place for secrets — they are stored in the clear and are not encrypted at rest.
Segments and Broadcasts
Send to a group of recipients matched by their traits, instead of one at a time, with broadcasts.create. Broadcasting is its own resource — notify always addresses a single recipient and always returns a SendResult.
Creating a Segment and Broadcasting to It
segment = RelayGrid.client.segments.create(
name: 'Pro plan',
rules: { all: [{ field: 'traits.plan', op: 'eq', value: 'pro' }] }
)
# A String is the segment's name, an Integer its id -- just like template:
broadcast = RelayGrid.client.broadcasts.create(
segment: 'Pro plan',
template: 'spring_sale',
attributes: { discount: '20%' }
)
broadcast # => { "id" => 3, "segment_id" => 7, "recipient_count" => 4210, "status" => "pending" }
RelayGrid.client.broadcasts.get(3) # poll until "status" leaves "pending"
RelayGrid.client.broadcasts.list # every broadcast on the account, newest firstsegment: takes a String (the segment's name, unique among your live segments) or an Integer (its id), the same way template: takes a template name. A numeric-looking String such as "7" is read as a name, never an id. attributes: are the template's attributes, the same hash notify takes, and scheduled_at: defers the broadcast under the same rules as a single send. Both are optional.
Resolution and delivery happen in the background, so create returns the broadcast row as a Hash as soon as it's enqueued (HTTP 202) — there is no message id or deliveries yet to report. The row carries id, segment_id, status (pending → finished, or limit_reached when the account's monthly allowance stopped it early), recipient_count, created_message_count, scheduled_at, finished_at, and created_at. Poll broadcasts.get(id) to watch the count settle.
broadcasts.create requires the user_segmentation feature and raises RelayGrid::LimitExceededError (402, feature_key: "user_segmentation") without it. Only create is gated — the segments resource gates every call at 403, but broadcasts.get and broadcasts.list keep working after the add-on is turned off, so past broadcasts stay readable.
A scheduled_at: broadcast defers each fanned-out message individually, so undoing one means calling messages.cancel_schedule on its messages one at a time. There is no broadcast-level bulk cancel — a known limitation, and worth weighing before you schedule a broadcast to a large segment.
Rules
Rules are { all: [{ field:, op:, value: }, ...] } — every condition must match (AND-only, no nesting). Fields: first_name, middle_name, last_name, created_at, channel (only "email" or "sms", matching a live contact for that channel), and traits.<key>. Ops: eq, neq, contains, gt, lt — not every field takes every op (for example channel only takes eq/neq); an unsupported combination raises RelayGrid::ValidationError (422) when you save it.
A created_at value is either a fixed timestamp or, with gt/lt, a rolling offset written -<number><unit>(h, d, or w). The offset is resolved each time the segment runs, so { field: "created_at", op: "gt", value: "-7d" }stays "signed up in the last 7 days" tomorrow, where a fixed date would freeze on the day you saved it.
An empty rules payload ({}) matches every recipient, which is fine for drafting and for preview below — but broadcasts.create refuses an unconditioned segment with RelayGrid::ValidationError (422) unless its rules carry match_all: true.
Managing Segments
RelayGrid.client.segments.list # => [{ "id" => 7, "name" => ..., "rules" => ... }, ...]
RelayGrid.client.segments.get(7) # includes a live "recipient_count"
RelayGrid.client.segments.update(7, name: 'Renamed')
RelayGrid.client.segments.delete(7)
RelayGrid.client.segments.preview(rules: segment['rules'])
# => { "count" => 4210, "sample" => [{ "id" => 1, "first_name" => "Ada", ... }, ...] }preview checks rules without saving a segment — what a rule-builder UI calls as the caller edits conditions — capped at 5 sample recipients.
traits.<key> gt/lt compares as text
A traits.<key> rule compiles to a Postgres traits ->> 'key' comparison, which is always text — so with gt/lt, "10" > "9" is false. If you need numeric or chronological ordering, zero-pad numbers ("007") or store a sortable string such as an ISO-8601 date, so text order matches the order you actually want.
Delivery Status
A send returns one delivery id per channel. Statuses are kept fresh by the delivery providers, so polling shows sent / delivered / opened progress with no work on your side. A send that failed but will be tried again reads retrying (delivery.retrying?), which is what makes failed mean final.
Checking a Delivery
delivery = RelayGrid.client.deliveries.get(201) delivery.status # => "delivered" delivery.delivered? # => true delivery.channel_type # => "push" # Or check a whole send at once RelayGrid.client.deliveries.get_all(result.delivery_ids)
Waiting for Deliveries
settled = result.wait_for_deliveries(timeout: 30, interval: 2) # Returns once every delivery is terminal — successful or not — so check # for yourself rather than treating a return as a win. settled.all?(&:success?)
Deliveries still being retried are not terminal, so this waits through a transient provider failure rather than reporting it. Prefer webhooks below over waiting: they cost you no polling and no blocked thread. A scheduled send is not terminal either, but waiting on one would otherwise block for the full timeout waiting for a dispatch that might be days away — so wait_for_deliveries detects result.scheduled? and returns immediately with the deliveries as-is (status "scheduled") instead.
A scheduled send can still fail at dispatch
A 201/success response at schedule time only means the message and its deliveries were created — the account's plan limit is re-checked again when the sweep actually dispatches the send. If quota is spent by then, the delivery ends up failed even though scheduling it originally succeeded. Watch for it via delivery.failed webhooks or by polling, not by trusting the schedule response alone.
wait_for_deliveries blocks the calling thread
Fine in a background job, a rake task, or a script. Do not call it inside a web request — it pins a request thread for up to timeout seconds. In a request, return the 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. Requires the relaygrid gem v0.2.0 or newer.
Registering an Endpoint
endpoint = RelayGrid.client.webhook_endpoints.create( url: 'https://app.example.com/relaygrid/webhooks' ) endpoint['secret'] # => "whsec_..." — returned here and by rotate_secret only
Store that secret before you drop the response; list and get return only a preview of it. An account has one endpoint: current returns it or nil, and creating a second raises on HTTP 409. The URL must be https on a publicly resolvable host.
Verifying and Handling Events
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
endconstruct_event checks the HMAC against the raw body and rejects a timestamp older than five minutes (change it with tolerance:, or pass nil to disable the replay window), then returns a RelayGrid::Event. Use verify_signature for the same checks without building the event. event.delivery is a Delivery in exactly the shape deliveries.get returns — so webhook-driven and polling code share handlers — and is nil for a ping.
Managing the Endpoint
endpoints = RelayGrid.client.webhook_endpoints endpoints.current # => the endpoint hash, or nil endpoints.update(id, url: '...') # move it endpoints.test(id) # queue a signed ping; returns the event id endpoints.attempts(id) # dispatch log: codes, errors, durations endpoints.rotate_secret(id) # new secret; the old one stops working now endpoints.reactivate(id) # clear an auto-disabled endpoint endpoints.delete(id)
Verify before you parse
Pass request.raw_post, not params. The signature covers the exact bytes RelayGrid sent, so JSON that has been parsed and re-serialized will never verify. Events are also at-least-once and unordered: deduplicate on event.id, and branch on event.delivery.status rather than on the order events arrive in.
Receiving Messages
Polling an Inbox
# Unseen messages for a recipient, newest first messages = RelayGrid.client.messages.new_for(user_id: 'user-123') # Mark one as read RelayGrid.client.messages.mark_as_seen(message_id)
Realtime (WebSocket)
A subscriber is scoped to one recipient. It mints a fresh channel token on every connect and reconnect, so the one-hour token expiry is invisible to you.
ws = RelayGrid.websocket(user_id: 'user-123')
ws.on_message do |message|
puts "New: #{message['rendered_subject']}"
end
ws.connect(blocking: true) # blocks until disconnectedNon-Blocking (Rails background thread)
Thread.new do
ws = RelayGrid.websocket(user_id: current_user_id)
ws.on_message do |message|
ActionCable.server.broadcast("notifications_#{current_user_id}", message)
end
ws.connect(blocking: true)
endMultiple Users
Each subscriber follows one recipient, so open one per user you want to watch.
sockets = ['user-123', 'user-456'].map do |user_id|
ws = RelayGrid.websocket(user_id: user_id)
ws.on_message { |message| handle(user_id, message) }
ws.connect
ws
end
# ... later
sockets.each(&:disconnect)Error Handling
Every failure is a RelayGrid::Error. Network and transport failures are wrapped too, so no underlying HTTP-library exception escapes the gem.
begin
RelayGrid.client.notify(user: { id: 'user-123' }, template: 'order_shipped')
rescue RelayGrid::AuthenticationError
# 401 — the API key is missing, invalid, inactive, or expired
rescue RelayGrid::LimitExceededError => e
# 402 — a plan limit is spent; the body names which one
puts "Limit reached: #{e.body['feature_key']}"
rescue RelayGrid::TemplateNotFoundError
# 404 — no template by that name in this account
rescue RelayGrid::ValidationError => e
# 422 — the payload was rejected
puts "Invalid: #{e.message}"
rescue RelayGrid::ConnectionError, RelayGrid::TimeoutError
# the request never completed
endRelayGrid::SignatureVerificationError is the one exception raised without a request having been made — it comes from Webhooks.construct_event when an inbound event's signature, timestamp, or body doesn't check out. It descends from RelayGrid::Error but deliberately not from APIError, so a blanket rescue APIError around your outbound calls will not swallow it.
Retries and idempotency
Reads retry automatically (3 attempts, jittered backoff) on 429, 5xx, and transport failures. Sends are never retried — notify and broadcasts.create alike, since a blind retry would deliver the recipient a second real notification, so whether to re-send is your call.
Testing Tip
Use WebMock to stub REST API calls and mock WebSocket connections in your test suite to avoid real network requests during development.