Skip to content
ProductAPIn8n

Three things a customer found in our API

A hash that is not a fingerprint. Documentation that says "points" where the renderer reads millimetres. An n8n node that swallows the field name. None of them was a bug in the narrow sense — and none was in any documentation, ours included.

Patrick Jerominek

Patrick Jerominek

Co-founder of xhub.io

October 2, 20266 min reading time
Three things a customer found in our API

Three things about our own API we only found when we used it for a customer. None of the three was in any documentation. Not in ours either. This post closes the series on our first customer pipeline — and it is the part that taught us the most.

The pipeline: a facility services company with two departments, invoices written in Word and Excel, mandatory e-invoicing from 2027. With n8n and our API we built a pipeline behind those templates that reads PDFs from a mailbox, rebuilds them as EN 16931 and sends them as ZUGFeRD. It works. But along the way it found three places where our documentation claimed something different from our code.


1. The hash in the response is not a fingerprint

The response to "generate e-invoice" contains a field called hash. We wanted to use it in the pipeline as part of the file name: the same document received twice should get the same file name — then the archive overwrites harmlessly instead of creating a duplicate. The test data contained exactly that case: two files, one document.

It did not work. Two calls with identical input returned two different hashes. The reason is in the code, not in the docs: hash is the SHA-256 of the generated bytes. A PDF/A-3 contains a creation timestamp and a document ID — every call produces different bytes, hence a different hash. As an integrity check for the file that is correct. As a fingerprint of the document it is useless.

The pipeline therefore computes its own fingerprint over the invoice data (FNV-1a over canonically sorted JSON). And we changed two things:

  • The description of hash in the OpenAPI spec now says what it is: hex SHA-256 of the returned document bytes, empty on 422, not stable across calls for PDF output.
  • There is an additional payloadHash: SHA-256 over the canonically sorted input. The same data in a different field order yields the same value. That is the fingerprint the pipeline had built for itself — now the API provides it.

What we learned: a field called hash promises more than one line of description has to deliver. If two things could be hashed, the docs must say which one.

2. "In points" — but the renderer reads millimetres

The visual layout of the e-invoice (the PDF the buyer sees) is rendered from a block template. The OpenAPI spec described page.margins as "Top margin in points". The pipeline set the margins in points, measured the result against the original with pdftotext -bbox — and was off by a factor of 2.83.

The renderer has a contract that grew historically: a template without the field lengthUnit counts as a legacy file, and for legacy files page margins and spacer heights are millimetres, everything else points. That was documented in the renderer, fixed in the editor — and simply absent from the REST OpenAPI spec. The OpenAPI spec is a hand-maintained duplicate of the renderer schema, and the field lengthUnit had never found its way there.

The pipeline measured the millimetre values and entered them. That was the wrong reaction — the right one would have been to fix the docs. Both have happened since:

  • lengthUnit (pt | mm | inch) is in the OpenAPI spec, with the legacy rule in the description.
  • The descriptions of margins, spacer heights and section heights say "in lengthUnit; mm when lengthUnit is omitted".
  • The public n8n template sets lengthUnit: 'pt' — always.

What we learned: documentation copied from a second source drifts. Whoever maintains a schema twice maintains one of them not at all.

3. The n8n node swallows the field name

On an incomplete document the API responds with HTTP 422 and a body that names the error: errors[].message = "Missing required data: seller.bankAccount.iban", plus complianceErrors[] with rule IDs for Schematron violations. Our own n8n community node showed none of it. The workflow said: "Request failed with status code 422". The field name had to be inferred from context.

The reason: the node only parsed the response body on HTTP 200 with success: false. On 4xx it rethrew the raw HTTP error, and n8n replaces its message with generic text. The pipeline's operating manual therefore got a paragraph "error details are not visible in the node" — an operating manual explaining a product problem instead of the product solving it.

Since version 1.1.3 of the node the body comes through: message with field name, errors[], complianceErrors[] and httpCode, also on the node's error output, so a workflow can use them in its feedback to the department. Building the fix surfaced one more thing: new NodeApiError(node, err, { message }) silently discards the new message when err already is a NodeApiError. The first test run was red because n8n's documentation does not say that either.

What we learned: everything the first customer finds belongs in the product — not in their operating manual. A "known limitation" paragraph is an invoice every next customer pays again.


Why no test found this

None of the three was a bug in the narrow sense. The hash was correct, the renderer was correct, the node returned an error. All three were places where the documentation claimed something different from the code — and where our own tests could not notice, because they assumed the same thing the documentation did. A test derived from the documentation checks the documentation against itself.

The first customer is the best test you could not write. They arrive without our assumptions, read the docs literally and build on them. What breaks in the process is the docs, not the customer.

What has changed since

  • OpenAPI: hash described honestly, payloadHash added, lengthUnit with the legacy rule, valid as an object-level verdict pointing to results[].valid.
  • Renderer: block-level locale on a summary block is inherited by its rows (that one was a real bug — the fourth finding, which does not get its own section here), headerRows/footerRows optional.
  • n8n node 1.1.3: 422/400 bodies passed through, OpenAPI sync script before every release, the LICENSE file the README had always linked to.
  • Template 06: PDF invoices from a mailbox → ZUGFeRD / XRechnung with lengthUnit, a gate on valid && results[] and its own fingerprint.
  • Knowledge: PDF invoice to e-invoice — the seven mapping errors only a real API call reveals.

If you are building a similar pipeline and are stuck at a spot where the docs say something different from the code: write to us. We want to know — it is the test we cannot write.

Share article

Similar Articles

Ready to master e-invoicing?

Get started in under 5 minutes with Invoice-api.xhub. No credit card required.