A successful render is HTTP 201 with the file as the response body. Not 200. That trips people up on the first call, and it is why a lot of working first integrations look like they failed.
Everything below is what comes back when the call genuinely did not
work. Each response was read off the live API, with the exact
curl beside it, so you can reproduce any of them.
The short version
| You get | Body | What it means |
|---|---|---|
201 | the file bytes | it worked |
401 | {"message":"Unauthorized"} | no key reached the server — no Authorization header and no token query parameter |
403 | {"message":"Token invalid"} | a key arrived and is not a key of any account |
403 | Token expired, Limit exceeded, or a size that needs a paid plan | the key is fine; its date, your monthly allowance or your plan is not |
404 | {"message":"Not Found"} | the URL does not match a route — usually a bad {format} segment |
400 | Invalid template type, Invalid template name, a size message | the key was accepted; the {scope}, the template id or the size was not |
400 | an HTML page | the request body is not parseable JSON. This one is not JSON — see below |
Every one of these is a JSON object with a single message
key — except the last. Plan for that one.
401 and 403 mean different things, and the difference saves an hour
Most APIs use 401 for "your key is wrong". This one does not.
401 is no key at all — no header and no token query parameter. Nothing else produces it:
curl -i -X POST https://api.ireceipt.pro/v1/pdf/public/invoice_universal_vzrt6k1s \
-H 'Content-Type: application/json' \
-d '{"variables":{}}'
# HTTP/2 401
# {"message":"Unauthorized"}
There is no WWW-Authenticate header on that response, so a
client library looking for one will not find a challenge to report.
403 is a key that arrived and was not accepted. A revoked key, a typo, a key from the wrong environment — all 403, all with the same two words:
curl -i -X POST https://api.ireceipt.pro/v1/pdf/public/invoice_universal_vzrt6k1s \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer not-a-real-key' \
-d '{"variables":{}}'
# HTTP/2 403
# {"message":"Token invalid"}
Two other 403 bodies are not about the key being wrong.
Token expired is a real key past its date.
Limit exceeded is your plan's monthly allowance used up —
only successful renders count against it. And a size the free plan does
not allow, over 1200 px on either side, answers
A size larger than 1200x1200 requires a paid subscription.
Not the culprit: the word Bearer. The
server strips bearer in any case, and all whitespace, and
looks up what is left — so Bearer <key>,
bearer <key> and the bare key all work. If you get
Token invalid with a key you trust, look for what is
not stripped: quotes pasted along with the key, a different
scheme such as Basic, a truncated copy. The details are on
authentication.
404 is about the URL, not about your template
The path is
POST /v1/{format}/{scope}/{templateId}, where
format is one of pdf, jpg,
png, webp and scope is
public or private. An unrecognised format is
a routing miss and comes back before anything else runs:
curl -i -X POST https://api.ireceipt.pro/v1/tiff/public/invoice_universal_vzrt6k1s \
-H 'Authorization: Bearer $KEY' -H 'Content-Type: application/json' \
-d '{"variables":{}}'
# HTTP/2 404
# {"message":"Not Found"}
tiff is not a format we render, so there is no route to
hit. A 404 here is never a statement about whether your template exists.
That one is a 400. With a key that works, a bad {scope}
answers 400 {"message":"Invalid template type"} and an id
that does not exist answers
400 {"message":"Invalid template name"}. With a key that
does not work you see the 403 first: the key is checked before the
template.
The one error that is not JSON
Send a body that is not valid JSON and you get a 400 whose body is an
HTML error page, not {"message": ...}:
curl -s -X POST https://api.ireceipt.pro/v1/pdf/public/invoice_universal_vzrt6k1s \
-H 'Authorization: Bearer $KEY' -H 'Content-Type: application/json' \
--data-binary '{"variables":'
# HTTP/2 400
# <!DOCTYPE html><html lang="en"><head>…<title>Error</title></head>
# <body><pre>Bad Request</pre></body></html>
That is the body parser rejecting the request before our code sees it,
and it is why a handler written as
JSON.parse(await res.text()) throws a
SyntaxError at exactly the moment you least want a second
error on top of the first. It is inconsistent with every other error and we
would rather it were not. Until it changes, the handler below reads the
content type instead of assuming.
In practice you will only hit this from hand-rolled requests —
string-concatenated JSON, a template literal with an
undefined in it, a proxy that truncated the body.
JSON.stringify does not produce it and neither does the SDK.
The retry ladder, and which failure actually pays for it
createFile makes up to five attempts, gated on
isRetryable(status): transport errors where no
response came back at all, plus 5xx, 408 and 429. Everything else —
400, 401, 403, 404, 422 — throws on the first attempt.
The sleeps are (attempt + 1) × 1000ms, so a fully retried
call spends 2 + 3 + 4 + 5 = 14 seconds across four of them.
That is the cost of a timeout or a 5xx, not of a bad
credential. A deliberately bad key throws
Token invalid in about half a second.
Which is worth knowing if createFile sits inside a request
timeout. The failure that can blow your budget is the slow one, and the
one you were probably guarding against cannot.
Verifying the key with a raw request before you wire up the SDK is still the right habit, and now it is cheap rather than necessary — one call, immediate answer:
curl -s -o /dev/null -w '%{http_code}\n' \
-X POST https://api.ireceipt.pro/v1/pdf/public/invoice_universal_vzrt6k1s \
-H "Authorization: Bearer $IRECEIPT_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"variables":{}}'
201 and you are fine. 403 and fix the key now
rather than through the SDK's retry ladder.
An error handler that covers all of them
Direct fetch, no SDK, no dependencies. It handles the HTML
case, keeps the status, and does not throw a second error while
reporting the first:
async function render(templateId, variables, opts = {}) {
const { format = "pdf", scope = "public", size } = opts;
const res = await fetch(
`https://api.ireceipt.pro/v1/${format}/${scope}/${templateId}`,
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.IRECEIPT_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(size ? { variables, size } : { variables }),
}
);
if (res.status === 201) return Buffer.from(await res.arrayBuffer());
// Read the content type rather than assuming JSON — a 400 from the
// body parser is an HTML page.
const type = res.headers.get("content-type") || "";
const detail = type.includes("application/json")
? (await res.json().catch(() => null))?.message ?? "(unreadable JSON body)"
: `(${type || "no content-type"}, ${res.status})`;
const err = new Error(`iReceipt PRO ${res.status}: ${detail}`);
err.status = res.status;
throw err;
}
What this page does not tell you
There is no per-second rate limit: the OpenAPI document declares no 429
response, and the API sends no RateLimit-* or
Retry-After header. The limit that exists is the monthly
allowance above.
Latency, uptime and throughput are not stated here either; the
status page
keeps no history, so there is nothing we could cite.
A 422 is in the SDK's non-retryable list and we have not been able to provoke one from the render route, so it is listed above as a code that fails fast rather than as a response shape we can show you.