RelayGrid logoRelayGrid

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 channel

user[: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 first

segment: 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
end

construct_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 disconnected

Non-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)
end

Multiple 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
end

RelayGrid::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 retriednotify 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.