<?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>DEV Community: Rostyslav Havryliuk</title>
    <description>The latest articles on DEV Community by Rostyslav Havryliuk (@rostyslab).</description>
    <link>https://dev.to/rostyslab</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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2765453%2Fc234a0c4-4cc0-4e8e-90c2-cf9f28d5f9f6.jpeg</url>
      <title>DEV Community: Rostyslav Havryliuk</title>
      <link>https://dev.to/rostyslab</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/rostyslab"/>
    <language>en</language>
    <item>
      <title>A HubSpot app card for the Polish company registry, with plain fetch and no SOAP library</title>
      <dc:creator>Rostyslav Havryliuk</dc:creator>
      <pubDate>Mon, 19 Oct 2026 09:17:48 +0000</pubDate>
      <link>https://dev.to/rostyslab/a-hubspot-app-card-for-the-polish-company-registry-with-plain-fetch-and-no-soap-library-53nm</link>
      <guid>https://dev.to/rostyslab/a-hubspot-app-card-for-the-polish-company-registry-with-plain-fetch-and-no-soap-library-53nm</guid>
      <description>&lt;p&gt;Every CRM in Poland has the same chore. A sales rep adds a company, opens the public REGON search in another tab, pastes the tax ID (NIP), and copies name, address, REGON and activity codes field by field. Typos included.&lt;/p&gt;

&lt;p&gt;I built a small demo that removes that step in HubSpot: a card on the company record with one button, "Pobierz z GUS" (fetch from GUS). It reads the NIP from the record, asks the national REGON registry, shows a diff of what would change, and writes only after the user clicks "Zapisz" (save). The save also leaves a note on the company timeline listing the fields that came from the registry.&lt;/p&gt;

&lt;p&gt;A short recording of the flow (manual copying first, then the card) is in the original post: &lt;a href="https://polaczone.pl/poradniki/dane-z-gus-w-hubspot-karta-firmy/" rel="noopener noreferrer"&gt;https://polaczone.pl/poradniki/dane-z-gus-w-hubspot-karta-firmy/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This post is about the parts that were not obvious: the registry speaks SOAP 1.2 with MTOM, and HubSpot has opinions about where your backend runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the backend lives
&lt;/h2&gt;

&lt;p&gt;HubSpot app cards are React components rendered by HubSpot. They cannot call arbitrary URLs. You get two options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Serverless functions inside the HubSpot project.&lt;/strong&gt; Since platform version 2026.03 they are back for apps, but an app that contains them can only be installed in an account with an Enterprise subscription. A developer test account works for building, real customers on Free or Starter would not be able to install it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;hubspot.fetch()&lt;/code&gt; to your own backend.&lt;/strong&gt; The URL must be HTTPS and listed in &lt;code&gt;permittedUrls.fetch&lt;/code&gt; in the app config (entries are prefixes). HubSpot signs every request and appends &lt;code&gt;portalId&lt;/code&gt;, &lt;code&gt;userId&lt;/code&gt; and &lt;code&gt;appId&lt;/code&gt; to the query string.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The registry made the choice easy. GUS asks for the IP addresses your requests will come from when you apply for a production key, and the session id it returns is only accepted from the IP that created it. A serverless runtime with a random egress IP fights both. So the backend is a small Node service on a VPS with a static IP, behind Caddy, and the card talks to it through &lt;code&gt;hubspot.fetch&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Talking to GUS without a SOAP library
&lt;/h2&gt;

&lt;p&gt;The service is BIR 1.2 (the REGON API). Three calls cover the flow: &lt;code&gt;Zaloguj&lt;/code&gt; (log in with the key, get a session id), &lt;code&gt;DaneSzukajPodmioty&lt;/code&gt; (search by NIP), &lt;code&gt;DanePobierzPelnyRaport&lt;/code&gt; (full report for a REGON, for example &lt;code&gt;BIR12OsPrawna&lt;/code&gt; and &lt;code&gt;BIR12OsPrawnaPkd&lt;/code&gt; for legal entities). GUS runs a public test environment with a published key, so you can build all of this before you have a production key.&lt;/p&gt;

&lt;p&gt;A request is a SOAP 1.2 envelope with WS-Addressing headers. The session id does not go into the envelope, it goes into an HTTP header called &lt;code&gt;sid&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;envelope&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope"&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; xmlns:ns="http://CIS/BIR/PUBL/2014/07" xmlns:dat="http://CIS/BIR/PUBL/2014/07/DataContract"&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;soap:Header xmlns:wsa="http://www.w3.org/2005/08/addressing"&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
  &lt;span class="s2"&gt;`&amp;lt;wsa:To&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/wsa:To&amp;gt;`&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;wsa:Action&amp;gt;http://CIS/BIR/PUBL/2014/07/IUslugaBIRzewnPubl/DaneSzukajPodmioty&amp;lt;/wsa:Action&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;/soap:Header&amp;gt;&amp;lt;soap:Body&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;ns:DaneSzukajPodmioty&amp;gt;&amp;lt;ns:pParametryWyszukiwania&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
  &lt;span class="s2"&gt;`&amp;lt;dat:Nip&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;nip&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/dat:Nip&amp;gt;`&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;/ns:pParametryWyszukiwania&amp;gt;&amp;lt;/ns:DaneSzukajPodmioty&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;/soap:Body&amp;gt;&amp;lt;/soap:Envelope&amp;gt;&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;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/soap+xml; charset=utf-8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sid&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;envelope&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response is &lt;code&gt;multipart/related&lt;/code&gt; (MTOM). You do not need a MIME parser for it: the useful part is the text between &lt;code&gt;&amp;lt;DaneSzukajPodmiotyResult&amp;gt;&lt;/code&gt; and its closing tag. That text is an XML document escaped as a string, so you unescape it once and get a flat &lt;code&gt;&amp;lt;root&amp;gt;&amp;lt;dane&amp;gt;…&amp;lt;/dane&amp;gt;&amp;lt;/root&amp;gt;&lt;/code&gt;, one &lt;code&gt;&amp;lt;dane&amp;gt;&lt;/code&gt; block per row. A regex over flat elements is enough here, there is no nesting to speak of.&lt;/p&gt;

&lt;p&gt;Things that cost me time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;GetValue&lt;/code&gt; is on a different contract.&lt;/strong&gt; Everything else uses the &lt;code&gt;IUslugaBIRzewnPubl&lt;/code&gt; action namespace. &lt;code&gt;GetValue&lt;/code&gt; (for example &lt;code&gt;StatusSesji&lt;/code&gt;, to check whether the session is still alive) lives on &lt;code&gt;http://CIS/BIR/2014/07/IUslugaBIR/GetValue&lt;/code&gt;. Sending it with the public action returns &lt;code&gt;ActionNotSupported&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An empty result is not always "company not found".&lt;/strong&gt; It can also mean an expired session. When a search comes back empty, ask &lt;code&gt;StatusSesji&lt;/code&gt;, log in again if it is 0, and retry once before you tell the user nothing was found.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Postal codes come in two formats.&lt;/strong&gt; The search returns &lt;code&gt;00-925&lt;/code&gt;, the full report returns &lt;code&gt;00925&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Names can contain markup.&lt;/strong&gt; GUS itself warns that names may carry HTML or SQL fragments, so every value is stripped and trimmed before it goes anywhere near a CRM.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sole traders are a different report.&lt;/strong&gt; A search row with &lt;code&gt;Typ&lt;/code&gt; &lt;code&gt;F&lt;/code&gt; is a natural person running a business. The legal-entity report does not apply, the card shows a warning, and the data is treated as personal data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Status needs dates, not a flag.&lt;/strong&gt; "Active", "suspended" or "deregistered" is derived from the suspension, resumption, end and deregistration dates in the report.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Verifying HubSpot's signature
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;hubspot.fetch&lt;/code&gt; requests carry &lt;code&gt;X-HubSpot-Signature-v3&lt;/code&gt; and &lt;code&gt;X-HubSpot-Request-Timestamp&lt;/code&gt;. The signature is an HMAC-SHA256 over method, full URL, raw body and timestamp, keyed with the app's client secret, base64 encoded. Two details: a specific set of percent-encoded characters in the URL must be decoded before hashing, and requests older than five minutes should be rejected.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createHmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;clientSecret&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;decodeUri&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;publicBaseUrl&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;rawBody&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&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;given&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;signatureHeader&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;base64&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;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;given&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;timingSafeEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;given&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The full URL means the public one, including the query parameters HubSpot appended, not whatever your reverse proxy forwards. If verification fails on every request, that is the first thing to check.&lt;/p&gt;

&lt;h2&gt;
  
  
  The write path
&lt;/h2&gt;

&lt;p&gt;The card never writes on its own. "Pobierz z GUS" calls &lt;code&gt;GET /gus/lookup?nip=…&lt;/code&gt;, the backend returns normalized registry data plus the matching HubSpot property values, and the card renders a three-column table: field, current value in HubSpot, value from GUS. Changed rows are bold, the old value is struck through, unchanged rows are muted.&lt;/p&gt;

&lt;p&gt;"Zapisz" sends only the changed fields to &lt;code&gt;POST /gus/apply&lt;/code&gt;. The backend accepts a fixed whitelist of property names, PATCHes the company through the CRM API, and creates a note associated with it. If the PATCH succeeds and the note fails, the response still says the data was saved, because telling the user "nothing was saved" would make them click again and write twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does not do yet
&lt;/h2&gt;

&lt;p&gt;The demo covers GUS only. A production integration also pulls the KRS extract for companies (board, capital), CEIDG for sole traders, and the VAT white list. It runs on new companies through webhooks and rechecks existing customers on a schedule. Those parts, the limits and the session rules are written up in more detail in a Polish guide to the REGON API: &lt;a href="https://polaczone.pl/poradniki/dane-firmy-z-gus-po-nip-api-bir/" rel="noopener noreferrer"&gt;https://polaczone.pl/poradniki/dane-firmy-z-gus-po-nip-api-bir/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The HubSpot integration itself is described here: &lt;a href="https://polaczone.pl/integracje/hubspot-gus-krs/" rel="noopener noreferrer"&gt;https://polaczone.pl/integracje/hubspot-gus-krs/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>hubspot</category>
      <category>api</category>
      <category>poland</category>
      <category>node</category>
    </item>
    <item>
      <title>KSeF: Poland's e-invoicing API will break your store. Here is where</title>
      <dc:creator>Rostyslav Havryliuk</dc:creator>
      <pubDate>Wed, 23 Sep 2026 11:22:59 +0000</pubDate>
      <link>https://dev.to/rostyslab/polands-e-invoicing-api-will-break-your-store-here-is-where-4kfo</link>
      <guid>https://dev.to/rostyslab/polands-e-invoicing-api-will-break-your-store-here-is-where-4kfo</guid>
      <description>&lt;p&gt;Poland made B2B e-invoicing mandatory. Biggest companies from 1 February 2026, everyone else from 1 April 2026. Invoices to companies now go through KSeF, a government system. Not a PDF you email. An XML the state accepts or rejects.&lt;/p&gt;

&lt;p&gt;Two dates that get lost in summaries. &lt;strong&gt;Receiving&lt;/strong&gt; invoices through KSeF is mandatory for everyone from 1 February 2026, the April deferral covers issuing only. So your purchase invoices have to be pulled from the API earlier than you have to push your own. And until the end of 2026 the smallest sellers can stay outside: invoiced sales of 10,000 zł gross or less &lt;strong&gt;per month&lt;/strong&gt;, lost from the invoice that crosses it. B2C sales and receipts do not count toward that threshold. (&lt;a href="https://ksef.podatki.gov.pl/informacje-ogolne-ksef-20/podstawy-prawne-oraz-kluczowe-terminy/" rel="noopener noreferrer"&gt;dates and legal basis, Ministry of Finance&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;I integrate stores and SaaS with this. Here is what actually breaks. Facts below are current as of September 2026, KSeF 2.0 API.&lt;/p&gt;

&lt;h2&gt;
  
  
  Most of your invoices do not go there
&lt;/h2&gt;

&lt;p&gt;B2C invoices are voluntary. The Ministry says so in its own FAQ. Mandatory means invoices to companies. The trigger is one field: the buyer's tax ID, the NIP. (&lt;a href="https://ksef.podatki.gov.pl/pytania-i-odpowiedzi-ksef-20/" rel="noopener noreferrer"&gt;Ministry FAQ&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;Neither Shopify nor Stripe collects it &lt;strong&gt;by default&lt;/strong&gt;. Stripe Checkout has tax ID collection and supports Polish VAT numbers, it is just off unless you turn it on. Shopify exposes tax IDs in B2B and through checkout customisation. Either way somebody has to enable it, map it, and decide what happens when it is missing.&lt;/p&gt;

&lt;p&gt;So the first question is not "which API". It is "does our checkout ask for a NIP, where does it land, and what do we do when a customer asks for a company invoice after the order". Answer that first.&lt;/p&gt;

&lt;p&gt;Out of scope too: OSS and IOSS, pro formas, internal notes, fiscal receipts with a NIP up to 450 zł until the end of 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do not call the API per order
&lt;/h2&gt;

&lt;p&gt;The rate limit docs describe an online store as a separate case. The recommendation: collect invoices and send them in batches, for example every 5 minutes. (&lt;a href="https://github.com/CIRFMF/ksef-api/blob/main/limity/limity-api.md" rel="noopener noreferrer"&gt;API limits, CIRFMF/ksef-api&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;That answers most limit questions. Numbers as published in September 2026:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;1 MB per invoice&lt;/li&gt;
&lt;li&gt;10,000 invoices per session&lt;/li&gt;
&lt;li&gt;50 ZIP files per batch, 100 MB per package before encryption&lt;/li&gt;
&lt;li&gt;interactive sending: 10/s, 30/min, 180/h&lt;/li&gt;
&lt;li&gt;over the limit: 429 plus &lt;code&gt;Retry-After&lt;/code&gt;, and the block grows if you keep pushing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Limits count per (context, IP). Test runs roughly 10x higher than production, which is why this surfaces after launch. Check the current numbers in that repo before you size anything, they move between API versions.&lt;/p&gt;

&lt;p&gt;Also: the retrieval API is for syncing to your database. Polling faster than every 15 minutes gets you blocked. So does fetching invoices one by one instead of using export.&lt;/p&gt;

&lt;h2&gt;
  
  
  Midnight will flip your invoices into offline mode
&lt;/h2&gt;

&lt;p&gt;If the issue date &lt;code&gt;P_1&lt;/code&gt; is earlier than the day KSeF accepts the file, the invoice becomes "offline24" automatically. You did not set a flag. It just happens. Now you owe a send deadline. (&lt;a href="https://github.com/CIRFMF/ksef-api/blob/main/offline/automatyczne-okreslanie-trybu-offline.md" rel="noopener noreferrer"&gt;automatic offline mode&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;Worse: batch sessions count the moment the session opens, interactive sessions count the moment each invoice is sent. A batch opened at 23:59 saves the day. An interactive one does not.&lt;/p&gt;

&lt;p&gt;Run your nightly job at 2am, not at midnight.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retries create second invoices
&lt;/h2&gt;

&lt;p&gt;KSeF blocks duplicates on three fields: seller NIP, document type, invoice number. Duplicate comes back as 440. Uniqueness holds for ten years. (&lt;a href="https://github.com/CIRFMF/ksef-api/blob/main/faktury/weryfikacja-faktury.md" rel="noopener noreferrer"&gt;invoice verification&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;This saves you on retries. On one condition: the invoice number must be stable.&lt;/p&gt;

&lt;p&gt;Generate the number at send time and a retry makes a new one. KSeF sees two different invoices. Two KSeF numbers. Two live invoices for one order. The only fix is a credit note to zero.&lt;/p&gt;

&lt;p&gt;There is no idempotency key in the API. Build it yourself:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;persist the invoice number with the order before the first send&lt;/li&gt;
&lt;li&gt;store the SHA-256 of the XML, computed before encryption&lt;/li&gt;
&lt;li&gt;read the session status before any retry, including the list of rejected invoices&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Out-of-order numbering is fine, by the way. The Ministry says so directly. A rejected invoice sent later does not need a correction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your queue is not FIFO
&lt;/h2&gt;

&lt;p&gt;A credit note must carry the KSeF number of the original. So a credit note for an offline invoice waits until the original gets its number.&lt;/p&gt;

&lt;p&gt;Same-day refunds are normal in e-commerce. Your queue needs that dependency, or it will fail in week one.&lt;/p&gt;

&lt;p&gt;Two more:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;correction notes are gone since February 2026, everything goes through the seller&lt;/li&gt;
&lt;li&gt;there is no cancel; wrong buyer NIP means credit note to zero and a new invoice&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Rejected means it never existed
&lt;/h2&gt;

&lt;p&gt;No invoice, no number, nothing in the records. You cannot correct it. You send a fixed file.&lt;/p&gt;

&lt;p&gt;In your system the order looks invoiced. The customer waits for a document that does not exist. If rejections only go to a log, nobody notices for a week. Send them to a human.&lt;/p&gt;

&lt;p&gt;KSeF checks the schema, UTF-8 without BOM, the issue date not being in the future, the NIP checksum (production only, so this one survives your tests), sizes and hashes.&lt;/p&gt;

&lt;p&gt;It does not check your math. Wrong totals get accepted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Offline modes: one flag, four deadlines, and two QR codes
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;Triggered by&lt;/th&gt;
&lt;th&gt;Send by&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;offline24&lt;/td&gt;
&lt;td&gt;you, no reason needed&lt;/td&gt;
&lt;td&gt;next business day&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;unavailability&lt;/td&gt;
&lt;td&gt;KSeF&lt;/td&gt;
&lt;td&gt;next business day after it ends&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;failure&lt;/td&gt;
&lt;td&gt;KSeF&lt;/td&gt;
&lt;td&gt;7 business days after it ends&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;total failure&lt;/td&gt;
&lt;td&gt;minister&lt;/td&gt;
&lt;td&gt;no obligation&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The mode itself is one boolean in the request. The deadline math is yours.&lt;/p&gt;

&lt;p&gt;The part that is not one boolean is the visualisation. Any invoice used outside KSeF needs a QR code, and an offline invoice needs &lt;strong&gt;two&lt;/strong&gt;: KOD I, which encodes the verification URL, issue date, seller NIP and a hash of the file, and KOD II, which proves you issued it, and which has to be signed with a KSeF certificate. Once the invoice reaches KSeF, you are back to one code with the KSeF number under it. (&lt;a href="https://github.com/CIRFMF/ksef-api/blob/main/kody-qr.md" rel="noopener noreferrer"&gt;QR codes&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;So offline support means: requesting and storing a certificate, signing locally, and rendering one or two codes depending on state. Budget for it separately.&lt;/p&gt;

&lt;p&gt;In 2026 the Ministry did not declare a single KSeF failure. Go-live pain was in the national login services.&lt;/p&gt;

&lt;h2&gt;
  
  
  Small stuff that eats a day
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;buyer NIP in the wrong field means the buyer never sees the invoice, even though it was accepted&lt;/li&gt;
&lt;li&gt;the KSeF number is 35 chars with a checksum, validate it locally&lt;/li&gt;
&lt;li&gt;the XML is the invoice, the PDF is a picture of it&lt;/li&gt;
&lt;li&gt;access tokens live minutes, the auth challenge expires in 10&lt;/li&gt;
&lt;li&gt;product names go into XML as they are, so your HTML entities will break the file&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The currency trap
&lt;/h2&gt;

&lt;p&gt;There was supposed to be a KSeF-specific exchange rate rule. It was repealed before it came into force. The general rule still applies.&lt;/p&gt;

&lt;p&gt;But the consolidated text of the VAT act was published before the repeal, so the dead rule is still printed there as future law. Plenty of articles repeat it.&lt;/p&gt;

&lt;p&gt;If someone shows you a "new KSeF rate rule", check their date. And confirm your case with a tax advisor, not with a blog post.&lt;/p&gt;

&lt;h2&gt;
  
  
  Penalties
&lt;/h2&gt;

&lt;p&gt;Statutory start is January 2027. In September 2026 the Ministry announced a deferral to the end of 2027, and that announcement says a law still has to be written. (&lt;a href="https://www.gov.pl/web/finanse/przedluzenie-odroczenia-kar-za-bledy-w-stosowaniu-ksef-do-konca-2027-r" rel="noopener noreferrer"&gt;Ministry announcement, 16 September 2026&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;So: not a fact yet. The obligation to use KSeF was never deferred.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you are an agency outside Poland
&lt;/h2&gt;

&lt;p&gt;A Polish VAT number alone does not put your client in scope. What matters is a seat or a fixed establishment in Poland taking part in the transaction. The Ministry published guidance on that in January 2026 and still asks for case-by-case assessment.&lt;/p&gt;

&lt;p&gt;Budget it as real work: collect a NIP, keep numbers stable, own a queue with retries and dependencies, surface rejections, handle offline QR, write the KSeF number back to the order.&lt;/p&gt;

&lt;p&gt;If you are wiring a Shopify, WooCommerce or Stripe setup to KSeF and you hit one of these, write to me and I will tell you what it takes: &lt;a href="https://polaczone.pl" rel="noopener noreferrer"&gt;polaczone.pl&lt;/a&gt;. The Polish version of this guide, with the same sources, is &lt;a href="https://polaczone.pl/poradniki/ksef-2026-sklep-internetowy/" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>poland</category>
      <category>api</category>
      <category>ecommerce</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
