RelayGrid logoRelayGrid

Node.js Library Documentation

Integrate RelayGrid into your Node.js and TypeScript services using our official client library.

One call to send

Hand the library 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

npm install relaygrid

Requires Node 22 or newer — the oldest release line with both a built-in fetch and a global WebSocket, which is what lets the package ship with nothing in dependencies. TypeScript types are included, and both ESM and CommonJS builds are published, so import and require both work.

Configuration

Build a client once and share it — instances are frozen and hold no per-request state:

import { RelayGrid } from "relaygrid";

export const relaygrid = new RelayGrid({
  apiKey: process.env.RELAYGRID_API_KEY,
  baseUrl: "https://relaygrid.dev",
  // Optional: override the WebSocket URL (defaults to baseUrl with a wss:// scheme)
  // wsUrl: process.env.RELAYGRID_WS_URL,
});

The account is resolved from your API key, so there is nothing else to configure. A blank apiKey or a malformed baseUrl throws ConfigurationError when the client is built, so a misconfigured deploy fails at boot rather than on your first send.

Module-Level Singleton

For an app that talks to a single account, skip passing a client around:

import { configure, notify } from "relaygrid";

configure({ apiKey: process.env.RELAYGRID_API_KEY });

await notify({ user: { id: "user-123" }, template: "order_shipped" });

A host app serving several accounts builds one client per account instead — they are independent, and safe to use concurrently.

Sending

Sending a Message

const result = await relaygrid.notify({
  user: { id: "user-123", firstName: "Ada", lastName: "Lovelace" },
  template: "order_shipped",
  attributes: { order_number: "1042", eta: "tomorrow" },
});

result.messageId;   // => 87
result.deliveryIds; // => [201, 202]
result.pushToken;   // => "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. Only the keys you actually pass are forwarded, so leaving one out never blanks it. For a recipient you know already exists, pass a bare id — a strict lookup that throws UserNotFoundError when it does not resolve.

Scheduling a Send

const result = await relaygrid.notify({
  user: { id: "user-123" },
  template: "order_shipped",
  scheduledAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000),
});

result.messageId; // => 87 — deliveries exist now, with status "scheduled"

// Cancel before it dispatches
await relaygrid.messages.cancelSchedule(result.messageId);

scheduledAt accepts a Date or a string. A Date is serialized with toISOString(), which always carries a Z offset; a raw string is sent through as-is, and the server rejects one with no explicit UTC offset with a 422. Requires the scheduled_deliveries feature on the account; without it the call throws LimitExceededError (402).

cancelSchedule(id) withdraws a message that has not dispatched yet. It throws 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:

await relaygrid.notify({
  user: { id: "user-123", firstName: "Ada", lastName: "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 object for recipient targeting data — segment rules read traits.<key>.

// On a send: shallow-merged into the recipient's existing traits,
// and a null value deletes just that key.
await relaygrid.notify({
  user: { id: "user-123", traits: { plan: "pro", signupSource: "referral" } },
  template: "order_shipped",
});

// Through users.upsert: replaces the recipient's whole traits document.
await relaygrid.users.upsert({ id: "user-123", firstName: "Ada", lastName: "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. Trait keys are your own strings and are never case-converted — unlike every other key this library maps (firstName to first_name and so on), a key like signupSource is sent and stored exactly as written. Traits are targeting data, not a place for secrets — they are stored in the clear for segment rules to query against.

What the Result Carries

result.isPush;              // true when a live push channel was dispatched
result.pushTokenExpiresAt;  // Date — tokens last one hour
result.renderedSubject;     // what the recipient actually saw
result.deliveryFor("email") // the Delivery for one channel
result.raw;                 // the untouched response body

Hand pushToken to your frontend to subscribe the recipient's browser or device. There is no refresh endpoint — issuing a new token with relaygrid.channelTokens.create({ userId }) is the refresh.

Segments and Broadcasts

A segment is a named, reusable rule set matched against recipient names, created_at, channel (a live contact for that channel — "email" or "sms" only), and traits.<key>. Send to every matching recipient at once with broadcasts.create. Broadcasting is its own resource — notify always addresses a single recipient and always resolves with a SendResult.

Creating a Segment and Broadcasting to It

const segment = await relaygrid.segments.create({
  name: "Pro plan",
  rules: { all: [{ field: "traits.plan", op: "eq", value: "pro" }] },
});

// A string is the segment's name, a number its id -- just like template
const broadcast = await relaygrid.broadcasts.create({
  segment: "Pro plan",
  template: "spring_sale",
  attributes: { discount: "20%" },
});

broadcast; // => { id, segmentId, recipientCount, status: "pending" }

await relaygrid.broadcasts.get(broadcast.id); // poll until status leaves "pending"
await relaygrid.broadcasts.list();            // every broadcast, newest first

segment takes a string (the segment's name, unique among your live segments) or a number (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 object notify takes, and scheduledAt defers the broadcast under the same rules as a single send. Both are optional. The params are typed as CreateBroadcastParams.

Resolution and delivery happen in the background, so create resolves with a Broadcast descriptor (not a SendResult) as soon as it's enqueued (HTTP 202) — there is no single message or delivery set yet to describe. It carries id, segmentId, status ("pending" "finished", or "limit_reached" when the account's monthly allowance stopped it early), recipientCount, createdMessageCount, scheduledAt, finishedAt, and createdAt — camelCased like every other response. Poll broadcasts.get(id) to watch the count settle.

A scheduledAt broadcast defers each fanned-out message individually, so undoing one means calling messages.cancelSchedule 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 AND-only, no nesting: { all: [{ field, op, value }, ...] }. Fields are the API's own snake_case names — they are rule values, not object keys, so the library's camelCase mapping never touches them: first_name, middle_name, last_name, created_at, channel, and traits.<key>. Operators: eq, neq, contains, gt, lt — not every field takes every operator (channel only takes eq/neq, and its value must be "email" or "sms"); an unsupported combination throws ValidationError (422) when you save it. The field and operator names are typed in SegmentRuleField / SegmentRuleOp, so an unknown one is caught at compile time too.

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.

preview returns a live count and a sample of matching recipients without saving anything — handy for a rule builder UI:

const { count, sample } = await relaygrid.segments.preview({
  all: [{ field: "traits.plan", op: "eq", value: "pro" }],
});

An empty rule set ({}, or omitted entirely) matches every active recipient in the account. That's fine for a draft or a preview, but broadcasting to it requires saying so explicitly with matchAll: true — otherwise broadcasts.create rejects it with ValidationError (422), so an empty segment can't broadcast to the whole account by accident.

Managing Segments

await relaygrid.segments.list();
await relaygrid.segments.get(segment.id);  // includes a live recipientCount
await relaygrid.segments.update(segment.id, { name: "Pro plan (renamed)" });
await relaygrid.segments.delete(segment.id);

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.

Both resources want the user_segmentation feature, and they gate it differently on purpose: every segments call is gated at 403, while only broadcasts.create is gated — at 402, throwing LimitExceededError with error.featureKey === "user_segmentation". One resource gate, one billing gate: broadcasts.get and broadcasts.list keep returning past broadcasts after the add-on is turned off. See the REST API docs for the exact response bodies.

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.isRetrying), which is what makes failed mean final.

Checking a Delivery

const delivery = await relaygrid.deliveries.get(201);
delivery.status;      // => "delivered"
delivery.isDelivered; // => true
delivery.channelType; // => "push"

// Or check a whole send at once
await relaygrid.deliveries.getAll(result.deliveryIds);

getAll handles the batch endpoint's three quirks for you: ids that no longer resolve come back absent rather than as an error, so one stale id never fails a whole poll; results are re-sorted into the order you asked for rather than the server's id order; and long lists are deduplicated before being split across requests.

Waiting for Deliveries

const settled = await result.waitForDeliveries({ timeoutMs: 30_000, intervalMs: 2_000 });

// Resolves once every delivery is terminal — successful or not — so check
// for yourself rather than treating a return as a win.
settled.every((delivery) => delivery.isSuccess);

Deliveries still being retried are not terminal, so this waits through a transient provider failure rather than reporting it. Pass throwOnTimeout: true to raise TimeoutWaitingForDeliveries instead of returning the deliveries as last seen. Prefer webhooks below over waiting: they cost you no polling and no held-open request. A scheduled send is not terminal either, but waiting on one would otherwise hold the request open for the full timeout waiting on a dispatch that might be days away — so waitForDeliveries detects result.isScheduled and resolves immediately with the deliveries as-is (status "scheduled") instead.

A scheduled send can still fail at dispatch

A successful 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.

sent is not the end of the story

sent means the provider accepted the message, not that it arrived — inbound provider webhooks keep advancing that row to delivered, opened or bounced. Treating it as final would report success on a message that later bounces, which is why isTerminal excludes it and waitForDeliveries keeps polling past it.

waitForDeliveries holds the caller open

Fine in a background job, a worker, or a script. Do not await it inside a request handler — it keeps the request alive for up to timeoutMs milliseconds. 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.

Registering an Endpoint

const endpoint = await relaygrid.webhookEndpoints.create(
  "https://app.example.com/relaygrid/webhooks"
);

endpoint.secret; // => "whsec_..." — returned here and by rotateSecret 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 undefined, and creating a second throws on HTTP 409. The URL must be https on a publicly resolvable host.

Verifying and Handling Events

import express from "express";
import { webhooks, SignatureVerificationError } from "relaygrid";

app.post(
  "/relaygrid/webhooks",
  express.raw({ type: "application/json" }),
  (req, res) => {
    let event;

    try {
      event = webhooks.constructEvent(
        req.body,
        req.header("X-RelayGrid-Signature"),
        process.env.RELAYGRID_WEBHOOK_SECRET
      );
    } catch (error) {
      if (error instanceof SignatureVerificationError) return res.sendStatus(400);
      throw error;
    }

    switch (event.type) {
      case "delivery.delivered": markDelivered(event.delivery.id); break;
      case "delivery.failed":    alert(event.delivery.friendlyErrorMessage); break;
    }

    res.sendStatus(200);
  }
);

constructEvent checks the HMAC against the raw body and rejects a timestamp older than five minutes (change it with toleranceSeconds, or pass null to disable the replay window), then returns a RelayGridEvent. Use verifySignature 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 null for a ping.

Managing the Endpoint

const endpoints = relaygrid.webhookEndpoints;

await endpoints.current();                  // the endpoint, or undefined
await endpoints.update(id, { url: "..." }); // move it
await endpoints.test(id);                   // queue a signed ping; returns the event id
await endpoints.attempts(id);               // dispatch log: codes, errors, durations
await endpoints.rotateSecret(id);           // new secret; the old one stops working now
await endpoints.reactivate(id);             // clear an auto-disabled endpoint
await endpoints.delete(id);

Verify before you parse

Reach for express.raw — or await request.text() in a Next.js route handler — not a parsed body. The signature covers the exact bytes RelayGrid sent, so JSON that has been parsed and re-serialized will never verify; constructEvent throws with that explanation if you hand it an object. 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
const messages = await relaygrid.messages.newFor({ userId: "user-123" });

// Mark one as read
await relaygrid.messages.markAsSeen(messageId);

// Naming the channel also marks that delivery opened, which drives open rates
await relaygrid.messages.markAsSeen(messageId, { notificationChannelId: 9 });

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.

const ws = relaygrid.websocket({ userId: "user-123" });

ws.onMessage((message) => console.log("New:", message.renderedSubject));
ws.onError((error) => console.error(error));

await ws.connect();

// ... later
ws.disconnect();

The socket opens unauthenticated — the channel token authorises the subscription, and a token that does not check out surfaces through onError with ws.subscribed staying false, rather than as a healthy socket that silently never delivers. Reconnects use exponential backoff. A service that only sends can import from relaygrid/realtime instead, and never load this module at all.

Standalone Consumer

In a long-running worker, keep the process alive until you disconnect:

const ws = relaygrid.websocket({ userId: process.env.WATCH_USER_ID });

ws.onMessage((message) => queue.push(message));

await ws.connect();
await ws.waitUntilClosed();

Multiple Users

Each subscriber follows one recipient, so open one per user you want to watch.

const sockets = await Promise.all(
  ["user-123", "user-456"].map(async (userId) => {
    const ws = relaygrid.websocket({ userId });
    ws.onMessage((message) => handle(userId, message));
    await ws.connect();
    return ws;
  })
);

// ... later
sockets.forEach((ws) => ws.disconnect());

Error Handling

Every failure is a RelayGridError. Network and transport failures are wrapped too, so no underlying fetch exception escapes the library.

import {
  AuthenticationError, LimitExceededError, TemplateNotFoundError,
  ValidationError, ConnectionError,
} from "relaygrid";

try {
  await relaygrid.notify({ user: { id: "user-123" }, template: "order_shipped" });
} catch (error) {
  if (error instanceof AuthenticationError) {
    // 401 — the API key is missing, invalid, inactive, or expired
  } else if (error instanceof LimitExceededError) {
    // 402 — a plan limit is spent; the error names which one
    console.log("Limit reached:", error.featureKey);
  } else if (error instanceof TemplateNotFoundError) {
    // 404 — no template by that name in this account
  } else if (error instanceof ValidationError) {
    // 422 — the payload was rejected
    console.log("Invalid:", error.errorMessage);
  } else if (error instanceof ConnectionError) {
    // the request never completed; TimeoutError is a subclass
  } else {
    throw error;
  }
}

SignatureVerificationError is the one error thrown without a request having been made — it comes from webhooks.constructEvent when an inbound event's signature, timestamp, or body doesn't check out. It descends from RelayGridError but deliberately not from ApiError, so a blanket instanceof ApiError check 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. A timeout on a send can still mean the message was created, so reconcile with messages.newFor (or broadcasts.list) rather than assuming it was not.

Testing Tip

Pass your own fetch when building the client — new RelayGrid({ apiKey, fetch: myStub }) — and your suite can assert the exact request shape without a network, no interceptor library required. Stub the WebSocket the same way with webSocketFactory.