This guide covers the upgrade from the v4 Kotlin SDK surface to the v5 surface.
v5 is a substantial SDK rewrite:
- the transport layer moved from Fuel to OkHttp
- service classes were regenerated from the OpenAPI spec
- models and enums were consolidated into shared packages
- most builder-style request objects were replaced with flat method arguments or small option data classes
- pagination, retries, request overrides, and error handling were standardized across the SDK
If your code imports SDK models directly, constructs request builders, or uses webhook verification, expect source changes.
- Upgrade your runtime to Java 17 before adopting v5.
- Recreate
WorkOSclients with constructor arguments instead of mutatingapiHostname/https/port. - Move any per-call
clientIdarguments into theWorkOSclient configuration. - Replace
*Apiclasses, per-servicemodelspackages, per-servicetypespackages, and builder classes. - Update list handling to use
Page<T>andautoPagingIterable(). - Replace webhook signature verification calls with the standalone
Webhookhelper. - Switch any Fuel customization to
OkHttpClient.
v4 was built around Fuel and older Kotlin/JVM targets. v5 now builds around OkHttp and targets Java 17 bytecode.
What changes:
- Java 17 is now required for consumers.
- Kotlin consumers should align to Kotlin 2.1.x or newer.
- Any code that depended on Fuel types, Fuel interceptors, or
FuelManagerneeds to move toOkHttpClient.
Before:
val workos = WorkOS(System.getenv("WORKOS_API_KEY"))After:
val workos = WorkOS(
apiKey = System.getenv("WORKOS_API_KEY"),
clientId = System.getenv("WORKOS_CLIENT_ID"), // optional unless your flow needs it
apiBaseUrl = "https://api.workos.com",
httpClient = okhttp3.OkHttpClient(),
retryConfig = com.workos.common.http.RetryConfig.DEFAULT,
)In v4, the client was configured after construction:
val workos = WorkOS(apiKey)
workos.apiHostname = "localhost"
workos.https = false
workos.port = 8000In v5, configuration is constructor-based:
val workos = WorkOS(
apiKey = apiKey,
apiBaseUrl = "http://localhost:8000",
)Also note:
clientIdnow lives on the client and is reused by User Management and SSO helpers.- low-level
workos.get(...),post(...),put(...),patch(...), anddelete(...)helpers are gone - if you were using those internals directly, move to service methods or
workos.baseClient.request(...)
Most *Api classes lost the Api suffix, and some service names changed.
| v4 | v5 |
|---|---|
OrganizationsApi |
Organizations |
DirectorySyncApi |
DirectorySync |
UserManagementApi |
UserManagement |
SsoApi |
SSO |
AuditLogsApi |
AuditLogs |
WidgetsApi |
Widgets |
PortalApi via workos.portal |
AdminPortal via workos.adminPortal |
MfaApi via workos.mfa |
MultiFactorAuth via workos.multiFactorAuth |
FgaApi via workos.fga |
redesigned as Authorization via workos.authorization (see section 3a) |
WebhooksApi |
Webhooks for endpoint management, plus standalone Webhook for signature verification |
Service accessors are now generated as properties directly on the WorkOS class.
Before:
val org = workos.organizations.getOrganization("org_123")
val link = workos.portal.generateLink(...)
val factor = workos.mfa.getFactor("auth_factor_123")After:
val org = workos.organizations.get("org_123")
val link = workos.adminPortal.generateLink(...)
val factor = workos.multiFactorAuth.getFactor("auth_factor_123")Generated service methods drop the resource name from each operation. Mechanically:
getUser(id)→get(id)listUsers(...)→list(...)createUser(...)→create(...)updateUser(id, ...)→update(id, ...)deleteUser(id)→delete(id)getOrganization(id)→get(id)listConnections(...)→list(...)
The same pattern applies across Organizations, DirectorySync, UserManagement, SSO, AuditLogs, and the rest. Where a service exposes more than one resource (for example, UserManagement also handles invitations, organization memberships, sessions, and authorized applications), the resource name is kept on the secondary methods (listInvitations, listOrganizationMemberships, listSessions, listAuthorizedApplications).
workos.fga is not just renamed to workos.authorization — the underlying API is a different shape.
v4 FgaApi was warrant-centric: writeWarrant, batchWriteWarrants, listWarrants, query, plus *Resource CRUD and check/checkBatch.
v5 Authorization is role- and permission-centric: assignRole, removeRole, listRoleAssignments, createOrganizationRole, listOrganizationRoles, getOrganizationRole, updateOrganizationRole, deleteOrganizationRole, addRolePermission, setRolePermissions, createPermission, listPermissions, createAuthorizationResource, listAuthorizationResources, listOrganizationMembershipsForResource, listResourcesForOrganizationMembership, and so on. check still exists.
Warrant-style writes do not have a one-to-one replacement. If you wrote warrants directly in v4, plan to re-model your authorization data against roles, permissions, and resources before upgrading.
In v4, UserManagementApi exposed enrollAuthFactor and listAuthFactors. In v5 those live on MultiFactorAuth:
v4 (workos.userManagement) |
v5 |
|---|---|
enrollAuthFactor(...) |
workos.multiFactorAuth.enrollFactor(...) |
listAuthFactors(userId) |
workos.multiFactorAuth.listUserAuthFactors(...) |
MultiFactorAuth also adds challengeFactor, verifyChallenge, getFactor, deleteFactor, and createUserAuthFactor.
Service accessors work natively from Java — no extra wrapper class needed:
WorkOS workos = new WorkOS(System.getenv("WORKOS_API_KEY"), System.getenv("WORKOS_CLIENT_ID"));
Organizations organizations = workos.getOrganizations();For more configurable construction without filling in every positional
constructor parameter, use the fluent WorkOS.builder():
WorkOS workos = WorkOS.builder()
.apiKey(System.getenv("WORKOS_API_KEY"))
.clientId(System.getenv("WORKOS_CLIENT_ID"))
.retryConfig(RetryConfig.DISABLED)
.build();Sealed-class operation parameters (ResourceTarget, CreateUserPassword,
CreateUserRole, etc.) emit Java-friendly overloads that take the
discriminating fields directly so you do not have to construct the variant
class explicitly:
// Kotlin idiom — sealed-class variant
workos.getAuthorization().check(membershipId, ResourceTarget.ByExternalId("ext_1", "type_slug"), "perm");
// Java-friendly overload
workos.getAuthorization().checkByExternalId(membershipId, "ext_1", "type_slug", "perm");Generated model and exception properties are exposed as @JvmField so
Java reads them as fields — connection.id, not connection.getId().
v4 split request/response types across many service-local packages:
com.workos.organizations.models.*com.workos.usermanagement.types.*com.workos.fga.builders.*com.workos.webhooks.models.*
v5 consolidates most generated types into:
com.workos.models.*com.workos.types.*
Examples:
| v4 import | v5 import |
|---|---|
com.workos.organizations.models.Organization |
com.workos.models.Organization |
com.workos.sso.models.Connection |
com.workos.models.Connection |
com.workos.common.models.Order |
per-resource ordering enums in com.workos.types.* (e.g. OrganizationsOrder, UserManagementUsersOrder, ConnectionsOrder, EventsOrder), or the generic PaginationOrder |
Also:
- most
builderspackages are gone - many
typesclasses used only to shape request bodies are gone - webhook event classes are no longer under
com.workos.webhooks.models
v4 leaned heavily on builders and service-specific option objects.
Before:
val org = workos.organizations.createOrganization(
OrganizationsApi.CreateOrganizationOptions.builder()
.name("Acme")
.domains(listOf("acme.com"))
.build()
)After:
val org = workos.organizations.create(
name = "Acme",
domains = listOf("acme.com"),
)Before:
val users = workos.userManagement.listUsers(
ListUsersOptions.builder()
.email("admin@acme.com")
.build()
)After:
val users = workos.userManagement.list(
email = "admin@acme.com",
)The general rule in v5 is:
- simple operations use flat method arguments
- URL-building helpers use small option data classes like
AuthKitAuthorizationUrlOptionsandSSOAuthorizationUrlOptions - per-request behavior uses a shared
RequestOptionsbuilder
Several v4 User Management and SSO methods required clientId on every call.
Before:
val auth = workos.userManagement.authenticateWithPassword(
clientId,
"user@example.com",
"secret-password",
)After:
val workos = WorkOS(
apiKey = apiKey,
clientId = clientId,
)
val auth = workos.userManagement.authenticateWithPassword(
email = "user@example.com",
password = "secret-password",
)The same applies to:
authenticateWithCodeauthenticateWithRefreshTokenauthenticateWithMagicAuthauthenticateWithEmailVerificationauthenticateWithTotp- AuthKit URL helpers
- SSO URL helpers
v4 returned service-specific list wrappers such as OrganizationList, Users, ConnectionList, and Invitations.
v5 returns Page<T> from every generated list(...) method.
Before:
val result = workos.organizations.listOrganizations()
for (org in result.data) {
println(org.id)
}After:
val page = workos.organizations.list(limit = 10)
for (org in page.data) {
println(org.id)
}
for (org in page.autoPagingIterable()) {
println(org.id)
}Important changes:
PaginationParamsis gonelist(...)methods accept cursor and filter arguments directly- cursor metadata now lives on
page.listMetadata - auto-pagination is built in via
page.autoPagingIterable()
PATCH endpoints now distinguish between:
- field omitted
- field set to a value
- field explicitly cleared to
null
For those endpoints, use PatchField.
import com.workos.common.http.PatchField
workos.webhooks.updateEndpoint(
id = "wh_123",
endpointUrl = PatchField.of("https://example.com/webhooks"),
status = PatchField.absent(),
events = PatchField.ofNull(),
)If you pass plain nullable values where a PatchField<T> is required, the call site must be updated.
This is one of the biggest behavior changes.
In v4, webhook signature verification lived on WebhooksApi, which was exposed as workos.webhooks.
In v5:
workos.webhooksis the API client for webhook endpoint CRUDWebhook()is the standalone signature verification helper
Before:
val event = workos.webhooks.constructEvent(payload, signatureHeader, secret)After:
import com.workos.models.WorkOSEvent
import com.workos.webhooks.Webhook
// Preferred: typed sealed-class event
val event: WorkOSEvent = Webhook()
.constructTypedEvent(payload, signatureHeader, secret)
when (event) {
is WorkOSEvent.UserCreated -> println("New user ${event.data.id}")
else -> println("Other event ${event.event}")
}
// Or use the raw form when you need to handle event types the SDK
// has not yet modeled.
val node = Webhook().constructEvent(payload, signatureHeader, secret)Also note:
constructTypedEvent(...)returns a sealedWorkOSEvent; unknown types fall back to the catch-allEventSchemavariantconstructEvent(...)still returns the rawJsonNodefor full control- the helper accepts both
v1=and legacys=signature formats
v4 had scattered request option classes such as CreateOrganizationRequestOptions.
v5 uses a shared RequestOptions builder:
import com.workos.common.http.RequestOptions
val requestOptions =
RequestOptions.builder()
.idempotencyKey(java.util.UUID.randomUUID().toString())
.header("X-Trace-Id", "trace_123")
.maxRetries(0)
.build()
val org = workos.organizations.create(
name = "Acme",
requestOptions = requestOptions,
)Supported per-request overrides include:
- headers
- timeout
- max retries
- base URL
- idempotency key
- API key or bearer token override
One subtle SSO difference: if you previously called sso.getProfile(accessToken), in v5 you override the bearer token via RequestOptions:
val profile = workos.sso.getProfile(
requestOptions =
RequestOptions.builder()
.apiKey(accessToken)
.build()
)v4 did not retry failed requests. v5 retries automatically with exponential backoff (up to 3 attempts by default). This affects all SDK methods.
If a v4 caller was retrying manually, remove the retry loop — the SDK handles it now. To disable retries or tune the policy:
import com.workos.common.http.RetryConfig
// Disable retries entirely
val workos = WorkOS(
apiKey = apiKey,
retryConfig = RetryConfig.DISABLED,
)
// Or customize
val workos = WorkOS(
apiKey = apiKey,
retryConfig = RetryConfig(maxRetries = 5),
)
// Per-request override
workos.organizations.create(
name = "Acme",
requestOptions = RequestOptions.builder().maxRetries(0).build(),
)For retried POST requests that do not already carry an Idempotency-Key
header, the SDK auto-generates one so the server can deduplicate. You can
supply your own via RequestOptions:
workos.organizations.create(
name = "Acme",
requestOptions = RequestOptions.builder()
.idempotencyKey(java.util.UUID.randomUUID().toString())
.build(),
)All SDK errors now inherit from com.workos.common.exceptions.WorkOSException.
Typed subclasses include:
BadRequestException(HTTP 400)UnauthorizedException(HTTP 401)NotFoundException(HTTP 404)UnprocessableEntityException(HTTP 422)RateLimitException(HTTP 429)GenericServerException(HTTP 5xx)WorkOSGenericException(other HTTP statuses without a specific type)TransportException(status0— IOException, timeout, DNS or TLS failure; the request never received an HTTP status)
New behavior to be aware of:
Retry-Afteris honored for retryable responsesRateLimitExceptionexposesretryAfterSecondsWorkOSExceptionincludesstatus,requestId,code,errors, andrawBodyerrorsis now a typedList<ApiError>?(wasList<Map<String, Any?>>?); each entry hasfield,code, andmessageproperties so callers can inspect validation failures withoutMapcasts
If you previously caught generic transport exceptions from Fuel, update those catch blocks to catch TransportException, WorkOSException, or the specific typed subclass you care about. If you specifically caught the v5 pre-release GenericException, rename to WorkOSGenericException (HTTP fallback) or switch to TransportException (network failure) depending on which case you meant.
If you support browser, mobile, CLI, or desktop PKCE flows, v5 adds PublicClient.
val publicClient = PublicClient.create(
clientId = System.getenv("WORKOS_CLIENT_ID"),
)
val authUrl = publicClient.getAuthorizationUrlWithPKCE(
com.workos.usermanagement.AuthKitAuthorizationUrlOptions(
redirectUri = "https://example.com/callback",
provider = "authkit",
)
)Use PublicClient when you do not want to send a client_secret on the wire.
v5 adds Session helpers for managing sealed AuthKit session cookies.
These use Iron Fe26.2 encryption (the same format used by the Node SDK's
iron-webcrypto package) so session cookies are interoperable across
SDKs.
import com.workos.session.Session
val session = Session.create(
workos = workos,
cookiePassword = System.getenv("WORKOS_COOKIE_PASSWORD"),
)
// Seal an auth response into a cookie value
val sealed = session.sealAuthResponse(authResponse)
// Read session data from a cookie
val data = session.unsealData(cookieValue)Key points:
cookiePasswordmust be at least 32 characters; security rests on password entropy, not iteration count.sealData/unsealDatalet you store arbitrary JSON in Iron-sealed cookies.sealAuthResponseproduces a cookie value compatible with the Node and Go SDK session helpers.- If the seal is malformed or the password doesn't match,
unsealDatareturns an empty map rather than throwing (matches Node SDK semantics).
// v4
val org = workos.organizations.getOrganization("org_123")
val all = workos.organizations.listOrganizations()
// v5
val org = workos.organizations.get("org_123")
val all = workos.organizations.list()// v4
val user = workos.userManagement.createUser(
CreateUserOptions(email = "user@example.com")
)
// v5
val user = workos.userManagement.create(
email = "user@example.com",
)// v4
val url = workos.userManagement
.getAuthorizationUrl(clientId, "https://example.com/callback")
.provider("authkit")
.build()
// v5
val url = workos.userManagement.getAuthorizationUrl(
com.workos.usermanagement.AuthKitAuthorizationUrlOptions(
redirectUri = "https://example.com/callback",
provider = "authkit",
)
)// v4
val url = workos.sso
.getAuthorizationUrl(clientId, "https://example.com/callback")
.connection("conn_123")
.build()
// v5
val url = workos.sso.getAuthorizationUrl(
com.workos.sso.SSOAuthorizationUrlOptions(
redirectUri = "https://example.com/callback",
connection = "conn_123",
)
)Every generated service method has both a blocking version and a
coroutine-aware <method>Suspend variant that wraps the call in
withContext(Dispatchers.IO). Pulled in transitively via
kotlinx-coroutines-core.
// Blocking
val org = workos.organizations.create(name = "Foo Corp")
// suspend
val org = workos.organizations.createSuspend(name = "Foo Corp")The blocking and suspend forms are independently named (create vs
createSuspend) — Kotlin does not allow overload resolution to
disambiguate fun foo() from suspend fun foo() with the same value
parameters.
In addition to the breaking changes above, this branch adds or expands:
PublicClientfor PKCE-safe public flowsSessionhelpers for sealed AuthKit session cookiesVaulthelpers and client-side crypto support- generated accessors for
Actions,ApiKeys,Connect,FeatureFlags,OrganizationDomains,Pipes, andRadar - broader generated model coverage, including forward-compat enum handling via
Unknown - typed
Webhook.constructTypedEvent(...)returning a sealedWorkOSEvent WorkOS.builder()for fluent Java-friendly construction- typed
ApiErrormodel onWorkOSException.errors TransportException(status0) split out ofWorkOSGenericException<method>Suspendcoroutine variants on every generated operation- Java-friendly overloads for sealed-class operation parameters
The fastest migration path is usually:
- upgrade Java first
- update imports from service-local
models/typespackages tocom.workos.modelsandcom.workos.types - move
clientIdintoWorkOS(...) - replace builder objects with direct method arguments
- update list handling to
Page<T> - replace webhook verification with
Webhook()
If you do those six steps first, the remaining compile errors should mostly collapse into straightforward method renames.
If you tracked a v5 pre-release of this SDK, also note: GenericException
was split into WorkOSGenericException (HTTP fallback) and
TransportException (network failure), and WorkOSException.errors is
now a typed List<ApiError>? rather than List<Map<String, Any?>>?.