Demo environment. Penrose Analytics is fictional, the demo people are shared, and the workspace resets nightly.

Gatehouse

HTTP API reference

Base URL https://auth.phoenixcodes.org, version 0.2.0. The same content as machine-readable OpenAPI 3.1 is at /api/openapi.json; a test fails if a route and this description disagree.

Conventions

Gatehouse is a reference application for sign-in and access control. Its HTTP surface is the SAML 2.0 endpoints that sign people in, the SCIM 2.0 endpoints a directory calls to keep people in sync, a public report card that grades another site's security headers, and two small service endpoints. Everything a person does inside the product goes through Better Auth under /api/auth/* and server actions; those are not part of this contract.

Authentication. It depends on the operation, and each one says which. SCIM takes a workspace token, Authorization: Bearer gh_scim_…, that an owner or admin creates in the console. The cron endpoint takes the deployment's CRON_SECRET the same way. The report card, the health check, the SAML and identity provider metadata and the SCIM discovery documents are public. SAML sign-in is authenticated by the identity provider's signature, not by a header.

Errors. The JSON endpoints (/api/health, /api/report-card, /api/cron/reseed) answer { "error": { "code", "message" } }: branch on code, show message. SCIM answers the RFC 7644 error body with application/scim+json. The browser-facing SAML endpoints do not answer JSON: a failure redirects to /sign-in?error=sso_<code> and the sign-in page says what happened.

Rate limits. Every public endpoint is limited, and a refused request is 429 with Retry-After in seconds. The report card allows 10 checks an hour per address. SCIM allows 120 requests a minute per token, 20 failed token checks a minute per address and 60 discovery requests a minute per address. SAML sign-in starts are limited to 10 a minute per address and responses to 20. The cron endpoint allows 6 runs an hour. An address is the first entry of X-Forwarded-For, kept only as a keyed hash.

Caching. Responses are no-store except the two metadata documents, which may be cached for five minutes.

The shared demo. This deployment's demo workspace is shared by every visitor and is reset each night. It accepts SCIM people only on the reserved .example domain, holds at most 25 of them, and refuses changes to its three demo people.

Service

Liveness and this description.

Check that the service is up

GET/api/health

Answers 200 when the deployment can reach its database and 503 when it cannot. The database answer is remembered for five seconds. The body never carries an error message, a host name or a setting.

Credential. None. Public.

Responses

200Everything passed.

application/json

Example
{
  "status": "ok",
  "version": "0.2.0",
  "commit": "905d177",
  "time": "2026-10-23T09:14:03.000Z",
  "checks": {
    "database": {
      "status": "ok",
      "latencyMs": 4
    }
  }
}

503The database did not answer in time.

application/json

Example
{
  "status": "degraded",
  "version": "0.2.0",
  "commit": "905d177",
  "time": "2026-10-23T09:14:03.000Z",
  "checks": {
    "database": {
      "status": "unavailable",
      "latencyMs": 3001
    }
  }
}

Try it

curl "https://auth.phoenixcodes.org/api/health"

Read this description

GET/api/openapi.json

This document, as OpenAPI 3.1 JSON. Public, and readable from any origin (CORS *) so a tool can import it.

Credential. None. Public.

Responses

200The OpenAPI document.

application/json

Try it

curl "https://auth.phoenixcodes.org/api/openapi.json"

Report card

Grade the security headers of a public site: one letter per header.

Grade a site's security headers

POST/api/report-card

Fetches the address once from this server (following up to five redirects, each one checked again), grades nine security headers and returns one letter for each and one overall. The fetch refuses private and reserved network addresses, ports other than 80 and 443, and addresses with credentials, and gives up after 3 seconds a hop and 8 overall. Limited to 10 checks an hour per address. Nothing about the site is kept except the letters and a keyed hash of the address.

Credential. None. Public.

Request body

Sent as application/json.

FieldTypeRequiredWhat it is
urlstringYesA public http or https address. A bare host name such as example.com is read as https. Only ports 80 and 443; no credentials; no private or reserved network addresses.
{
  "url": "https://example.com"
}

Responses

200The graded card.

application/json

Example
{
  "requestedUrl": "https://example.com/",
  "finalUrl": "https://example.com/",
  "host": "example.com",
  "status": 200,
  "redirects": [],
  "overall": {
    "grade": "D",
    "score": 56,
    "note": null
  },
  "checks": [
    {
      "id": "hsts",
      "title": "Strict-Transport-Security",
      "grade": "B",
      "score": 80,
      "applicable": true,
      "seen": [
        "max-age=31536000"
      ],
      "finding": "https is required for six months or more, but not on subdomains.",
      "fix": "Add includeSubDomains if every subdomain can be served over https.",
      "example": "Strict-Transport-Security: max-age=63072000; includeSubDomains; preload"
    },
    {
      "id": "csp",
      "title": "Content-Security-Policy",
      "grade": "F",
      "score": 0,
      "applicable": true,
      "seen": [],
      "finding": "There is no Content-Security-Policy, so a single injected script can run with the page's full power.",
      "fix": "Start with a policy that allows only your own scripts and shuts the rest, then loosen it where it breaks something.",
      "example": "Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-…'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'"
    },
    {
      "id": "framing",
      "title": "Framing (X-Frame-Options, frame-ancestors)",
      "grade": "A",
      "score": 94,
      "applicable": true,
      "seen": [
        "X-Frame-Options: SAMEORIGIN"
      ],
      "finding": "Only this site can frame its own pages.",
      "fix": "Add frame-ancestors to the Content-Security-Policy as well; it is the current standard and X-Frame-Options is the fallback.",
      "example": "Content-Security-Policy: frame-ancestors 'none'\nX-Frame-Options: DENY"
    },
    {
      "id": "nosniff",
      "title": "X-Content-Type-Options",
      "grade": "A+",
      "score": 100,
      "applicable": true,
      "seen": [
        "nosniff"
      ],
      "finding": "Browsers trust the declared content type and don't guess.",
      "fix": null,
      "example": null
    },
    {
      "id": "referrer",
      "title": "Referrer-Policy",
      "grade": "A+",
      "score": 100,
      "applicable": true,
      "seen": [
        "strict-origin-when-cross-origin"
      ],
      "finding": "Other sites learn this site's name only; full addresses stay inside the site.",
      "fix": null,
      "example": null
    },
    {
      "id": "permissions",
      "title": "Permissions-Policy",
      "grade": "D",
      "score": 45,
      "applicable": true,
      "seen": [],
      "finding": "Nothing limits which powerful browser features (camera, microphone, location) the page or its frames can ask for.",
      "fix": "Send a Permissions-Policy that switches off every feature you don't use.",
      "example": "Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=()"
    },
    {
      "id": "cookies",
      "title": "Cookies",
      "grade": "A",
      "score": 94,
      "applicable": false,
      "seen": [],
      "finding": "The site set no cookies on this request, so there is nothing to grade.",
      "fix": null,
      "example": null
    },
    {
      "id": "coop",
      "title": "Cross-Origin-Opener-Policy",
      "grade": "D",
      "score": 45,
      "applicable": true,
      "seen": [],
      "finding": "Pages that open this one (or that it opens) keep a handle on its window, which cross-site attacks can use.",
      "fix": "Send Cross-Origin-Opener-Policy: same-origin unless a sign-in popup from another site needs the link.",
      "example": "Cross-Origin-Opener-Policy: same-origin"
    },
    {
      "id": "corp",
      "title": "Cross-Origin-Resource-Policy",
      "grade": "D",
      "score": 45,
      "applicable": true,
      "seen": [],
      "finding": "Other sites can load this response as an image or script and, with the right bug in the browser, read it.",
      "fix": "Send Cross-Origin-Resource-Policy: same-origin, or same-site if sibling subdomains use it.",
      "example": "Cross-Origin-Resource-Policy: same-origin"
    }
  ],
  "gradedAt": "2026-10-23T09:14:03.000Z"
}

400The body is not JSON (invalid_json), the address cannot be used (invalid_url) or it points at a network this service will not contact (blocked_address).

application/json

Example
{
  "error": {
    "code": "blocked_address",
    "message": "That address points at a private network."
  }
}

413The body is over 4 KB (payload_too_large).

application/json

415The body was not sent as application/json (unsupported_media_type).

application/json

422The body is not an object with a "url" string (invalid_request).

application/json

429Too many checks from this address (rate_limited).

application/json; headers Retry-After

Example
{
  "error": {
    "code": "rate_limited",
    "message": "Ten checks an hour are allowed."
  }
}

502The site could not be fetched: unreachable, tls_error, bad_redirect or too_many_redirects.

application/json

504The site did not answer in time (timeout).

application/json

Try it

curl -X POST "https://auth.phoenixcodes.org/api/report-card" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.com"}'

SAML

Gatehouse as a SAML 2.0 service provider: its metadata, the start of a sign-in and the endpoint that receives the identity provider's response.

Read Gatehouse's SAML metadata

GET/api/saml/metadata

The service provider's entity id (https://auth.phoenixcodes.org/api/saml/metadata), its assertion consumer service (https://auth.phoenixcodes.org/api/saml/acs, HTTP-POST) and the certificate that verifies its signed requests. Give this URL to an identity provider that can import metadata.

Credential. None. Public.

Responses

200SAML 2.0 metadata.

application/xml; headers Cache-Control

Example
<md:EntityDescriptor …/>

404This deployment has no service-provider key, so SAML is off.

text/plain

Example
SAML is not configured on this deployment.

Try it

curl "https://auth.phoenixcodes.org/api/saml/metadata"

Start a SAML sign-in

GET/api/saml/login

Builds a signed AuthnRequest for a connection, records it, and redirects the browser to the identity provider (HTTP-Redirect binding). A signed gh_flow cookie ties the response to this browser. Limited to 10 a minute per address. If the connection is unknown, switched off or rate-limited, the browser is sent to the sign-in page with ?error=. Anywhere else a sign-in begins by following a link to this URL; there is no JSON.

Credential. None. Public.

Parameters

NameInTypeRequiredWhat it is
tenantquerystringNoThe workspace's slug. Defaults to the demo workspace.
connectionquerystringNoThe connection's slug within the workspace.
nextquerystringNoA path on this site to open after sign-in. Anything else is replaced by /app.

Responses

302To the identity provider's sign-on URL, with SAMLRequest, SigAlg, Signature and RelayState in the query.

headers Location, Set-Cookie

303Back to /sign-in?error= with one of: sso_unavailable, sso_rate_limited, sso_unknown, sso_failed.

headers Location

Try it

curl "https://auth.phoenixcodes.org/api/saml/login?next=%2Fapp%2Fsso"

Receive the identity provider's response

POST/api/saml/acs

The assertion consumer service (HTTP-POST binding). The browser delivers the identity provider's signed response here. Gatehouse checks it against the request it recorded (signature, issuer, audience, recipient, time window, one use, the browser that asked), maps its attributes to a person and a role, provisions or links them and starts a session. Limited to 20 a minute per address. Both outcomes are redirects, so this is called by a browser, not by a client library.

Credential. None. Public.

Request body

Sent as application/x-www-form-urlencoded.

FieldTypeRequiredWhat it is
SAMLResponsestringYesThe base64-encoded SAML response. Refused above 256 KB.
RelayStatestringNoEchoed from the request; only used as a hint.

Responses

303Signed in: to the path saved when the sign-in began, with the session cookies. Refused: to /sign-in?error=sso_<code>, where the code is one of sso_unavailable, sso_rate_limited, sso_unknown, sso_failed, sso_bad_request, sso_unsolicited, sso_unknown_request, sso_browser_mismatch, sso_connection_unavailable, sso_invalid_xml, sso_invalid_signature, sso_issuer_mismatch, sso_audience_mismatch, sso_recipient_mismatch, sso_request_mismatch, sso_outside_validity, sso_idp_error, sso_replayed, sso_expired, sso_attributes_invalid, sso_provisioning_refused, sso_invalid_response.

headers Location, Set-Cookie

404This deployment has no service-provider key, so SAML is off.

text/plain

Example
SAML is not configured on this deployment.

Try it

curl -X POST "https://auth.phoenixcodes.org/api/saml/acs"

Built-in IdP

The demo's own identity provider, in the same codebase. A real deployment would point a connection at Okta or Entra instead.

Read the demo identity provider's metadata

GET/idp/metadata

The built-in provider's entity id (https://auth.phoenixcodes.org/idp/metadata), its sign-on URL (https://auth.phoenixcodes.org/idp/sso) and its signing certificate.

Credential. None. Public.

Responses

200SAML 2.0 metadata.

application/xml

Example
<md:EntityDescriptor …/>

404The built-in provider is not configured on this deployment.

text/plain

Example
The demo identity provider is not configured here.

Try it

curl "https://auth.phoenixcodes.org/idp/metadata"

Receive a signed sign-in request

GET/idp/sso

The built-in provider's single sign-on endpoint (HTTP-Redirect binding). It verifies the request's signature against the service provider's certificate, checks the issuer, the assertion consumer URL, the destination and the request's age, then hands the browser to its own sign-in form with a short-lived signed ticket. Limited to 30 a minute per address.

Credential. None. Public.

Parameters

NameInTypeRequiredWhat it is
SAMLRequestquerystringYesThe deflated, base64-encoded AuthnRequest.
SigAlgquerystringYesThe signature algorithm URI.
SignaturequerystringYesThe base64 signature over the query string.
RelayStatequerystringNoEchoed back to the service provider.

Responses

303To /idp/login?t=<ticket>.

headers Location

400No signed request in the query.

text/plain

Example
A signed SAMLRequest is required.

403The signature does not verify, or the request names the wrong service provider, destination or address, or is stale.

text/plain

Example
The AuthnRequest signature could not be verified.

404The built-in provider is not configured on this deployment.

text/plain

Example
The demo identity provider is not configured here.

429Too many requests from this address.

text/plain; headers Retry-After

Example
Too many requests.

Try it

curl "https://auth.phoenixcodes.org/idp/sso"

SCIM

SCIM 2.0 Users (RFC 7643, RFC 7644): the calls a directory makes to create, update and deactivate people in one workspace.

What this SCIM service supports

GET/api/scim/v2/ServiceProviderConfig

Patch and filter are supported; bulk, sort, ETags and password changes are not. No token is needed; requests are limited per address.

Credential. None. Public.

Responses

200The document.

application/scim+json

Example
{
  "schemas": [
    "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"
  ],
  "documentationUri": "https://auth.phoenixcodes.org/docs/scim",
  "patch": {
    "supported": true
  },
  "bulk": {
    "supported": false,
    "maxOperations": 0,
    "maxPayloadSize": 0
  },
  "filter": {
    "supported": true,
    "maxResults": 200
  },
  "changePassword": {
    "supported": false
  },
  "sort": {
    "supported": false
  },
  "etag": {
    "supported": false
  },
  "authenticationSchemes": [
    {
      "type": "oauthbearertoken",
      "name": "Workspace token",
      "description": "A token an owner or admin creates under Single sign-on, Provisioning. Send it as Authorization: Bearer <token>.",
      "specUri": "https://www.rfc-editor.org/info/rfc6750",
      "documentationUri": "https://auth.phoenixcodes.org/docs/scim#tokens",
      "primary": true
    }
  ],
  "meta": {
    "resourceType": "ServiceProviderConfig",
    "location": "https://auth.phoenixcodes.org/api/scim/v2/ServiceProviderConfig"
  }
}

429Too many requests or too many failed token checks (tooMany).

application/scim+json; headers Retry-After

Example
{
  "schemas": [
    "urn:ietf:params:scim:api:messages:2.0:Error"
  ],
  "status": "429",
  "scimType": "tooMany",
  "detail": "Too many requests for this token. Wait before trying again."
}

Try it

curl "https://auth.phoenixcodes.org/api/scim/v2/ServiceProviderConfig"

The resource types it holds

GET/api/scim/v2/ResourceTypes

One: User. No token is needed; requests are limited per address.

Credential. None. Public.

Responses

200The document.

application/scim+json

Example
{
  "schemas": [
    "urn:ietf:params:scim:api:messages:2.0:ListResponse"
  ],
  "totalResults": 1,
  "startIndex": 1,
  "itemsPerPage": 1,
  "Resources": [
    {
      "schemas": [
        "urn:ietf:params:scim:schemas:core:2.0:ResourceType"
      ],
      "id": "User",
      "name": "User",
      "endpoint": "/Users",
      "description": "A person with access to the workspace.",
      "schema": "urn:ietf:params:scim:schemas:core:2.0:User",
      "meta": {
        "resourceType": "ResourceType",
        "location": "https://auth.phoenixcodes.org/api/scim/v2/ResourceTypes/User"
      }
    }
  ]
}

429Too many requests or too many failed token checks (tooMany).

application/scim+json; headers Retry-After

Example
{
  "schemas": [
    "urn:ietf:params:scim:api:messages:2.0:Error"
  ],
  "status": "429",
  "scimType": "tooMany",
  "detail": "Too many requests for this token. Wait before trying again."
}

Try it

curl "https://auth.phoenixcodes.org/api/scim/v2/ResourceTypes"

The User schema

GET/api/scim/v2/Schemas

The attributes of the User resource that this service reads and writes. No token is needed; requests are limited per address.

Credential. None. Public.

Responses

200The document.

application/scim+json

Example
{
  "schemas": [
    "urn:ietf:params:scim:api:messages:2.0:ListResponse"
  ],
  "totalResults": 1,
  "startIndex": 1,
  "itemsPerPage": 1,
  "Resources": [
    {
      "schemas": [
        "urn:ietf:params:scim:schemas:core:2.0:Schema"
      ],
      "id": "urn:ietf:params:scim:schemas:core:2.0:User",
      "name": "User",
      "description": "A person with access to the workspace (the subset Gatehouse stores).",
      "attributes": [
        {
          "name": "id",
          "type": "string",
          "multiValued": false,
          "description": "Gatehouse's id for this person's membership of the workspace.",
          "required": false,
          "caseExact": true,
          "mutability": "readOnly",
          "returned": "always",
          "uniqueness": "server"
        },
        {
          "name": "externalId",
          "type": "string",
          "multiValued": false,
          "description": "The directory's own id for this person.",
          "required": false,
          "caseExact": true,
          "mutability": "readWrite",
          "returned": "default",
          "uniqueness": "none"
        },
        {
          "name": "userName",
          "type": "string",
          "multiValued": false,
          "description": "The e-mail address the person signs in with.",
          "required": true,
          "caseExact": false,
          "mutability": "immutable",
          "returned": "always",
          "uniqueness": "server"
        },
        {
          "name": "name",
          "type": "complex",
          "multiValued": false,
          "description": "The person's name.",
          "required": false,
          "caseExact": false,
          "mutability": "readWrite",
          "returned": "default",
          "uniqueness": "none",
          "subAttributes": [
            {
              "name": "formatted",
              "type": "string",
              "multiValued": false,
              "description": "The display name.",
              "required": false,
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            }
          ]
        },
        {
          "name": "displayName",
          "type": "string",
          "multiValued": false,
          "description": "The display name. The same string as name.formatted.",
          "required": false,
          "caseExact": false,
          "mutability": "readWrite",
          "returned": "default",
          "uniqueness": "none"
        },
        {
          "name": "emails",
          "type": "complex",
          "multiValued": true,
          "description": "The sign-in address, as userName.",
          "required": false,
          "caseExact": false,
          "mutability": "readOnly",
          "returned": "default",
          "uniqueness": "none",
          "subAttributes": [
            {
              "name": "value",
              "type": "string",
              "multiValued": false,
              "description": "The address.",
              "required": false,
              "caseExact": false,
              "mutability": "readOnly",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "type",
              "type": "string",
              "multiValued": false,
              "description": "Always work.",
              "required": false,
              "caseExact": false,
              "mutability": "readOnly",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "primary",
              "type": "boolean",
              "multiValued": false,
              "description": "Always true.",
              "required": false,
              "caseExact": false,
              "mutability": "readOnly",
              "returned": "default",
              "uniqueness": "none"
            }
          ]
        },
        {
          "name": "active",
          "type": "boolean",
          "multiValued": false,
          "description": "False once the person is deactivated: they keep their history and cannot sign in to the workspace.",
          "required": false,
          "caseExact": false,
          "mutability": "readWrite",
          "returned": "default",
          "uniqueness": "none"
        }
      ],
      "meta": {
        "resourceType": "Schema",
        "location": "https://auth.phoenixcodes.org/api/scim/v2/Schemas/urn:ietf:params:scim:schemas:core:2.0:User"
      }
    }
  ]
}

429Too many requests or too many failed token checks (tooMany).

application/scim+json; headers Retry-After

Example
{
  "schemas": [
    "urn:ietf:params:scim:api:messages:2.0:Error"
  ],
  "status": "429",
  "scimType": "tooMany",
  "detail": "Too many requests for this token. Wait before trying again."
}

Try it

curl "https://auth.phoenixcodes.org/api/scim/v2/Schemas"

List or find people

GET/api/scim/v2/Users

People of the token's workspace, oldest first. filter takes comparisons joined by and and or, not(…), parentheses and the operators eq ne co sw ew pr over id, externalId, userName, displayName, name.formatted, emails.value and active (at most 400 characters). Paging is 1-based: startIndex and count (default 100, at most 200).

Credential. A workspace token that an owner or admin creates under Single sign-on, Provisioning. 256 random bits, shown once, revocable. It acts inside its workspace only.

Parameters

NameInTypeRequiredWhat it is
filterquerystringNoA SCIM filter.
startIndexqueryintegerNoThe 1-based index of the first match to return.
countqueryintegerNoHow many people to return.

Responses

200A ListResponse.

application/scim+json; headers X-RateLimit-Limit, X-RateLimit-Remaining

Example
{
  "schemas": [
    "urn:ietf:params:scim:api:messages:2.0:ListResponse"
  ],
  "totalResults": 1,
  "startIndex": 1,
  "itemsPerPage": 1,
  "Resources": [
    {
      "schemas": [
        "urn:ietf:params:scim:schemas:core:2.0:User"
      ],
      "id": "mem_k3m9x2q7b4n8d1ya",
      "externalId": "00u1abc",
      "userName": "ada@acme.example",
      "name": {
        "formatted": "Ada Lovelace"
      },
      "displayName": "Ada Lovelace",
      "emails": [
        {
          "value": "ada@acme.example",
          "type": "work",
          "primary": true
        }
      ],
      "active": true,
      "meta": {
        "resourceType": "User",
        "created": "2026-10-23T09:14:03.000Z",
        "lastModified": "2026-10-23T09:14:03.000Z",
        "location": "https://auth.phoenixcodes.org/api/scim/v2/Users/mem_k3m9x2q7b4n8d1ya"
      }
    }
  ]
}

400The body is not JSON (invalidSyntax), a value or filter is not valid (invalidValue, invalidFilter), an operation names a path this service does not support (invalidPath, noTarget) or one that cannot change (mutability).

application/scim+json

Example
{
  "schemas": [
    "urn:ietf:params:scim:api:messages:2.0:Error"
  ],
  "status": "400",
  "scimType": "invalidFilter",
  "detail": "Invalid filter: unexpected end of input."
}

401No token, a malformed one, an unknown one or a revoked one.

application/scim+json; headers WWW-Authenticate

Example
{
  "schemas": [
    "urn:ietf:params:scim:api:messages:2.0:Error"
  ],
  "status": "401",
  "detail": "A valid workspace token is required."
}

429Too many requests or too many failed token checks (tooMany).

application/scim+json; headers Retry-After

Example
{
  "schemas": [
    "urn:ietf:params:scim:api:messages:2.0:Error"
  ],
  "status": "429",
  "scimType": "tooMany",
  "detail": "Too many requests for this token. Wait before trying again."
}

500Our side. The body carries no detail.

application/scim+json

Try it

curl "https://auth.phoenixcodes.org/api/scim/v2/Users?filter=userName%20eq%20%22ada%40acme.example%22" \
  -H "Authorization: Bearer $SCIM_TOKEN"

Create a person

POST/api/scim/v2/Users

Creates a person and adds them to the token's workspace. userName is the e-mail address they sign in with. A token never attaches an account that already exists: a taken address is 409 uniqueness, because otherwise whoever controls a SAML connection could provision a victim's address and then assert it. The shared demo workspace accepts only .example addresses.

Credential. A workspace token that an owner or admin creates under Single sign-on, Provisioning. 256 random bits, shown once, revocable. It acts inside its workspace only.

Request body

Sent as application/scim+json or application/json.

FieldTypeRequiredWhat it is
schemasarray of stringYesMust include the core User schema URN.
userNamestringYesThe e-mail address the person signs in with. Unique, compared without case.
externalIdstringNoThe directory's own id for this person.
displayNamestringNo
nameobjectNoGatehouse keeps one display name: formatted, or given and family name joined.
emailsarray of objectNoAccepted and ignored: the address is userName.
activeboolean or stringNoDefaults to true. The strings "True" and "False" (Microsoft Entra) are accepted.
{
  "schemas": [
    "urn:ietf:params:scim:schemas:core:2.0:User"
  ],
  "userName": "ada@acme.example",
  "displayName": "Ada Lovelace",
  "externalId": "00u1abc"
}

Responses

201The person that was created.

application/scim+json; headers X-RateLimit-Limit, X-RateLimit-Remaining, Location

Example
{
  "schemas": [
    "urn:ietf:params:scim:schemas:core:2.0:User"
  ],
  "id": "mem_k3m9x2q7b4n8d1ya",
  "externalId": "00u1abc",
  "userName": "ada@acme.example",
  "name": {
    "formatted": "Ada Lovelace"
  },
  "displayName": "Ada Lovelace",
  "emails": [
    {
      "value": "ada@acme.example",
      "type": "work",
      "primary": true
    }
  ],
  "active": true,
  "meta": {
    "resourceType": "User",
    "created": "2026-10-23T09:14:03.000Z",
    "lastModified": "2026-10-23T09:14:03.000Z",
    "location": "https://auth.phoenixcodes.org/api/scim/v2/Users/mem_k3m9x2q7b4n8d1ya"
  }
}

400The body is not JSON (invalidSyntax), a value or filter is not valid (invalidValue, invalidFilter), an operation names a path this service does not support (invalidPath, noTarget) or one that cannot change (mutability).

application/scim+json

Example
{
  "schemas": [
    "urn:ietf:params:scim:api:messages:2.0:Error"
  ],
  "status": "400",
  "scimType": "invalidFilter",
  "detail": "Invalid filter: unexpected end of input."
}

401No token, a malformed one, an unknown one or a revoked one.

application/scim+json; headers WWW-Authenticate

Example
{
  "schemas": [
    "urn:ietf:params:scim:api:messages:2.0:Error"
  ],
  "status": "401",
  "detail": "A valid workspace token is required."
}

403The change is refused by a rule: the last active owner, the shared demo people, or the demo workspace being full.

application/scim+json

Example
{
  "schemas": [
    "urn:ietf:params:scim:api:messages:2.0:Error"
  ],
  "status": "403",
  "detail": "This person is the workspace's last active owner. Deactivating them would lock everyone out."
}

409An account with that userName already exists (uniqueness).

application/scim+json

Example
{
  "schemas": [
    "urn:ietf:params:scim:api:messages:2.0:Error"
  ],
  "status": "409",
  "scimType": "uniqueness",
  "detail": "An account with that userName already exists."
}

413The body is over 16 KB.

application/scim+json

415The body was not sent as application/scim+json or application/json.

application/scim+json

429Too many requests or too many failed token checks (tooMany).

application/scim+json; headers Retry-After

Example
{
  "schemas": [
    "urn:ietf:params:scim:api:messages:2.0:Error"
  ],
  "status": "429",
  "scimType": "tooMany",
  "detail": "Too many requests for this token. Wait before trying again."
}

500Our side. The body carries no detail.

application/scim+json

Try it

curl -X POST "https://auth.phoenixcodes.org/api/scim/v2/Users" \
  -H "Authorization: Bearer $SCIM_TOKEN" \
  -H "Content-Type: application/scim+json" \
  -d '{"schemas":["urn:ietf:params:scim:schemas:core:2.0:User"],"userName":"ada@acme.example","displayName":"Ada Lovelace","externalId":"00u1abc"}'

Read a person

GET/api/scim/v2/Users/{id}

One person of the token's workspace.

Credential. A workspace token that an owner or admin creates under Single sign-on, Provisioning. 256 random bits, shown once, revocable. It acts inside its workspace only.

Parameters

NameInTypeRequiredWhat it is
idpathstringYesThe person's id, as returned in id. It is only valid in the token's workspace.

Responses

200The person.

application/scim+json; headers X-RateLimit-Limit, X-RateLimit-Remaining

Example
{
  "schemas": [
    "urn:ietf:params:scim:schemas:core:2.0:User"
  ],
  "id": "mem_k3m9x2q7b4n8d1ya",
  "externalId": "00u1abc",
  "userName": "ada@acme.example",
  "name": {
    "formatted": "Ada Lovelace"
  },
  "displayName": "Ada Lovelace",
  "emails": [
    {
      "value": "ada@acme.example",
      "type": "work",
      "primary": true
    }
  ],
  "active": true,
  "meta": {
    "resourceType": "User",
    "created": "2026-10-23T09:14:03.000Z",
    "lastModified": "2026-10-23T09:14:03.000Z",
    "location": "https://auth.phoenixcodes.org/api/scim/v2/Users/mem_k3m9x2q7b4n8d1ya"
  }
}

401No token, a malformed one, an unknown one or a revoked one.

application/scim+json; headers WWW-Authenticate

Example
{
  "schemas": [
    "urn:ietf:params:scim:api:messages:2.0:Error"
  ],
  "status": "401",
  "detail": "A valid workspace token is required."
}

404No such person in this workspace. A person of another workspace looks the same.

application/scim+json

Example
{
  "schemas": [
    "urn:ietf:params:scim:api:messages:2.0:Error"
  ],
  "status": "404",
  "detail": "No user with id mem_k3m9x2q7b4n8d1ya in this workspace."
}

429Too many requests or too many failed token checks (tooMany).

application/scim+json; headers Retry-After

Example
{
  "schemas": [
    "urn:ietf:params:scim:api:messages:2.0:Error"
  ],
  "status": "429",
  "scimType": "tooMany",
  "detail": "Too many requests for this token. Wait before trying again."
}

500Our side. The body carries no detail.

application/scim+json

Try it

curl "https://auth.phoenixcodes.org/api/scim/v2/Users/mem_k3m9x2q7b4n8d1ya" \
  -H "Authorization: Bearer $SCIM_TOKEN"

Change a person

PATCH/api/scim/v2/Users/{id}

Changes active, displayName (or name.formatted) and externalId, as one to 20 operations applied together: a refused one leaves the person unchanged. Both the explicit-path shape and the shapes Okta (no path, an object of values) and Microsoft Entra ("True" and "False" as strings) send are accepted. userName is read-only. Setting active to false ends the person's sessions. The workspace's last active owner and the shared demo people cannot be deactivated.

Credential. A workspace token that an owner or admin creates under Single sign-on, Provisioning. 256 random bits, shown once, revocable. It acts inside its workspace only.

Parameters

NameInTypeRequiredWhat it is
idpathstringYesThe person's id, as returned in id. It is only valid in the token's workspace.

Request body

Sent as application/scim+json or application/json.

FieldTypeRequiredWhat it is
schemasarray of stringYes
Operationsarray of objectYesApplied together: one that is refused leaves the person unchanged.
{
  "schemas": [
    "urn:ietf:params:scim:api:messages:2.0:PatchOp"
  ],
  "Operations": [
    {
      "op": "replace",
      "path": "active",
      "value": false
    }
  ]
}

Responses

200The person after the change.

application/scim+json; headers X-RateLimit-Limit, X-RateLimit-Remaining

Example
{
  "schemas": [
    "urn:ietf:params:scim:schemas:core:2.0:User"
  ],
  "id": "mem_k3m9x2q7b4n8d1ya",
  "externalId": "00u1abc",
  "userName": "ada@acme.example",
  "name": {
    "formatted": "Ada Lovelace"
  },
  "displayName": "Ada Lovelace",
  "emails": [
    {
      "value": "ada@acme.example",
      "type": "work",
      "primary": true
    }
  ],
  "active": false,
  "meta": {
    "resourceType": "User",
    "created": "2026-10-23T09:14:03.000Z",
    "lastModified": "2026-10-23T09:14:03.000Z",
    "location": "https://auth.phoenixcodes.org/api/scim/v2/Users/mem_k3m9x2q7b4n8d1ya"
  }
}

400The body is not JSON (invalidSyntax), a value or filter is not valid (invalidValue, invalidFilter), an operation names a path this service does not support (invalidPath, noTarget) or one that cannot change (mutability).

application/scim+json

Example
{
  "schemas": [
    "urn:ietf:params:scim:api:messages:2.0:Error"
  ],
  "status": "400",
  "scimType": "invalidFilter",
  "detail": "Invalid filter: unexpected end of input."
}

401No token, a malformed one, an unknown one or a revoked one.

application/scim+json; headers WWW-Authenticate

Example
{
  "schemas": [
    "urn:ietf:params:scim:api:messages:2.0:Error"
  ],
  "status": "401",
  "detail": "A valid workspace token is required."
}

403The change is refused by a rule: the last active owner, the shared demo people, or the demo workspace being full.

application/scim+json

Example
{
  "schemas": [
    "urn:ietf:params:scim:api:messages:2.0:Error"
  ],
  "status": "403",
  "detail": "This person is the workspace's last active owner. Deactivating them would lock everyone out."
}

404No such person in this workspace. A person of another workspace looks the same.

application/scim+json

Example
{
  "schemas": [
    "urn:ietf:params:scim:api:messages:2.0:Error"
  ],
  "status": "404",
  "detail": "No user with id mem_k3m9x2q7b4n8d1ya in this workspace."
}

413The body is over 16 KB.

application/scim+json

415The body was not sent as application/scim+json or application/json.

application/scim+json

429Too many requests or too many failed token checks (tooMany).

application/scim+json; headers Retry-After

Example
{
  "schemas": [
    "urn:ietf:params:scim:api:messages:2.0:Error"
  ],
  "status": "429",
  "scimType": "tooMany",
  "detail": "Too many requests for this token. Wait before trying again."
}

500Our side. The body carries no detail.

application/scim+json

Try it

curl -X PATCH "https://auth.phoenixcodes.org/api/scim/v2/Users/mem_k3m9x2q7b4n8d1ya" \
  -H "Authorization: Bearer $SCIM_TOKEN" \
  -H "Content-Type: application/scim+json" \
  -d '{"schemas":["urn:ietf:params:scim:api:messages:2.0:PatchOp"],"Operations":[{"op":"replace","path":"active","value":false}]}'

Deactivate a person

DELETE/api/scim/v2/Users/{id}

Deactivates; it does not delete. The row, the history and the audit trail stay, the person's sessions end, and a later PATCH can reactivate them. Repeating it is harmless.

Credential. A workspace token that an owner or admin creates under Single sign-on, Provisioning. 256 random bits, shown once, revocable. It acts inside its workspace only.

Parameters

NameInTypeRequiredWhat it is
idpathstringYesThe person's id, as returned in id. It is only valid in the token's workspace.

Responses

204Deactivated.

headers X-RateLimit-Limit, X-RateLimit-Remaining

401No token, a malformed one, an unknown one or a revoked one.

application/scim+json; headers WWW-Authenticate

Example
{
  "schemas": [
    "urn:ietf:params:scim:api:messages:2.0:Error"
  ],
  "status": "401",
  "detail": "A valid workspace token is required."
}

403The change is refused by a rule: the last active owner, the shared demo people, or the demo workspace being full.

application/scim+json

Example
{
  "schemas": [
    "urn:ietf:params:scim:api:messages:2.0:Error"
  ],
  "status": "403",
  "detail": "This person is the workspace's last active owner. Deactivating them would lock everyone out."
}

404No such person in this workspace. A person of another workspace looks the same.

application/scim+json

Example
{
  "schemas": [
    "urn:ietf:params:scim:api:messages:2.0:Error"
  ],
  "status": "404",
  "detail": "No user with id mem_k3m9x2q7b4n8d1ya in this workspace."
}

429Too many requests or too many failed token checks (tooMany).

application/scim+json; headers Retry-After

Example
{
  "schemas": [
    "urn:ietf:params:scim:api:messages:2.0:Error"
  ],
  "status": "429",
  "scimType": "tooMany",
  "detail": "Too many requests for this token. Wait before trying again."
}

500Our side. The body carries no detail.

application/scim+json

Try it

curl -X DELETE "https://auth.phoenixcodes.org/api/scim/v2/Users/mem_k3m9x2q7b4n8d1ya" \
  -H "Authorization: Bearer $SCIM_TOKEN"

Maintenance

Called by the platform's scheduler, not by people.

Reset the shared demo

GET/api/cron/reseed

Puts the demo workspace back to its fixtures, removes people provisioned into it and sweeps stale sign-in flows, requests and report-card rows. Vercel Cron calls it nightly with Authorization: Bearer <CRON_SECRET>. A deployment without a CRON_SECRET keeps the route closed. Limited to 6 runs an hour.

Credential. The deployment's shared secret for scheduled calls.

Responses

200Reset. A deployment with the demo switched off answers { ok: true, skipped } instead.

application/json

Example
{
  "ok": true,
  "removedTenants": [
    "ten_penrose"
  ],
  "removedUsers": 0,
  "swept": {
    "flows": 3,
    "requests": 3,
    "reportCards": 0
  },
  "seeded": {
    "tenants": 1,
    "users": 3,
    "memberships": 3,
    "policies": 8,
    "ssoConnections": 1,
    "authEvents": 8
  },
  "ms": 412
}

401No bearer token, or the wrong one (unauthorized).

application/json; headers WWW-Authenticate

Example
{
  "error": {
    "code": "unauthorized",
    "message": "Send the cron secret as a bearer token."
  }
}

429Run too often (rate_limited).

application/json; headers Retry-After

500The reset failed (reseed_failed).

application/json

503This deployment has no CRON_SECRET, so the route is closed (not_configured).

application/json

Try it

curl "https://auth.phoenixcodes.org/api/cron/reseed" \
  -H "Authorization: Bearer $CRON_SECRET"

Reset the shared demo (POST)

POST/api/cron/reseed

The same as GET, for callers that prefer to POST.

Credential. The deployment's shared secret for scheduled calls.

Responses

200Reset.

application/json

401No bearer token, or the wrong one (unauthorized).

application/json

429Run too often (rate_limited).

application/json; headers Retry-After

500The reset failed (reseed_failed).

application/json

503No CRON_SECRET (not_configured).

application/json

Try it

curl -X POST "https://auth.phoenixcodes.org/api/cron/reseed" \
  -H "Authorization: Bearer $CRON_SECRET"