<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: eleata team</title>
    <description>The latest articles on Forem by eleata team (@eleata).</description>
    <link>https://forem.com/eleata</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3912974%2F2ab9e84a-1f6f-4c03-96ba-6f6203bfa359.png</url>
      <title>Forem: eleata team</title>
      <link>https://forem.com/eleata</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/eleata"/>
    <language>en</language>
    <item>
      <title>How multi-provider LLM routers silently fail</title>
      <dc:creator>eleata team</dc:creator>
      <pubDate>Fri, 08 May 2026 14:58:04 +0000</pubDate>
      <link>https://forem.com/eleata/how-multi-provider-llm-routers-silently-fail-5fdd</link>
      <guid>https://forem.com/eleata/how-multi-provider-llm-routers-silently-fail-5fdd</guid>
      <description>&lt;h1&gt;
  
  
  How multi-provider LLM routers silently fail
&lt;/h1&gt;

&lt;p&gt;A failure mode common to several Python LLM routers: a 429 caused by an&lt;br&gt;
exhausted long-period quota is treated identically to a 429 caused by a&lt;br&gt;
transient per-minute rate limit. The cooldown TTL ends up applied to&lt;br&gt;
both, and one of the two cases is wrong by orders of magnitude.&lt;/p&gt;

&lt;p&gt;This essay describes the failure mode in concrete terms and outlines the&lt;br&gt;
small fix.&lt;/p&gt;


&lt;h2&gt;
  
  
  The shape of the failure
&lt;/h2&gt;

&lt;p&gt;Most LLM routers track a single "this provider is unhealthy until X"&lt;br&gt;
field per deployment. When a request fails, the router sets &lt;code&gt;X = now +&lt;br&gt;
cooldown&lt;/code&gt;, and &lt;code&gt;is_call_allowed()&lt;/code&gt; returns &lt;code&gt;False&lt;/code&gt; until then.&lt;/p&gt;

&lt;p&gt;That works perfectly for a transient per-minute rate limit, where&lt;br&gt;
"cooldown" is correctly measured in seconds. It works very badly for a&lt;br&gt;
monthly quota cap, where the provider only resumes serving requests&lt;br&gt;
when its billing period rolls over — possibly weeks away.&lt;/p&gt;

&lt;p&gt;A trace, with &lt;code&gt;cooldown = 60s&lt;/code&gt;, against a provider that exhausted its&lt;br&gt;
monthly token budget at &lt;code&gt;t=0&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;t=0       Provider returns 429 (body: "monthly quota exhausted")
          Router sets unhealthy_until = t+60s
t=60      Router's is_call_allowed() → True → call → 429 again
          Router sets unhealthy_until = t+120s
t=120     ... same cycle ...
... (1440 more retries in 24h, all fail) ...
t=86400   Period rolls over, provider works
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's roughly 1440 wasted round trips per day per provider, until the&lt;br&gt;
billing period ends. No exception is raised — the router believes it&lt;br&gt;
is working as designed.&lt;/p&gt;


&lt;h2&gt;
  
  
  How current routers handle this
&lt;/h2&gt;

&lt;p&gt;I read the routing layers of three projects in the space at the time&lt;br&gt;
of writing. Specific code references below; behavior may change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;LiteLLM&lt;/strong&gt; (&lt;code&gt;BerriAI/litellm&lt;/code&gt;). The &lt;code&gt;Router&lt;/code&gt; class exposes&lt;br&gt;
&lt;code&gt;cooldown_time&lt;/code&gt; (single TTL) and &lt;code&gt;allowed_fails&lt;/code&gt; (count). When a&lt;br&gt;
deployment exceeds &lt;code&gt;allowed_fails&lt;/code&gt;, it goes into the &lt;code&gt;CooldownCache&lt;/code&gt;&lt;br&gt;
for &lt;code&gt;cooldown_time&lt;/code&gt;. Operators can configure both per deployment, but&lt;br&gt;
the body of the failure isn't inspected. A 429 caused by a quota cap&lt;br&gt;
gets the same TTL as a 429 caused by RPM throttling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ClawRouter&lt;/strong&gt; (&lt;code&gt;BlockRunAI/ClawRouter&lt;/code&gt;). The &lt;code&gt;categorizeError&lt;/code&gt; function&lt;br&gt;
in &lt;code&gt;proxy.ts&lt;/code&gt; classifies failures. For HTTP 403, the body is matched&lt;br&gt;
against &lt;code&gt;/plan.*limit|quota.*exceeded|subscription|allowance/i&lt;/code&gt; and, on&lt;br&gt;
a hit, returns &lt;code&gt;quota_exceeded&lt;/code&gt;. For HTTP 429, the function returns&lt;br&gt;
&lt;code&gt;rate_limited&lt;/code&gt; regardless of body. The cooldown for &lt;code&gt;rate_limited&lt;/code&gt; is&lt;br&gt;
&lt;code&gt;RATE_LIMIT_COOLDOWN_MS = 60_000&lt;/code&gt; — a hardcoded 60 seconds. Several&lt;br&gt;
providers I tested while developing this library return 429 (not 403)&lt;br&gt;
for some long-period quota failures, so the quota-exceeded branch&lt;br&gt;
never fires for those.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OmniRoute&lt;/strong&gt; (&lt;code&gt;diegosouzapw/OmniRoute&lt;/code&gt;). A textbook circuit breaker&lt;br&gt;
FSM in &lt;code&gt;circuitBreaker.ts&lt;/code&gt;: &lt;code&gt;failureThreshold&lt;/code&gt; (count) and&lt;br&gt;
&lt;code&gt;resetTimeout&lt;/code&gt; (single TTL). Custom &lt;code&gt;isFailure(error)&lt;/code&gt; callback is&lt;br&gt;
allowed, but the default treats any exception equally.&lt;/p&gt;

&lt;p&gt;None of these is a defect of negligence. The failure mode requires&lt;br&gt;
parsing the response &lt;em&gt;body&lt;/em&gt; on a status code most projects map to a&lt;br&gt;
single category. The fix is small but easy to miss.&lt;/p&gt;


&lt;h2&gt;
  
  
  Three states, not one
&lt;/h2&gt;

&lt;p&gt;The fix is to track three orthogonal pieces of state per&lt;br&gt;
&lt;code&gt;(provider, model, credential_alias)&lt;/code&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;rate_limit_state&lt;/code&gt; — short-lived TTL from &lt;code&gt;Retry-After&lt;/code&gt; headers,
capped at 1 hour by default. Per-dimension (RPM/TPM/RPD).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;quota_state&lt;/code&gt; — long-period (&lt;code&gt;daily&lt;/code&gt; or &lt;code&gt;monthly&lt;/code&gt;) consumption.
&lt;code&gt;exhausted=True&lt;/code&gt; is set only when the response body matches a known
quota keyword. The TTL is the time until the period rolls over.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;circuit_state&lt;/code&gt; — closed/open/half-open from generic 5xx and
timeout streaks. Independent of the other two: a provider can be
healthy on rate/quota but flaky on the network path, or vice
versa.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Pre-call, the routing decision becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;circuit_open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;retry_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;    &lt;span class="n"&gt;skip&lt;/span&gt; &lt;span class="n"&gt;until&lt;/span&gt; &lt;span class="n"&gt;retry_at&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;circuit_open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;retry_at&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;  &lt;span class="n"&gt;skip&lt;/span&gt; &lt;span class="nf"&gt;indefinitely &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auth&lt;/span&gt; &lt;span class="n"&gt;failure&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;rate_limit_active&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;               &lt;span class="n"&gt;skip&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="err"&gt;–&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;quota_exhausted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                 &lt;span class="n"&gt;skip&lt;/span&gt; &lt;span class="n"&gt;until&lt;/span&gt; &lt;span class="n"&gt;period_end&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;quota_near_cap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                  &lt;span class="n"&gt;skip&lt;/span&gt; &lt;span class="n"&gt;until&lt;/span&gt; &lt;span class="n"&gt;period_end&lt;/span&gt;
&lt;span class="n"&gt;otherwise&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                          &lt;span class="n"&gt;allow&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Post-call, the failure dispatch becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;status 429 + body matches quota keyword:    update quota_state
status 429 + Retry-After (no quota body):   update rate_limit_state
status 401 / 403 (auth):                     open circuit indefinitely
status 5xx / timeout (transient):            increment circuit error_streak
status 200:                                  bump quota counters; close circuit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The state contracts are simple enough that in-memory, SQLite, and&lt;br&gt;
Postgres backends share the same Protocol. The implementation is small&lt;br&gt;
relative to the failure mode it addresses; the difficulty is mostly in&lt;br&gt;
the failure-classification regex and in keeping the increment + status&lt;br&gt;
decision atomic under concurrent failures.&lt;/p&gt;


&lt;h2&gt;
  
  
  Two bugs the adversarial review caught in my own first draft
&lt;/h2&gt;

&lt;p&gt;Before publishing the implementation I ran it through an adversarial&lt;br&gt;
review. Two HIGH-severity bugs were caught that I would have shipped&lt;br&gt;
otherwise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Auth circuit not respected.&lt;/strong&gt; A 401 / 403 should keep the circuit&lt;br&gt;
open indefinitely — there's no point retrying a rejected API key. My&lt;br&gt;
first draft set &lt;code&gt;retry_at=None&lt;/code&gt; to mean "never retry", but the guard&lt;br&gt;
function checked &lt;code&gt;if retry_at and retry_at &amp;gt; now&lt;/code&gt;, which is &lt;em&gt;falsy&lt;/em&gt;&lt;br&gt;
when &lt;code&gt;retry_at is None&lt;/code&gt;. Net effect: traffic kept flowing on a key the&lt;br&gt;
provider had already rejected. Every call would re-fail with the same&lt;br&gt;
401.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Race in circuit opening.&lt;/strong&gt; The original code did read-decide-write&lt;br&gt;
across two awaits:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;streak&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;get_circuit&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="n"&gt;error_streak&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;streak&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;open&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...)&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;closed&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;error_streak&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;streak&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two concurrent failures could both read &lt;code&gt;streak=2&lt;/code&gt;, both decide&lt;br&gt;
&lt;code&gt;will_open=False&lt;/code&gt;, and both write &lt;code&gt;status='closed'&lt;/code&gt; with their&lt;br&gt;
respective increments. The post-condition: &lt;code&gt;streak=4 &amp;gt;= threshold=3&lt;/code&gt;&lt;br&gt;
but &lt;code&gt;status='closed'&lt;/code&gt;. The fix is to do increment + status decision in&lt;br&gt;
one atomic SQL statement:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;circuits&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt;
  &lt;span class="n"&gt;error_streak&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;error_streak&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;CASE&lt;/span&gt;
      &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;error_streak&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="s1"&gt;'open'&lt;/span&gt;
      &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;
  &lt;span class="k"&gt;END&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both bugs now have regression tests. Both are exactly the kind of bug&lt;br&gt;
single-process tests on a quiet branch don't catch.&lt;/p&gt;




&lt;h2&gt;
  
  
  What this is and isn't
&lt;/h2&gt;

&lt;p&gt;It's a small Python library, MIT-licensed, with three backends&lt;br&gt;
(in-memory, SQLite, Postgres) and zero required runtime dependencies.&lt;br&gt;
It does state — it doesn't make HTTP calls or wrap a client. You use&lt;br&gt;
your existing OpenAI/Anthropic/etc. SDK and call &lt;code&gt;guard()&lt;/code&gt; before each&lt;br&gt;
request and &lt;code&gt;record_outcome()&lt;/code&gt; after.&lt;/p&gt;

&lt;p&gt;It is alpha. It does what it says, and the failure mode it addresses&lt;br&gt;
is real, but I would not bet a production system on a one-week-old&lt;br&gt;
library written by one person. The point of writing this essay&lt;br&gt;
alongside the code is so that even if this particular implementation&lt;br&gt;
goes nowhere, the pattern is documented and someone else's router can&lt;br&gt;
adopt it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Repo
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Code: &lt;a href="https://github.com/eleata/resilient-llm-router" rel="noopener noreferrer"&gt;https://github.com/eleata/resilient-llm-router&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Persistence demo (no deps, no API keys): &lt;code&gt;python examples/persistence_demo.py&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Caps catalog (5 providers seeded): &lt;code&gt;src/resilient_llm_router/caps.toml&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you maintain or use a router that has the failure mode described&lt;br&gt;
here, I'd be glad to compare notes.&lt;/p&gt;

</description>
      <category>python</category>
      <category>ai</category>
      <category>llm</category>
      <category>opensource</category>
    </item>
    <item>
      <title>How to validate Peppol BIS 3 invoices in 5 lines of Python (or Node, or Go)</title>
      <dc:creator>eleata team</dc:creator>
      <pubDate>Tue, 05 May 2026 01:23:49 +0000</pubDate>
      <link>https://forem.com/eleata/how-to-validate-peppol-bis-3-invoices-in-5-lines-of-python-or-node-or-go-3gkb</link>
      <guid>https://forem.com/eleata/how-to-validate-peppol-bis-3-invoices-in-5-lines-of-python-or-node-or-go-3gkb</guid>
      <description>&lt;p&gt;Starting September 2026 every B2B invoice in France must be e-invoiced (Peppol/Factur-X). Germany has mandated XRechnung for B2G since 2020. Italy has been on FatturaPA since 2019. Spain's Verifactu rolls out 2025-2026.&lt;/p&gt;

&lt;p&gt;If you build accounting software, an ERP, or any e-commerce flow that touches EU customers, you'll likely need to validate these invoice formats at some point. The two main open-source Schematron engines are &lt;a href="https://peppol.helger.com" rel="noopener noreferrer"&gt;phive&lt;/a&gt; (by Philip Helger of the EN16931 working group) and &lt;a href="https://mustangproject.org" rel="noopener noreferrer"&gt;Mustang&lt;/a&gt; (by Jochen Stärk and the FNFE-MPE working group). Both are great. Both ship as Java JARs or web forms — no API, no SDKs.&lt;/p&gt;

&lt;p&gt;I built a thin REST API on top of Mustang 2.23.0 that gives you what's missing: SDKs, GitHub Action, and async batch.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 5-line version
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# pip install eleata-peppol
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;eleata_peppol&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Client&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ELEATA_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;peppol-bis-3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;xml&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invoice.xml&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. Same Schematron rules CEN/OpenPeppol publish — equivalent accuracy to the phive reference web form.&lt;/p&gt;

&lt;h2&gt;
  
  
  Node version
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Eleata&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@eleata/peppol&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:fs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Eleata&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ELEATA_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;xrechnung-2.x&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;xml&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./invoice.xml&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  In CI (GitHub Action)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;eleata/validate-xrechnung-action@v1&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;files&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./invoices/**/*.xml&lt;/span&gt;
    &lt;span class="na"&gt;api-key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.ELEATA_KEY }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add a &lt;code&gt;validates-in-CI&lt;/code&gt; badge to your README. Break the build before you ship a broken invoice.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's in the box
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;POST /v1/validate (sync) and /v1/validate/batch (async + webhook callback)&lt;/li&gt;
&lt;li&gt;4 formats: Peppol BIS 3, XRechnung 2.x, Factur-X 1.07.2 (PDF/A-3 hybrid extracted natively), UBL 2.1&lt;/li&gt;
&lt;li&gt;Public shareable validation reports at /r/{id}&lt;/li&gt;
&lt;li&gt;50 anonymized real EU invoice fixtures (CC0)&lt;/li&gt;
&lt;li&gt;Free 200 validations/month, no credit card&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Honest about scope
&lt;/h2&gt;

&lt;p&gt;This is &lt;strong&gt;not&lt;/strong&gt; a full Peppol Access Point — Ecosio, B2BRouter, Pagero do that. We are only the validator-as-an-API layer. If you need to send invoices through the Peppol network, you still need an Access Point.&lt;/p&gt;

&lt;p&gt;Repos public on &lt;a href="https://github.com/eleata" rel="noopener noreferrer"&gt;github.com/eleata&lt;/a&gt;. Live demo at &lt;a href="https://peppol.eleata.io" rel="noopener noreferrer"&gt;peppol.eleata.io&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Curious to hear from anyone using a different validator (xrechnung-tooling, validator-fhir, InvoiceNavigator). What pain points hit you first when integrating?&lt;/p&gt;

</description>
      <category>api</category>
      <category>peppol</category>
      <category>eu</category>
      <category>xrechnung</category>
    </item>
  </channel>
</rss>
