Getting started
TypeScript SDK
Use the @gege-mn/ray TypeScript SDK to send notifications, manage templates and webhooks, handle RayError and verify webhook signatures.
@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_KEYenvironment variable, and throws aRayErrorwith codemissing_api_keyif neither is set. On runtimes withoutprocess.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.