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

Guides

Sending notifications

Send notifications with POST /send, to one recipient, fan out to up to 1000 recipients, or deliver one message over several channels at once.

View as Markdownllms.txtAPI reference

POST https://ray-api.gege.mn/send renders a message and queues its delivery. It requires an API key with the write scope (Authorization: Bearer $RAY_API_KEY) and returns 202 Accepted with a sendId before any provider is called. Delivery is asynchronous; check it with GET /sends/{sendId} or a delivery webhook.

Request modes

One request uses exactly one of these modes.

Mode Required fields Creates
Single recipient channelConfigId, recipient, and templateId or content One delivery row.
Fan-out channelConfigId, targets[] (1 to 1000), and templateId or content One delivery row per target, all rendered from the same content.
Multi-channel deliveries[] (1 to 10), each with its own channelConfigId, recipient, and templateId or content One delivery row per channel, for one logical recipient.
Feed-only feed.title and externalUserId, no channel One feed entry and no channel delivery.

Every delivery needs a content source: templateId (a published template) or inline content (channel-shaped fields, the same shape a template of that kind stores). Never both.

Request body

Field Type Notes
channelConfigId uuid The channel config to send through. Single-recipient and fan-out modes. Get ids from GET /channels.
templateId uuid A published template whose kind matches the channel. Mutually exclusive with content.
content object Inline channel-shaped content, validated against the channel's content schema. Placeholders are rendered with params. Mutually exclusive with templateId.
params object Values for {{variables}}. Any JSON (strings, numbers, booleans, null, arrays, nested objects), max nesting depth 16 and 10,000 nodes. Default {}. One set applies to every recipient.
logTitle string, max 500 Feed title template. Only with inline content (templates carry their own).
logDescription string, max 2000 Feed description template. Only with inline content.
recipient object Channel-specific target, see the channel guides. Mutually exclusive with targets.
targets array, 1 to 1000 Fan-out: [{ "recipient": {...}, "externalUserId": "..." }]. externalUserId is optional per target.
externalUserId string, 1 to 256 Your id for the end user. Stored on the delivery rows and used for feed entries.
showInFeed boolean Default false. Creates a feed entry for each recipient that has an externalUserId. Not valid with deliveries.
feed object { "title"?: string (max 500), "description"?: string (max 2000) }. Explicitly creates a feed entry. See Feed entries.
deliveries array, 1 to 10 Multi-channel mode. Each entry: channelConfigId, templateId or content, recipient, optional params (falls back to top-level params), optional logTitle and logDescription (inline content only).
notBefore ISO 8601 datetime Schedule delivery. Offsets are accepted (2026-09-15T09:00:00+08:00). See Scheduling.
priority "high" or "low" Default "high". Use "low" for bulk or marketing sends so they never delay transactional ones. See Rate limits.
trackClicks boolean Default false. Email channels only. See Click tracking.
campaignId string, 1 to 256 Groups click stats across sends.

Optional request header: Idempotency-Key, see Idempotency.

Response:

{ "sendId": "6a7b8c9d-0e1f-4a2b-9c3d-4e5f6a7b8c9d" }

With the TypeScript SDK

The TypeScript SDK takes the same request body. Every example on this page works as ray.send(body, { idempotencyKey }); the raySend fetch helper used below is the dependency-free equivalent.

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

const ray = new Ray(process.env.RAY_API_KEY);

try {
  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" },
    },
    { idempotencyKey: "signup-code-user_123-2026-09-14T09:30" },
  );
} catch (err) {
  if (err instanceof RayError) console.error(err.status, err.code, err.message);
  throw err;
}

Single recipient

Send with a published template:

curl -X POST https://ray-api.gege.mn/send \
  -H "Authorization: Bearer $RAY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: signup-code-user_123-2026-09-14T09:30" \
  -d '{
    "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
  }'
type SendResponse = { sendId: string };

async function raySend(body: Record<string, unknown>, idempotencyKey?: string): Promise<SendResponse> {
  const res = await fetch("https://ray-api.gege.mn/send", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.RAY_API_KEY}`,
      "Content-Type": "application/json",
      ...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
    },
    body: JSON.stringify(body),
  });
  if (res.status !== 202) {
    const err = (await res.json().catch(() => ({}))) as { error?: string; message?: string };
    throw new Error(`Ray ${res.status} ${err.error}: ${err.message}`);
  }
  return (await res.json()) as SendResponse;
}

const { sendId } = await raySend(
  {
    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,
  },
  "signup-code-user_123-2026-09-14T09:30",
);

Send inline content instead of a template (here, a push notification):

curl -X POST https://ray-api.gege.mn/send \
  -H "Authorization: Bearer $RAY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "channelConfigId": "3a4b5c6d-7e8f-4a9b-8c0d-1e2f3a4b5c6d",
    "content": { "title": "Order shipped", "body": "Order {{orderId}} is on its way." },
    "logTitle": "Order shipped",
    "logDescription": "Order {{orderId}} left the warehouse.",
    "params": { "orderId": "A-1042" },
    "recipient": { "deviceToken": "fcm-device-token-from-your-app" },
    "externalUserId": "user_123",
    "showInFeed": true
  }'

With inline content, every top-level {{variable}} in content, logTitle and logDescription must have a value in params.

Fan-out

targets[] sends the same message to up to 1000 recipients on one channel in a single call.

curl -X POST https://ray-api.gege.mn/send \
  -H "Authorization: Bearer $RAY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "channelConfigId": "9f1c2a7e-3b4d-4e5f-8a6b-1c2d3e4f5a6b",
    "templateId": "1d2e3f4a-5b6c-4d7e-8f9a-0b1c2d3e4f5a",
    "params": { "releaseName": "September update" },
    "targets": [
      { "recipient": { "email": "[email protected]" }, "externalUserId": "user_1" },
      { "recipient": { "email": "[email protected]" }, "externalUserId": "user_2" },
      { "recipient": { "email": "[email protected]" } }
    ],
    "showInFeed": true,
    "priority": "low"
  }'
const users = [
  { email: "[email protected]", id: "user_1" },
  { email: "[email protected]", id: "user_2" },
];

const { sendId } = await raySend({
  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",
});

How fan-out behaves:

  • The content is rendered once with one params set and reused for every target. For per-recipient values (a name, a code), make one send per recipient.
  • Every target is validated before anything is written. One invalid recipient rejects the whole request with 400 and nothing is sent.
  • Suppression is checked per recipient. Suppressed targets get status suppressed and are not sent.
  • All rows share one sendId. GET /sends/{sendId} returns counts by status plus paginated rows.
  • A fan-out with more than one target fires one send.completed webhook instead of per-recipient events. See Webhooks.
  • For more than 1000 recipients, split into several sends.

Multi-channel

deliveries[] sends one logical notification to one person over several channels, for example push, email and Slack. Each delivery carries its own channelConfigId, content source and channel-shaped recipient.

curl -X POST https://ray-api.gege.mn/send \
  -H "Authorization: Bearer $RAY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "externalUserId": "user_42",
    "params": { "feature": "dark mode" },
    "feed": { "title": "New feature is live", "description": "Try dark mode in settings." },
    "deliveries": [
      {
        "channelConfigId": "3a4b5c6d-7e8f-4a9b-8c0d-1e2f3a4b5c6d",
        "content": { "title": "New: {{feature}}", "body": "Open settings to try it." },
        "recipient": { "deviceToken": "fcm-device-token-from-your-app" }
      },
      {
        "channelConfigId": "9f1c2a7e-3b4d-4e5f-8a6b-1c2d3e4f5a6b",
        "templateId": "1d2e3f4a-5b6c-4d7e-8f9a-0b1c2d3e4f5a",
        "recipient": { "email": "[email protected]" }
      },
      {
        "channelConfigId": "7c8d9e0f-1a2b-4c3d-9e4f-5a6b7c8d9e0f",
        "content": { "text": "Shipped *{{feature}}* to user_42" },
        "params": { "feature": "dark mode (beta)" },
        "recipient": {}
      }
    ]
  }'
const { sendId } = await raySend({
  externalUserId: "user_42",
  params: { feature: "dark mode" },
  feed: { title: "New feature is live", description: "Try dark mode in settings." },
  deliveries: [
    {
      channelConfigId: "3a4b5c6d-7e8f-4a9b-8c0d-1e2f3a4b5c6d",
      content: { title: "New: {{feature}}", body: "Open settings to try it." },
      recipient: { deviceToken: "fcm-device-token-from-your-app" },
    },
    {
      channelConfigId: "9f1c2a7e-3b4d-4e5f-8a6b-1c2d3e4f5a6b",
      templateId: "1d2e3f4a-5b6c-4d7e-8f9a-0b1c2d3e4f5a",
      recipient: { email: "[email protected]" },
    },
  ],
});

Rules for deliveries[]:

  • 1 to 10 entries. Cannot be combined with top-level channelConfigId, templateId, content, recipient, targets, logTitle, logDescription or showInFeed.
  • A delivery's params replaces (does not merge with) the top-level params.
  • feed requires a top-level externalUserId and creates exactly one feed entry, however many channels carry the message.
  • Each channel's row is delivered and retried independently. One channel failing does not affect the others.
  • There is no cross-product of targets[] and deliveries[]. To send multi-channel to many people, make one request per person.

Feed-only

A pure in-app notification with no channel delivery: pass feed and externalUserId and nothing channel-related.

curl -X POST https://ray-api.gege.mn/send \
  -H "Authorization: Bearer $RAY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "externalUserId": "user_42",
    "feed": { "title": "Your export is ready", "description": "Download it from the Reports page." }
  }'

feed.title is required. Besides feed and externalUserId, only notBefore is meaningful. A feed-only send fires send.completed immediately if you subscribe to it.

Feed entries

Feed entries are separate from delivery. They are created when the send is accepted (or become visible at notBefore), and stay visible even if the email bounces or the recipient is suppressed. Read them with GET /notifications.

Request Feed entries created
showInFeed: true with recipient One, if top-level externalUserId is set. Otherwise none.
showInFeed: true with targets[] One per target that has its own externalUserId. Targets without one are skipped.
feed with recipient or targets[] Same as showInFeed (with targets[], only per-target externalUserId values count), and title / description override the template's. If no externalUserId is given at all, the request fails with 400.
feed with deliveries[] Exactly one, for the top-level externalUserId (required).
Feed-only Exactly one.

The entry's title is feed.title if given, otherwise the rendered logTitle of the first delivery (from its template or inline logTitle). The description falls back the same way.

Suppressed recipients

Email channels check each recipient address against the channel config's suppression list, which Ray fills from SES hard bounces and complaints. A suppressed recipient is accepted (the request still returns 202) but its row is recorded with status suppressed and never sent. Suppressed rows don't count toward your quota. Manage suppressions in the dashboard under Suppressions.

Limits

Limit Value
targets[] 1 to 1000 entries
deliveries[] 1 to 10 entries
Request body for POST /send 45 MiB (email attachments are sent inline as base64)
params Nesting depth 16, 10,000 JSON nodes
externalUserId, campaignId 1 to 256 characters

Each delivery row and each feed entry counts as one notification toward your monthly quota, except suppressed rows and test sends. See Rate limits and quotas.

Errors

Status error Typical message
400 validation_error write scope required, exactly one of `recipient` or `targets` is required, exactly one of `templateId` or `content` is required, missing required params: name, code, recipient: <reason>, template kind 'slack_text' is not compatible with channel 'ses_email', feed-only sends require `feed.title`
402 quota_exceeded monthly notification quota exceeded (Free plan)
404 not_found channel config not found, template not found or not published
409 conflict Idempotency-Key conflicts, see Idempotency
413 payload_too_large request body exceeds the size limit
429 rate_limit_exceeded rate limit exceeded

Branch on the HTTP status and error code, not on message text. See Errors.

What happens after 202

  1. Ray writes one delivery row per recipient or channel with status pending (or suppressed), plus any feed entries, in one transaction.
  2. A worker picks each row up (highest priority first), waits for the channel's provider pacing, and calls the provider.
  3. The row becomes delivered, failed_terminal, or failed_retryable (retried with exponential backoff).
  4. When every row is final, the send is complete and subscribed webhooks fire.
PreviousTypeScript SDKNextIdempotency

On this page

  • Request modes
  • Request body
  • With the TypeScript SDK
  • Single recipient
  • Fan-out
  • Multi-channel
  • Feed-only
  • Feed entries
  • Suppressed recipients
  • Limits
  • Errors
  • What happens after 202
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