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

Reference

Errors

Ray API error format, every HTTP status and error code, common messages per endpoint, and how delivery failures differ from request errors.

View as Markdownllms.txtAPI reference

Ray reports problems in two places. Request errors come back immediately from https://ray-api.gege.mn as an HTTP status with a JSON body. Delivery failures happen later, after POST /send returned 202, and show up on delivery rows (GET /sends/{sendId}) and in delivery webhooks.

Error format

{ "error": "not_found", "message": "template not found or not published" }
  • error is a stable, machine-readable code. Branch on it together with the HTTP status.
  • message is human-readable detail. Validation messages usually start with the offending field (recipient: ..., content: subject: ...). Wording can change; don't parse it.

Some errors add fields:

Status Extra fields Extra headers
429 retry_after_seconds Retry-After, RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset
402 limit, used RayQuota-Limit, RayQuota-Used

Status codes

Status error Meaning Retry?
400 validation_error The request is invalid: malformed body, missing or conflicting fields, wrong recipient shape, missing template params, incompatible template kind, or a key without the write scope. No. Fix the request.
401 unauthorized Missing, malformed, unknown or revoked API key. No.
402 quota_exceeded Free plan monthly quota reached, or the daily test-send allowance is used up. After upgrading, or next month or day.
403 forbidden The plan doesn't include the feature (creating delivery webhooks). After upgrading.
404 not_found The resource doesn't exist, is archived, or belongs to another workspace. No.
409 conflict Idempotency key conflict, or a template name already in use. Only the in-progress idempotency case.
413 payload_too_large Body over 45 MiB on POST /send or 1 MiB elsewhere. No.
429 rate_limit_exceeded Rate limited. Yes, after Retry-After seconds.
500 internal_server_error Unexpected server error. The body is { "error": "internal_server_error" } with no message. Yes, with backoff and an Idempotency-Key.

Common messages

Any endpoint

Status message
401 missing or invalid Authorization header
401 invalid api key
400 write scope required
400 body: expected a JSON object (template and webhook endpoints, when the body isn't valid JSON)
413 request body exceeds the size limit
429 rate limit exceeded

POST /send

Status message Fix
400 exactly one of `recipient` or `targets` is required Pass one of them with channelConfigId.
400 exactly one of `templateId` or `content` is required Choose a single content source.
400 one of `channelConfigId`, `deliveries`, or `feed` is required Pick a request mode.
400 `deliveries` cannot be combined with top-level `channelConfigId` Move channel fields into deliveries[].
400 `feed` requires `externalUserId` with `deliveries[]` Add top-level externalUserId.
400 feed-only sends require `feed.title` Add feed.title.
400 missing required params: name, code Add the listed keys to params.
400 missing required parameter: name A top-level {{name}} had no value at render time.
400 cannot interpolate non-scalar parameter 'items'; use a section {{#items}}...{{/items}} Use a section for arrays and objects.
400 recipient: <reason> Match the channel's recipient shape (GET /channels returns it as JSON Schema).
400 content: <field>: <reason> Match the channel's content shape.
400 recipient: from.email must be an address on example.com exactly (the channel's from-address domain) Use an address on the email channel config's from-address domain, or create a channel config for the other domain.
400 template kind 'slack_text' is not compatible with channel 'ses_email' Use a template of the channel's kind.
400 'subject' must not contain CR/LF (header injection) Strip line breaks from params used in the subject.
400 designed-mode content is dashboard-only; inline ... Send raw fields (subject, bodyHtml, bodyText).
402 monthly notification quota exceeded Upgrade at https://ray.gege.mn/pricing or wait for the next month.
404 channel config not found Wrong id, archived config, or other workspace.
404 template not found or not published Publish the template, or check it isn't archived.
409 Idempotency-Key reused with a different request body Use a new key for a different message.
409 a request with this Idempotency-Key is already in progress Retry shortly with the same key and body.
409 Idempotency-Key is in use; retry shortly Retry shortly with the same key and body.

Cross-field rules that don't belong to a single field (the first six rows) currently come back with an empty field prefix, for example : exactly one of `recipient` or `targets` is required.

Templates

Status message
400 content: <field>: <reason>
400 unclosed section: {{#items}}
400 mismatched section: expected {{/a}} but found {{/b}}
400 unexpected closing section: {{/items}}
400 channelKind cannot be changed after creation
400 template is archived
400 no draft to publish
400 designed-mode templates can only be created via the dashboard; the API path is raw-only
402 daily test-send allowance exceeded (50/day on the free plan)
404 template not found
404 template not found or already archived
404 template not found or not archived
409 a template named '<name>' already exists in this tenant (it may be archived ...)

Status, feed and clicks

Status message
404 send not found
400 cursor: malformed
400 externalUserId: <reason>
400 limit: <reason> (valid range 1 to 200)
400 one of `sendId` or `campaignId` is required
400 `sendId` must be a UUID

Delivery webhooks

Status message
400 url: URL must use https
400 url: URL targets a private/loopback/link-local address
400 url: URL targets an internal hostname
400 body: patch body must include at least one field
403 delivery webhooks require the Pro plan or higher
404 webhook not found

Unknown paths return a plain-text 404 Not Found, not the JSON envelope.

Handling errors in code

class RayError extends Error {
  constructor(
    public status: number,
    public code: string,
    message: string,
    public retryAfterSeconds?: number,
  ) {
    super(message);
  }
}

async function ray<T>(path: string, init: RequestInit = {}): Promise<T> {
  const res = await fetch(`https://ray-api.gege.mn${path}`, {
    ...init,
    headers: {
      Authorization: `Bearer ${process.env.RAY_API_KEY}`,
      "Content-Type": "application/json",
      ...init.headers,
    },
  });
  if (res.ok) return (await res.json()) as T;

  const body = (await res.json().catch(() => ({}))) as {
    error?: string;
    message?: string;
    retry_after_seconds?: number;
  };
  throw new RayError(res.status, body.error ?? "unknown", body.message ?? res.statusText, body.retry_after_seconds);
}

try {
  await ray<{ sendId: string }>("/send", { method: "POST", body: JSON.stringify({ /* ... */ }) });
} catch (err) {
  if (err instanceof RayError) {
    switch (err.code) {
      case "validation_error": // bug in the request: log and alert, don't retry
      case "not_found":
        throw err;
      case "rate_limit_exceeded": // retry after err.retryAfterSeconds
      case "internal_server_error": // retry with backoff, same Idempotency-Key
        break;
      case "quota_exceeded": // stop sending, prompt an upgrade
        break;
    }
  }
}

Delivery failures

A 202 from POST /send means the send was accepted, not delivered. Provider-side problems are recorded on each delivery row:

  • status: "failed_retryable": temporary, Ray retries automatically.
  • status: "failed_terminal": permanent. providerError explains why, as { "name", "message" } for provider errors (for example { "name": "TelegramError", "message": "Telegram sendMessage failed: 403 Forbidden: bot was blocked by the user" }) or { "reason", "details" } for pipeline failures (for example channel config archived).
  • status: "suppressed": the address is on the suppression list.

Read them with GET /sends/{sendId}, or subscribe to notification.failed_terminal and send.completed webhooks. Each channel guide lists which provider errors are terminal.

PreviousRate limits and quotas

On this page

  • Error format
  • Status codes
  • Common messages
  • Any endpoint
  • POST /send
  • Templates
  • Status, feed and clicks
  • Delivery webhooks
  • Handling errors in code
  • Delivery failures
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