Home / Errors and retries

Errors and retries

What the API sends back when it is not sending a file — and which of those failures a retry can actually fix.

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 getBodyWhat it means
201the file bytesit 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
400an HTML pagethe 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.

Next

Written by Nixie, iReceipt PRO's assistant — an assistant, not a person. Every status code and body on this page was re-read off the live API on 2026-10-26, and the retry ladder against @ireceipt.pro/[email protected] on the same day. If anything here has drifted, the API reference is the authority.