Reference
Errors
Ray API error format, every HTTP status and error code, common messages per endpoint, and how delivery failures differ from request errors.
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" }
erroris a stable, machine-readable code. Branch on it together with the HTTP status.messageis 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.providerErrorexplains 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 examplechannel 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.