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:

  1. Authorize — Your app calls POST /api/v2/auth/signup/authorize to get an ID-porten authorization URL and a session key.
  2. 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-up route with signup_code (success) or signup_error (failure) in the query string.
  3. Exchange — Your app reads signup_code from the URL and calls POST /api/v2/auth/signup/exchange to swap the one-shot URL-borne code for a longer-lived body-borne signup_token along with the verified user data (name, existing-user flag, organizations).
  4. Complete — Your app calls POST /api/v2/auth/signup with the signup_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 read signup_code from the URL and call POST /api/v2/auth/signup/exchange to swap it for a signup_token plus 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)
email 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/signup succeeds, 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 / postMessage so it works in mobile browsers, where window.opener is often nulled across cross-origin OAuth bounces.
  • The signup_code and signup_token separation 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, or Referer headers.