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 Authorization header at all |
403 | {"message":"Token invalid"} | there was an Authorization header and the server would not take it |
404 | {"message":"Not Found"} | the URL does not match a route — usually a bad {format} segment |
400 | an HTML page | the request body is not parseable JSON. This one is not JSON — see below |
Four of the five are a JSON object with a single message
key. Plan for the fifth.
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 the header being absent. 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 the header being present and unacceptable. A revoked key, a typo, a key from the wrong environment — all 403, all with the same three 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"}
The one worth knowing before you spend an afternoon on it.
A key sent without the Bearer prefix is
also a 403 Token invalid — the same message a genuinely
bad key gives you.
So "Token invalid" really means "I could not accept that header", and
a malformed header looks identical to a wrong key. If you are getting
403 and you are confident the key is right, check the word
Bearer and the space after it before you check anything
else. The SDK builds the header for you, so this only bites people
calling the endpoint directly — which, on day one, is most people.
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.
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 the other four 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 —
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 five
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
No rate limit is documented either way. The OpenAPI document declares no 429 response, and the SDK retries one — so the honest position is that we do not publish a limit and you should not infer one from the absence. 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.