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

Status and feeds

Read per-send delivery status with GET /sends/{sendId} and show end users their notification history with the GET /notifications feed.

View as Markdownllms.txtAPI reference

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
}
  • aggregate counts rows by status across the whole send (not just this page). total is always present; a status appears only if at least one row has it.
  • rows are ordered oldest first. nextCursor is null on the last page.
  • templateId and templateVersionId are null for inline-content deliveries.
  • Email recipient.attachments are 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 404 send 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 notBefore for 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"
}
  • logTitle and logDescription are the entry's title and description: feed.title / feed.description if given, otherwise the template's rendered logTitle / logDescription.
  • channelConfigId and templateId are both null for feed-only sends. templateId is also null when the first delivery used inline content.
  • Treat nextCursor as opaque.
  • status is always "delivered", and providerMessageId and dispatchedAt are always null. They are kept for compatibility; delivery detail lives on GET /sends/{sendId}.
  • The feed is read-only. There is no read or unread state; track that in your app by entry id if 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: recipient and the rendered content become { "redacted": true }, logTitle becomes [redacted] and logDescription becomes empty. Status, provider ids, errors and timestamps remain.
  • Feed entries are stored separately from delivery records.
PreviousScheduling and test sendsNextClick tracking

On this page

  • Per-send status
  • Delivery statuses
  • Customer feed
  • Serve the feed from your backend
  • Retention
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