Getting started
Ray documentation
Ray is a notification delivery API for email, push, SMS, Slack, Discord, Telegram and webhooks. Core concepts and a 5-minute quickstart.
Ray is a multi-tenant notification delivery API: one HTTP endpoint (POST /send) delivers transactional notifications over email (Amazon SES or any SMTP relay), mobile push (Firebase Cloud Messaging), SMS (Twilio or sendsms.mn), Slack, Discord, Telegram and your own HTTPS webhook. Ray is a relay, so you bring your own provider credentials and Ray handles templates, idempotency, fan-out, retries, rate limiting, suppression, signed delivery webhooks, a per-user notification feed, click tracking and delivery logs. (Ray the notification API is not related to the Ray distributed computing framework.)
- Dashboard: https://ray.gege.mn
- API base URL:
https://ray-api.gege.mn - Live API reference: https://ray-api.gege.mn/docs
- OpenAPI 3.1 spec: https://ray-api.gege.mn/openapi.json
- TypeScript SDK:
npm install @gege-mn/ray, see TypeScript SDK - MCP server for AI agents:
https://ray-api.gege.mn/mcp, see MCP server - Agent skills: https://github.com/gege-mn/ray-skills
Every page in these docs is also available as raw markdown by appending .md to its URL (for example https://ray.gege.mn/docs/sending.md), and all pages are concatenated at https://ray.gege.mn/llms-full.txt.
Core concepts
| Concept | What it is |
|---|---|
| Workspace | Your tenant. Every API key, channel, template, send and webhook belongs to exactly one workspace. Data in another workspace is invisible (it returns 404). |
| API key | A bearer token (ck_live_...) created in the dashboard with read and/or write scope. See Authentication. |
| Channel config | A set of provider credentials stored encrypted in Ray (an SES identity, an SMTP relay, an FCM service account, a Slack webhook URL, ...). Referenced by channelConfigId. Created in the dashboard under Channels. |
| Template | Reusable, versioned message content for one template kind (email_html, fcm_basic, slack_text, discord_text, telegram_text, webhook_json) with {{variable}} placeholders. See Templates. |
| Inline content | Channel-shaped content sent directly in POST /send instead of a templateId. Rendered through the same pipeline as a template. |
| Recipient | The per-send delivery target. Its shape depends on the channel: { "email": "..." } for email, { "deviceToken": "..." } for push, { "chatId": "..." } for Telegram, { "phoneNumber": "..." } for SMS, {} for Slack, Discord and webhooks. |
| Params | The JSON values substituted into {{variables}} at render time. |
| Send | One accepted POST /send call, identified by sendId. A send has one delivery row per recipient (fan-out) or per channel (multi-channel). See Sending. |
| Feed entry | An in-app notification for one of your end users, keyed by your own externalUserId and read back with GET /notifications. See Status and feeds. |
| Suppression | An address Ray will not send to on a given channel config, added automatically from SES hard bounces and complaints. |
| Delivery webhook | A signed HTTPS callback Ray POSTs to your server when deliveries finish. See Webhooks. |
Channels
| Channel | Channel type | Template kind | Recipient | Guide |
|---|---|---|---|---|
| Amazon SES email | ses_email |
email_html |
{ "email", "name"?, "cc"?, "bcc"?, "from"?, "replyTo"?, "headers"?, "attachments"? } |
Email (SES) |
| SMTP email | smtp_email |
email_html |
same as SES | Email (SMTP) |
| Firebase Cloud Messaging | fcm_push |
fcm_basic |
{ "deviceToken" } or { "topic" } |
Push (FCM) |
| Slack incoming webhook | slack_webhook |
slack_text |
{} |
Slack |
| Discord webhook | discord_webhook |
discord_text |
{} |
Discord |
| Telegram bot | telegram_bot |
telegram_text |
{ "chatId" } |
Telegram |
| Twilio SMS | twilio_sms |
sms_text |
{ "phoneNumber" } (E.164) |
SMS (Twilio) |
| sendsms.mn SMS | sendsms_mn |
sms_text |
{ "phoneNumber" } (8 digits) |
SMS (sendsms.mn) |
| HTTPS webhook | generic_webhook |
webhook_json |
{} |
Webhook |
An email_html template works with both SES and SMTP channel configs, and an sms_text template works with both Twilio and sendsms.mn.
Quickstart
This sends your first email in about five minutes. You need provider credentials for one channel; the fastest options are a Slack or Discord webhook URL, or SMTP credentials you already have.
1. Create an API key
- Sign in at https://ray.gege.mn (GitHub sign-in) and create a workspace.
- Open API keys, click create, keep both
readandwritescopes checked. - Copy the key. It is shown in full only once.
export RAY_API_KEY="ck_live_..." # paste your key
Check it works:
curl https://ray-api.gege.mn/me \
-H "Authorization: Bearer $RAY_API_KEY"
{ "tenantId": "0f9e8d7c-6b5a-4c3d-9e2f-1a0b9c8d7e6f", "apiKeyId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", "scopes": ["read", "write"] }
2. Configure a channel
In the dashboard open Channels, click New channel, pick a provider and paste its credentials. For email over SMTP that is host, port, username, password and a from address; each channel guide lists exactly what to collect from the provider.
Then find the new config's id with the API:
curl https://ray-api.gege.mn/channels \
-H "Authorization: Bearer $RAY_API_KEY"
{
"channels": [
{
"id": "5d2e8f1a-6b7c-4d8e-9f0a-2b3c4d5e6f7a",
"name": "transactional",
"kind": "smtp_email",
"templateKind": "email_html",
"recipientSchema": { "type": "object", "properties": { "email": { "type": "string", "format": "email" } }, "required": ["email"] }
}
]
}
recipientSchema is the JSON Schema of the recipient object that channel expects (abbreviated above).
3. Send a notification
The quickest send uses inline content, so no template is needed yet:
curl -X POST https://ray-api.gege.mn/send \
-H "Authorization: Bearer $RAY_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"channelConfigId": "5d2e8f1a-6b7c-4d8e-9f0a-2b3c4d5e6f7a",
"content": {
"subject": "Hello from Ray, {{name}}",
"bodyHtml": "<p>Hi {{name}}, this is your first Ray notification.</p>",
"bodyText": "Hi {{name}}, this is your first Ray notification."
},
"params": { "name": "Ada" },
"recipient": { "email": "[email protected]" }
}'
{ "sendId": "6a7b8c9d-0e1f-4a2b-9c3d-4e5f6a7b8c9d" }
The 202 Accepted response means the send is queued. Delivery happens asynchronously.
For a Slack channel the same call is:
curl -X POST https://ray-api.gege.mn/send \
-H "Authorization: Bearer $RAY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"channelConfigId": "7c8d9e0f-1a2b-4c3d-9e4f-5a6b7c8d9e0f",
"content": { "text": "Deploy finished for *{{service}}*" },
"params": { "service": "billing-api" },
"recipient": {}
}'
The same email send with the TypeScript SDK (npm install @gege-mn/ray):
import { Ray } from "@gege-mn/ray";
const ray = new Ray(process.env.RAY_API_KEY);
const { sendId } = await ray.send(
{
channelConfigId: "5d2e8f1a-6b7c-4d8e-9f0a-2b3c4d5e6f7a",
content: {
subject: "Hello from Ray, {{name}}",
bodyHtml: "<p>Hi {{name}}, this is your first Ray notification.</p>",
bodyText: "Hi {{name}}, this is your first Ray notification.",
},
params: { name: "Ada" },
recipient: { email: "[email protected]" },
},
{ idempotencyKey: "welcome-ada" },
);
Or with plain fetch, no dependency:
const res = await fetch("https://ray-api.gege.mn/send", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.RAY_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify({
channelConfigId: "5d2e8f1a-6b7c-4d8e-9f0a-2b3c4d5e6f7a",
content: {
subject: "Hello from Ray, {{name}}",
bodyHtml: "<p>Hi {{name}}, this is your first Ray notification.</p>",
bodyText: "Hi {{name}}, this is your first Ray notification.",
},
params: { name: "Ada" },
recipient: { email: "[email protected]" },
}),
});
if (res.status !== 202) throw new Error(`Ray send failed: ${res.status} ${await res.text()}`);
const { sendId } = (await res.json()) as { sendId: string };
4. Check delivery
curl https://ray-api.gege.mn/sends/6a7b8c9d-0e1f-4a2b-9c3d-4e5f6a7b8c9d \
-H "Authorization: Bearer $RAY_API_KEY"
{
"sendId": "6a7b8c9d-0e1f-4a2b-9c3d-4e5f6a7b8c9d",
"createdAt": "2026-09-14T09:30:00.000Z",
"aggregate": { "total": 1, "delivered": 1 },
"rows": [ { "id": "c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f", "status": "delivered", "providerMessageId": "<[email protected]>", "providerError": null } ],
"nextCursor": null
}
Rows are abbreviated. A row moves from pending to delivered, failed_terminal or suppressed; see Status and feeds.
Next steps
- Move content into a versioned template and send with
templateId. - Send to many recipients or several channels at once: Sending.
- Make retries safe with Idempotency.
- Get told when deliveries finish with Webhooks.
- Use the typed client in Node.js, Bun, Deno or edge runtimes: TypeScript SDK.
- Using an AI coding agent: connect the MCP server, then see Ray for AI agents.
- Limits and error codes: Rate limits and quotas, Errors.