Rate limits
Limits apply to calls that start work. Reading the model list is limited per address; polling never counts against your plan.
Starting generations
Each POST /v1/generations counts against three limits at once:
| Account | Per key, per minute | Per account, per minute | Per account, per day |
|---|---|---|---|
| No plan | 20 | 40 | 2,500 |
| Any paid plan | 60 | 120 | 10,000 |
| Paid plan with $100 or more of top-up credit | 120 | 240 | 25,000 |
- A top-up balance of $100 or more moves an account up one row, with or without a plan.
- Minutes are fixed windows; the day resets at midnight UTC.
- A retry that returns an existing generation (same
Idempotency-Key) does not count.
Viral video library
Calls to the viral video library also count against the library's own limits, per account per minute: 20 searches (the trending feed included), 40 full-video reads and 30 saves.
What never counts
- Polling and reading:
GET /v1/generations/{id}, the/v1/jobs/...aliases,/contentandGET /v1/generations. - Requests refused before they reach the limiter (bad input, unknown model).
One safety ceiling still applies to reads: 1,200 a minute per key (20 a second), across GET /v1/generations/{id}, the /v1/jobs/... aliases, /content, GET /v1/generations and GET /v1/balance. Past it, reads answer 429 read_rate_limited with Retry-After. Polling each generation every few seconds stays far below it; to watch many at once, read them together with GET /v1/generations.
Public endpoints
Counted per IP address, per minute:
| Endpoints | Limit |
|---|---|
GET /v1/models, GET /v1/models/{id}, GET /v1/status, GET /v1/open/catalog, GET /v1/balance | 120 |
POST /v1/estimate, POST /v1/open/estimate | 60 |
POST /v1/generations/quote | 120 |
Past the limit they answer 429 rate_limited with Retry-After. Model list answers can be cached for 60 seconds (status for 30), so you rarely need to ask more often.
The 429 answer
A submit over its limit answers 429 with the limit that was hit, what was counted, and when it resets:
{
"error": {
"message": "Request limit reached for this API key (61/60 requests this minute on the Base plan). Resets at 2026-11-01T18:05:00.000Z. Job status/result polling never counts against rate limits.",
"type": "rate_limit_error",
"code": "request_per_minute"
},
"limits": { "per_minute": 60, "per_user_per_minute": 120, "per_day": 10000 },
"observed": { "minute_count": 61, "user_minute_count": 61, "day_count": 812 },
"plan": "base",
"reset_at": "2026-11-01T18:05:00.000Z"
}Wait the number of seconds in the Retry-After header, then retry with the same Idempotency-Key.
Idempotency
Send an Idempotency-Key header on every POST /v1/generations. If the answer is lost (a timeout, a dropped connection), send the exact same request again with the same key:
- The first call's generation comes back, with its current status. Nothing is started, held or charged a second time.
- A key belongs to your account and stays tied to the first generation it made. A later call with the same key returns that generation even if the body is different, so use a new key for new work.
- Two calls with the same key at the same moment still make only one generation.
- Format: 8 to 100 characters of letters, digits and
_ : . -. A UUID works well. Anything else answers400 invalid_idempotency_key.
curl -X POST https://vidlab.ai/v1/generations \
-H "Authorization: Bearer $VIDLAB_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 6f1c2a9e-3b4d-4e8f-9a0b-1c2d3e4f5a6b" \
-d '{"model": "gpt-image-2", "input": {"prompt": "A red sneaker on white"}}'import os, time, uuid, requests
key = str(uuid.uuid4()) # keep it with the work item, reuse it on every retry
def start(body, tries=4):
for i in range(tries):
try:
r = requests.post(
"https://vidlab.ai/v1/generations",
headers={
"Authorization": f"Bearer {os.environ['VIDLAB_API_KEY']}",
"Idempotency-Key": key,
},
json=body,
timeout=30,
)
except requests.RequestException:
r = None
if r is not None and r.ok:
return r.json()["data"]
if r is not None and r.status_code < 500 and r.status_code != 429:
raise RuntimeError(r.json()["error"]["message"])
wait = int(r.headers.get("retry-after", 0)) if r is not None else 0
time.sleep(wait or 2 ** i)
raise RuntimeError("VidLab did not answer")import { randomUUID } from "node:crypto";
const key = randomUUID(); // keep it with the work item, reuse it on every retry
async function start(body, tries = 4) {
for (let i = 0; i < tries; i++) {
const res = await fetch("https://vidlab.ai/v1/generations", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.VIDLAB_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": key,
},
body: JSON.stringify(body),
}).catch(() => null);
if (res && res.ok) return (await res.json()).data;
if (res && res.status < 500 && res.status !== 429) throw new Error((await res.json()).error.message);
const wait = Number(res?.headers.get("retry-after")) || 2 ** i;
await new Promise((r) => setTimeout(r, wait * 1000));
}
throw new Error("VidLab did not answer");
}