Legitimer integration API v1
Contract revision: 2026-09-14. Base URL: https://legitimer.dk/api/integrations/v1. This is the external contract; clients need no Legitimer source, database or employee login. The current environment accepts approved synthetic identities and documents only. Real-person processing is not authorized by API access.
Connect once
Register your company with Legitimer and arrange approval of its backend connection. Supply a display name and an exact HTTPS return URL with no query or fragment (for example https://your-app.example/return). Obtain your company's API key through a private operator channel. Employee authentication, employee permissions and business case records remain your application's responsibility. Legitimer company membership does not authenticate your employees to your system.
Use HTTPS with normal certificate verification. Every call requires Authorization: Bearer <key>; the key is 43 base64url characters. Send JSON for POST. Do not send browser cookies, Origin or Sec-Fetch-Site: this is a backend interface, not a browser/CORS API. Never put the key in a URL or frontend bundle.
GET /status → 200 {"version":"integration-v1"} validates credentials and interface availability, not company approval.
GET /connection → 200 {"companyId":"<UUID>","name":"<company name>"} identifies the approved active company. All request operations also require this active connection. Treat companyId as an opaque identifier, not a lookup into another database. A missing/suspended connection denies external operations; your own application's login and local records need not become unavailable.
Request and handoff
1. Authorize the employee locally and persist a request intent, associated with their company and business case, before calling Legitimer. 2. POST /requests with exactly:
{
"idempotencyId": "1789207200_10000000-0000-4000-8000-000000000002",
"reference": "LEJ-1042",
"checks": ["identity"],
"requestedAttributes": ["cpr_number"],
"minutes": 60
}
Generate a fresh ID as <10-digit current Unix seconds>_<random UUID v4>. The example is illustrative; never reuse its timestamp/UUID. Creation accepts at most 600 seconds old / 30 seconds in the future. Keep clocks synchronized. reference is at most 80 characters after Unicode NFKC normalization/trimming; it may be empty, but must not contain controls or personnummer-like sequences. Do not include names, addresses or other unnecessary personal information. checks contains identity, address, or both, without duplicates. requestedAttributes is optional: omit it or use [] for no personal-value disclosure, or use ["cpr_number"] to let the customer choose whether to share their CPR number. It is separate from checks; never assume a requested CPR will be disclosed. minutes is 1, 60 or 1440. Display name/return URL are registered, not supplied per request. Unknown input fields are rejected; request bodies are at most 4KB. 3. A successful POST returns 201 with id, requesterName, reference, checks, requestedAttributes, createdAt, expiresAt, state, and customerUrl. It is not an approved result. Read the request to obtain its current disclosure. 4. Send an invitation using the email endpoint below, or give the customer customerUrl unchanged through an intentional handoff. It is an opaque, sensitive HTTPS URL on the Legitimer origin. Do not construct, decode, log or depend on its path, query, fragment or token format. Keep it out of analytics and persistent case storage. The customer signs in with MitID and explicitly approves what may be shared. Neither your API key nor a debug login can bypass that approval. 5. When the customer follows the return action, the browser navigates to the registered return URL with ?request=<URL-encoded request ID>. This is only a correlation hint—not proof of authentication, completion or consent. Require your own employee session, verify local case ownership, then GET the API.
Invite the customer by email
The customer does not need to visit your website. Legitimer sends the invitation from besked@legitimer.dk, names the requesting company, and links straight to the MitID/consent flow. Do not email an invitation to someone who has not agreed to the business process. Sending email neither verifies the recipient nor approves a result. The address is delivery information, not a trusted identity claim; anyone holding an unclaimed invitation link can attempt to claim it with their own MitID account. Protect the link and ensure the resulting business process is appropriate for that boundary. It does not assert a match to an expected named recipient, email address or person identifier.
POST /requests/:id/email, JSON:
{
"idempotencyId": "10000000-0000-4000-8000-000000000003",
"email": "customer@example.com"
}
Use a fresh UUID v4 for this delivery operation (not the timestamp-prefixed request ID). Persist it with the intended recipient before sending. The email must be an ASCII mailbox, up to 254 characters; display names and header fields are not accepted. No custom subject, body, return URL, attachments or result content can be supplied. The request must exist and remain open and unexpired. The request's original expiry still applies; sending does not extend it.
200 returns {id, state, createdAt}. These are email-operation fields, separate from the verification request. States are processing, accepted, rejected, or unknown. accepted means the provider accepted the message for sending, not inbox delivery. There are no delivery/bounce webhooks in this revision. processing after a timeout or restart is also an uncertain outcome, not a queue waiting to be retried. A failed email leaves the verification request intact; the customer link remains usable until normal expiry/revocation.
GET /requests/:id/email/:operationId returns the same delivery shape (no email address or message contents). Use it after an uncertain send. Repeating POST with the same operation ID, request and exact email returns the recorded state without sending again. A different recipient/request under that ID is a 409. Concurrent identical sends dispatch at most once. A new delivery requires an explicit action and a new UUID; it may duplicate an earlier uncertain delivery. Never automatically resend processing or unknown operations.
Email uses the approved company connection, regardless of which employee sent the request in your own application. Limits are 100 invitations/day globally, 50/company/day, 5/recipient/hour and one per request per 60 seconds. Limits are enforced in PostgreSQL across API instances. 429 means wait before an explicit new send. 503 can mean email is disabled or the result is uncertain: GET the operation before taking further action. A 404 means no retained operation is available, not permission to create a second verification request.
Opening the invitation, including by an email scanner, cannot sign in, claim a request or consent. Those require the customer's authenticated explicit actions. An API-created invitation retains your registered return action; the customer does not need to use it for your backend to retrieve the approved result.
Read the current answer
GET /requests/:id → 200 with the context above and result. customerUrl is present for non-terminal states; it is absent for terminal ones. The id equals the original idempotency ID. Dates are ISO 8601 timestamps.
| State | Meaning | Result |
|---|---|---|
awaiting_customer | Customer has not started | null |
in_progress | Customer is reviewing/providing evidence | null |
awaiting_approval | Customer has a preview to approve | null |
completed | Customer approved the current answer | sharing-v3 object |
rejected | Customer declined | null |
expired | Request/answer availability ended | null |
revoked | Access was closed, including invalidated consent | null |
completed can later become expired or revoked. Changes to relevant evidence, profile/account state or consent can close disclosure. Fetch again when showing an answer; do not represent a cached answer as current during an outage.
A completed result contains only:
version: "sharing-v3",requesterName,reference;authentication: {method: "mitid", authenticatedAt};assessment: {version, outcome, reasons, nextAction, assessedAt};comparisons: [{field, outcome, reason}]for precisely the requested fields;attributes:{}by default, or the explicitly approved CPR object described below;ruleVersion, numericnormalizationVersion,availableUntil,approvedAt.
identity requests holder_name and date_of_birth; address requests address. Comparison outcomes: match, mismatch, insufficient_evidence. Reasons are normalized_equal, normalized_different, trusted_data_missing, document_data_missing, or address_structure_not_comparable.
The current assessment policy is comparison-sufficiency-v1. reasons contains the field/outcome/reason entries; assessedAt timestamps the customer-approved assessment. The current comparison rule is evidence-comparison-v2 and normalizationVersion is 1.
Assessment outcomes:
sufficient: sufficient under the stated deterministic comparison policy only.inconsistent: compared information conflicts.inconclusive: a required trusted comparison source is unavailable or cannot be compared. This is a valid, completed, shareable answer.needs_more_evidence: a supported customer action can resolve a gap. This is customer-side work, not an approved external result; the external request stays in progress. Do not repeatedly request ineffective uploads.
For externally approved results nextAction is null. No raw document, OCR, trusted-profile value, customer user ID or internal evidence identifier is disclosed. The only personal value supported for disclosure is the separately approved CPR. Do not label the answer document authenticity, verified residence, AML certification or broader identity assurance. Do not recompute the assessment.
CPR and supported documents
The current document policy accepts Danish sundhedskort as JPG/PNG images only; PDF input is not supported in this flow. Other document options are unavailable. A readable, complete card is eligible for comparison; a plain text page, unsupported card or unreadable/incomplete card is not. This classification does not establish authenticity, current validity or residence. Birth-date comparison may use the date encoded in the card's CPR number, without disclosing that number unless the customer separately approves it.
When you request ["cpr_number"], the customer sees an unchecked choice, followed by the exact number in their approval preview. They may decline CPR and still complete the comparison request. attributes: {} means no CPR was shared; it is not a processing failure. Never infer, reconstruct or request it through another field. An approved value has this base shape (the optional registerCheck extension is described below):
"attributes": {
"cpr_number": {
"value": "2802034000",
"source": "document",
"verification": "not_independently_verified"
}
}
That example is synthetic. The value is read from the selected card, not verified against the MitID user's CPR or an authoritative register. A matching name/date of birth does not validate the remaining digits. Do not label it “verified CPR”. Only request it for a defined purpose. Restrict access to the result, never put CPR in URLs, email, logs, analytics or case-search indexes, and minimize retention. CPR is an optional disclosure, not a new top-level comparison field or recipient-binding guarantee.
Optional register check (feature-gated)
attributes.cpr_number.registerCheck may accompany the approved number. It is included in the customer's exact approval preview; it is absent when CPR sharing is declined. Existing source: "document" and verification: "not_independently_verified" remain unchanged. The check does not upgrade the top-level assessment, verify a card's authenticity or establish a CPR-to-MitID link. The current implementation supports only a fictional register; production register access is not configured.
Example test result:
{
"version": "cpr-register-check-v1",
"source": "test_register",
"environment": "test",
"registerVersion": "fictional-register-2026-09-14-v1",
"status": "match",
"reason": "normalized_equal",
"comparisons": [
{"field": "holder_name", "outcome": "match"},
{"field": "address", "outcome": "match"}
],
"identityBinding": "not_checked"
}
source:test_registerornone;environment:testorlive. A test register always hasenvironment: "test". Never describe its result as a real CPR-register check, even if the website is publicly hosted.status:match,mismatch,insufficient_evidenceornot_checked.match/mismatchcurrently come only from the test register. They compare the requested name/address against an independent fixed record keyed by its CPR. They do not prove that the record belongs to the authenticated customer.reason:normalized_equal,normalized_different,document_data_missing,not_found,protected,unavailable,provider_unconfigured,synthetic_only,document_unusableordisabled.comparisons: bounded field outcomes, never register values. Empty when no comparison can run.identitypermitsholder_name;addresspermitsaddress. Unrequested comparisons do not affect the status or appear in the disclosure. Address compares street line, postcode and city exactly after case/whitespace normalization; omitted country is not inferred.registerVersion: fixed test dataset version, ornullwhen no lookup ran.identityBindingis alwaysnot_checkedin this revision.
Unknown/unavailable/protected records yield insufficient_evidence, not an accusation of fraud. A disabled gate yields not_checked. Display missing or unrecognized extensions conservatively; do not infer successful verification. The check is deterministic and derived from the selected extraction and register version at assessment time. The existing assessment.assessedAt and approvedAt describe the shared snapshot; no separate external lookup has occurred. Changed gate configuration, source, dataset version or check result invalidates the consent snapshot and withdraws completed disclosures on their next read.
Changing the selected card or disclosure choice invalidates the approval preview. Revocation, expiry, document deletion and account/identity changes withdraw access to the number with the rest of the result. Do not continue displaying a cached number after a failed read. Previously published sharing-v2 results are not silently upgraded; create a new request and obtain new customer consent.
Close access
POST /requests/:id/revoke, JSON {} → 200 with context and result: null. It closes an active/completed request, or leaves rejected/expired/revoked requests closed. Repeating it is safe; do not automatically retry uncertain writes. A fresh GET confirms the outcome. Revocation cannot erase copies a recipient already made; your application must handle its own retention and stop showing revoked answers.
Errors and deterministic recovery
Errors have {"error":"integration_..."} and never contain diagnostic secrets. Branch on HTTP status; do not expose technical error codes to customers.
| HTTP | Meaning / action |
|---|---|
| 400 / 413 | Invalid input/body size; correct the input |
| 401 | Invalid API credential; operator repairs it, not employee re-login |
| 403 | Browser-origin request or missing/suspended company connection |
| 404 | Unknown/not-owned request; IDs alone authorize nothing |
| 409 | Conflicting intent, closed creation window, or concurrent state change |
| 429 | Throttled; delay reads and ask for an explicit retry of writes |
| 503 / timeout / network failure | Unknown outcome; do not assume no commit |
Persist the exact ID and input before a POST. After uncertain creation, GET that same ID first. If found, recover it; do not POST a fresh ID. If absent and still within the creation window, an explicit retry may POST the exact same intent. Concurrent identical creation requests resolve to one request; different intent under the same ID conflicts. After the window closes, GET still works, but POST does not—even for a previously accepted ID. If an unconfirmed request remains absent, require operator resolution before starting over; never move the timestamp as automatic recovery. If a previously confirmed request is later gone, do not recreate it under its old ID. A distinct new business request needs a new explicit employee action and new customer consent.
When a read fails, clear the answer/link and show temporary unavailability while retaining your local case. This is not an expired/revoked state and cannot justify starting a replacement. Unknown states/versions or malformed responses also fail closed. Do not fall back to database reads, shared sessions or private Legitimer endpoints. A reasonable client polls a visible case at most every ten seconds, uses timeouts, and never automatically retries writes. Current limits: 180 calls/minute per API key per process, and 200 created requests/day across the prototype environment. These are ceilings, not throughput guarantees.
Operations and compatibility
Keep key material server-side; rotate through the provider's private operator channel and update your backend. Do not rebind a different company by matching its name. No employee credentials, API keys, handoff links, CPR numbers or results in access logs. Store only necessary local correlation; protect it with your own authorization.
Versioned paths/disclosure versions are the compatibility boundary, not matching repository commits or images. Additive fields may be ignored (never blindly forwarded); changed required fields/enums/meaning need an explicitly accepted new contract. This document is the publication source for /company/integration, linked from the signed-in company area. Documentation itself contains no secrets and is public. Company-created requests use the same consent/result policy but are managed in the company UI; API IDs do not grant access to those requests.
Example: a property manager using Ejda
1. An employee signs in to Ejda using Ejda's own login and opens a rental case. 2. Ejda's backend authorizes that employee and creates an identity request with the rental case number as the reference. 3. Ejda calls the email endpoint, or displays customerUrl for a direct handoff. 4. The prospective tenant opens Legitimer, signs in with MitID, follows the requested checks and chooses whether to share the resulting answer. 5. Ejda retrieves the current result server-to-server and shows it on the case. An inconclusive answer is still a completed response, not a broken workflow. 6. If the case is withdrawn, Ejda revokes the request and stops showing the answer.
This works with separate repositories, servers and databases. Ejda never reads Legitimer tables, shares a user session, or imports its internal code. Customers can complete the email journey without an Ejda account or visit.
Runnable backend example
Node.js 20 or later. Set LEGITIMER_API_KEY in your backend's secret environment. This demonstrates the HTTP calls, not employee authorization or case storage. Replace saveIntent with a durable, authorized write in your own application. Do not paste API keys, customer links or responses into logs.
import { randomUUID } from 'node:crypto';
const base = 'https://legitimer.dk/api/integrations/v1';
async function api(path, body) {
const response = await fetch(base + path, {
method: body === undefined ? 'GET' : 'POST',
redirect: 'error', signal: AbortSignal.timeout(12000),
headers: {
Authorization: `Bearer ${process.env.LEGITIMER_API_KEY}`,
...(body === undefined ? {} : { 'Content-Type': 'application/json' })
},
...(body === undefined ? {} : { body: JSON.stringify(body) })
});
if (!response.ok) throw new Error(`Legitimer HTTP ${response.status}`);
return response.json();
}
async function invite(saveIntent, email) {
const id = `${Math.floor(Date.now() / 1000)}_${randomUUID()}`;
const deliveryId = randomUUID();
const intent = { idempotencyId: id, reference: 'LEJ-1042',
checks: ['identity'], requestedAttributes: ['cpr_number'], minutes: 1440 };
await saveIntent({ id, intent, deliveryId, email });
await api('/requests', intent);
await api(`/requests/${id}/email`, { idempotencyId: deliveryId, email });
return id;
}
// Later, after authorizing access to the associated local case:
// const current = await api(`/requests/${storedId}`);
// const emailState = await api(`/requests/${storedId}/email/${storedDeliveryId}`);
// await api(`/requests/${storedId}/revoke`, {});
After any error, apply the deterministic recovery rules above; do not rerun invite as recovery. Validate response versions and required fields before use. An example completed answer for identity checks where CPR was not shared:
{
"version": "sharing-v3",
"requesterName": "Ejda",
"reference": "LEJ-1042",
"authentication": { "method": "mitid", "authenticatedAt": "2026-09-13T10:00:00.000Z" },
"assessment": {
"version": "comparison-sufficiency-v1",
"outcome": "sufficient",
"reasons": [
{ "field": "holder_name", "outcome": "match", "reason": "normalized_equal" },
{ "field": "date_of_birth", "outcome": "match", "reason": "normalized_equal" }
],
"nextAction": null,
"assessedAt": "2026-09-13T10:03:00.000Z"
},
"comparisons": [
{ "field": "holder_name", "outcome": "match", "reason": "normalized_equal" },
{ "field": "date_of_birth", "outcome": "match", "reason": "normalized_equal" }
],
"attributes": {},
"ruleVersion": "evidence-comparison-v2",
"normalizationVersion": 1,
"availableUntil": "2026-09-14T09:58:00.000Z",
"approvedAt": "2026-09-13T10:04:00.000Z"
}
This is the result value inside GET's request envelope, not the entire response.
Acceptance checklist and retention
- Test on approved synthetic identities/documents only in the current environment.
- Test invalid credentials, unapproved/suspended company and cross-company IDs.
- Test duplicate create, changed input, late creation, timeout recovery and restarts.
- Test invitation sending, rejection, uncertain delivery and intentional resend.
- Test a customer who has never visited your website, and cancelled MitID login.
- Test matching, conflicting and inconclusive answers, refusal and unapproved previews.
- Test CPR not requested, requested but declined, explicitly approved, unavailable, changed before approval, and withdrawn after approval. Never silently retain a prior choice.
- Test revocation, expiry and evidence/profile changes after an approved result.
- Verify no raw documents, OCR, profile values or secret URLs enter your logs.
- Verify customer URLs survive the mail provider unchanged; check account-level tracking and message retention settings before live delivery. Legitimer authors text-only invitations with per-contact pixel tracking consent disabled; provider processing and recipient email clients must still be evaluated operationally.
Request data is physically pruned after expiry plus seven days, on an hourly cleanup schedule. Invitation recipients and delivery records cascade with it; company UI associations cascade too. Expiry blocks access immediately without waiting for cleanup. Brevo and your own system have separate retention settings. There is no API list endpoint, webhook, customer-profile lookup, raw-document download, general login/SSO endpoint, or self-service API-key issuance in v1. Store request IDs in your own authorized case records and retrieve them individually.