Guides
Idempotency
Make POST /send safe to retry with the Idempotency-Key header. Replay rules, 409 conflicts, the 24 hour window and duplicate handling.
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 /sendsupports idempotency keys.POST /templates/{id}/test-sendand 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_idper delivery row. Store it and ignore repeats. - The HTTPS webhook channel sends the delivery row id as the envelope
id, the same across retries.