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.
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.
A short recording of the flow (manual copying first, then the card) is in the original post: https://polaczone.pl/poradniki/dane-z-gus-w-hubspot-karta-firmy/
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.
Where the backend lives
HubSpot app cards are React components rendered by HubSpot. They cannot call arbitrary URLs. You get two options:
- Serverless functions inside the HubSpot project. 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.
-
hubspot.fetch()to your own backend. The URL must be HTTPS and listed inpermittedUrls.fetchin the app config (entries are prefixes). HubSpot signs every request and appendsportalId,userIdandappIdto the query string.
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 hubspot.fetch.
Talking to GUS without a SOAP library
The service is BIR 1.2 (the REGON API). Three calls cover the flow: Zaloguj (log in with the key, get a session id), DaneSzukajPodmioty (search by NIP), DanePobierzPelnyRaport (full report for a REGON, for example BIR12OsPrawna and BIR12OsPrawnaPkd 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.
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 sid:
const envelope =
'<soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope"' +
' xmlns:ns="http://CIS/BIR/PUBL/2014/07" xmlns:dat="http://CIS/BIR/PUBL/2014/07/DataContract">' +
'<soap:Header xmlns:wsa="http://www.w3.org/2005/08/addressing">' +
`<wsa:To>${endpoint}</wsa:To>` +
'<wsa:Action>http://CIS/BIR/PUBL/2014/07/IUslugaBIRzewnPubl/DaneSzukajPodmioty</wsa:Action>' +
'</soap:Header><soap:Body>' +
'<ns:DaneSzukajPodmioty><ns:pParametryWyszukiwania>' +
`<dat:Nip>${nip}</dat:Nip>` +
'</ns:pParametryWyszukiwania></ns:DaneSzukajPodmioty>' +
'</soap:Body></soap:Envelope>';
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/soap+xml; charset=utf-8', sid },
body: envelope,
signal: AbortSignal.timeout(10_000),
});
The response is multipart/related (MTOM). You do not need a MIME parser for it: the useful part is the text between <DaneSzukajPodmiotyResult> and its closing tag. That text is an XML document escaped as a string, so you unescape it once and get a flat <root><dane>…</dane></root>, one <dane> block per row. A regex over flat elements is enough here, there is no nesting to speak of.
Things that cost me time:
-
GetValueis on a different contract. Everything else uses theIUslugaBIRzewnPublaction namespace.GetValue(for exampleStatusSesji, to check whether the session is still alive) lives onhttp://CIS/BIR/2014/07/IUslugaBIR/GetValue. Sending it with the public action returnsActionNotSupported. -
An empty result is not always "company not found". It can also mean an expired session. When a search comes back empty, ask
StatusSesji, log in again if it is 0, and retry once before you tell the user nothing was found. -
Postal codes come in two formats. The search returns
00-925, the full report returns00925. - Names can contain markup. 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.
-
Sole traders are a different report. A search row with
TypFis 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. - Status needs dates, not a flag. "Active", "suspended" or "deregistered" is derived from the suspension, resumption, end and deregistration dates in the report.
Verifying HubSpot's signature
hubspot.fetch requests carry X-HubSpot-Signature-v3 and X-HubSpot-Request-Timestamp. 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.
const expected = createHmac('sha256', clientSecret)
.update(method + decodeUri(publicBaseUrl + req.url) + rawBody + timestamp)
.digest();
const given = Buffer.from(signatureHeader, 'base64');
const ok = given.length === expected.length && timingSafeEqual(given, expected);
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.
The write path
The card never writes on its own. "Pobierz z GUS" calls GET /gus/lookup?nip=…, 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.
"Zapisz" sends only the changed fields to POST /gus/apply. 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.
What it does not do yet
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: https://polaczone.pl/poradniki/dane-firmy-z-gus-po-nip-api-bir/
The HubSpot integration itself is described here: https://polaczone.pl/integracje/hubspot-gus-krs/



