Signup
The signup API provides an ID-porten verified registration flow. Users authenticate with their Norwegian national identity (personnummer) via ID-porten, select an organization they represent, and complete account creation. The flow supports both new users and existing users adding a new organization.
All signup endpoints are public (no authentication required) and rate-limited.
Endpoints
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/v2/auth/signup/authorize | Initiate ID-porten authorization |
| GET | /api/v2/auth/signup/callback | Handle ID-porten callback (browser-only) |
| POST | /api/v2/auth/signup/exchange | Exchange URL-borne signup_code for a body-borne signup_token |
| POST | /api/v2/auth/signup | Complete signup |
Signup Flow
The signup process is a four-step flow:
- Authorize — Your app calls
POST /api/v2/auth/signup/authorizeto get an ID-porten authorization URL and a session key. - Callback — Redirect the user (full-page navigation) to the authorization URL. After ID-porten authenticates the user, the browser is redirected to
GET /api/v2/auth/signup/callback, which in turn redirects back to your app’s/sign-uproute withsignup_code(success) orsignup_error(failure) in the query string. - Exchange — Your app reads
signup_codefrom the URL and callsPOST /api/v2/auth/signup/exchangeto swap the one-shot URL-borne code for a longer-lived body-bornesignup_tokenalong with the verified user data (name, existing-user flag, organizations). - Complete — Your app calls
POST /api/v2/auth/signupwith thesignup_token, selected organization, and (for new users) email and password to create the account.
The signup_code is single-use with a tight 60-second TTL — it only needs to survive the callback’s 302 redirect long enough for the SPA’s mount effect to exchange it. The longer-lived signup_token returned by /exchange has a 15-minute TTL and covers the user’s form completion time.
The callback uses a server-side redirect (not window.opener / postMessage) so the flow works in mobile browsers, where opener is often nulled across cross-origin OAuth bounces. The redirect target is the APP_BASE_URL configured on the server (e.g. https://app.snapbooks.no/sign-up).
The flow is intentionally a body-bearer design (no cookies) so it works in browsers with strict cross-site cookie policies — for example iOS Safari ITP, which drops cross-site Set-Cookie headers issued during OAuth-bounce redirect chains. The signup_token lives only in the SPA’s in-memory state and the request body — never in URLs, browser history, or Referer headers.
Initiate Authorization
POST /api/v2/auth/signup/authorize
Returns an ID-porten authorization URL and a session key. Rate limited to 30 requests per hour.
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| provider | string | No | Identity provider. Currently only id-porten (default) |
Response
{
"authorization_url": "https://login.idporten.no/authorize?...",
"session_key": "abc123def456"
}
Navigate the user to authorization_url (full-page redirect — not a popup). The session_key does not need to be retained on the client; ID-porten preserves it via the OAuth state parameter and the callback re-emits it in the post-redirect URL as signup_code.
Error Responses
| Status | Description |
|---|---|
| 400 | Unsupported provider |
| 429 | Rate limit exceeded (30 per hour) |
| 502 | id_porten.par_failed — ID-porten’s Pushed Authorization Request (PAR) failed. Retryable |
Handle Callback
GET /api/v2/auth/signup/callback
Handles the OAuth callback from ID-porten. This endpoint is called by the browser as a redirect from ID-porten — it is not called directly by your application. Rate limited to 20 requests per hour.
On success, the endpoint stores the verified session in Redis (60-second TTL) and returns a 302 Found redirect to {APP_BASE_URL}/sign-up?signup_code={code}. On failure, it redirects to {APP_BASE_URL}/sign-up?signup_error={message}.
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| provider | string | No | Identity provider. Currently only id-porten (default) |
| code | string | Yes | Authorization code from ID-porten (added by the OAuth redirect) |
| state | string | Yes | State parameter from the OAuth redirect |
Response
302 Found redirect to one of:
- Success:
{APP_BASE_URL}/sign-up?signup_code={signup_code}— the SPA should readsignup_codefrom the URL and callPOST /api/v2/auth/signup/exchangeto swap it for asignup_tokenplus the verified session data. - Failure:
{APP_BASE_URL}/sign-up?signup_error={error_message}— the SPA should display the error and offer to restart the flow.
If APP_BASE_URL is not configured on the server the endpoint returns 500 Internal Server Error instead of redirecting.
Exchange Code for Token
POST /api/v2/auth/signup/exchange
Exchanges the one-shot, URL-borne signup_code for a body-borne signup_token and returns the verified session data the SPA needs to render the final signup form (user’s name, whether they already have a Snapbooks account, and the organizations they can register). Rate limited to 60 requests per hour.
The signup_code is single-use — it is deleted from Redis on first read, so a captured code leaked to analytics, browser history, or Referer headers is dead by the time anyone reads those logs.
Call this immediately after the callback redirects your app to /sign-up?signup_code={code}.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
| code | string | Yes | The signup_code from the callback redirect’s query string |
Example Request
{
"code": "abc123def456"
}
Response
{
"signup_token": "EXAMPLE_SIGNUP_TOKEN",
"given_name": "Ola",
"family_name": "Nordmann",
"is_existing_user": false,
"organizations": [
{
"id": 101,
"name": "Nordmann AS",
"organization_number": "123456789",
"already_registered": false
}
]
}
| Field | Type | Description |
|---|---|---|
| signup_token | string | Body-borne token for the final POST /api/v2/auth/signup call. 15-minute TTL |
| given_name | string | User’s first name from ID-porten |
| family_name | string | User’s last name from ID-porten |
| is_existing_user | boolean | Whether the verified identity matches an existing Snapbooks user |
| organizations | array | Organizations the user can represent via Altinn |
Each organization object:
| Field | Type | Description |
|---|---|---|
| id | integer | Snapbooks organization ID |
| name | string | Organization name |
| organization_number | string | Norwegian organization number |
| already_registered | boolean | Whether this organization already has a Snapbooks client account |
Error Responses
| Status | Description |
|---|---|
| 400 | Missing code in the request body |
| 404 | Invalid or expired signup_code (codes are single-use with a 60-second TTL) |
| 429 | Rate limit exceeded (60 per hour) |
Complete Signup
POST /api/v2/auth/signup
Completes the signup by creating a user account (if needed) and a client account for the selected organization. Rate limited to 50 requests per hour.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
| signup_token | string | Yes | The body-borne token returned by POST /api/v2/auth/signup/exchange |
| organization_id | integer | Yes | ID of the organization to register (must be in the session’s authorized list) |
| string | Conditional | Email address. Required for new users | |
| password | string | Conditional | Password. Required for new users |
| consent | object | No | The visitor’s cookie consent, {"ads": true\|false}. Marketing touches are stored only when ads is true |
| attribution | object | No | Marketing touches collected in the browser (see below) |
For existing users (where is_existing_user was true in the exchange response), only signup_token and organization_id are required. The user is logged in automatically.
For new users, email and password are also required. If the email belongs to an existing account without an ID-porten identity, the user must provide the correct password to link the identity.
Example Request (New User)
{
"signup_token": "EXAMPLE_SIGNUP_TOKEN",
"organization_id": 101,
"email": "ola@nordmann.no",
"password": "securePassword123"
}
Example Request (Existing User)
{
"signup_token": "EXAMPLE_SIGNUP_TOKEN",
"organization_id": 101
}
Marketing Attribution
attribution and consent let a web client hand over the campaign touches it
collected before signup. Both are optional — omitting them changes nothing else
about the signup — and recording them can never fail the signup.
attribution has the shape {"first": touch, "last": touch, "clicks": {"<param>": touch}},
where each touch is:
| Field | Type | Description |
|---|---|---|
| params | object | Campaign parameters from the landing URL. Only allowlisted keys are kept: the click IDs gclid, gbraid, wbraid, li_fat_id, twclid, fbclid, msclkid, and utm_source, utm_medium, utm_campaign, utm_term, utm_content |
| landing_path | string | The path the visitor landed on |
| referrer_domain | string | The referring domain |
| ts | integer | When the touch happened, as a Unix timestamp in milliseconds |
The keys of clicks are click-ID parameter names. A touch stored there is kept
only when the first click ID present in its params — looked up in the fixed
order gclid, gbraid, wbraid, li_fat_id, twclid, fbclid, msclkid —
is the same parameter as the key. So file each touch under whichever of its click
IDs comes first in that order: a landing URL carrying both fbclid and gclid
belongs under gclid, and the same touch under fbclid is dropped.
Nothing is stored unless consent.ads is true. A touch is dropped when it has
no usable ts, is more than 10 minutes in the future, is older than 366 days,
carries neither an allowlisted click ID nor any UTM value, or has a campaign
value that cannot be stored. The rest of the signup proceeds either way.
A touch identical to one the user already has is not stored twice. Two touches
count as identical when their touch type, click-ID type, click ID, timestamp,
utm_source, utm_medium and utm_campaign all match — utm_term,
utm_content, landing_path and referrer_domain are not compared, so a touch
that differs only in those is treated as a duplicate and dropped.
Example Request (With Attribution)
{
"signup_token": "EXAMPLE_SIGNUP_TOKEN",
"organization_id": 101,
"email": "ola@nordmann.no",
"password": "securePassword123",
"consent": { "ads": true },
"attribution": {
"first": {
"params": { "utm_source": "google", "utm_medium": "cpc", "gclid": "Cj0KCQ..." },
"landing_path": "/regnskapsprogram",
"referrer_domain": "www.google.com",
"ts": 1759000000000
},
"last": {
"params": { "utm_source": "newsletter", "utm_medium": "email" },
"landing_path": "/priser",
"referrer_domain": "",
"ts": 1760000000000
},
"clicks": {
"gclid": {
"params": { "gclid": "Cj0KCQ..." },
"landing_path": "/regnskapsprogram",
"referrer_domain": "www.google.com",
"ts": 1759000000000
}
}
}
}
Response
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"user": {
"self_url": "/api/v2/users/42",
"id": 42,
"first_name": "Ola",
"last_name": "Nordmann",
"email": "ola@nordmann.no",
"email_verified": false,
"profile_image_url": null,
"accounts": [
{
"id": 1234,
"account": { "id": 456, "unique_name": "nordmann-as", "display_name": "Nordmann AS" },
"role": { "id": 3, "name": "CA" }
}
],
"contracts": []
},
"status": true,
"message": "User created successfully"
}
The response includes:
| Field | Type | Description |
|---|---|---|
| token | string | JWT access token |
| user | object | The created or existing user |
| status | boolean | Always true on success |
| message | string | "User created successfully" for new users, "Organization added successfully" for existing users |
A refresh_token cookie is also set as an HTTP-only secure cookie. Status code is 201 Created.
Error Responses
| Status | Description |
|---|---|
| 400 | Missing signup_token or organization_id |
| 400 | Invalid or expired signup_token (tokens expire after 15 minutes) |
| 400 | Organization not in the session’s authorized list |
| 400 | Organization already registered |
| 400 | Missing email or password for new user |
| 400 | Password is too weak |
| 400 | A user with this email already exists (with a different ID-porten identity) |
| 400 | Incorrect password (when linking to existing email account) |
| 400 | This identity is already registered |
| 429 | Rate limit exceeded (50 per hour) |
Notes
- The signup flow uses ID-porten for identity verification. The user’s personnummer is stored as an HMAC-SHA256 hash for privacy.
- Organizations are fetched from Altinn during the callback step. Only organizations the user can represent (via Altinn authorized parties) are available for selection.
- Organizations that already have a Snapbooks client account cannot be registered again.
- The signup session is single-use — once
POST /api/v2/auth/signupsucceeds, the underlying Redis session is deleted. - After signup, ID-porten tokens are stored to provide immediate Altinn access without requiring the user to authenticate again.
- The flow uses a server-side redirect rather than
window.opener/postMessageso it works in mobile browsers, wherewindow.openeris often nulled across cross-origin OAuth bounces. - Marketing attribution is recorded on a best-effort basis in its own savepoint, so a malformed
attributionorconsentpayload can never fail the signup. - The
signup_codeandsignup_tokenseparation is a deliberate two-stage credential design: the short-lived URL-borne code minimises the window for URL leakage, while the longer-lived body-borne token never appears in URLs, history, orRefererheaders.
Related Resources
- OAuth2 Authentication — OAuth2 flows for existing users
- Users — user account management
- Client Accounts — client account management
- Integrations — ID-porten and Altinn integration details