Errors and rate limits
Every CastPDF error code with its HTTP status, what it means and what to do, plus the rate limits per plan and Retry-After.
On this page
Error format
Every error response has the same JSON body. code is stable and meant for your code; message is for people and may change. docs_url links to the code's row below. details is present only for some codes: template errors include the Liquid line and column when they are known.
{
"error": {
"code": "rate_limited",
"message": "Rate limit of 60 requests per minute exceeded. Retry in 42 seconds.",
"docs_url": "https://castpdf.com/docs/errors#rate_limited"
}
}Every response, errors included, has an x-request-id header. Include it when you contact support.
Error codes
These are the codes the /v1 API returns, with their HTTP status:
| Code | HTTP | Meaning | What to do |
|---|---|---|---|
invalid_request | 400 | The request failed validation: invalid JSON, a missing, unknown or out-of-range field, both or neither of template_id and html, css together with template_id, metadata over 8 KB, a malformed Idempotency-Key, or page options the renderer cannot use. A body that is not application/json gets this code with HTTP 415. | Fix the request; the message names the problem. Retrying the same request fails again. |
invalid_api_key | 401 | The Authorization header is missing, is not "Bearer <key>", or the key is unknown or revoked. | Send Authorization: Bearer with an active key from the dashboard. |
plan_limit_reached | 402 | A live key used all documents included in this period, and overage is not available: the Free plan, a scheduled cancellation, an overdue payment, no active subscription, or this period’s overage was already billed. Also returned when creating a template would go over the plan’s template limit (Free 5, Starter 25, Growth 100, Pro 2,000 and Scale 2,000); then details.limit is "templates" and details.max is the number. Deleted templates are kept for 30 days, and all templates together are limited to that number plus 100; over it, details.limit is "templates_including_deleted". | Upgrade, resolve the payment, or wait for the next period. Test keys are not affected. For templates, delete one you no longer need, or upgrade. |
spending_cap_reached | 402 | The next overage document would take this period’s overage cost above your spending cap. | Raise the spending cap in the dashboard or upgrade. Renders resume immediately. |
forbidden | 403 | The team is suspended, a signed download link has an invalid signature, or a test key tried to create, change, pin or delete a template (test keys can only read and preview templates). | For a suspended team, contact support. For a download, use the url returned by the API unchanged. To change templates, use a live key or the dashboard. |
not_found | 404 | No route matches the method and path. | Check the method and path against the API reference. |
template_not_found | 404 | The template does not exist, was deleted, belongs to another workspace, or the requested version does not exist. | Check template_id and template_version (GET /v1/templates/:id/versions lists the versions that exist). |
document_not_found | 404 | No document with this id exists for your workspace. Test keys only see test documents. | Check the id; read live documents with a live key. |
idempotency_conflict | 409 | A request with the same Idempotency-Key is still being processed. | Wait for the first request to finish, then retry with the same key to receive its result. |
file_expired | 410 | The PDF is no longer stored: its retention period ended, the signed link expired, or an Idempotency-Key refers to a document whose file is gone. | Generate the document again (with a new Idempotency-Key if you replayed one). |
document_too_large | 413 | The rendered HTML is larger than 5 MB, the PDF would have more than 50 pages, or the PDF would be larger than 40 MB. The message says which. | Split the document or reduce its content. For a PDF over 40 MB, use smaller or fewer images. |
payload_too_large | 413 | The request body is larger than 5 MB, or, rarely, the rendered document is too large to render. | Send less data, or move large assets to public URLs the renderer can fetch. |
template_render_error | 422 | The Liquid template could not be parsed or rendered: a syntax error, an unknown filter, bad filter input, template HTML over 500 KB (a stored template, or the html of POST /v1/pdf when data is sent), or a render over the 1 second or 64 MB limit. details.line and details.column point at the problem when known. | Fix the template or the data. Retrying unchanged fails again. |
render_failed | 422 | The document crashed the renderer, for example by exhausting its memory. | Simplify the document (very large images, heavy scripts). Retrying unchanged is likely to fail again. |
content_lost | 422 | In print mode, part of the document could not fit on any page and would have been cut off, so no PDF was made. Known causes: a table inside a table cell that is taller than a page, and a cell with rowspan that is taller than a page. Print mode cannot split either. | Put a long inner table after the outer table instead of inside it, or split the rowspan cell into one row per line. Or render the document in fast mode, which splits both. Retrying unchanged fails again. |
idempotency_mismatch | 422 | This Idempotency-Key was already used with a different request body. | Use a new key for a different request. |
rate_limited | 429 | Too many requests in the current one-minute window for your workspace and key mode, or from your IP address. Also returned when your team already has as many PDFs rendering at once as its plan allows (Free 1, Starter 2, Growth 3, Pro 4 and Scale 4); then details.reason is "concurrency" and details.limit is the number. Template changes are also limited to 60 a minute per team. | Wait for the number of seconds in the Retry-After header, then retry. |
storage_limit_reached | 429 | The team already stores as many PDFs as its plan allows (Free 500 MB, Starter 5 GB, Growth 20 GB, Pro 60 GB and Scale 200 GB), or 200 MB of test-key PDFs. details.scope is "plan" or "test". | Request the PDF directly ("response": "binary", no Idempotency-Key) meanwhile. Older files expire on their own and free the space. |
internal_error | 500 | Something went wrong on our side. The message may include a reference; the x-request-id header always identifies the request. | Retry once. If it persists, contact support with the x-request-id. |
service_busy | 503 | Too many documents are being prepared at once, or the rendering service is busy or restarting. Also returned when storing PDFs is paused because the server is low on disk space; PDFs returned directly ("response": "binary") still work. | Retry after a few seconds, with backoff. If storing is paused, request the PDF directly until it resumes. |
render_timeout | 504 | Rendering took longer than 30 seconds (loading resources, pagination and printing included). | Reduce the document’s size or slow external resources. Retrying unchanged is likely to time out again. |
Rate limits
Requests are limited per minute, per team and per key mode: all live keys of a team share one budget, and all test keys share another. A live key's budget depends on the plan:
| Key | Requests per minute |
|---|---|
| Live key, Free plan | 30 |
| Live key, Starter plan | 60 |
| Live key, Growth plan | 120 |
| Live key, Pro plan | 300 |
| Live key, Scale plan | 600 |
| Test key (any plan) | 20 |
- Previews (
POST /v1/templates/:id/preview) also have a limit of 30 per minute per team and key mode. - At the same time, a team can have this many PDFs rendering: Free 1, Starter 2, Growth 3, Pro 4 and Scale 4, across live keys, test keys, previews and the dashboard. One more gets
429 rate_limitedwithdetails: { "reason": "concurrency", "limit": N }andRetry-After: 1. - Per IP address, all requests together (including file downloads and requests with an invalid key) are limited to 600 per minute.
Responses to authenticated requests carry the budget of the limit that applies:
| Header | Meaning |
|---|---|
x-ratelimit-limit | Requests allowed per minute |
x-ratelimit-remaining | Requests left in the current window |
x-ratelimit-reset | Seconds until the window resets |
Retry-After | On a 429 only: seconds to wait before retrying |
A request over the limit gets 429 with the code rate_limited. Wait for Retry-After seconds, then retry.
Retrying safely
429 rate_limited: retry after theRetry-Afterdelay.503 service_busy: retry after a few seconds, with exponential backoff. If the message says storing PDFs is paused, request the PDF directly ("response": "binary") meanwhile.429 storage_limit_reached: the team's stored PDFs are at the plan's limit. Retrying soon gives the same answer; request the PDF directly, or wait for older files to expire (stored PDF limits).500 internal_error: retry once; if it persists, contact support with thex-request-id.409 idempotency_conflict: the first request with this key is still running; retry later with the same key.- Network errors and timeouts: retry
POST /v1/pdfwith the sameIdempotency-Key. If the first attempt succeeded, you get its document back and nothing is counted twice (idempotency). 4xxcodes other than409and429describe a problem with the request, the template or your plan: retrying the same request gives the same answer.
async function createPdf(body, idempotencyKey) {
for (let attempt = 1; ; attempt++) {
const res = await fetch('https://api.castpdf.com/v1/pdf', {
method: 'POST',
headers: {
Authorization: 'Bearer YOUR_API_KEY',
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify(body),
});
const retryable = res.status === 429 || res.status === 503;
if (!retryable || attempt === 5) return res;
const seconds = Number(res.headers.get('retry-after')) || 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, seconds * 1000));
}
}import time
import requests
def create_pdf(body, idempotency_key):
for attempt in range(1, 6):
res = requests.post(
"https://api.castpdf.com/v1/pdf",
headers={"Authorization": "Bearer YOUR_API_KEY", "Idempotency-Key": idempotency_key},
json=body,
timeout=60,
)
if res.status_code not in (429, 503) or attempt == 5:
return res
time.sleep(int(res.headers.get("Retry-After", 2 ** attempt)))Other codes
The dashboard uses the same error format. These codes come only from the dashboard (sign-in, API keys, billing and the Generate PDF button), never from /v1:
| Code | HTTP | Meaning | What to do |
|---|---|---|---|
unauthenticated | 401 | The dashboard session is missing or expired. | Sign in again. |
invalid_credentials | 401 | The email or password is incorrect. | Check them, or reset the password. |
invalid_token | 400 | An email verification or password reset link is invalid or has expired. | Request a new link. |
payment_failed | 402 | A payment for a billing change (for example an upgrade) failed at the payment provider. | Check the payment method and try again. |
email_not_verified | 403 | The account email is not verified yet. Until it is, you cannot use the dashboard’s Generate PDF button, create live API keys, subscribe to a paid plan or make other billing changes. A checkout also needs the team owner’s email to be verified. | Verify the email address. The dashboard can send a new link from its banner, from next to Generate PDF and from the verify page. |
email_taken | 409 | An account with this email already exists. | Sign in instead. |
subscription_exists | 409 | The team already has a subscription. | Change the plan of the existing subscription instead. |
no_subscription | 409 | The billing action needs an active subscription, and the team has none. | Choose a plan to subscribe. |
no_customer | 409 | The team has no billing account yet. | Subscribe to a plan first. |
subscription_locked | 409 | The subscription cannot change right now: a renewal or plan change is in progress, a payment is overdue, or a cancellation is scheduled. | Follow the message (update the payment method, resume the subscription, or try again after renewal). |
checkout_in_progress | 409 | Another checkout for the team is in progress. | Try again in a moment. |
billing_provider_error | 502 | The billing provider rejected the request or returned an unexpected response. | Try again later. |