---
title: "Request a magic-link signup"
method: POST
path: "/api/v1/account/signup"
tags: ["Account"]
---

# Request a magic-link signup

`POST /api/v1/account/signup`

## Request

- Base URL: `https://www.apier.no`
- URL: `https://www.apier.no/api/v1/account/signup`
- Auth: none required

Consumer self-serve sign-up entry point. Accepts an email (required) and an optional company_name, requests a Supabase Auth magic link, and returns a constant-time `{ok: true}` response on every valid Zod input — regardless of whether the email is already registered. The email-enumeration defense is intentional: returning a different shape for new vs existing emails would let attackers probe the registered-email set. The login flow MUST omit company_name to avoid clobbering returning-user metadata; the signup flow sends it together with consent: true — active acceptance of the Terms of Service and Privacy Policy, required on every signup-shaped body (GDPR active opt-in; never defaulted). Consent is version-bound: on every consenting signup the server stamps the CURRENT terms version (published in the machine-readable manifest at /.well-known/terms.json) into user_metadata beside the consent timestamp, and the optional terms_version field lets an agent assert which version it read — an assertion that does not match the current version is rejected rather than recorded, so a stale consent can never be stamped. Rate-limited at two layers: 5 requests per hour per IP (fail-CLOSED — a rate-limit-store outage blocks that IP rather than widening the gate) and a global hourly signup ceiling across all callers (an IP-agnostic circuit-breaker against distributed burner-signup floods; fail-OPEN so a store outage never takes the whole surface offline). Returns 400 on request-validation failure: a malformed / non-JSON body (VALIDATION_FAILED with a $body details entry and summary Invalid JSON body) or a Zod failure (malformed email, a disposable / temporary-inbox email domain on a signup-shaped body — login-shaped/email-only bodies are not domain-blocked so returning users aren't locked out, company_name present but empty / oversize, a signup-shaped body without consent: true, a signup-shaped body whose terms_version claim does not equal the current terms version, or a login-shaped body carrying consent or terms_version — both fields belong to the signup shape exclusively); 429 when either rate-limit layer is hit.

## Request body

- union: Exactly one of two body shapes is accepted; the presence of company_name is the discriminator. Login shape: email only — consent must be omitted (a login body carrying consent is rejected so it can never rewrite a returning user's stored consent record). Signup shape: email + company_name + consent, where consent must be the literal true (GDPR active opt-in — false and absent both fail), plus an optional terms_version — a claim of the exact terms version being accepted (discover the current value at /.well-known/terms.json); when present it must equal the current version — and an optional return_path, a server-allowlisted same-origin relative path the browser returns to after the magic-link round-trip (defaults to /dashboard).
  - object: Login-shaped body: email only. company_name, consent, terms_version AND return_path must all be omitted. A returning user's user_metadata (company_name, consent_accepted_at) is never touched by this shape.
    - `email` string, email, required: Email address to send the magic link to. Re-used to provision the api_consumers row on first sign-in.
  - object: Signup-shaped body: company_name present and consent required to be the literal true. Both false and absent fail with 400 VALIDATION_FAILED — GDPR consent is an active opt-in, never a default.
    - `email` string, email, required: Email address to send the magic link to. Re-used to provision the api_consumers row on first sign-in.
    - `company_name` string, required: Display name for the consumer, surfaced on the dashboard. Required on the signup shape; the login shape omits it entirely so a returning user's user_metadata.company_name is preserved across re-authentications.
    - `consent` true, required: Active consent to the Terms of Service (/terms; legally governing Norwegian version at /vilkar) and the Privacy Policy (/privacy; Norwegian version at /personvern). Must be the literal value true — both false and absent fail with 400 VALIDATION_FAILED, because GDPR consent is an active opt-in, never a default. When true, the server stamps the consent moment as an ISO-8601 UTC timestamp into the user's Supabase user_metadata (consent_accepted_at, together with consent_terms_version — the exact terms version accepted) so the consent record is preserved with the account.
    - `terms_version` string: Optional claim of the exact terms version being accepted, discovered from the machine-readable terms manifest at /.well-known/terms.json (its version field). Server-authoritative: the server ALWAYS records its own current terms version — this field is a claim to be checked, never a value to be stored. If present it must exactly equal the current version; a stale or unknown value fails with 400 VALIDATION_FAILED whose details message names the current version so an agent can self-correct without a second discovery round-trip. If absent, the current version is stamped anyway. The recorded version lands in Supabase user_metadata as consent_terms_version beside consent_accepted_at — timestamp and version are written together as one fact, so every consent record names the exact terms it was given for. Only valid on the signup shape; a login-shaped body carrying terms_version is rejected.
    - `return_path` string: Optional post-signup return destination — a SAME-ORIGIN RELATIVE path (e.g. /pricing?interval=annual) the browser lands on after the magic-link round-trip completes, so a buyer who was bounced from pricing to signup returns to where they were. A same-origin ABSOLUTE URL is also accepted and normalized to its validated relative path (path + query, hash dropped). Allowlisted SERVER-SIDE (the client's value is a claim, never trusted): only paths whose leading segments are exactly /pricing, /dashboard, /dashboard/billing or /docs pass, matched on full segments so lookalikes like /pricing-evil never do, and only the query key interval survives — and only with the exact value monthly or annual. Anything else (cross-origin absolute URLs, protocol-relative //host values, traversal segments, unknown paths or query keys) silently degrades to the /dashboard default rather than failing the signup — the field is an open-redirect defence on a value the user typically never typed, not an input contract. Values longer than 512 characters fail Zod validation with 400 VALIDATION_FAILED (the documented maxLength IS the enforced bound, matching the resolver's cap). Only valid on the signup shape; a login-shaped body carrying return_path is rejected.

## Response `200`

Magic link request accepted. The same response is returned whether the email was new or already registered. The X-Correlation-ID response header echoes the request's correlation id (or a freshly-minted one) so the caller can thread it into their own trace system. The Link header carries `</openapi.json>; rel="service-desc"`.

- object
  - `success` true, required
  - `data` object, required
    - `ok` true, required
  - `_meta` TrustMetadata, required: Metadata included in every response for trust and verifiability.
    - `rulebook_version` string, required: Current Rulebook version. Internal rule-version records carry structured origin metadata (`initial_seed` | `human_review` | `lovdata_drift_response` | `audit_finding` | `customer_question` | `legal_correction`) for operator forensics, but that attribution is deliberately NOT exposed on public responses to avoid leaking operational signal.
    - `data_freshness` string, date-time, required: When this data was last fetched (ISO 8601 UTC).
    - `last_verified` string, date-time, required: When this data was last verified against source (ISO 8601 UTC).
    - `source` string, required: Origin of this data. Defaults to `apier.no`. The agent-readiness probe (`GET /api/v1/health/agent-readiness`) is the one surface that may instead emit `apier.no telemetry` — it does so when at least one of its endpoint rates came from measured live telemetry, and falls back to `apier.no` when every rate is a static default; all other endpoints always emit `apier.no`.
    - `data_source` string: Authoritative origin of the underlying data (listed alongside rulebook_version, data_freshness, last_verified, legal_basis for Rulebook-influenced endpoints). Distinct from `source` (the response origin): `data_source` identifies the authoritative data publisher — e.g. 'Brønnøysund Enhetsregisteret + Apier Universal Rulebook'. Present on registry-backed endpoints.
    - `served_from` 'cache' | 'live' | 'static': Cache-aware endpoints only — 'cache' when the response was served from the local copy, 'live' when the gateway just refreshed from the upstream, 'static' when the payload was computed deterministically from pure code with no DB or upstream read (e.g. /v1/public/deadlines). Absent on endpoints that don't carry this semantic.
    - `stale` boolean: Cache-aware endpoints only — true when the upstream was unreachable and a within-limit cached row was served as a fallback. Absent on normal responses.
    - `schema_version` string, required: OpenAPI contract version at the time of the response; matches `info.version`. Always present on every `/v1/*` response so a future schema break can be traced to the exact rows / audit events that predate / postdate it. Agent-facing machine interfaces (v1/capabilities, workflows.json) may override with their own narrower surface version; all other endpoints inherit the manifest-level version.
    - `legal_basis` string: Legal basis under which the response data is reused. Present on Rulebook-influenced endpoints (v1/company/{org}/obligations, /v1/company/{org}/deadlines). Common value: 'NLOD — public registry reuse'.
    - `rule_count` integer: Rule-evaluation count — total rules evaluated for this request. Present on endpoints that invoke the Rulebook evaluator (v1/company/{org}/obligations).
    - `applicable_count` integer: Count of rules whose evaluation_result is 'applicable'. Present on evaluator endpoints.
    - `not_applicable_count` integer: Count of rules whose evaluation_result is 'not_applicable'. Present on evaluator endpoints.
    - `insufficient_data_count` integer: Count of rules whose evaluation_result is 'insufficient_data' (Tier 2 gated). Present on evaluator endpoints. rule_count = applicable_count + not_applicable_count + insufficient_data_count.
    - `cache_age_ms` integer, nullable: Cache layer: milliseconds since the cached row was computed, or null on a cache miss (served_from === 'live'). Agents use it alongside data_freshness as a staleness signal. Present on endpoints wired to the cache (v1/company/{org}/obligations, /deadlines, /summary, /v1/public/obligations, /v1/public/deadlines).
    - `response_time_ms` integer: Speed signal: total server-side processing time in milliseconds, measured via performance.now() at the route boundary. Mirrored on the `X-Apier-Response-Time-Ms` header so agents can key on it without parsing the body. Present on every endpoint.
    - `response_timestamp` string, date-time: RFC 3339 / ISO 8601 UTC timestamp stamped by the response builder at wire-exit. Mirrored on the `provenance_log.response_timestamp` column by a fire-and-forget writer. Distinct from `data_freshness` (when upstream data was fetched) and `last_verified` (when the citation was verified); this is the moment the envelope left the server. Always populated at runtime — deliberately left out of the `required` list to avoid a cascading contract change that would invalidate every pre-existing example envelope in this spec (append-only response contracts). A future revision may promote it into the `required` list alongside a bulk example refresh.
    - `response_hash` string: SHA-256 hash of the canonical-JSON form of the envelope with `_meta` stripped before hashing, prefixed with `sha256:`. Mirrored on `provenance_log.response_hash` so a consumer can independently re-hash the bytes they received and compare against what we persisted (integrity receipt). Stripping `_meta` is load-bearing: volatile fields (response_time_ms, cache_age_ms, served_from) and the hash itself would otherwise feed into the digest and break the cache-hit-bytewise-identical invariant.
    - `source_snapshot_id` string: Optional free-text pointer into an upstream sync snapshot the response derives from — e.g. a Brreg sync's `brreg-<ISO>` snapshot id. Absent when the response is not tied to a specific upstream snapshot (capabilities, tools, static deadlines).
    - `is_sandbox` true: Present and `true` only when the response was generated from sandbox fixture data (currently `ALTINN_MODE=sandbox` on `/api/v1/altinn/list-acting-capacity`). Absent (NOT `false`) on every other response — the absent-vs-false distinction is deliberate so downstream agents branching on `if (response._meta.is_sandbox)` cannot be misled into treating non-sandbox responses as positively-confirmed-not-sandbox. Distinct from the `/v1/sandbox/*` `is_sandbox: true` literal in `SandboxMeta` (which carries the sandbox surface tag); this flag rides through the production `/api/v1/altinn/*` surface to forensically tag a sandbox-mode response that an OEM evaluator received.
    - `cost_nok` number: What this call debited from the caller's prepaid credit balance, in NOK, rendered from the integer-øre ledger (50 øre → 0.5; the ledger itself never leaves whole øre and no float arithmetic touches a balance). Present on the 2xx body of every metered company-data read — the same debit that sets the `X-Credits-Cost-Ore` header, so the two never disagree. `0` when nothing was charged: credit enforcement running dark (the response then also carries `X-Credit-Check: shadow`) or the sandbox surface. Absent on unmetered endpoints and on error envelopes — a refunded 5xx/403/404 claims no cost rather than a wrong one. Lives under `_meta`, which `response_hash` excludes, so the provenance receipt is unaffected; the route's ETag is computed before the credit wrapper stamps it, so a conditional request can still short-circuit to 304. Append-only addition.
    - `wallet_remaining_nok` number, nullable: The prepaid credit balance remaining AFTER this call's debit, in NOK (same rendering as `cost_nok`; mirrors the `X-Credits-Balance-Ore` header). `null` when no balance was read: credit enforcement running dark, or the sandbox surface. Present wherever `cost_nok` is. An agent planning a sequence of metered calls can divide this by the per-call price from GET /api/v1/pricing to know how many reads remain before a 402 INSUFFICIENT_CREDITS. Append-only addition.
    - `spend_cap_remaining_nok` number: What the calling key may still spend under its tighter spend cap (Europe/Oslo calendar day or month) AFTER this call's debit, in NOK (same rendering as `cost_nok`; never below 0). Present on the 2xx body of a metered read only when credit enforcement is live AND the key has a daily or monthly cap (set with PATCH /api/v1/account/keys/{id}); absent otherwise. When it reaches 0 the next metered call on this key returns 402 SPEND_CAP_REACHED until the cap resets. Append-only addition.

## Other responses

- `400`: Validation failed — malformed JSON, malformed email, a disposable / temporary-inbox email domain on a SIGNUP-shaped body (throwaway providers such as mailinator.com are blocked against burner-account abuse; new-account creation only — a login-shaped/email-only body is never domain-blocked so a returning user is not locked out; the details entry has field 'email'), empty/oversize company_name (when company_name is provided it must be 1–200 chars; omit it entirely on the login flow to avoid clobbering returning-user metadata), a signup-shaped body whose consent is false or absent, a signup-shaped body whose terms_version does not exactly equal the current terms version (the details message names the current version — re-fetch /.well-known/terms.json and retry), or a login-shaped body carrying consent or terms_version at all. X-Correlation-ID + Link headers carry the same correlation/discovery semantics as the 200 response.
- `429`: Rate limit exceeded — either the per-IP limit (5 requests per hour per IP, fail-CLOSED, or the rate-limit store was unavailable on that check) or the global hourly signup ceiling: an IP-agnostic circuit-breaker that caps total signup attempts per hour across ALL callers, so a distributed burner-signup flood that keeps every single IP under the per-IP limit is still bounded. The global ceiling fails OPEN (a store outage never takes the whole signup surface offline). Try again after the hour window resets. Implementations SHOULD honour the Retry-After header when present.

---

[API](https://skmtc.dev/apier/apis/apier-no-api.md) · [All operations](https://skmtc.dev/apier/apis/apier-no-api/llms.txt) · [OpenAPI document](https://skmtc.dev/apier/apis/apier-no-api/revisions/9ec19bc87311?raw)
