Errors
Failures carry a JSON body of the shape { "message": "..." }. Validation failures on the newer
endpoints may add an errors array naming the offending fields.
401 is the exception: it has no body at all. A missing, unknown or revoked token all produce
the same bare 401 — and so does a workspace on the Unlimited plan, which cannot use the API at
all. If a freshly generated token returns 401 on every endpoint, check the plan before debugging
the token.
| Code | Meaning |
|---|---|
200 | Success. On the render endpoints this means queued, not finished |
206 | Partial content. Only on POST /videos/from-presentation: conversion outlived the request timeout and is still running. Not a failure — poll GET /videos rather than retrying |
400 | The request was rejected. The message names the reason |
401 | Not authorised. No body |
403 | The resource belongs to another workspace |
404 | Not found |
429 | Either a rate limit or a quota — see below, they are not the same thing |
500 | Internal error |
Telling the two kinds of 429 apart
Both return 429, and the remedy is different. Read the message.
A rate limit means "you are going too fast". Wait and retry.
{ "message": "Too many requests per minute, please try again later.", "retryAfter": 42 }
These responses carry a Retry-After header with the same number of seconds. Use it instead of a
fixed backoff.
A quota means "you have created too many of something". Retrying will not help until the window
rolls or you upgrade.
{ "message": "You have reached the limit of 1000 videos per last 8 hours, please try again later" }
Rate limits
Only a few endpoints are rate limited, and the headers ship on every response from them — not
just the rejections — so you can see how much headroom is left before you are throttled.
| Endpoint | Per minute | Per hour | Applies to |
|---|---|---|---|
POST /videos/voice/{videoId}/{slideId} | 20 | 200 | Every caller |
POST /outline/{outlineId}/generate-chapters | 5 | 60 | API callers only, per workspace |
POST /outline/{outlineId}/generate-script | 5 | 60 | API callers only, per workspace |
| Header | On |
|---|---|
X-RateLimit-Limit-Min / X-RateLimit-Limit-Hour | Your ceiling for each window |
X-RateLimit-Remaining-Min / X-RateLimit-Remaining-Hour | Requests left in each window |
X-RateLimit-Reset-Min / X-RateLimit-Reset-Hour | Unix time (seconds) when each window resets |
Retry-After | Only on a 429 — seconds to wait |
The outline limits count public-API traffic only: the same workspace working in the Elai app does
not consume them.
Entity creation limits
A rolling 8-hour window per workspace. Exceeding one returns 429.
The buckets are Free versus paid-per-plan. A trial granted by an admin shares the paid
limits, not the Free ones — so a trialling Enterprise workspace gets 6000 videos, not 50.
| Entity | Free | Creator | Team | Enterprise |
|---|---|---|---|---|
| Videos (templates included) | 50 | 1000 | 1000 | 6000 |
| Folders | 10 | 100 | 100 | 100 |
| Uploaded media | 200 | 1000 | 1000 | 1000 |
| Custom fonts | 10 | 100 | 100 | 100 |
The Free column applies when the workspace's status is Free. Every other status — trial, active,
cancelled — reads the paid column for its plan. Two legacy plans, Corporate and Platinum, have no
entry and are therefore unlimited.
Absolute caps
These are not windows — they do not roll. You are at the limit until you delete something or
upgrade.
| Limit | Free | Creator | Team | Enterprise |
|---|---|---|---|---|
| Upload folders | 5 | 5 | 20 | Unlimited |
{ "message": "You have reached the limit of 20 upload folders for your team plan. Please upgrade your plan or delete some folders." }
Per-request limits
| Limit | Value |
|---|---|
Rows in one templateData batch (POST /videos/renderTemplate/{videoId}) | 200 |
| Slides per video | 100 |
| Slides rendered on a Free plan | 3 |
| Webhook response deadline | 10 seconds, single attempt, no retry |
Failures that are not HTTP errors
Rendering is fire-and-forget. POST /videos/render/{videoId} and
POST /videos/renderTemplate/{videoId} both validate after responding, so no render failure
reaches you as a status code. Those arrive as a video_error
webhook, or as status: "error" on
GET /videos/{videoId}.
Webhook deliveries are single-attempt with a 10-second deadline and no retry, so a missed
video_error is not resent. If you cannot afford to lose one, reconcile with
GET /videos as well.
