Rate limits and retries
Rate limits are counted in weight units, so heavier requests cost more. Going over the limit returns 429. Overload or temporary unavailability returns 503. Both responses tell you when to retry.
#Request weight
| Endpoint | Weight |
|---|---|
GET /v1/me | 1 |
GET /v1/workspaces | 1 |
GET /v1/campaigns | 2 |
GET /v1/campaigns/{id} | 1 |
GET /v1/leads | 2 |
GET /v1/leads/{id} | 1 |
GET /v1/leads/{id}/messages | 3 |
GET /v1/events | 2, 3 with include_text=true |
#Limits by plan
Limits apply both to each key and to the whole organization, across all of its keys combined.
| Window | Pro | Premium | Enterprise |
|---|---|---|---|
| Per key, per second | 5 | 10 | 10 |
| Per key, per minute | 300 | 600 | 600 |
| Per organization, per second | 10 | 25 | 25 |
| Per organization, per minute | 600 | 1500 | 1500 |
| Concurrent requests per key | 5 | 5 | 5 |
| Concurrent requests per organization | 10 | 10 | 10 |
For example, on the Pro plan one key can make 2 lead list requests per second (weight 2, limit 5) and up to 150 such requests per minute. GET /v1/me returns the key's current limits in the limits field.
The API also limits requests from a single IP address: 20 per second on average, with short bursts up to 40. If one address receives 20 responses with status 401 within 5 minutes, the address is blocked for 15 minutes, and all its requests get 429 with Retry-After. Check your key before you retry requests that failed with 401.
Sometimes limits are temporarily reduced, for example during maintenance on our side. When that happens, limits.mode returns emergency and the limit fields show lower values. Code that reads the response headers adjusts on its own.
#Headers
Successful responses and 429 responses for key and organization limits include the limit state. A 429 response for the IP address limit doesn't include these headers, only Retry-After.
RateLimit-Policy: "key_second";q=5;w=1, "key_minute";q=300;w=60, "org_second";q=10;w=1, "org_minute";q=600;w=60
RateLimit: "key_second";r=3;t=1, "key_minute";r=291;t=60, "org_second";r=8;t=1, "org_minute";r=583;t=60
RateLimit-Limit: 5
RateLimit-Remaining: 3
RateLimit-Reset: 1| Header | Value |
|---|---|
RateLimit-Policy | The windows: name, limit q in weight units and window length w in seconds. |
RateLimit | For each window, the remaining amount r and the seconds until reset t. |
RateLimit-Limit | The limit of the window closest to running out. |
RateLimit-Remaining | The amount remaining in that window. |
RateLimit-Reset | Seconds until that window resets. |
#429 response
If a request's weight exceeds the remaining amount, or you exceed the number of concurrent requests, you get 429 rate_limit_exceeded with a Retry-After header in seconds. The request was not processed. Wait Retry-After seconds and add random jitter so that several processes don't retry at the same moment.
#503 response
503 means the request wasn't processed right now. The code tells you why:
| Code | Reason | Retry-After |
|---|---|---|
server_busy | The service is overloaded | 1 second |
service_unavailable | The data couldn't be read for now | 5 seconds |
integration_initializing | A new key is still being prepared | 5 seconds |
Retry with a growing delay: start with Retry-After, then double it up to 60 seconds. If you keep getting 503 for more than a few minutes, contact support and include the Request-Id.
#Retries are safe
All v1 routes only read data, so you can retry any request without side effects. An error doesn't advance the cursor: retrying with the same cursor returns the same page.
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function meliora(path, params = {}, maxAttempts = 6) {
const url = new URL(`https://api.melioralab.io/v1${path}`);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
for (let attempt = 1; ; attempt++) {
let res;
try {
res = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.MELIORA_API_KEY}` },
signal: AbortSignal.timeout(15_000),
});
} catch (networkError) {
if (attempt >= maxAttempts) throw networkError;
await sleep(Math.min(60, 2 ** attempt) * 1000);
continue;
}
if (res.ok) return res.json();
const problem = await res.json().catch(() => ({}));
if ((res.status === 429 || res.status === 503) && attempt < maxAttempts) {
const base = Number(res.headers.get('Retry-After')) || 5;
const wait = Math.min(60, base * (res.status === 503 ? 2 ** (attempt - 1) : 1));
await sleep((wait + Math.random() * Math.max(1, wait * 0.25)) * 1000);
continue;
}
const error = new Error(`Meliora API ${res.status} ${problem.code ?? ''}`);
Object.assign(error, { status: res.status, code: problem.code, requestId: res.headers.get('Request-Id') });
throw error;
}
}import os
import random
import time
import requests
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['MELIORA_API_KEY']}"
class MelioraError(Exception):
def __init__(self, status, code, request_id):
super().__init__(f"Meliora API {status} {code}")
self.status, self.code, self.request_id = status, code, request_id
def meliora(path, params=None, max_attempts=6):
for attempt in range(1, max_attempts + 1):
try:
resp = session.get(f"https://api.melioralab.io/v1{path}", params=params, timeout=15)
except requests.RequestException:
if attempt == max_attempts:
raise
time.sleep(min(60, 2 ** attempt))
continue
if resp.ok:
return resp.json()
if resp.status_code in (429, 503) and attempt < max_attempts:
base = int(resp.headers.get("Retry-After", "5"))
wait = min(60, base * (2 ** (attempt - 1) if resp.status_code == 503 else 1))
time.sleep(wait + random.uniform(0, max(1, wait * 0.25)))
continue
try:
code = resp.json().get("code")
except ValueError:
code = None
raise MelioraError(resp.status_code, code, resp.headers.get("Request-Id"))
raise RuntimeError("unreachable")#Staying within limits
- Poll the event feed once a minute instead of walking all leads on a schedule.
- Use
limit=100for lists and up to 200 for the feed: fewer requests for the same amount of data. - Filter on our side:
campaign_id,statusandusernamecost less than exporting the whole list. - Don't run more than 5 concurrent requests per key. Extra requests get 429.
- Request message text only where you need it: events with
include_text=trueand conversations have a weight of 3.