Guides
Scheduling and test sends
Schedule Ray notifications for later with notBefore, and test a published template against a real recipient with POST /templates/{id}/test-send.
Two tools for controlling when and how a notification goes out: notBefore delays a normal send, and test sends deliver a template to yourself without touching customer feeds, webhooks or your monthly quota. All requests go to https://ray-api.gege.mn with Authorization: Bearer $RAY_API_KEY (write scope).
Scheduled sends
Add notBefore (an ISO 8601 datetime) to any POST /send request.
curl -X POST https://ray-api.gege.mn/send \
-H "Authorization: Bearer $RAY_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: trial-ending-user_123" \
-d '{
"channelConfigId": "9f1c2a7e-3b4d-4e5f-8a6b-1c2d3e4f5a6b",
"templateId": "1d2e3f4a-5b6c-4d7e-8f9a-0b1c2d3e4f5a",
"params": { "name": "Ada", "daysLeft": 3 },
"recipient": { "email": "[email protected]" },
"externalUserId": "user_123",
"showInFeed": true,
"notBefore": "2026-09-20T09:00:00+08:00"
}'
const inThreeDays = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString();
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": "trial-ending-user_123",
},
body: JSON.stringify({
channelConfigId: "9f1c2a7e-3b4d-4e5f-8a6b-1c2d3e4f5a6b",
templateId: "1d2e3f4a-5b6c-4d7e-8f9a-0b1c2d3e4f5a",
params: { name: "Ada", daysLeft: 3 },
recipient: { email: "[email protected]" },
notBefore: inThreeDays,
}),
});
const { sendId } = (await res.json()) as { sendId: string };
How scheduling behaves:
- The request is validated, rendered and stored immediately, and returns
202with asendId. Validation errors surface now, not at delivery time. - Content is rendered at request time. Publishing a new template version later does not change a scheduled send.
- Delivery rows stay
pending(withnotBeforeset) until the time arrives, then dispatch normally. - A
notBeforein the past dispatches right away. - Both
Zand numeric offsets (+08:00) are accepted. - Feed entries of a scheduled send are hidden from
GET /notificationsuntilnotBefore. - Notifications are counted toward your quota when the request is accepted.
- If the channel config or the workspace is archived before the send is due, the rows end as
failed_terminal(channel config archivedorworkspace archived). - There is no API to cancel or reschedule a scheduled send. For reminders that may become obsolete, schedule them in your own system and call
POST /sendwhen due.
Test sends
POST /templates/{id}/test-send delivers a template's published version through a real channel config to a real recipient. It runs the full pipeline (render, suppression check, provider call), so it proves credentials, content and recipient all work.
A test send differs from a normal send:
- The delivery row is marked
isTest: true. - It never creates a feed entry and never fires delivery webhooks.
- It does not count toward your monthly quota. It has its own daily allowance instead (below).
- It respects suppressions: a suppressed address is recorded as
suppressedand not sent. - It takes only a template. To test inline content, make a normal
POST /send. - It does not accept
Idempotency-Key,notBefore,priorityor fan-out.
The dashboard's Send test button on a template uses the same endpoint.
Request
| Field | Type | Notes |
|---|---|---|
channelConfigId |
uuid | Required. Must be a channel compatible with the template kind. |
recipient |
object | Required. Channel-specific shape, see the channel guides. |
params |
object | Values for the template's required params. Default {}. |
curl -X POST https://ray-api.gege.mn/templates/1d2e3f4a-5b6c-4d7e-8f9a-0b1c2d3e4f5a/test-send \
-H "Authorization: Bearer $RAY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"channelConfigId": "9f1c2a7e-3b4d-4e5f-8a6b-1c2d3e4f5a6b",
"recipient": { "email": "[email protected]" },
"params": { "name": "Ada", "code": "000000" }
}'
202 Accepted:
{ "sendId": "6a7b8c9d-0e1f-4a2b-9c3d-4e5f6a7b8c9d" }
async function testTemplate(templateId: string, channelConfigId: string, recipient: object, params: object) {
const res = await fetch(`https://ray-api.gege.mn/templates/${templateId}/test-send`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.RAY_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ channelConfigId, recipient, params }),
});
if (res.status !== 202) throw new Error(`test send failed: ${res.status} ${await res.text()}`);
const { sendId } = (await res.json()) as { sendId: string };
return sendId; // poll GET /sends/{sendId} to see the provider result
}
Check the outcome with GET /sends/{sendId}. A failed provider call shows up there as failed_terminal with providerError.
Daily allowance
Test sends are limited per workspace per UTC day:
| Plan | Test sends per day |
|---|---|
| Free | 50 |
| Starter | 250 |
| Pro | 500 |
| Scale | 500 |
Every test-send response carries RayTestQuota-Limit, RayTestQuota-Used and RayTestQuota-Remaining headers. Past the allowance the call fails with 402:
{
"error": "quota_exceeded",
"message": "daily test-send allowance exceeded (50/day on the free plan)",
"limit": 50,
"used": 50
}
Errors
| Status | error |
message |
|---|---|---|
400 |
validation_error |
missing required params: ..., recipient: <reason>, template kind '...' is not compatible with channel '...', write scope required |
402 |
quota_exceeded |
daily test-send allowance exceeded (...) |
404 |
not_found |
channel config not found, template not found or not published |