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.
Responses
200Everything passed.
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.
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.
Responses
200The OpenAPI document.
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.
Request body
| Field | Type | Required | What it is |
|---|---|---|---|
url | string | Yes | A 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.
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).
Example
{
"error": {
"code": "blocked_address",
"message": "That address points at a private network."
}
}413The body is over 4 KB (payload_too_large).
415The body was not sent as application/json (unsupported_media_type).
422The body is not an object with a "url" string (invalid_request).
429Too many checks from this address (rate_limited).
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.
504The site did not answer in time (timeout).
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.
Responses
200SAML 2.0 metadata.
Example
<md:EntityDescriptor …/>404This deployment has no service-provider key, so SAML is off.
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.
Parameters
| Name | In | Type | Required | What it is |
|---|---|---|---|---|
tenant | query | string | No | The workspace's slug. Defaults to the demo workspace. |
connection | query | string | No | The connection's slug within the workspace. |
next | query | string | No | A 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.
303Back to /sign-in?error= with one of: sso_unavailable, sso_rate_limited, sso_unknown, sso_failed.
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.
Request body
| Field | Type | Required | What it is |
|---|---|---|---|
SAMLResponse | string | Yes | The base64-encoded SAML response. Refused above 256 KB. |
RelayState | string | No | Echoed 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.
404This deployment has no service-provider key, so SAML is off.
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.
Responses
200SAML 2.0 metadata.
Example
<md:EntityDescriptor …/>404The built-in provider is not configured on this deployment.
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.
Parameters
| Name | In | Type | Required | What it is |
|---|---|---|---|---|
SAMLRequest | query | string | Yes | The deflated, base64-encoded AuthnRequest. |
SigAlg | query | string | Yes | The signature algorithm URI. |
Signature | query | string | Yes | The base64 signature over the query string. |
RelayState | query | string | No | Echoed back to the service provider. |
Responses
303To /idp/login?t=<ticket>.
400No signed request in the query.
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.
Example
The AuthnRequest signature could not be verified.404The built-in provider is not configured on this deployment.
Example
The demo identity provider is not configured here.429Too many requests from this address.
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.
Responses
200The document.
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).
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.
Responses
200The document.
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).
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.
Responses
200The document.
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).
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).
Parameters
| Name | In | Type | Required | What it is |
|---|---|---|---|---|
filter | query | string | No | A SCIM filter. |
startIndex | query | integer | No | The 1-based index of the first match to return. |
count | query | integer | No | How many people to return. |
Responses
200A ListResponse.
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).
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.
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).
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.
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.
Request body
| Field | Type | Required | What it is |
|---|---|---|---|
schemas | array of string | Yes | Must include the core User schema URN. |
userName | string | Yes | The e-mail address the person signs in with. Unique, compared without case. |
externalId | string | No | The directory's own id for this person. |
displayName | string | No | |
name | object | No | Gatehouse keeps one display name: formatted, or given and family name joined. |
emails | array of object | No | Accepted and ignored: the address is userName. |
active | boolean or string | No | Defaults 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.
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).
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.
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.
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).
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.
415The body was not sent as application/scim+json or application/json.
429Too many requests or too many failed token checks (tooMany).
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.
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.
Parameters
| Name | In | Type | Required | What it is |
|---|---|---|---|---|
id | path | string | Yes | The person's id, as returned in id. It is only valid in the token's workspace. |
Responses
200The person.
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.
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.
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).
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.
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.
Parameters
| Name | In | Type | Required | What it is |
|---|---|---|---|---|
id | path | string | Yes | The person's id, as returned in id. It is only valid in the token's workspace. |
Request body
| Field | Type | Required | What it is |
|---|---|---|---|
schemas | array of string | Yes | |
Operations | array of object | Yes | Applied 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.
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).
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.
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.
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.
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.
415The body was not sent as application/scim+json or application/json.
429Too many requests or too many failed token checks (tooMany).
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.
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.
Parameters
| Name | In | Type | Required | What it is |
|---|---|---|---|---|
id | path | string | Yes | The person's id, as returned in id. It is only valid in the token's workspace. |
Responses
204Deactivated.
401No token, a malformed one, an unknown one or a revoked one.
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.
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.
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).
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.
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.
Responses
200Reset. A deployment with the demo switched off answers { ok: true, skipped } instead.
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).
Example
{
"error": {
"code": "unauthorized",
"message": "Send the cron secret as a bearer token."
}
}429Run too often (rate_limited).
500The reset failed (reseed_failed).
503This deployment has no CRON_SECRET, so the route is closed (not_configured).
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.
Responses
200Reset.
401No bearer token, or the wrong one (unauthorized).
429Run too often (rate_limited).
500The reset failed (reseed_failed).
503No CRON_SECRET (not_configured).
Try it
curl -X POST "https://auth.phoenixcodes.org/api/cron/reseed" \
-H "Authorization: Bearer $CRON_SECRET"