The whole control surface — sites, PINs, share links, magic links and the audit log — and how a platform integrates against it.
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.
Either works, and both are audited as method: admin:
Authorization: Bearer $DOORMAN_ADMIN_TOKEN, compared in
constant time. How a fresh instance gets its first site.OIDC_ISSUER,
checked against DOORMAN_ADMIN_AUDIENCE when that is set. What an integrating
platform should use.Anything else is 401.
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.
400 — hosts 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.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.
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.
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.
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.
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 sends | Effect |
|---|---|
no clientSecret key | keeps 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.
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.
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.
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.
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:
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.