Reference
Rate limits and quotas
Ray API rate limits per plan, monthly notification quotas, provider pacing per channel, send priority, retries and request size limits.
Four separate mechanisms shape throughput in Ray: API rate limits (how fast you can call https://ray-api.gege.mn), the monthly notification quota (how much you can send), provider pacing (how fast queued messages leave for each provider) and send priority (which queued messages go first).
API rate limits
Two token buckets apply to every request:
- Per client IP, before authentication. This throttles unauthenticated traffic and key guessing.
- Per workspace, after authentication, shared by all API keys of the workspace. The size depends on the plan.
| Plan | Sustained requests per second | Burst |
|---|---|---|
| Free | 10 | 20 |
| Starter | 60 | 120 |
| Pro | 120 | 240 |
| Scale | 300 | 600 |
A bucket holds up to Burst tokens and refills at the sustained rate. Each request takes one token, whatever it does: one POST /send with 1000 targets is one request. To send many notifications, prefer fan-out over one request per recipient.
Responses carry these headers:
| Header | Meaning |
|---|---|
RateLimit-Limit |
Sustained requests per second of the bucket. |
RateLimit-Remaining |
Whole tokens left. |
RateLimit-Reset |
Seconds until the bucket is full again. |
Because two buckets apply, treat these headers as a hint. The authoritative signal is 429:
HTTP/1.1 429 Too Many Requests
Retry-After: 1
RateLimit-Limit: 10
RateLimit-Remaining: 0
RateLimit-Reset: 1
Content-Type: application/json
{ "error": "rate_limit_exceeded", "message": "rate limit exceeded", "retry_after_seconds": 1 }
Wait Retry-After seconds, then retry:
async function rayFetch(path: string, init: RequestInit = {}, maxAttempts = 5): Promise<Response> {
for (let attempt = 1; ; attempt++) {
const res = await fetch(`https://ray-api.gege.mn${path}`, {
...init,
headers: { Authorization: `Bearer ${process.env.RAY_API_KEY}`, ...init.headers },
});
if (res.status !== 429 || attempt >= maxAttempts) return res;
const seconds = Number(res.headers.get("Retry-After")) || 2 ** attempt;
await new Promise((r) => setTimeout(r, seconds * 1000));
}
}
Retrying POST /send is only safe with an Idempotency-Key (a 429 itself means nothing was sent, but a timeout does not).
Monthly quota
Each plan includes a number of notifications per calendar month (UTC). See https://ray.gege.mn/pricing for plans and prices.
| Plan | Notifications per month | At the limit |
|---|---|---|
| Free | 25,000 | Hard cap. Sends that would exceed it are refused with 402. |
| Starter | 250,000 | Soft cap. Sends keep working; responses are flagged over quota. |
| Pro | 1,000,000 | Soft cap. |
| Scale | 5,000,000 | Soft cap. |
There is no metered overage billing. Going over a soft cap shows an upgrade prompt; it does not block sending.
What counts as one notification:
- each delivery row created by
POST /send(one per recipient in fan-out, one per channel in multi-channel), and - each feed entry.
What does not count:
- suppressed recipients,
- test sends (they have a separate daily allowance),
- idempotent replays,
- requests that fail (
4xxor5xx).
Rows that are accepted and later fail at the provider (failed_terminal) do count.
POST /send responses carry quota headers:
| Header | Meaning |
|---|---|
RayQuota-Limit |
Monthly quota. |
RayQuota-Used |
Notifications counted this month, including this request. |
RayQuota-Remaining |
max(0, limit - used). |
RayQuota-Status |
over when a soft-capped plan is past its quota. Absent otherwise. |
On the Free plan, a request that would cross the quota is refused as a whole (a 1000-target send with 500 notifications left is rejected, not partially sent):
HTTP/1.1 402 Payment Required
RayQuota-Limit: 25000
RayQuota-Used: 24500
{ "error": "quota_exceeded", "message": "monthly notification quota exceeded", "limit": 25000, "used": 24500 }
Read current usage at any time:
curl https://ray-api.gege.mn/billing/subscription \
-H "Authorization: Bearer $RAY_API_KEY"
{
"plan": "starter",
"subscription": {
"provider": "polar",
"plan": "starter",
"status": "active",
"currentPeriodEnd": "2026-10-01T00:00:00.000Z",
"cancelAtPeriodEnd": false
},
"usage": { "monthlyQuota": 250000, "used": 18342, "remaining": 231658 }
}
plan uses internal keys: free, starter, growth (Pro) and business (Scale). subscription is null without a paid subscription.
Provider pacing
Past the API, queued deliveries leave at a rate each provider tolerates. A large send is never rejected for provider limits; it is queued and drains at the channel's pace.
| Channel | Pacing | Configurable |
|---|---|---|
ses_email |
ratePerMinute, default 840 (14 per second), max 1600 |
Yes, in the channel config. Set it to your SES maximum send rate times 60. |
smtp_email |
ratePerMinute, default 840, max 1600 |
Yes. Match your relay's limit. |
telegram_bot |
ratePerMinute, default 1500, max 1600 |
Yes. |
twilio_sms |
ratePerMinute, default 300, max 1600 |
Yes. Twilio queues anything above a sender's own throughput. |
sendsms_mn |
ratePerMinute, default 300, max 1600 |
Yes. |
slack_webhook |
1 message per second | No (Slack's limit per incoming webhook). |
fcm_push |
None | No. |
discord_webhook |
None (Discord 429s are retried) |
No. |
generic_webhook |
None | No. |
Pacing is keyed by credential, not by channel config: two SES configs with the same region and access key share one pace, as do two configs using the same SMTP host, port and username, the same Telegram bot token, the same Twilio Account SID and Auth Token, the same sendsms.mn API key and token, or the same Slack webhook URL. For fully independent throughput (for example marketing apart from transactional mail), use a separate provider credential.
A 10,000-recipient email at 840 per minute takes about 12 minutes to drain.
Send priority
All sends share one queue. Without priority, a 10,000-recipient newsletter queued first would delay a password-reset email queued a second later. Set priority on POST /send:
"high"(default): transactional messages such as sign-in codes, receipts and alerts."low": bulk and marketing.
Workers always take high-priority deliveries before low-priority ones, so transactional sends jump ahead of a queued bulk send. Priority changes ordering, not the provider pace; if both share a credential they still share its pacing.
{
"channelConfigId": "9f1c2a7e-3b4d-4e5f-8a6b-1c2d3e4f5a6b",
"templateId": "1d2e3f4a-5b6c-4d7e-8f9a-0b1c2d3e4f5a",
"targets": [{ "recipient": { "email": "[email protected]" } }],
"priority": "low"
}
Retries and timeouts
| What | Behavior |
|---|---|
| Provider call timeout | 10 seconds per request, 5 seconds to connect. |
| Retryable provider failure | Row becomes failed_retryable and is retried up to 5 times with exponential backoff from 30 seconds. |
| Row stuck retrying | Moved to failed_terminal an hour after its last attempt. |
| Delivery webhook to your endpoint | 10 second timeout, up to 10 retries with exponential backoff from 30 seconds, disabled after 10 consecutive failures. |
Each channel guide lists which provider errors are terminal and which are retried.
Request size limits
| Endpoint | Max body |
|---|---|
POST /send |
45 MiB (room for base64 email attachments) |
| Every other endpoint | 1 MiB |
Larger bodies get 413 with { "error": "payload_too_large", "message": "request body exceeds the size limit" }.
Other limits
| Limit | Value |
|---|---|
targets[] per send |
1000 |
deliveries[] per send |
10 |
| Test sends per UTC day | Free 50, Starter 250, Pro 500, Scale 500 |
| Email attachments | 20 per message, about 10 MiB each, about 25 MiB total |
Email cc and bcc |
50 each (SES allows 50 recipients per message in total) |
Email replyTo |
1 to 10 addresses |
Email headers |
15 per message (the SES limit); value 1 to 995 characters, name plus value at most 996 |