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.
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
paramsset 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
400and nothing is sent. - Suppression is checked per recipient. Suppressed targets get status
suppressedand 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.completedwebhook 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,logDescriptionorshowInFeed. - A delivery's
paramsreplaces (does not merge with) the top-levelparams. feedrequires a top-levelexternalUserIdand 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[]anddeliveries[]. 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
- Ray writes one delivery row per recipient or channel with status
pending(orsuppressed), plus any feed entries, in one transaction. - A worker picks each row up (highest priority first), waits for the channel's provider pacing, and calls the provider.
- The row becomes
delivered,failed_terminal, orfailed_retryable(retried with exponential backoff). - When every row is final, the send is complete and subscribed webhooks fire.