Admin API

beta

The whole control surface — sites, PINs, share links, magic links and the audit log — and how a platform integrates against it.

Admin API

Doorman ships no admin UI. The platform that already knows your customers owns that layer; doorman renders only the visitor-facing gate. Everything else is this API.

Base path: {reservedPrefix}/api/v1 — by default /_doorman/api/v1, reachable on any hostname the instance serves. OpenAPI: /_doorman/openapi/v1.json.

Authentication

Either works, and both are audited as method: admin:

  • Bootstrap tokenAuthorization: Bearer $DOORMAN_ADMIN_TOKEN, compared in constant time. How a fresh instance gets its first site.
  • Keycloak bearer — a client-credentials access token from OIDC_ISSUER, checked against DOORMAN_ADMIN_AUDIENCE when that is set. What an integrating platform should use.

Anything else is 401.

Sites

GET    /sites                 → Site[]
GET    /sites/{slug}          → Site
PUT    /sites/{slug}          → Site      (create or replace; the route slug wins)
DELETE /sites/{slug}          → 204
{
  "slug": "griller",
  "displayName": "Griller.dk",
  "hosts": ["griller.arvo.works"],     // required, lowercased, unique in the instance
  "upstream": {
    "scheme": "http",
    "host": "griller",                  // required — the docker network alias
    "port": 3000,
    "preserveHost": true                // forward the public Host, not "griller:3000"
  },
  "access": {
    "public": false,                    // true = no gate (crawler policy still applies)
    "pinEnabled": true,
    "magicLinkEnabled": false,
    "emailAllowlist": ["anna@kunde.dk", "@kunde.dk"],
    "staffSsoEnabled": true,
    "shareLinksEnabled": true,
    "sessionHours": 168
  },
  "sso": {                              // the customer's own IdP — see "Customer SSO" below
    "enabled": false,
    "label": null,                      // gate button text; falls back to a per-locale default
    "issuer": null,                     // e.g. https://id.agentics.dk/realms/agentics
    "clientId": null,
    "hasClientSecret": false,           // read-only: whether a secret is on file
    "requiredRole": null,               // empty = anyone the IdP authenticates
    "allowedDomains": [],               // "@kunde.dk" or exact addresses; empty = no restriction
    "extraScopes": []                   // in addition to openid, profile, email
  },
  "injection": {
    "noIndex": true,
    "blockCrawlerUserAgents": true,
    "banner": false,
    "bannerText": null,
    "bannerHref": null,
    "headHtml": null,                   // injected before </head>
    "bodyHtml": null                    // injected before </body>
  },
  "theme": {
    "title": null,                      // gate heading; falls back to displayName
    "subtitle": null,
    "logoUrl": null,
    "accent": "#f87f2e",
    "locale": "da"                      // "da" | "en"
  },
  "enabled": true,
  "expiresAt": null                     // after this the site 503s — the review window is over
}

PUT is a full replace, not a patch: read, modify, write back. Changes take effect immediately — the store reloads and the proxy re-binds without a restart.

  • 400hosts empty or upstream.host missing.
  • 409 — one of the hostnames is already served by another site; the body names the offending host and the slug that owns it. Without that, a typo in a hostname is indistinguishable from a write that quietly did nothing.
  • DELETE removes the whole site directory: PINs, share links and the audit log. To stop serving while keeping the history, set enabled: false or an expiresAt instead.

PINs

GET    /sites/{slug}/pins            → Pin[]
POST   /sites/{slug}/pins            → Pin
DELETE /sites/{slug}/pins/{code}     → 204    (revokes; the record stays for the audit trail)
// POST body — every field optional
{ "label": "Anna hos kunden", "expiresAt": "2026-09-01T00:00:00Z", "maxUses": 20, "code": null }

Omit code and doorman generates one like GRIL-4821 from an alphabet without O/0, I/1 or S/5 — these get read aloud on the phone and typed by people who did not choose them.

Wrong PINs are rate-limited per (site, address); after DOORMAN_MAX_PIN_ATTEMPTS the site locks that address out for DOORMAN_LOCKOUT_MINUTES, and the correct code is refused too. That is the point of a lockout, and it is written to the audit log as locked.

Share links

GET    /sites/{slug}/links           → ShareLink[]
POST   /sites/{slug}/links           → { link, url }
DELETE /sites/{slug}/links/{id}      → 204
{ "label": "til bygherren", "expiresAt": "2026-08-15T00:00:00Z", "singleUse": true }

The response url is what you paste into an email: https://<host>/?k=<token>. Visiting it mints the normal cookie and immediately redirects to the clean URL, so the token does not linger in history, referrers or screenshots. The token is signed and checked against the stored record, so revoking a link kills it even though the signature is still valid. An open-ended link still gets a 90-day signature — a leaked URL should not outlive the engagement.

The url is shown once and never again. Doorman stores the record, not the token. If the platform does not surface it at that moment, it is gone.

Magic links

Turn on access.magicLinkEnabled and list who may in via access.emailAllowlist — full addresses (anna@kunde.dk) or whole domains (@kunde.dk). The visitor types an email on the gate page; a match gets a one-click link, and a non-match gets the same response, because a gate that answers differently is an address-enumeration oracle.

This is the method that turns "someone had the code" into a real identity in the audit log. It needs SMTP_HOST; without it, magic links are simply off.

Customer SSO

sso delegates a site's gate to the customer's identity provider — their Keycloak realm, their Entra tenant. It is per site, and separate from access.staffSsoEnabled, which is the operator's own instance-wide provider. Both can be on at once, and on a site that delegates to a customer's IdP they usually should be: it is how the operator keeps a way in.

A visitor is let through when the provider authenticates them and they clear requiredRole and allowedDomains. Both are empty by default, which on a tenant-wide Entra app means the whole tenant. Roles are matched case-insensitively against Keycloak's nested realm_access.roles, a plain role claim, or Entra's flat roles claim.

Register this redirect URI at the provider, once per hostname:

https://{host}/_doorman/signin-sso

One callback path serves the whole fleet; doorman resolves which site's provider is answering from the hostname it arrived on.

The client secret is write-only

clientSecret is accepted on a PUT and never comes back on a GET — not masked, not as null. hasClientSecret is what a reader gets instead.

Body sendsEffect
no clientSecret keykeeps whatever is stored — what read-modify-write does
"clientSecret": "…"replaces it
"clientSecret": ""clears it

Send the field only when an operator actually typed one. A UI that pre-fills a placeholder and posts it back overwrites the real secret with the placeholder.

The recommended setup is a public client with PKCE, where there is no secret to store at all.

Audit

GET /sites/{slug}/audit?limit=200    → AuditEntry[]   (newest first, max 2000)
{
  "ts": "2026-07-27T09:12:44Z",
  "site": "griller",
  "event": "granted",            // granted | denied | locked | link-issued | pin-created | site-changed …
  "method": "pin",               // pin | magic-link | share-link | customer-sso | staff-sso | admin
  "subject": "Anna hos kunden",  // email, PIN label, or Keycloak subject
  "ip": "…",
  "userAgent": "…",
  "detail": null
}

Append-only, one file per month under the site's directory.

Request log

The audit trail answers who was let in. The request log answers who was here, turned-away visitors included — the question a data-subject request or an "was this page reachable" incident actually asks.

Off unless the site turns it on, because the entries are personal data:

"requestLog": {
  "enabled": false,
  "retentionDays": 30,   // clamped to 1–730; whole day files deleted past the window
  "assets": false,       // false = page views only
  "queryString": false   // even when true, credential parameters are redacted
}

Top-level on the site record, next to sso, so a one-level-deep read-modify-write of access cannot wipe it.

GET /sites/{slug}/requests?limit=200&from=2026-08-01&to=2026-08-12&q=203.0.113.9
                                     → RequestLogEntry[]   (newest first, max 5000)
{
  "ts": "2026-08-12T19:41:07Z",
  "method": "GET",
  "host": "eclipse.arvo.works",
  "path": "/",
  "query": "v=kort&k=<redacted>",  // null unless queryString is on
  "status": 200,                   // doorman's own answer, gate redirects and crawler 403s included
  "ms": 34,
  "ip": "203.0.113.9",             // X-Forwarded-For
  "userAgent": "…",
  "referer": "…",
  "access": "pin",                 // public | pin | magic-link | share-link | staff-sso | customer-sso; null when refused
  "subject": "Anna hos kunden",
  "bot": false,
  "nav": true
}

from/to are UTC days (YYYY-MM-DD; a malformed one is a 400). q is a case-insensitive substring over the whole entry — an IP, an email, a path — which is what makes a data-subject request one call. One file per UTC day, retention swept hourly, and deleting the site deletes the log with it.

Analytics

The third question: not who was let in, not who was here, but how the site is doing. Independent of the request log — it works with that off, which is the configuration most sites should be in.

"analytics": {
  "enabled": false,
  "identity": "session",   // none | session | device — 'device' is the only one that
                           // stores anything on a visitor's machine, and it waits for consent
  "sessionMinutes": 30,    // clamped 1–1440
  "countBots": false,
  "retentionDays": 0,      // 0 = keep the rollups indefinitely; clamped 1–730 otherwise
  "maxKeys": 1000,         // per dimension per day before the tail folds into "(other)"; 50–20000
  "consent": {
    "cookie": "doorman_consent",  // the cookie the site's own banner writes; read, never written
    "mirror": true,               // doorman's server-set copy, dm_c
    "mirrorDays": 180,            // clamped 1–400
    "honourDays": 365             // clamped 1–730
  }
}

Top-level on the site record, like requestLog and for the same reason.

GET /sites/{slug}/stats?days=30&from=…&to=…&top=20   → StatsResponse
GET /sites/{slug}/stats/cohorts?weeks=12             → CohortsResponse
{
  "slug": "griller",
  "identity": "session",
  "from": "2026-07-14",
  "to": "2026-08-12",
  "totals": {
    "views": 1840, "sessions": 612, "dailyVisitors": 498,
    "bounces": 377, "bounceRate": 0.616,
    "durationSeconds": 52000, "averageSessionSeconds": 94.5,
    "identified": 210, "returning": 63, "bots": 41,
    "consent": { "granted": 210, "denied": 88, "unknown": 314, "coverage": 0.3431 }
  },
  "top": {
    "paths":     [{ "key": "/", "count": 900 }],
    "referrers": [{ "key": "google.com", "count": 120 }],
    "sources":   [], "devices": [], "statuses": []
  },
  "days": [ /* the raw DayRollup per UTC day, for charting */ ],
  "caveats": [ "dailyVisitors is the sum of each day's distinct-visitor count, …" ]
}

Render the caveats. They are not boilerplate: every figure has a limit that follows from how it was collected, and the instance states them so a dashboard cannot present a partial figure as a whole one. Daily uniques do not sum across days — the salt they were derived from is rotated at UTC midnight and never retained — and coverage is the share of sessions the cohort numbers actually cover.

from/to are UTC days (YYYY-MM-DD; malformed, or from after to, is a 400). A range that includes today is flushed before it is built, so the current day is never a misleading zero.

Cohorts are weekly acquisition cohorts over consented devices, retained[n] = still active in the n-th week after the first. They stay empty unless identity is device — see consent for the cookie contract that unlocks it, and analytics for what each figure means.

Integrating a platform

The whole integration is four calls: PUT the site when a project is created, POST a PIN or a share link when someone clicks "invite", GET the audit log for the "who reviewed this" panel, and DELETE when the engagement ends.

Two things to get right:

  • Do not iframe the gate. It sets a host-scoped cookie and redirects; it is a page, not a widget.
  • Treat 409 as a user-facing error. It means the hostname belongs to another site — almost always a typo, and silently ignoring it looks like the save button is broken.