How to Integrate with the Visma eAccounting API

A developer guide to Visma eAccounting API integration. Covers OAuth 2.0 scopes, token management, pagination, fiscal year handling, and country-specific VAT with JavaScript code examples.

Saurabh RaiSaurabh Rai

Saurabh Rai · Developer Relations Engineer, Apideck

9 min read
How to Integrate with the Visma eAccounting API

Visma eAccounting is one of the more widely deployed cloud accounting platforms in Northern Europe, particularly in Norway and the Netherlands. If your product targets SMEs in those markets, sooner or later a customer will ask you to sync invoices, pull expense data, or push payments into their eAccounting company. The API handles all of that, but the path to a working integration has a few non-obvious steps that are worth mapping out clearly.

One naming note before anything else: Visma has rebranded eAccounting to "Spiris" in some markets, and the official developer portal now refers to it as the Spiris API. The underlying API, authentication server, and endpoints are the same product. If you see the two names used interchangeably in documentation and forum posts, that's why. If your customers are specifically on Spiris in Sweden, see our Spiris / Visma eAccounting API integration guide for the Sweden-specific differences around BAS accounts, Moms VAT, and ROT/RUT invoice handling.

Getting Registered

Access to the Visma eAccounting API requires joining the Spiris Partner Programme. Registration is self-service for sandbox access: fill in the form at the Visma Developer Self-Service portal, and you'll receive a client_id, a client_secret, and credentials for a sandbox eAccounting company by email.

Use a business email. That address becomes the contact point for your developer account and the address Visma API support will reach out to. When you register, you also set your redirect_uri. This value is fixed unless you contact Visma support to update it, so plan ahead rather than hardcoding localhost and hoping to change it later.

Production access requires a separate request to API support after sandbox testing is complete. Visma will never ask you for your client_secret, not during registration and not in any support context. If that ever comes up, treat it as a red flag.

Authentication: OAuth 2.0 with a Resource-Level Scope Model

The API uses the OAuth 2.0 authorization code flow, with the identity server hosted at https://identity.vismaonline.com. The two required scopes for any eAccounting integration are ea:api (grants access to the Bookkeeping & Invoicing/eAccounting API) and offline_access (required to receive a refresh token). Beyond those, each resource area has its own scope.

ScopeAccess
ea:salesFull access to sales resources (invoices, customers)
ea:sales_readonlyRead-only access to sales resources
ea:purchaseFull access to purchase resources (bills, suppliers)
ea:purchase_readonlyRead-only access to purchase resources
ea:accountingFull access to accounting resources (journal entries, accounts)
ea:accounting_readonlyRead-only access to accounting resources

The scope model creates friction that isn't obvious until you've already shipped. You define the maximum set of scopes your app can request when registering in the developer portal. During the OAuth flow, you must explicitly include every scope you need in the authorization request. Your customer sees and approves those scopes as part of connecting their account.

If you omit a scope in the authorization request, even if your app registration permits it, the API returns an authorization error for that resource area. Correcting it requires your customer to go through the OAuth flow again. Get your scope list right the first time, and default to the _readonly variants unless your integration genuinely needs write access.

A sample authorization request looks like this:

GET https://identity.vismaonline.com/connect/authorize
  ?client_id=<client_id>
  &redirect_uri=<redirect_uri>
  &scope=ea:api+offline_access+ea:sales+ea:purchase+ea:accounting
  &state=<state_string>
  &response_type=code
  &prompt=select_account
  &acr_values=service:44643EB1-3F76-4C1C-A672-402AE8085934

After the user authorizes, the identity server redirects to your redirect_uri with an authorization code. Exchange it for an access token and refresh token at the token endpoint (https://identity.vismaonline.com/connect/token):

async function exchangeCodeForTokens(code) {
  const response = await fetch('https://identity.vismaonline.com/connect/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code,
      redirect_uri: process.env.VISMA_REDIRECT_URI,
      client_id: process.env.VISMA_CLIENT_ID,
      client_secret: process.env.VISMA_CLIENT_SECRET,
    }),
  });

  if (!response.ok) {
    throw new Error(`Token exchange failed: ${response.status}`);
  }

  const { access_token, refresh_token, expires_in } = await response.json();
  // Store both tokens per tenant. expires_in is in seconds (typically 3600).
  return { access_token, refresh_token, expires_at: Date.now() + expires_in * 1000 };
}

Token Management

Access tokens expire after 60 minutes. Your integration must handle token refresh for every tenant, not just on first authentication. For production systems that sync data on a schedule, you'll need to refresh before expiry and store updated tokens per tenant.

The refresh token is valid for two years, with one critical exception: if a user changes their Visma password, the refresh token is immediately invalidated. There is no webhook or notification for this. The first sign your integration will see is a 401 response on the next API call. Build explicit handling for this case: detect the 401, mark the tenant as requiring re-authorization, and trigger a new OAuth flow with a prompt to the user. Without this, the integration silently stops syncing data for any customer who resets their password.

async function refreshAccessToken(tenant) {
  const response = await fetch('https://identity.vismaonline.com/connect/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'refresh_token',
      refresh_token: tenant.refresh_token,
      client_id: process.env.VISMA_CLIENT_ID,
      client_secret: process.env.VISMA_CLIENT_SECRET,
    }),
  });

  if (response.status === 400) {
    // Refresh token invalidated — most likely a password change.
    // Mark tenant as needing re-authorization and surface this to the user.
    await markTenantAsUnauthorized(tenant.id);
    throw new Error('REAUTH_REQUIRED');
  }

  const { access_token, refresh_token, expires_in } = await response.json();
  await saveTenantTokens(tenant.id, { access_token, refresh_token, expires_in });
  return access_token;
}

async function callApi(tenant, path) {
  // Proactively refresh if within 5 minutes of expiry.
  if (tenant.expires_at - Date.now() < 5 * 60 * 1000) {
    tenant.access_token = await refreshAccessToken(tenant);
  }

  const response = await fetch(`https://eaccountingapi.vismaonline.com/v2/${path}`, {
    headers: { Authorization: `Bearer ${tenant.access_token}` },
  });

  if (response.status === 401) {
    // Unexpected 401 after a valid token — catch any edge cases.
    await markTenantAsUnauthorized(tenant.id);
    throw new Error('REAUTH_REQUIRED');
  }

  return response.json();
}

Working with Core Endpoints

The full API reference is available at eaccountingapi.vismaonline.com/scalar/v2. Required fields are marked in bold in the docs, which makes it easier to identify the minimum viable payload for each resource.

A few patterns apply across the entire API:

Pagination. Responses are paginated with a default page size of 50 results. Without explicit pagination handling, a sync that returns 50 records may be complete, or it may be missing hundreds more. Always check the pagination metadata in the response and implement full page traversal before shipping.

async function getAllInvoices(tenant) {
  const invoices = [];
  let pageIndex = 1; // eAccounting uses 1-based page indexing

  while (true) {
    const data = await callApi(
      tenant,
      `customerinvoices?pagesize=50&pageindex=${pageIndex}`
    );

    invoices.push(...data.Data);

    // TotalNumberOfPages is returned in the response metadata
    if (pageIndex >= data.Meta.TotalNumberOfPages) break;
    pageIndex++;
  }

  return invoices;
}

Fiscal years. Account-related operations require a FiscalYearId. This is tenant-specific and must be fetched per customer before creating or querying accounts. Hardcoding a year or skipping this lookup is a common source of 400 errors in production.

async function getActiveFiscalYear(tenant) {
  const data = await callApi(tenant, 'fiscalyears');
  // Find the fiscal year that contains today's date
  const today = new Date().toISOString().split('T')[0];
  return data.Data.find(
    (fy) => fy.StartDate <= today && fy.EndDate >= today
  );
}

async function createCustomerInvoice(tenant, invoicePayload) {
  const fiscalYear = await getActiveFiscalYear(tenant);

  const response = await fetch(
    'https://eaccountingapi.vismaonline.com/v2/customerinvoices',
    {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${tenant.access_token}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        ...invoicePayload,
        // FiscalYearId is required — the API returns 400 without it
        FiscalYearId: fiscalYear.Id,
      }),
    }
  );

  if (!response.ok) {
    // The response body includes an ErrorId — log it for support escalations
    const error = await response.json();
    throw new Error(`Invoice creation failed [ErrorId: ${error.ErrorId}]: ${error.Message}`);
  }

  return response.json();
}

VAT and account codes. VAT rates and chart of accounts structures vary by country and, for some fields, by individual tenant configuration. Norway and the Netherlands use different VAT rate structures and account number conventions. Your integration needs to handle this per-tenant rather than assuming a fixed mapping.

For customer invoices specifically, Visma's Invoice Payments FAQ and Autoinvoice guide cover the workflows for payment reconciliation and automated sending in detail. These are worth reading before building the invoicing leg of an integration, since some behaviors (like triggering email delivery) require specific payload fields that aren't obvious from the endpoint reference alone.

Rate Limits and Error Handling

The API enforces rate limits, documented in the developer portal. Every non-successful response includes a unique Error ID. Include that Error ID and your client_id whenever you contact Visma API support. Responses without the Error ID significantly slow down support resolutions.

Support is market-specific:

The Maintenance Burden

A working Visma eAccounting integration takes real engineering effort to build. The OAuth scope model requires careful upfront planning, the token refresh logic needs to handle password-change invalidation explicitly, pagination must be handled on every list endpoint, and country-specific VAT and account structures have to be resolved per tenant. None of these are blockers, but together they represent a meaningful investment to build correctly and to maintain as the API evolves.

If you're building accounting integrations across multiple platforms alongside eAccounting, a unified API layer can absorb the per-platform differences in authentication, data models, and error behavior. Apideck's Accounting API normalizes data across 35+ accounting platforms through a single schema, and the Vault component handles OAuth flows and token management without requiring custom code per connector. For teams that need to ship integrations quickly across multiple markets and platforms, that tradeoff is often worth examining before committing to a stack of direct integrations.

For teams building specifically against Visma eAccounting, the official developer portal and API reference are the ground truth. The sandbox registration is free and takes a few minutes. Get credentials, poke around in the sandbox company, and build the OAuth flow before writing any data-sync logic. It's the fastest way to catch scope issues before they become customer re-authorization requests.

Ready to get started?

Scale your integration strategy and deliver the integrations your customers need in record time.

Ready to get started?
Talk to an expert

Trusted by fast-moving product & engineering teams

JobNimbus
Blue Zinc
Exact
Drata
Octa
Apideck Blog

Insights, guides, and updates from Apideck

Discover company news, API insights, and expert blog posts. Explore practical integration guides and tech articles to make the most of Apideck's platform.