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

Idempotency

Make POST /send safe to retry with the Idempotency-Key header. Replay rules, 409 conflicts, the 24 hour window and duplicate handling.

View as Markdownllms.txtAPI reference

Network calls fail in ambiguous ways: a timeout does not tell you whether Ray accepted the send. Pass an Idempotency-Key header on POST https://ray-api.gege.mn/send and you can retry the exact same request without sending twice.

curl -X POST https://ray-api.gege.mn/send \
  -H "Authorization: Bearer $RAY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: password-reset-user_123-7f3c" \
  -d '{
    "channelConfigId": "9f1c2a7e-3b4d-4e5f-8a6b-1c2d3e4f5a6b",
    "templateId": "1d2e3f4a-5b6c-4d7e-8f9a-0b1c2d3e4f5a",
    "params": { "resetUrl": "https://app.example.com/reset?t=abc123" },
    "recipient": { "email": "[email protected]" }
  }'

Rules

The key is any non-empty string you choose (surrounding whitespace is trimmed). Keys are scoped to your workspace. Ray stores the key with a hash of the request body and the response for 24 hours.

Situation Result
No Idempotency-Key header Every call is a new send.
New key The send is processed normally and the 202 response is stored.
Same key, same body, first request finished The stored 202 response with the original sendId is returned. Nothing is sent again.
Same key, different body 409 conflict: Idempotency-Key reused with a different request body.
Same key while the first request is still being processed 409 conflict: a request with this Idempotency-Key is already in progress (or Idempotency-Key is in use; retry shortly). Retry after a short delay to get the stored response.
Same key after 24 hours The key has expired and the request is treated as new.

Details:

  • The body hash is computed over the JSON with object keys sorted, so key order and whitespace don't matter. Any value difference does, including array order.
  • Only successful sends are stored. If a request fails validation (400, 404), no key is recorded; fix the body and retry with the same key.
  • A replayed request does not consume quota again.
  • Only POST /send supports idempotency keys. POST /templates/{id}/test-send and other endpoints ignore the header.

Choosing keys

Derive the key from the business event, not from the HTTP attempt, so every retry of the same event carries the same key:

  • order-confirmation-<orderId>
  • password-reset-<userId>-<resetTokenId>
  • a UUID generated once when the event is created and stored with it

A fresh crypto.randomUUID() per retry defeats the purpose.

Retrying safely

async function sendWithRetry(body: unknown, idempotencyKey: string, attempts = 4) {
  for (let attempt = 1; ; attempt++) {
    let res: Response | undefined;
    try {
      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": idempotencyKey,
        },
        body: JSON.stringify(body),
        signal: AbortSignal.timeout(15_000),
      });
    } catch (err) {
      // Network error or timeout: the send may or may not exist. Retrying with
      // the same key is safe.
      if (attempt >= attempts) throw err;
    }

    if (res?.status === 202) return (await res.json()) as { sendId: string };

    if (res) {
      const err = (await res.json().catch(() => ({}))) as { error?: string; message?: string };
      const retryable =
        res.status === 429 ||
        res.status >= 500 ||
        // 409 on a matching body means the first attempt is still in flight.
        (res.status === 409 && !err.message?.includes("different request body"));
      if (!retryable || attempt >= attempts) {
        throw new Error(`Ray ${res.status} ${err.error}: ${err.message}`);
      }
      const retryAfter = Number(res.headers.get("Retry-After"));
      if (retryAfter > 0) {
        await new Promise((r) => setTimeout(r, retryAfter * 1000));
        continue;
      }
    }
    await new Promise((r) => setTimeout(r, 2 ** attempt * 250));
  }
}

With the TypeScript SDK

The TypeScript SDK sends the idempotencyKey option as the Idempotency-Key header:

import { Ray } from "@gege-mn/ray";

const ray = new Ray(process.env.RAY_API_KEY);

const { sendId } = await ray.send(
  {
    channelConfigId: "9f1c2a7e-3b4d-4e5f-8a6b-1c2d3e4f5a6b",
    templateId: "1d2e3f4a-5b6c-4d7e-8f9a-0b1c2d3e4f5a",
    params: { resetUrl: "https://app.example.com/reset?t=abc123" },
    recipient: { email: "[email protected]" },
  },
  { idempotencyKey: "password-reset-user_123-7f3c" },
);

When you don't pass a key, the SDK generates one per ray.send call and reuses it for its own retries of that call, so a timeout followed by an automatic retry can't double-send. That doesn't cover your code calling ray.send again for the same event, for example a job that is run twice. The rules in Choosing keys still apply: pass a key derived from the business event.

Crash window

While a request is being processed its key is reserved for up to 5 minutes. If the Ray API process died in the middle of your request, retries within those 5 minutes get 409 and retries after it are processed as a new send. That can, rarely, deliver twice. Ray chooses at-least-once delivery over losing a send.

Duplicates after acceptance

The idempotency key protects the API call. Delivery itself is at-least-once: if a provider call times out, Ray cannot know whether the provider accepted the message and retries it. To deduplicate on the receiving side:

  • Delivery webhooks carry a stable notification_log_id per delivery row. Store it and ignore repeats.
  • The HTTPS webhook channel sends the delivery row id as the envelope id, the same across retries.
PreviousSending notificationsNextTemplates

On this page

  • Rules
  • Choosing keys
  • Retrying safely
  • With the TypeScript SDK
  • Crash window
  • Duplicates after acceptance
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