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 |
| 422 | ID-porten Pushed Authorization Request (PAR) failed — retryable |
| 429 | Rate limit exceeded (30 per hour) |
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": "g3T-fOuRrR_kKkKkK...",
"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 |
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": "g3T-fOuRrR_kKkKkK...",
"organization_id": 101,
"email": "ola@nordmann.no",
"password": "securePassword123"
}
Example Request (Existing User)
{
"signup_token": "g3T-fOuRrR_kKkKkK...",
"organization_id": 101
}
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. - 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