API: Authenticate with OAuth2
Sist oppdatert: 16.09.2026This article is about OAuth2 authentication, for developers building an integration that other Luca users will connect to. If you only need access to your own company, the simpler personal API key described in the intro article is a better fit.
Luca implements OAuth 2.1: the authorization code flow with PKCE. A client secret alone is no longer accepted.
Every step below is implemented, end to end and with comments, in our example integration on GitHub — a small Node app that discovers the endpoints, runs the flow, and lets you explore the GraphQL API with the token it gets. If you would rather read working code than prose, start there.
1. Register your application
Please contact support@lucalabs.com with your application name and the exact redirect URIs you will use. We are happy to accept new integrations, so don’t hesitate to send us a request.
PS: We recommend developing your integration just using a personal API key first. Then you don’t have to contact us at all, and the API is identical. Just remember to always specify a company (it is not required in the personal API). You can then add the multiuser and multi company authentication with oauth at the end.
2. Discover the endpoints
Rather than hard-coding URLs, read them from the standard discovery documents:
https://go.lucaregnskap.no/.well-known/oauth-authorization-server
https://go.lucaregnskap.no/.well-known/oauth-protected-resource
These name the authorization, token and revocation endpoints, the scopes Luca offers, and the PKCE methods it supports. They also differ per Luca-based service, so reading them is what makes one integration work across all of them.
3. Authenticate the user
Generate a random code_verifier, hash it, and send the hash as code_challenge. Keep the verifier — you will need
it in step 4, and it must never leave your server.
code_challenge = BASE64URL(SHA256(code_verifier))
Then redirect the user to:
https://go.lucaregnskap.no/oauth/authorize
?client_id={CLIENT_ID}
&response_type=code
&redirect_uri={REDIRECT_URI}
&scope=accounting.read%20accounting.write
&code_challenge={CODE_CHALLENGE}
&code_challenge_method=S256
&state={STATE}
The user will then
- log in, if not already logged in
- be asked whether to give your application access, and tick which of their companies it may reach. They may tick more than one.
Parameters:
client_id– (required)redirect_uri– (required) must exactly match one of the URIs registered for your clientresponse_type– (required) alwayscodescope– (optional, but always send it) space-separated.accounting.readto read,accounting.writeto also make changes. Omitting it grantsaccounting.read. You may only ask for the scopes your application is registered for; asking for more is refused withinvalid_scope. Ask only for what you need: the consent screen shows the user exactly what you requested.code_challenge– (required) see abovecode_challenge_method– (required) alwaysS256state– (recommended) returned unchanged, so you can protect against cross-site request forgeryorganisation_number– (optional) pins the authorization to this one company, even if the user has access to several. If the user has no access to it, they are told so rather than shown a company list.locale– (optional)nboren. Defaults to the user’s own setting.
After a successful authorization the user is redirected back to your redirect_uri with:
code– an authorization code, valid for 10 minutes, to exchange for tokensstate– whatever you sentiss– the issuer that produced the response. Check it matches the authorization server you started with before redeeming the code.
If the user cancels, you are redirected back with error=access_denied instead.
A request Luca cannot accept at all — an unregistered redirect_uri, an unknown client_id, a scope the
application does not have — is answered with an error page at Luca rather than a redirect, so your callback never
fires and your integration simply never hears back. If testing leaves you waiting on a callback that never
arrives, look at the browser window: the reason is on screen there.
4. Fetch the access and refresh tokens
Exchange the code, sending the code_verifier you kept from step 3:
curl -X POST \
-H "Accept: application/json" \
-d "grant_type=authorization_code" \
-d "code=$AUTHORIZATION_CODE" \
-d "client_id=$CLIENT_ID" \
-d "client_secret=$CLIENT_SECRET" \
-d "code_verifier=$CODE_VERIFIER" \
-d "redirect_uri=$REDIRECT_URI" \
'https://go.lucaregnskap.no/oauth/token'
The client_secret may go in an Authorization: Basic header instead of a form field; Luca accepts both. A
public client sends no secret at all and is identified by PKCE alone.
Response:
{
"access_token": "{ACCESS_TOKEN}",
"refresh_token": "{REFRESH_TOKEN}",
"token_type": "Bearer",
"scope": "accounting.read accounting.write",
"expires_in": 3600,
"created_at": 1789543199
}
Read scope from the response rather than assuming you got what you asked for. expires_in is seconds from
created_at, and the access token is an opaque string — there is nothing in it to decode, which is what lets
Luca revoke one the instant a user withdraws access.
5. Use the access token
Set the header Authorization to Bearer ACCESS_TOKEN and call POST /api/v1/graphql as usual. Send
Accept: application/json too — without it the response body is still JSON but is labelled text/html, which
some HTTP clients will refuse to parse.
Every query and mutation takes a companyId argument naming which of the authorized companies the field is
about. It accepts the company’s id or its organisation number, and it is required on every field, even when the
user ticked only one company — so nothing depends on a default that could change later.
{
saleInvoices(companyId: "987654321") { nodes { id } }
}
Because the company is per field rather than per request, one document can read several companies at once:
{
first: saleInvoices(companyId: "987654321") { nodes { id } }
second: saleInvoices(companyId: "123456789") { nodes { id } }
}
Two fields need no companyId, because their job is to tell you which companies you may reach: companies
lists them, and company(id: ...) looks one up. Both are narrowed to the companies the user authorized.
You can find more information about GraphQL in the Get started guide.
A field without companyId fails with a message listing the companies the token may name. So does a field
naming a company outside that list, or one the user has since lost access to — the rest of the document still
resolves, as GraphQL field errors do.
A token granted only accounting.read is rejected on mutations, even where the user would have been allowed to
perform them in Luca.
Do not match on the text of these errors. They arrive as ordinary GraphQL field errors with no
machine-readable code, and the message is translated into the language of the Luca user who granted the token —
which your integration does not choose and cannot override with Accept-Language or a locale parameter. A
Norwegian user’s token produces Norwegian messages. Treat them as text for a human to read, and drive your own
logic off which field failed and whether you sent a companyId.
If the token is missing, expired or revoked, the API answers 401 with a
WWW-Authenticate: Bearer resource_metadata="..." header naming the protected-resource document from step 2. A
client that meets the API without knowing anything else can follow that header to find the authorization server
and start the flow.
6. Refresh your access token
Access tokens last one hour. Use the refresh token to get a new one:
curl -X POST \
-H "Accept: application/json" \
-d "grant_type=refresh_token" \
-d "refresh_token=$REFRESH_TOKEN" \
-d "client_id=$CLIENT_ID" \
-d "client_secret=$CLIENT_SECRET" \
'https://go.lucaregnskap.no/oauth/token'
A confidential client authenticates here exactly as it does in step 4. Leaving the secret out is answered with
401 invalid_client.
Response:
{
"access_token": "{NEW_ACCESS_TOKEN}",
"refresh_token": "{NEW_REFRESH_TOKEN}",
"token_type": "Bearer",
"scope": "accounting.read accounting.write",
"expires_in": 3600,
"created_at": 1789546799
}
Refresh tokens rotate. Every refresh returns a new refresh token and invalidates the old one, so store the new value each time. Presenting a refresh token that has already been used is treated as a sign the token was stolen, and revokes the whole authorization — the user then has to authorize again.
7. Revoke a token
curl -X POST \
-d "token=$REFRESH_TOKEN" \
-d "token_type_hint=refresh_token" \
-d "client_id=$CLIENT_ID" \
-d "client_secret=$CLIENT_SECRET" \
'https://go.lucaregnskap.no/oauth/revoke'
Revoke the refresh token. Both tokens belong to one authorization, so revoking the refresh token takes the
access token with it; revoking only the access token leaves a refresh token alive that can mint a new one.
token_type_hint is optional and only helps Luca look the token up faster.
A confidential client authenticates here too, and this is the one worth checking the status of: without the
secret the request is refused with 403 and the token keeps working. A revocation that quietly did nothing
is the worst shape a failure can take. A successful revocation answers 200 with an empty body — as does
revoking a token that never existed, which is deliberate, so nobody can probe for valid tokens here.
Users can also withdraw your application’s access themselves, under Settings → Advanced in Luca. That revokes every token the authorization produced, for every company it covered.