About a dozen workos resource commands moved from the REST backend to the
dashboard GraphQL account plane. This ships as one breaking release.
Command names, flags, and positionals are unchanged. workos org list,
workos user get <id>, workos webhook create --url ... all take the same
arguments they always did. The only thing that changed is the --json output
shape. If you script against --json with jq or in CI, read this guide. If
you only read the human-readable tables, nothing changes for you.
Why the change: the backend's vocabulary (enum casing, metadata encoding, field
names) was leaking into --json, which made it fragile. The CLI now applies one
consistent contract (src/utils/output-conventions.ts) so the next backend
migration is not another user-visible break. Two rules of that contract:
- Enum values are always lowercase (snake_case if multiword).
- Enum input is case-insensitive, so any value the CLI prints is accepted back.
Source note: the old REST shapes below are derived from the release's
feat!commit bodies, thescripts/parity-smoke.tsACCEPTEDmap, and the README correction commit. The sibling../maincheckout was not reachable from this worktree, so a few old REST values could not be independently confirmed; those are flagged inline and listed under Unconfirmed facts.
The local credential-profile commands (env add/remove/switch/list/provision/claim)
are now workos profile .... The old name collided with the new
workos environment command (dashboard environments), and the two are
different things: a profile is an API key + settings stored on your machine;
an environment is the server-side WorkOS environment it targets.
workos env ... keeps working as an alias — no script changes required. Only
the canonical name in help text, hints, and telemetry changed.
| Change | Old | New | Affected commands |
|---|---|---|---|
| List envelope | { "data": [...], "list_metadata": {...} } |
{ "<resource>": [...], "pagination": {...} } |
all list views |
metadata encoding |
object map {"team":"blue"} (array of pairs during prerelease) |
object map {"team":"blue"} |
organization, user |
| Lifecycle key | status |
state |
user identities, membership, session |
| Enum casing | mixed (Pending, Active, Verified, SOME) |
lowercase (pending, active, verified, some) |
invitation, webhook, org-domain, membership, role, feature-flag |
role.type |
EnvironmentRole / OrganizationRole |
environment / organization |
role |
webhook.state |
enabled |
active |
webhook |
invitation.organization |
flat organizationId string |
nested { "id", "name" } object |
invitation |
feature-flag.enabled |
flat flag value | derived from the active environment's state | feature-flag |
| Internal fields | present (stripeCustomerId, resourceTypeId, request context, ...) |
dropped | all |
metadata is an object map in the final release:
{ "metadata": { "team": "blue" } }.metadata.team resolves in jq. Affects organization and user.
Why this section is called out: during the GraphQL prerelease, metadata was
briefly emitted as GraphQL's array-of-pairs transport form:
{ "metadata": [{ "key": "team", "value": "blue" }] }That form fails silently. jq '.metadata.team' returns empty rather than
erroring, so a broken pipeline looks like it is returning "no value" instead of
crashing. The final release folds it back to a map.
Action:
- If you script against a stable REST release,
.metadata.teamworked before and still works. No change. - If you already migrated a script to the array form during the prerelease
(
.metadata[] | select(.key=="team") | .value), revert it to.metadata.team.
# WRONG (prerelease array form) - remove this
jq -r '.metadata[] | select(.key=="team") | .value'
# RIGHT
jq -r '.metadata.team'All enum values in --json are now lowercase, snake_case if multiword. Every
value the migrated commands emit today is single word (active, verified,
pending, environment, organization, standard, dns, manual, some),
so the snake_case rule is not yet visible in output, but a future multiword enum
will render as e.g. user_registration, not userregistration.
Enum input is case-insensitive, so values round-trip: whatever the CLI prints for a field, it accepts back for that field.
# Both accepted; the CLI prints `verified`.
workos organization create foo.com:verified
workos organization create foo.com:VerifiedOld -> new values (from the feat! commit body):
| Field | Old | New |
|---|---|---|
invitation.state |
Pending |
pending |
webhook.state |
Active* |
active |
org-domain.state |
Verified |
verified |
org-domain.verificationStrategy |
Dns / Manual |
dns / manual |
membership.state / membership.type |
Active / Standard |
active / standard |
role.type |
Environment* |
environment |
feature-flag.accessType |
SOME |
some |
* See Deliberate differences from REST:
the REST plane used different vocabulary for webhook.state (enabled) and
role.type (EnvironmentRole). The casings above are the GraphQL backend
passthrough values that the release normalizes.
Fix pattern (do not hardcode casing; downcase before comparing):
# Fragile
jq 'select(.state == "Pending")'
# Robust
jq 'select((.state | ascii_downcase) == "pending")'The lifecycle key is state on every resource, never status. This affects
three places that previously used status:
| Command | Old | New |
|---|---|---|
| user identities | .identities[].status |
.identities[].state |
| membership | .status |
.state |
| session | .status |
.state |
# Old
jq -r '.identities[].status'
# New
jq -r '.identities[].state'Each section shows the new payload (from the command's *.spec.ts) and the
jq change. List views are wrapped in { "<resource>": [...], "pagination": {...} };
the examples show a single row unless the envelope is the point.
New:
{
"id": "org_1",
"name": "FooCorp",
"createdAt": "2026-01-01T00:00:00.000Z",
"usersCount": 3,
"allowProfilesOutsideOrganization": false,
"externalId": null,
"domains": [{ "id": "dom_1", "domain": "foo.com", "state": "verified" }],
"metadata": { "team": "blue" }
}List envelope changed from { data, list_metadata } to { organizations, pagination }.
Internal fields like stripeCustomerId are dropped. domains[].state is lowercase.
# List row ids: old envelope -> new envelope
jq -r '.data[].id' # old
jq -r '.organizations[].id' # new
# Filter verified domains (casing changed)
jq '.organizations[].domains[] | select(.state == "verified")'
# Read a metadata key
jq -r '.organization.metadata.team'New:
{
"id": "user_1",
"email": "jane@example.com",
"firstName": "Janet",
"lastName": "Doe",
"metadata": { "team": "blue" },
"identities": [
{
"id": "ident_1",
"state": "active",
"organization": { "id": "org_1", "name": "FooCorp" },
"roles": [{ "id": "role_1", "name": "member" }]
}
]
}identities[].status became identities[].state and is lowercase. metadata
is a map. Internal fields (googleOauthProfile, identity customAttributes) are
dropped. List envelope is { users, pagination }.
# Identity state: renamed status -> state, lowercased
jq -r '.user.identities[] | select(.state == "active") | .id'
# Metadata key
jq -r '.user.metadata.team'New:
{
"id": "role_env",
"slug": "admin",
"name": "Admin",
"description": "Administrator",
"type": "environment",
"permissions": ["users:read"],
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-02-01T00:00:00.000Z"
}type dropped the redundant Role suffix and is lowercase:
EnvironmentRole -> environment, OrganizationRole -> organization.
permissions is an array of permission slugs.
# Old: matched the REST vocabulary
jq 'select(.type == "EnvironmentRole")'
# New
jq 'select(.type == "environment")'New:
{
"id": "perm_1",
"slug": "users:read",
"name": "Read users",
"description": "Read user records",
"system": false,
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-02-01T00:00:00.000Z"
}No enum or metadata changes. Internal fields (environmentId,
isEnabledForApiKeys) are dropped. List envelope is { permissions, pagination }.
jq -r '.permissions[] | select(.system == false) | .slug'New:
{
"id": "om_1",
"userId": "user_1",
"organizationId": "org_1",
"state": "active",
"type": "standard",
"role": "member",
"roles": ["member"],
"directoryUserId": null,
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-01T00:00:00.000Z"
}status -> state; state and type are lowercase (Active/Standard ->
active/standard). List envelope is { memberships, pagination }.
# Old
jq -r '.data[] | select(.status == "Active") | .id'
# New
jq -r '.memberships[] | select(.state == "active") | .id'New:
{
"id": "invite_1",
"email": "jane@example.com",
"state": "pending",
"createdAt": "2026-01-01T00:00:00.000Z",
"organization": { "id": "org_1", "name": "FooCorp" }
}Two changes: state is lowercase (Pending -> pending), and the flat
organizationId string is now a nested organization object.
# Organization id: flat string -> nested object
jq -r '.organizationId' # old
jq -r '.organization.id' # new
# Filter pending (casing changed)
jq '.invitations[] | select(.state == "pending")'New:
{
"id": "session_1",
"state": "active",
"createdAt": "...",
"updatedAt": "...",
"expiresAt": "2026-03-01T00:00:00.000Z",
"endedAt": null,
"ipAddress": "...",
"userAgent": "Mozilla/5.0",
"provider": "Password",
"organization": { "id": "org_1", "name": "FooCorp" },
"impersonator": null
}status -> state; values are lowercase (active, revoked, expired).
# Old
jq -r '.data[] | select(.status == "Active") | .id'
# New
jq -r '.sessions[] | select(.state == "active") | .id'New:
{
"id": "event_1",
"event": "dsync.user.created",
"data": { "directory_id": "dir_1" },
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-01T00:00:00.000Z"
}The event type is under event. Internal request context and metadata that
GraphQL carries are dropped. List envelope is { events, pagination }.
jq -r '.events[] | select(.event == "dsync.user.created") | .id'Sort order reversed. This one changes which rows you get, not just their shape.
| old (REST) | new (GraphQL) | |
|---|---|---|
event list order |
oldest first | newest first |
event list --limit 10 returns |
the 10 oldest events | the 10 most recent events |
If you took the first element to mean "the latest event", it now means the opposite of what it used to, and no error is raised:
# Old: this was the OLDEST event. Taking [0] to mean "latest" was already a bug,
# it just happened to be a different bug.
workos event list --events user.created --limit 1 | jq -r '.data[0].id'
# New: [0] is genuinely the most recent event.
workos event list --events user.created --limit 1 | jq -r '.events[0].id'--after still requests the next page, but "next" now moves toward older
events because the feed starts with the newest. Treat cursors as opaque and do
not reuse a cursor saved by the REST version after upgrading.
If you were paginating to the end of the REST feed just to reach recent events, you can now drop that work and read from the first page.
New (get detail):
{
"id": "flag_1",
"slug": "beta",
"name": "...",
"description": "...",
"enabled": false,
"defaultEnabled": true,
"accessType": "some",
"organizationTargets": [{ "id": "org_1", "name": "FooCorp" }],
"userTargets": [{ "id": "user_1", "email": "a@example.com" }],
"tags": []
}accessType is lowercase (SOME -> some). enabled now reflects the
active environment's flag state, not a flat global flag. List rows carry the
list subset (through enabled); get adds targeting fields.
# Old
jq 'select(.accessType == "SOME")'
# New
jq 'select(.accessType == "some")'
jq -r '.flags[] | select(.enabled) | .slug'New:
{
"id": "org_domain_1",
"domain": "example.com",
"state": "verified",
"organizationId": "org_1",
"subdomain": null,
"verificationStrategy": "dns",
"verificationContent": "workos-verify=abc123",
"domainCaptureEnabled": false
}state (Verified -> verified) and verificationStrategy
(Dns/Manual -> dns/manual) are lowercase.
# Old
jq 'select(.state == "Verified")'
# New
jq 'select(.state == "verified")'
jq -r 'select(.verificationStrategy == "dns") | .verificationContent'New:
{
"id": "portal_setup_link_1",
"link": "https://setup.workos.com/abc",
"intents": ["sso"],
"state": "active",
"expiresAt": "2026-08-01T00:00:00Z"
}The URL is under link. intents are echoed in the CLI's own lowercase
vocabulary (Sso -> sso). state is lowercase.
jq -r '.portalSetupLink.link'
jq -r '.portalSetupLink.intents[]'New:
{
"id": "we_123",
"url": "https://example.com/hook",
"events": ["dsync.user.created"],
"state": "active",
"createdAt": "2024-01-01T00:00:00Z"
}Two changes: the endpoint URL is under url (REST used endpoint_url), and
state is active where REST said enabled. List envelope is
{ webhookEndpoints, pagination }.
# URL: renamed field
jq -r '.endpoint_url' # old (REST)
jq -r '.url' # new
# State: vocabulary + presence changed
jq 'select(.status == "enabled")' # old (REST)
jq 'select(.state == "active")' # newconfig redirect add, config cors add, and config homepage-url set emit
small confirmation objects, not resource shapes:
{ "uri": "https://app.example.com/callback", "alreadyExists": false }
{ "origin": "https://app.example.com", "alreadyExists": true }
{ "homepageUrl": "https://app.example.com", "applicationId": "app_1" }These are new commands on the dashboard plane; no field renames from a prior shape apply.
These are intentional curations, declared in the ACCEPTED map of
scripts/parity-smoke.ts. They are not bugs, and the parity smoke reports them
as INFO, not FAIL.
| Field | REST | Now | Reason |
|---|---|---|---|
role.type |
EnvironmentRole / OrganizationRole |
environment / organization |
Drops the redundant Role suffix. |
webhook.state |
enabled |
active |
Aligns webhook lifecycle with session's active/expired/revoked vocabulary. |
invitation.organization |
flat organizationId |
nested { id, name } |
Structural: consistent with other nested org references. |
user.identities |
different shape, status key |
curated identities, status -> state |
Structural + lifecycle-key rename. |
feature-flag.enabled |
flat flag | derived from the active environment's state | Flag state is per-environment server-side; the CLI reports the active environment. |
Enum casing is deliberately absent from ACCEPTED: the CLI lowercases all enum
values, so a casing-only difference is treated as a bug that must fail the
parity run, not an accepted divergence.
scripts/parity-smoke.ts compares this release against a ../main (REST)
checkout against the same WorkOS environment. It matches rows by id, compares
every shared field, and fails on any divergence not listed in ACCEPTED. Use it
to confirm your understanding of a field before and after.
bun run scripts/parity-smoke.ts # read-only
bun run scripts/parity-smoke.ts --seed # seed fixtures so lists are non-emptyPrereqs are documented at the top of the script: a dashboard workos auth login
on this branch, a WORKOS_API_KEY for the REST plane pinned to the same
environment, and ../main checked out on main.
The sibling ../main checkout was not reachable from this worktree, so the
following old REST values could not be independently verified against source:
- Exact REST casing for
invitation.state,org-domain.state,org-domain.verificationStrategy,membership.state/membership.type, andfeature-flag.accessType. The old values shown come from thefeat!commit body, which describes them as GraphQL backend passthrough casing normalized in this release. Whether the last stable REST release emitted that exact casing to users is not confirmed here. - The precise old REST envelope key names beyond
data/list_metadata(confirmed via the README correction commit e67867d fororg list); other commands are assumed to have used the same REST envelope. - The full old REST field set for
user.identities,session, andportal. TheACCEPTEDmap confirms the direction of the structural changes but not the complete prior shapes.
Confirmed directly from source: all new shapes (from *.spec.ts), the
conventions (src/utils/output-conventions.ts), the deliberate divergences
(ACCEPTED in scripts/parity-smoke.ts), the metadata array-vs-map history and
the lifecycle key rename (feat! commit 22d441d), and the snake_case enum rule
(commit 461df62).