Guides
Status and feeds
Read per-send delivery status with GET /sends/{sendId} and show end users their notification history with the GET /notifications feed.
Ray exposes two read APIs. GET /sends/{sendId} is for you: the delivery status of every row of a send, with provider ids and errors. GET /notifications is for your end users: an in-app notification feed per externalUserId. Both live at https://ray-api.gege.mn, accept any valid API key (Authorization: Bearer $RAY_API_KEY), and are scoped to the key's workspace.
Per-send status
GET /sends/{sendId}
| Query | Notes |
|---|---|
limit |
Rows per page, 1 to 200, default 50. |
cursor |
The nextCursor from the previous page. |
curl "https://ray-api.gege.mn/sends/6a7b8c9d-0e1f-4a2b-9c3d-4e5f6a7b8c9d?limit=50" \
-H "Authorization: Bearer $RAY_API_KEY"
{
"sendId": "6a7b8c9d-0e1f-4a2b-9c3d-4e5f6a7b8c9d",
"createdAt": "2026-09-14T09:30:00.120Z",
"aggregate": { "total": 3, "delivered": 2, "suppressed": 1 },
"rows": [
{
"id": "c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f",
"channelConfigId": "9f1c2a7e-3b4d-4e5f-8a6b-1c2d3e4f5a6b",
"templateId": "1d2e3f4a-5b6c-4d7e-8f9a-0b1c2d3e4f5a",
"templateVersionId": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e",
"externalUserId": "user_1",
"showInFeed": false,
"recipient": { "email": "[email protected]" },
"logTitle": "Verification code sent",
"logDescription": "We emailed Ada a sign-in code.",
"status": "delivered",
"providerMessageId": "0100019252a1b2c3-5e7f9a1b-2c3d-4e5f-8a6b-7c8d9e0f1a2b-000000",
"providerError": null,
"isTest": false,
"notBefore": null,
"createdAt": "2026-09-14T09:30:00.120Z",
"updatedAt": "2026-09-14T09:30:01.480Z",
"dispatchedAt": "2026-09-14T09:30:01.480Z"
}
],
"nextCursor": null
}
aggregatecounts rows by status across the whole send (not just this page).totalis always present; a status appears only if at least one row has it.rowsare ordered oldest first.nextCursorisnullon the last page.templateIdandtemplateVersionIdarenullfor inline-content deliveries.- Email
recipient.attachmentsare listed without their file contents. - A feed-only send has no delivery rows and returns
"aggregate": { "total": 0 }and"rows": []. - An unknown or other-workspace id returns
404send not found.
Fetch every row of a large fan-out:
type SendDetail = {
sendId: string;
aggregate: Record<string, number> & { total: number };
rows: Array<{ id: string; status: string; recipient: unknown; providerError: unknown }>;
nextCursor: string | null;
};
async function getAllRows(sendId: string) {
const rows: SendDetail["rows"] = [];
let cursor: string | null = null;
do {
const url = new URL(`https://ray-api.gege.mn/sends/${sendId}`);
url.searchParams.set("limit", "200");
if (cursor) url.searchParams.set("cursor", cursor);
const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.RAY_API_KEY}` } });
if (!res.ok) throw new Error(`GET /sends failed: ${res.status}`);
const page = (await res.json()) as SendDetail;
rows.push(...page.rows);
cursor = page.nextCursor;
} while (cursor);
return rows;
}
Prefer delivery webhooks over polling. If you do poll, back off and stop once no row is pending, claimed or failed_retryable.
Delivery statuses
| Status | Final | Meaning |
|---|---|---|
pending |
no | Accepted and waiting for a worker, or waiting for notBefore. |
claimed |
no | A worker is sending it now. |
failed_retryable |
no | A transient failure (network error, timeout, provider 5xx, throttling). It will be retried. |
delivered |
yes | The provider accepted the message. For email this means SES or your SMTP relay accepted it, not that it reached the inbox. |
failed_terminal |
yes | A permanent failure (invalid recipient, bad credentials, rejected content) or retries ran out. See providerError. |
suppressed |
yes | Not sent because the address is on the channel config's suppression list. Not billed. |
Retries use exponential backoff starting at 30 seconds, up to 5 retries. A row still failed_retryable or claimed an hour after its last update is moved to failed_terminal, and a row stranded in pending for 2 hours past its due time is too. Each channel guide lists which provider errors are terminal and which are retried.
providerError shapes:
{ "name": "MessageRejected", "message": "Email address is not verified." }
{ "reason": "channel config archived", "details": null }
The first is a provider error (name is the error class or provider error name). The second is a pipeline failure before the provider was called; other reasons include workspace archived, channel config invalid, config decryption failed, attachment content missing and dead-letter sweep: row never reached a terminal state.
providerMessageId is the provider's id when it returns one (SES message id, SMTP Message-ID, FCM message id, Telegram message_id, Twilio message SID, the sendsms.mn id when it returns one). Slack, Discord and webhook channels return no id, so Ray stores slack-<rowId>, discord-<rowId> or webhook-<rowId>.
Customer feed
GET /notifications?externalUserId=<your user id>
The feed is a list of in-app notifications for one of your end users, newest first. Feed entries are created by POST /send with showInFeed or feed (see Feed entries). They are separate records, independent of delivery:
- An entry appears as soon as the send is accepted, or at
notBeforefor scheduled sends. - It stays even if the email bounced, the push token was dead, or the recipient was suppressed.
- A multi-channel send produces exactly one entry.
- Test sends never create entries.
| Query | Notes |
|---|---|
externalUserId |
Required, 1 to 256 characters. external_user_id is accepted as an alias. |
limit |
1 to 200, default 50. |
cursor |
The nextCursor from the previous page. |
templateId |
Only entries whose send used this template (for multi-channel sends, the first delivery's template). |
channelConfigId |
Only entries whose send used this channel config (for multi-channel sends, the first delivery's config). |
after, before |
Inclusive bounds on createdAt. ISO 8601 in UTC with a Z suffix, for example 2026-09-01T00:00:00Z. |
status |
Kept for compatibility. Entries are always delivered; any other value returns an empty page. |
curl "https://ray-api.gege.mn/notifications?externalUserId=user_123&limit=20" \
-H "Authorization: Bearer $RAY_API_KEY"
{
"notifications": [
{
"id": "d4e5f6a7-b8c9-4d0e-9f1a-2b3c4d5e6f7a",
"sendId": "6a7b8c9d-0e1f-4a2b-9c3d-4e5f6a7b8c9d",
"channelConfigId": "9f1c2a7e-3b4d-4e5f-8a6b-1c2d3e4f5a6b",
"templateId": "1d2e3f4a-5b6c-4d7e-8f9a-0b1c2d3e4f5a",
"externalUserId": "user_123",
"logTitle": "Verification code sent",
"logDescription": "We emailed Ada a sign-in code.",
"status": "delivered",
"providerMessageId": null,
"createdAt": "2026-09-14T09:30:00.120Z",
"dispatchedAt": null
}
],
"nextCursor": "MjAyNi0wOS0xNFQwOTozMDowMC4xMjBafGQ0ZTVmNmE3LWI4YzktNGQwZS05ZjFhLTJiM2M0ZDVlNmY3YQ"
}
logTitleandlogDescriptionare the entry's title and description:feed.title/feed.descriptionif given, otherwise the template's renderedlogTitle/logDescription.channelConfigIdandtemplateIdare bothnullfor feed-only sends.templateIdis alsonullwhen the first delivery used inline content.- Treat
nextCursoras opaque. statusis always"delivered", andproviderMessageIdanddispatchedAtare alwaysnull. They are kept for compatibility; delivery detail lives onGET /sends/{sendId}.- The feed is read-only. There is no read or unread state; track that in your app by entry
idif you need it.
Serve the feed from your backend
Your API key must stay server-side, so proxy the feed through your own endpoint and pass the signed-in user's id:
// GET /api/notifications in your app (Next.js route handler)
export async function GET(request: Request) {
const userId = await getSignedInUserId(request); // your auth
const cursor = new URL(request.url).searchParams.get("cursor");
const url = new URL("https://ray-api.gege.mn/notifications");
url.searchParams.set("externalUserId", userId);
url.searchParams.set("limit", "20");
if (cursor) url.searchParams.set("cursor", cursor);
const res = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.RAY_API_KEY}` },
});
return new Response(res.body, { status: res.status, headers: { "Content-Type": "application/json" } });
}
logTitle and logDescription are not escaped by Ray. Render them as text, not as HTML.
Retention
- Delivery records (
GET /sends/{sendId}rows) are kept for your plan's retention window: 30 days on Free, 90 days on Starter, 365 days on Pro, 730 days on Scale. - Message content is scrubbed after 30 days on every plan:
recipientand the rendered content become{ "redacted": true },logTitlebecomes[redacted]andlogDescriptionbecomes empty. Status, provider ids, errors and timestamps remain. - Feed entries are stored separately from delivery records.