— Engineering · browser extensions

One Chrome extension, five Norwegian accounting platforms

A Norwegian bookkeeper retypes the same Brønnøysund data into the same five fields dozens of times a day. The org number, the name, the postal address, the postal code, the city. Every time they onboard a new customer or supplier into Tripletex, Visma, PowerOffice Go, 24SevenOffice, or Fiken, they alt-tab to brreg.no, copy a value, alt-tab back, paste, repeat.

We built a Chrome extension that does this with one click. 17.9 KB, MIT-licensed, works across all five platforms. This post is the architecture behind it and the surprises along the way.

Platforms
5
one extension
Extension size
17.9 KB
unzipped 35 KB
Fill latency
~380 ms
click to filled
Adapter LOC
~60 lines
per platform

The architecture

One injector plus one adapter per platform. The injector is platform-agnostic: it scans for org-number inputs, attaches a button, calls Nordic Data's API on click, and hands the result to the platform-specific adapter. The adapter knows which Nordic Data fields map to which DOM inputs.

// extension/core/inject.js — runs on every content-script page
// Pattern: scan + observe DOM, attach button, dispatch to adapter

const adapter = window.__ndcAdapter;          // set by per-platform script
if (!adapter) return;                       // site not supported

function scan() {
  for (const sel of adapter.orgnrSelectors) {
    document.querySelectorAll(sel).forEach(attachButton);
  }
}

scan();
new MutationObserver(scan).observe(document.body, {
  childList: true, subtree: true
});

Each per-platform adapter is loaded as a separate content script via Manifest V3 matches rules. The adapter registers itself on window.__ndcAdapter before inject.js runs.

// extension/manifest.json — one content_scripts entry per platform
{
  "content_scripts": [
    {
      "matches": ["https://tripletex.no/*", "https://*.tripletex.no/*"],
      "js": ["adapters/tripletex.js", "core/inject.js"],
      "run_at": "document_idle"
    },
    { "matches": ["https://*.visma.net/*"],         "js": ["adapters/visma.js", "core/inject.js"], "run_at": "document_idle" },
    { "matches": ["https://*.fiken.no/*"],         "js": ["adapters/fiken.js", "core/inject.js"], "run_at": "document_idle" },
    // ... PowerOffice, 24SevenOffice
  ]
}

Verifying field names against OpenAPI specs

The single biggest mistake to make on a project like this is guessing field names. Norwegian accounting platforms have their own internal vocabularies, and you can be off by one and not notice until production. Our defence: read each platform's OpenAPI spec and use the same field names there as in the DOM selectors.

Three of the five platforms expose public OpenAPI specs:

PlatformSpec sourceOrg-number field
Tripletextripletex.no/v2/swagger.jsonorganizationNumber
Visma.net Financialsintegration.visma.net/API-index/corporateId
Fikenapi.fiken.no/api/v2/docs/swagger.yamlorganizationNumber
PowerOffice GoNo public specvatNumber (educated guess)
24SevenOfficeNo public specOrganizationNumber (educated guess)

The biggest gotcha: Visma.net does not call it organizationNumber. Visma's CustomerUpdateDto uses corporateId for the Norwegian org number, with the VAT number living in a separate vatRegistrationId field formatted as "NO123456789MVA". If you wrote a Visma adapter from memory, you would silently fail against Visma's API for every customer write.

Contacts are similarly surprising on Visma: phone numbers live at mainContact.phone1 and mainContact.phone2, not mainContact.phone or root-level phoneNumber. Addresses go into mainAddress.addressLine1, not billingAddress.*. None of this is documented anywhere except in the OpenAPI spec.

Lesson

When you build an integration against a platform whose UI you can't see, the OpenAPI spec is the source of truth. The web UI's input name attributes almost always match the API field names — but only the API spec tells you the truth about which fields exist and what they're called. For Visma we would have shipped broken adapter code if we hadn't checked.

Multi-candidate selectors

Even with verified API field names, the live UI's input name attributes are not 100% guaranteed to match. Modern frameworks rewrite ids, dev teams sometimes use data-cy markers for Cypress tests but not name, and design systems occasionally rebrand fields. Our defence is a candidate list: each logical field maps to multiple possible selectors, and we use the first one that exists on the page.

// extension/adapters/tripletex.js — multi-candidate strategy

window.__ndcAdapter = {
  platform: "tripletex",

  // Anchor selectors — find the org-number input that triggers everything
  orgnrSelectors: [
    'input[name="organizationNumber"]',
    'input[data-cy="organizationNumber"]',
    'input[data-cy*="organization"]',
    'input[id$="organizationNumber"]',
    'input[placeholder*="rganisasjonsnummer"]',
    'input[aria-label*="rganisasjonsnummer"]',
  ],

  async fillForm(company, { triggerElement }) {
    const root = triggerElement.closest("form") ||
                 triggerElement.closest("[role='form']");
    const map = [
      ["name", ["name", "displayName"], company.name],
      ["email", ["email", "invoiceEmail"], company.email],
      ["phoneNumber", ["phoneNumber"], company.phone],
      // ... etc
    ];
    for (const [_, candidates, value] of map) {
      for (const cand of candidates) {
        const el = root.querySelector(`input[name="${cand}"]`) ||
                   root.querySelector(`input[data-cy="${cand}"]`) ||
                   root.querySelector(`input[id*="${cand}" i]`);
        if (el) { setVal(el, value); break; }
      }
    }
  },
};

Three things happen here. First, the adapter scopes its search to the nearest form root, so we don't accidentally fill fields on an unrelated tab in a tab strip. Second, each logical field has multiple selector candidates, tried in order. Third, every value setter uses the same setVal() helper — which is where the next trick lives.

The setVal trick: setting React-compatible input values

This took us most of one afternoon to figure out. If you do this:

document.querySelector('input[name="name"]').value = "EQUINOR ASA";

on a React-based form (which is what Tripletex's newer pages use), the input visually shows "EQUINOR ASA" but React's internal state is unchanged. When the user clicks Save, React serialises its internal state — which is still empty — and the platform writes a blank field.

The fix is to call React's native setter, which fires React's onChange and updates the controlled-component state:

function setVal(el, value) {
  if (!el || value == null) return;
  // Bypass React's intercepted setter
  const nativeSetter = Object.getOwnPropertyDescriptor(
    window.HTMLInputElement.prototype, "value"
  ).set;
  nativeSetter.call(el, value);
  // Then fire the events React listens for
  el.dispatchEvent(new Event("input", { bubbles: true }));
  el.dispatchEvent(new Event("change", { bubbles: true }));
}

The same trick works for Angular (which Visma uses) and Vue. Plain non-framework inputs (which 24SevenOffice and parts of PowerOffice use) accept either approach. We use this one everywhere for consistency.

MutationObserver: catching SPA-rendered forms

All five platforms are SPAs. The customer-creation form is mounted on click, not on page load. If we ran scan() only once on document_idle, we'd miss every dynamically-mounted form.

A MutationObserver watching document.body with { childList: true, subtree: true } fires on every DOM change. We re-run scan() on each event. attachButton() guards against double-attachment by tagging the input with a dataset.ndcAttached marker.

The performance cost is negligible: the observer fires hundreds of times during a typical page interaction, but each scan() is a handful of querySelectorAll calls, none of which traverse the page deeply. We benchmarked it at < 1ms per scan on a beefy Tripletex customer-list page.

The popup: API key as the only setting

Most browser extensions over-engineer their popup. We did not. The popup has exactly two interactive elements: a password-style input where you paste your Nordic Data API key, and a Save button. That's the entire configuration.

// extension/popup.js — entire file
const input = document.getElementById("apiKey");
chrome.storage.sync.get(["ndc_api_key"], (r) => {
  if (r.ndc_api_key) input.value = r.ndc_api_key;
});
document.getElementById("save").addEventListener("click", () => {
  chrome.storage.sync.set({ ndc_api_key: input.value.trim() });
});

The key is read by every content script before each lookup:

async function getApiKey() {
  return new Promise((resolve) => {
    chrome.storage.sync.get(["ndc_api_key"], (r) =>
      resolve(r.ndc_api_key || ""));
  });
}

Storage syncs across the user's Chrome profiles via Google's account-sync. They paste once and the extension works on every laptop they sign into.

What surprised us

  1. Visma's corporateId. Already covered. The biggest gotcha of the whole project.
  2. Tripletex's physicalAddress vs postalAddress. Two separate Address refs. Most platforms only have one. We fill both with the same source when the source doesn't distinguish.
  3. Fiken's unified Contact model. Fiken doesn't have separate Customer and Supplier entities; one Contact with boolean customer and supplier flags. Our adapter passes recordType in and sets the flags accordingly.
  4. Country fields are inconsistent. Tripletex wants a { name: "Norway" } sub-object on its Country ref. Visma wants countryId: "NO" (ISO 2-letter). Fiken wants country: "Norway" as a plain string. PowerOffice wants country: "NO" as a 2-letter code. Each adapter handles its own format.
  5. None of the platforms publish a "this form maps to this DTO" doc. The OpenAPI spec tells you which fields the API accepts. Which DOM inputs in the UI map to which fields is something you only discover by inspecting the form.

What's next

The extension is at v0.1.0, freshly packaged as a 17.9 KB Chrome Web Store zip. Three immediate next steps:

Source for the extension is at github.com/nordic-data/connect; the underlying data API at nordicdata.cloud. If you build a similar shape for another country's accounting platforms, the architecture above should port directly — replace the Norwegian Brønnøysund / Aksjonær / Doffin sources with your country's equivalents.

Try the extension when it's live

Pending Chrome Web Store review. In the meantime, the same data is in the API.

Look up a Norwegian company →