Ray
PricingDocsSign inStart free
Browse the docs

Getting started

Ray documentationAuthenticationUsing Ray with AI coding agentsMCP serverTypeScript SDK

Guides

Sending notificationsIdempotencyTemplatesScheduling and test sendsStatus and feedsClick trackingDelivery webhooks

Channels

Email with Amazon SESEmail with SMTPPush notifications with Firebase Cloud MessagingSlack notificationsDiscord notificationsTelegram notificationsSMS with TwilioSMS with sendsms.mnHTTPS webhook channel

Reference

Rate limits and quotasErrors

Getting started

Ray documentationAuthenticationUsing Ray with AI coding agentsMCP serverTypeScript SDK

Guides

Sending notificationsIdempotencyTemplatesScheduling and test sendsStatus and feedsClick trackingDelivery webhooks

Channels

Email with Amazon SESEmail with SMTPPush notifications with Firebase Cloud MessagingSlack notificationsDiscord notificationsTelegram notificationsSMS with TwilioSMS with sendsms.mnHTTPS webhook channel

Reference

Rate limits and quotasErrors

Getting started

TypeScript SDK

Use the @gege-mn/ray TypeScript SDK to send notifications, manage templates and webhooks, handle RayError and verify webhook signatures.

View as Markdownllms.txtAPI reference

@gege-mn/ray is the official TypeScript SDK for the Ray API. It is a typed client over the REST endpoints described in these docs, with zero runtime dependencies, built on fetch, and shipped as both ESM and CommonJS. Source: https://github.com/gege-mn/ray-node.

Method arguments and return values use the same field names as the REST API, so every guide in these docs applies unchanged: Sending for request bodies, Templates for content shapes, Errors for error codes.

Install

npm install @gege-mn/ray

pnpm add @gege-mn/ray, yarn add @gege-mn/ray and bun add @gege-mn/ray work too.

Runtime support

Runtime Notes
Node.js 18+ ESM (import) and CommonJS (require).
Bun Supported.
Deno Import with the npm: specifier: import { Ray } from "npm:@gege-mn/ray".
Edge runtimes Cloudflare Workers, Vercel Edge Functions and others with a global fetch. Pass the API key explicitly (see below).

Use the SDK only on the server. An API key in a browser bundle or a mobile app is readable by anyone who has it.

Initialize

import { Ray } from "@gege-mn/ray";

const ray = new Ray(process.env.RAY_API_KEY);
  • The API key argument is optional. Without it the client reads the RAY_API_KEY environment variable, and throws a RayError with code missing_api_key if neither is set. On runtimes without process.env (Cloudflare Workers, for example), pass the key from your runtime's bindings: new Ray(env.RAY_API_KEY).
  • Options go in the second argument, or pass a single options object with apiKey:
const ray = new Ray(process.env.RAY_API_KEY, { timeoutMs: 10_000, maxRetries: 3 });

// or, one options object
const staging = new Ray({ apiKey: process.env.RAY_API_KEY, baseUrl: "https://ray-api.gege.mn" });
Option Default Notes
apiKey RAY_API_KEY env ck_live_...
baseUrl https://ray-api.gege.mn Also read from the RAY_BASE_URL environment variable.
timeoutMs 30000 Per attempt. 0 disables the timeout.
maxRetries 2 See Retries.
fetch global fetch A custom fetch implementation.
userAgent Prepended to the SDK's own User-Agent.

Every method also accepts per-call timeoutMs, maxRetries, signal (an AbortSignal) and headers in its last, optional argument.

CommonJS:

const { Ray } = require("@gege-mn/ray");

Create one client and reuse it. It holds no connection state.

Send notifications

ray.send(body, options?) calls POST /send and resolves to { sendId } once Ray has accepted the send (202). Delivery happens asynchronously; see Delivery status.

Send modes

The body is the POST /send request body. One request uses exactly one mode.

Single recipient with a published template:

const { sendId } = await ray.send(
  {
    channelConfigId: "9f1c2a7e-3b4d-4e5f-8a6b-1c2d3e4f5a6b",
    templateId: "1d2e3f4a-5b6c-4d7e-8f9a-0b1c2d3e4f5a",
    params: { name: "Ada", code: "482913" },
    recipient: { email: "[email protected]", name: "Ada Lovelace" },
    externalUserId: "user_123",
    showInFeed: true,
  },
  { idempotencyKey: "signup-code-user_123-2026-09-14T09:30" },
);

Inline content instead of a template (a Slack message):

await ray.send({
  channelConfigId: "7c8d9e0f-1a2b-4c3d-9e4f-5a6b7c8d9e0f",
  content: { text: "Deploy finished for *{{service}}*" },
  params: { service: "billing-api" },
  recipient: {},
});

Fan-out, the same message to up to 1000 recipients:

await ray.send({
  channelConfigId: "9f1c2a7e-3b4d-4e5f-8a6b-1c2d3e4f5a6b",
  templateId: "1d2e3f4a-5b6c-4d7e-8f9a-0b1c2d3e4f5a",
  params: { releaseName: "September update" },
  targets: users.map((u) => ({ recipient: { email: u.email }, externalUserId: u.id })),
  showInFeed: true,
  priority: "low",
});

Multi-channel, one person over several channels:

await ray.send({
  externalUserId: "user_42",
  feed: { title: "Your invoice is ready" },
  deliveries: [
    {
      channelConfigId: "3a4b5c6d-7e8f-4a9b-8c0d-1e2f3a4b5c6d",
      content: { title: "Invoice ready", body: "Invoice {{number}} is ready." },
      params: { number: "INV-1042" },
      recipient: { deviceToken: "fcm-device-token-from-your-app" },
    },
    {
      channelConfigId: "9f1c2a7e-3b4d-4e5f-8a6b-1c2d3e4f5a6b",
      templateId: "1d2e3f4a-5b6c-4d7e-8f9a-0b1c2d3e4f5a",
      params: { number: "INV-1042" },
      recipient: { email: "[email protected]" },
    },
  ],
});

Feed-only, an in-app notification with no channel delivery:

await ray.send({
  externalUserId: "user_42",
  feed: { title: "Your export is ready", description: "Download it from the Reports page." },
});

Scheduling (notBefore), priority, trackClicks and campaignId are body fields like any other. See Sending for every field and rule, and Scheduling.

Idempotency keys

Pass idempotencyKey and the SDK sends it as the Idempotency-Key header. Derive it from the business event so that a retry of the same event, from any process, carries the same key:

await ray.send(body, { idempotencyKey: `order-shipped-${order.id}` });

If you don't pass one, the SDK generates a key for that call, so its own automatic retries of the call can't deliver twice. A generated key does not protect against your code calling ray.send twice for the same event (a job that runs again, a queue that redelivers). For that, pass your own key. See Idempotency for replay and 409 rules.

Delivery status

ray.sends.get(id, { cursor? }) calls GET /sends/{sendId}: status counts for the whole send plus a page of delivery rows.

const status = await ray.sends.get(sendId);
console.log(status.aggregate); // { total: 3, delivered: 2, suppressed: 1 }

sends.get also takes limit (1 to 200, default 50). Read every row of a large fan-out with the listAllRows iterator, which follows nextCursor for you:

for await (const row of ray.sends.listAllRows(sendId)) {
  if (row.status === "failed_terminal") console.log(row.recipient, row.providerError);
}

To page by hand, pass the previous page's nextCursor as cursor: ray.sends.get(sendId, { cursor: page.nextCursor }).

Prefer delivery webhooks over polling in production.

Channels

ray.channels.list() calls GET /channels. Use it to find a channelConfigId and the exact recipient shape a channel expects.

const { channels } = await ray.channels.list();
const email = channels.find((c) => c.templateKind === "email_html");

Channel configs are created in the dashboard, not through the API.

Templates

Method Endpoint
ray.templates.list(query?) GET /templates, optional { folder, includeArchived }
ray.templates.get(id) GET /templates/{id}
ray.templates.create(body) POST /templates
ray.templates.update(id, body) PATCH /templates/{id}
ray.templates.publish(id) POST /templates/{id}/publish
ray.templates.archive(id) POST /templates/{id}/archive
ray.templates.unarchive(id) POST /templates/{id}/unarchive
ray.templates.testSend(id, body) POST /templates/{id}/test-send

Create and publish a template, then test it against your own inbox:

const { id, requiredParams } = await ray.templates.create({
  name: "Verification code",
  folder: "auth",
  channelKind: "email_html",
  content: {
    subject: "Your code is {{code}}",
    bodyHtml: "<p>Hi {{name}}, your code is <b>{{code}}</b>.</p>",
    bodyText: "Hi {{name}}, your code is {{code}}.",
  },
  logTitle: "Verification code sent",
  logDescription: "We emailed {{name}} a sign-in code.",
  publish: true,
});

const test = await ray.templates.testSend(id, {
  channelConfigId: "9f1c2a7e-3b4d-4e5f-8a6b-1c2d3e4f5a6b",
  recipient: { email: "[email protected]" },
  params: { name: "Ada", code: "000000" },
});

Edit the draft and publish it. update takes the same body as create (see Update the draft):

await ray.templates.update(id, {
  name: "Verification code",
  folder: "auth",
  channelKind: "email_html",
  content: {
    subject: "{{code}} is your sign-in code",
    bodyHtml: "<p>Hi {{name}}, your code is <b>{{code}}</b>.</p>",
    bodyText: "Hi {{name}}, your code is {{code}}.",
  },
  logTitle: "Verification code sent",
  logDescription: "We emailed {{name}} a sign-in code.",
});
await ray.templates.publish(id);

Test sends don't create feed entries, fire webhooks or use monthly quota. See Test sends.

Customer feed

ray.notifications.list(query) calls GET /notifications. externalUserId is required; limit, cursor, templateId, channelConfigId, after and before are optional.

const { notifications, nextCursor } = await ray.notifications.list({
  externalUserId: "user_123",
  limit: 20,
});

// Or iterate the whole feed, newest first:
for await (const entry of ray.notifications.listAll({ externalUserId: "user_123" })) {
  console.log(entry.logTitle);
}

Serve the feed to your frontend through your own backend endpoint, never by calling Ray from the browser.

Click stats

ray.clicks.get(query) calls GET /clicks with a sendId, a campaignId, or both.

const { totalClicks, byUrl } = await ray.clicks.get({ campaignId: "spring-sale-2026" });

Delivery webhooks

Method Endpoint
ray.webhooks.list(query?) GET /tenant-webhooks, optional { includeArchived }
ray.webhooks.get(id) GET /tenant-webhooks/{id}
ray.webhooks.create(body) POST /tenant-webhooks
ray.webhooks.update(id, body) PATCH /tenant-webhooks/{id}
ray.webhooks.delete(id) DELETE /tenant-webhooks/{id}
const webhook = await ray.webhooks.create({
  name: "production",
  url: "https://api.example.com/webhooks/ray",
  events: ["notification.failed_terminal", "send.completed"],
});
// webhook.secret is returned only here and on rotation. Store it now.

const rotated = await ray.webhooks.update(webhook.id, { rotateSecret: true });

Delivery webhooks require the Pro plan or higher. See Delivery webhooks for events, payloads and URL rules.

Account and usage

const me = await ray.me(); // GET /me: { tenantId, apiKeyId, scopes }
const usage = await ray.usage(); // GET /billing/subscription: { plan, subscription, usage }

if (usage.usage.remaining !== null && usage.usage.remaining < 1000) {
  console.warn("Ray quota almost used up");
}

Errors

Every failed call throws a RayError:

Property Notes
status HTTP status, or 0 when no response was received.
code The stable error code from the API (validation_error, not_found, rate_limit_exceeded, ...), or an SDK code when there was no response: connection_error, timeout, missing_api_key.
message Human-readable detail. Don't parse it.
requestId The x-request-id response header. Include it when you contact support.
retryAfter Seconds to wait, on 429 responses.
body The parsed error body. 402 responses carry limit and used.
import { Ray, RayError } from "@gege-mn/ray";

const ray = new Ray();

try {
  await ray.send(body, { idempotencyKey: `order-shipped-${order.id}` });
} catch (err) {
  if (!(err instanceof RayError)) throw err;
  switch (err.code) {
    case "validation_error": // the request is wrong: log err.message and fix it
    case "not_found":
      throw err;
    case "conflict": // Idempotency-Key reused with a different body
      throw err;
    case "quota_exceeded": // Free plan monthly quota: stop sending, prompt an upgrade
      throw err;
    default: // rate limit, 5xx, network: already retried by the SDK, so hand it to your job queue
      console.error(`Ray ${err.status} ${err.code}: ${err.message} (request ${err.requestId})`);
      throw err;
  }
}

Branch on status and code, not on message text. The full list of codes and messages is in Errors.

Retries

Failed attempts are retried up to maxRetries times (default 2) with exponential backoff, or after the Retry-After delay the API asks for. A requested wait over 60 seconds is not waited out; the error is thrown instead. What gets retried depends on the call:

Call Retried on
GET methods Network errors, timeouts, 429, 5xx.
ray.send The same, plus 409 "already in progress". Every attempt carries the same Idempotency-Key (yours or a generated one), so a retry never delivers twice.
Other writes (templates, test sends, webhooks) 429 only, which Ray returns before the request is processed. A timeout or 5xx is thrown, because the change may already have happened.

Other 4xx responses are thrown immediately. Set maxRetries: 0 on the client or on one call to turn retries off.

If all attempts fail, the last error is thrown. For notifications that must eventually go out, call ray.send from a durable job queue with your own event-derived idempotencyKey, so a retry minutes or hours later is still deduplicated (keys are kept for 24 hours).

Verify webhook signatures

verifyWebhookSignature checks the X-Ray-Signature header of a delivery webhook against the raw request body and your webhook secret: an HMAC-SHA256 over <t>.<raw body>, compared in constant time, with timestamps more than 5 minutes from now rejected. It uses Web Crypto, so the same import works in Node.js, Bun, Deno and edge runtimes. The scheme is described in Verify signatures.

Option Notes
payload The raw body as received: a string, Uint8Array (a Node.js Buffer works) or ArrayBuffer.
headers The request headers: a Fetch Headers object, Node.js req.headers, or a plain object.
secret The webhook secret. Pass an array of secrets during a rotation; any match is accepted.
toleranceSeconds Default 300.

It resolves to true or false. parseWebhookEvent takes the same options, resolves to the typed event, and throws WebhookVerificationError (with a reason) when the request can't be trusted.

Always verify the exact bytes you received. Parsing and re-serializing the JSON first changes the body and the signature won't match.

Next.js route handler

// app/api/webhooks/ray/route.ts
import { verifyWebhookSignature } from "@gege-mn/ray";

export async function POST(request: Request) {
  const payload = await request.text();
  const valid = await verifyWebhookSignature({
    payload,
    headers: request.headers,
    secret: process.env.RAY_WEBHOOK_SECRET!,
  });
  if (!valid) return new Response("invalid signature", { status: 400 });

  const event = JSON.parse(payload);
  switch (event.event) {
    case "notification.failed_terminal":
      await markDeliveryFailed(event.notification_log_id, event.provider_error); // your code, idempotent
      break;
    case "send.completed":
      await recordSendTotals(event.send_id, event.totals); // your code, idempotent
      break;
  }
  return new Response(null, { status: 204 });
}

Express

Keep the body raw for this route, so the signature is checked over the bytes Ray sent:

import express from "express";
import { parseWebhookEvent, WebhookVerificationError } from "@gege-mn/ray";

const app = express();

app.post("/webhooks/ray", express.raw({ type: "application/json" }), async (req, res) => {
  try {
    const event = await parseWebhookEvent({
      payload: req.body, // Buffer
      headers: req.headers,
      secret: process.env.RAY_WEBHOOK_SECRET!,
    });
    res.status(204).end();
    void handleRayEvent(event); // process after acknowledging
  } catch (err) {
    if (err instanceof WebhookVerificationError) return res.status(400).send(err.reason);
    throw err;
  }
});

Respond with a 2xx within 10 seconds and deduplicate on notification_log_id (or send_id for send.completed): delivery is at-least-once. See Delivery behavior.

Related

  • MCP server: the same operations as tools for an AI agent.
  • Using Ray with AI coding agents: skills, llms.txt and a copy-paste prompt.
  • OpenAPI spec: the source of truth for request and response shapes.
PreviousMCP serverNextSending notifications

On this page

  • Install
  • Runtime support
  • Initialize
  • Send notifications
  • Send modes
  • Idempotency keys
  • Delivery status
  • Channels
  • Templates
  • Customer feed
  • Click stats
  • Delivery webhooks
  • Account and usage
  • Errors
  • Retries
  • Verify webhook signatures
  • Next.js route handler
  • Express
  • Related
Ray

Notification delivery for teams that already have providers.

Product

PricingDocsAPI referenceMCP serverTypeScript SDKAgent skillsllms.txt

Legal

TermsPrivacyRefunds

Contact

[email protected]

© 2026 L’Atelier Gege LLC