Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
c5b9131
fix(pagination): parse items envelope for message, event, and deliver…
cursoragent Sep 30, 2026
513be93
fix(query): serialize object-array query params in indexed form
cursoragent Sep 30, 2026
6e01ac0
style: apply spotless formatting
cursoragent Sep 30, 2026
6632238
feat(api): add users().unsetPreferences and objects().unsetPreferences
cursoragent Sep 30, 2026
4bc4ac6
fix(api): add required id to bulk add subscriptions
cursoragent Sep 30, 2026
476c66f
feat(api): add objects().bulk().deleteSubscriptions
cursoragent Sep 30, 2026
2022b16
feat(api)!: re-point guide message actions to path-less endpoints and…
cursoragent Sep 30, 2026
1537d62
feat(api): add users().preferenceCenter() with getConfig and generate…
cursoragent Sep 30, 2026
c1f1c5d
fix(api): accept plural preference center row types returned by the API
cursoragent Sep 30, 2026
bd7ddc8
feat(api): add workflowRecipientRuns() service with list and get
cursoragent Sep 30, 2026
f5f3e56
feat(api)!: remove duplicate messages().activities() sub-resource
cursoragent Sep 30, 2026
4e8bcff
feat(api)!: return nothing from 204 endpoints
cursoragent Sep 30, 2026
6af4d5e
feat(api): add locale, exclude, mode, and inserted_at filters to feed…
cursoragent Sep 30, 2026
33567e2
feat(api): add resolve_full_preference_settings to tenants get and cr…
cursoragent Sep 30, 2026
47ee972
feat(api): add trigger settings, schedule actor, tenant name, and inl…
cursoragent Sep 30, 2026
3c0ffa8
feat(api): add message source run fields and connection knock_tenant_id
cursoragent Sep 30, 2026
fdcebb3
feat(lib): add user token signing (UserTokens.signUserToken, buildUse…
cursoragent Sep 30, 2026
8b99db3
docs: add migration guide, user token README section, and example
cursoragent Sep 30, 2026
d5a21cd
chore: release as 2.0.0
cursoragent Sep 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .stats.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
configured_endpoints: 89
configured_endpoints: 98
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/knock%2Fknock-5176d1bb3a88b127808b197c9ae1cf366fd56599fd8c7b7241ac829e72d69a42.yml
openapi_spec_hash: 92953a04021af2d0132fd9eebeb844b9
config_hash: 7460c5bd6d1a7041faa274f677789407
114 changes: 114 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# Migration guide

## 2.0.0: OpenAPI modernization

This release brings the SDK in line with the current [Knock OpenAPI spec](https://api.knock.app/v1/openapi) and with the [Node SDK](https://github.com/knocklabs/knock-node). Most changes are additive. The breaking changes are listed below.

### Endpoints that return `204 No Content` return nothing

These methods used to return a `String` (always empty). They now return nothing, `CompletableFuture<Void?>` for the async client, and their raw-response variants return `HttpResponse` instead of `HttpResponseFor<String>`:

- `users().delete`, `objects().delete`, `tenants().delete`
- `users().unsetChannelData`, `objects().unsetChannelData`
- `audiences().addMembers`, `audiences().removeMembers`
- `workflows().cancel`

```java
// Before
String result = client.users().delete("user_1");

// After
client.users().delete("user_1");
```

### Message lists read `items` instead of `entries`

The API returns message, event, and delivery log lists under `items`. The old `entries()` accessor always returned an empty list. The page accessor is now `items()` on:

- `MessageListPage`, `UserListMessagesPage`, `ObjectListMessagesPage`
- `MessageListEventsPage`, `MessageListDeliveryLogsPage`

The same applies to the async page classes and to the `*PageResponse` classes (`items()` / `_items()`). `autoPager()` works unchanged.

```java
// Before
List<Message> messages = client.messages().list().entries();

// After
List<Message> messages = client.messages().list().items();
```

### Guide message actions use the path-less endpoints

`users().guides().markMessageAsSeen`, `markMessageAsInteracted`, and `markMessageAsArchived` now call `PUT /v1/users/{user_id}/guides/messages/{action}` and no longer take a `messageId`. The guide, step, and channel are identified in the request body instead:

```java
// Before
client.users().guides().markMessageAsSeen("user_1", "message_1", params);

// After
client.users().guides().markMessageAsSeen(
"user_1",
GuideMarkMessageAsSeenParams.builder()
.channelId("channel_1")
.guideId("guide_1")
.guideKey("onboarding")
.guideStepRef("step_1")
.content(GuideMarkMessageAsSeenParams.Content.builder().build())
.build());
```

- The three `GuideMarkMessageAs*Response` classes are replaced by a single `GuideActionResponse`.
- `GuideGetChannelResponse` matches the API's current shape: `entries()`, `guideGroups()`, `guideGroupDisplayLogs()`, and `ineligibleGuides()` replace `guides()` and `recipient()`.

### `messages().activities()` is removed

`messages().activities().list(...)` duplicated `messages().listActivities(...)`, which calls the same endpoint. Use `listActivities`:

```java
// Before
client.messages().activities().list(ActivityListParams.builder().messageId("message_1").build());

// After
client.messages().listActivities("message_1");
```

### Recipient filters accept object references

`UserListSubscriptionsParams.objects`, `ObjectListSubscriptionsParams.recipients`, and `ScheduleListParams.recipients` are now `List<RecipientReference>` instead of `List<String>`, so that object recipients can be filtered on. The `addObject(String)` and `addRecipient(String)` overloads still accept user IDs:

```java
ScheduleListParams.builder()
.workflow("digest")
.addRecipient("user_1")
.addRecipient(RecipientReference.ObjectReference.builder().collection("projects").id("p1").build())
.build();
```

Object references are now sent in the indexed form (`recipients[0][collection]=...`) that the API expects.

### Bulk add subscriptions requires `id`

Each item in `objects().bulk().addSubscriptions` now requires the object `id`, as the API does:

```java
BulkAddSubscriptionsParams.Subscription.builder()
.id("project_1")
.addRecipient("user_1")
.build();
```

### Schedule `repeats` is optional

`ScheduleCreateParams.repeats()` returns `Optional<List<ScheduleRepeatRule>>`, because a schedule can be created with only `scheduled_at`.

## New functionality

- `workflowRecipientRuns()` with `list` and `get`
- `users().preferenceCenter()` with `getConfig` and `generateSignedUrl`
- `users().unsetPreferences` and `objects().unsetPreferences`
- `objects().bulk().deleteSubscriptions`
- `users().guides().resetGuideEngagements` and `unarchiveGuideMessage`
- `app.knock.api.lib.UserTokens` for signing user tokens (see the README)
- New request fields: workflow trigger `settings` (`sandbox_mode`, `skip_delay`), schedule `actor`, tenant `name`, `resolve_full_preference_settings` on tenants, `create_audience` on audience members, feed `locale` / `exclude` / `mode` / `inserted_at`, and inline user `avatar` / `locale` / `phone_number`
- New response fields: message `source` run identifiers and connection `knock_tenant_id`
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,34 @@ while (page != null) {
}
```

## User tokens

When [enhanced security mode](https://docs.knock.app/in-app-ui/security-and-authentication) is enabled, client-side requests (for example, from an in-app feed or a Slack channel picker) must be authenticated with a user token signed by your Knock signing key. `UserTokens.signUserToken` produces the same RS256 JWT as the Node SDK:

```java
import app.knock.api.lib.Grant;
import app.knock.api.lib.SignUserTokenOptions;
import app.knock.api.lib.TokenEntity;
import app.knock.api.lib.UserTokens;
import java.util.Arrays;

// Uses the `KNOCK_SIGNING_KEY` environment variable and expires in one hour
String token = UserTokens.signUserToken("dnedry");

// Or configure the key, expiry, and grants explicitly
String scopedToken = UserTokens.signUserToken(
"dnedry",
SignUserTokenOptions.builder()
.signingKey(System.getenv("MY_SIGNING_KEY"))
.expiresInSeconds(600)
.addGrant(UserTokens.buildUserTokenGrant(
TokenEntity.ofTenant("jurassic-park"),
Arrays.asList(Grant.SLACK_CHANNELS_READ, Grant.CHANNEL_DATA_READ)))
.build());
```

The signing key can be a PEM or a base64-encoded PEM.

## Logging

The SDK uses the standard [OkHttp logging interceptor](https://github.com/square/okhttp/tree/master/okhttp-logging-interceptor).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import app.knock.api.services.blocking.ScheduleService
import app.knock.api.services.blocking.SharedService
import app.knock.api.services.blocking.TenantService
import app.knock.api.services.blocking.UserService
import app.knock.api.services.blocking.WorkflowRecipientRunService
import app.knock.api.services.blocking.WorkflowService

/**
Expand Down Expand Up @@ -71,6 +72,8 @@ interface KnockClient {

fun audiences(): AudienceService

fun workflowRecipientRuns(): WorkflowRecipientRunService

/**
* Closes this client, relinquishing any underlying resources.
*
Expand Down Expand Up @@ -112,5 +115,7 @@ interface KnockClient {
fun channels(): ChannelService.WithRawResponse

fun audiences(): AudienceService.WithRawResponse

fun workflowRecipientRuns(): WorkflowRecipientRunService.WithRawResponse
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import app.knock.api.services.async.ScheduleServiceAsync
import app.knock.api.services.async.SharedServiceAsync
import app.knock.api.services.async.TenantServiceAsync
import app.knock.api.services.async.UserServiceAsync
import app.knock.api.services.async.WorkflowRecipientRunServiceAsync
import app.knock.api.services.async.WorkflowServiceAsync

/**
Expand Down Expand Up @@ -71,6 +72,8 @@ interface KnockClientAsync {

fun audiences(): AudienceServiceAsync

fun workflowRecipientRuns(): WorkflowRecipientRunServiceAsync

/**
* Closes this client, relinquishing any underlying resources.
*
Expand Down Expand Up @@ -112,5 +115,7 @@ interface KnockClientAsync {
fun channels(): ChannelServiceAsync.WithRawResponse

fun audiences(): AudienceServiceAsync.WithRawResponse

fun workflowRecipientRuns(): WorkflowRecipientRunServiceAsync.WithRawResponse
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ import app.knock.api.services.async.TenantServiceAsync
import app.knock.api.services.async.TenantServiceAsyncImpl
import app.knock.api.services.async.UserServiceAsync
import app.knock.api.services.async.UserServiceAsyncImpl
import app.knock.api.services.async.WorkflowRecipientRunServiceAsync
import app.knock.api.services.async.WorkflowRecipientRunServiceAsyncImpl
import app.knock.api.services.async.WorkflowServiceAsync
import app.knock.api.services.async.WorkflowServiceAsyncImpl

Expand Down Expand Up @@ -98,6 +100,10 @@ class KnockClientAsyncImpl(private val clientOptions: ClientOptions) : KnockClie
AudienceServiceAsyncImpl(clientOptionsWithUserAgent)
}

private val workflowRecipientRuns: WorkflowRecipientRunServiceAsync by lazy {
WorkflowRecipientRunServiceAsyncImpl(clientOptionsWithUserAgent)
}

override fun sync(): KnockClient = sync

override fun withRawResponse(): KnockClientAsync.WithRawResponse = withRawResponse
Expand Down Expand Up @@ -128,6 +134,8 @@ class KnockClientAsyncImpl(private val clientOptions: ClientOptions) : KnockClie

override fun audiences(): AudienceServiceAsync = audiences

override fun workflowRecipientRuns(): WorkflowRecipientRunServiceAsync = workflowRecipientRuns

override fun close() = clientOptions.httpClient.close()

class WithRawResponseImpl internal constructor(private val clientOptions: ClientOptions) :
Expand Down Expand Up @@ -185,6 +193,11 @@ class KnockClientAsyncImpl(private val clientOptions: ClientOptions) : KnockClie
AudienceServiceAsyncImpl.WithRawResponseImpl(clientOptions)
}

private val workflowRecipientRuns:
WorkflowRecipientRunServiceAsync.WithRawResponse by lazy {
WorkflowRecipientRunServiceAsyncImpl.WithRawResponseImpl(clientOptions)
}

override fun shared(): SharedServiceAsync.WithRawResponse = shared

override fun recipients(): RecipientServiceAsync.WithRawResponse = recipients
Expand All @@ -210,5 +223,8 @@ class KnockClientAsyncImpl(private val clientOptions: ClientOptions) : KnockClie
override fun channels(): ChannelServiceAsync.WithRawResponse = channels

override fun audiences(): AudienceServiceAsync.WithRawResponse = audiences

override fun workflowRecipientRuns(): WorkflowRecipientRunServiceAsync.WithRawResponse =
workflowRecipientRuns
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ import app.knock.api.services.blocking.TenantService
import app.knock.api.services.blocking.TenantServiceImpl
import app.knock.api.services.blocking.UserService
import app.knock.api.services.blocking.UserServiceImpl
import app.knock.api.services.blocking.WorkflowRecipientRunService
import app.knock.api.services.blocking.WorkflowRecipientRunServiceImpl
import app.knock.api.services.blocking.WorkflowService
import app.knock.api.services.blocking.WorkflowServiceImpl

Expand Down Expand Up @@ -88,6 +90,10 @@ class KnockClientImpl(private val clientOptions: ClientOptions) : KnockClient {
AudienceServiceImpl(clientOptionsWithUserAgent)
}

private val workflowRecipientRuns: WorkflowRecipientRunService by lazy {
WorkflowRecipientRunServiceImpl(clientOptionsWithUserAgent)
}

override fun async(): KnockClientAsync = async

override fun withRawResponse(): KnockClient.WithRawResponse = withRawResponse
Expand Down Expand Up @@ -118,6 +124,8 @@ class KnockClientImpl(private val clientOptions: ClientOptions) : KnockClient {

override fun audiences(): AudienceService = audiences

override fun workflowRecipientRuns(): WorkflowRecipientRunService = workflowRecipientRuns

override fun close() = clientOptions.httpClient.close()

class WithRawResponseImpl internal constructor(private val clientOptions: ClientOptions) :
Expand Down Expand Up @@ -175,6 +183,10 @@ class KnockClientImpl(private val clientOptions: ClientOptions) : KnockClient {
AudienceServiceImpl.WithRawResponseImpl(clientOptions)
}

private val workflowRecipientRuns: WorkflowRecipientRunService.WithRawResponse by lazy {
WorkflowRecipientRunServiceImpl.WithRawResponseImpl(clientOptions)
}

override fun shared(): SharedService.WithRawResponse = shared

override fun recipients(): RecipientService.WithRawResponse = recipients
Expand All @@ -200,5 +212,8 @@ class KnockClientImpl(private val clientOptions: ClientOptions) : KnockClient {
override fun channels(): ChannelService.WithRawResponse = channels

override fun audiences(): AudienceService.WithRawResponse = audiences

override fun workflowRecipientRuns(): WorkflowRecipientRunService.WithRawResponse =
workflowRecipientRuns
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
@file:JvmName("EmptyHandler")

package app.knock.api.core.handlers

import app.knock.api.core.http.HttpResponse
import app.knock.api.core.http.HttpResponse.Handler

@JvmSynthetic internal fun emptyHandler(): Handler<Void?> = EmptyHandlerInternal

private object EmptyHandlerInternal : Handler<Void?> {
override fun handle(response: HttpResponse): Void? = null
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package app.knock.api.lib

import app.knock.api.core.http.QueryParams
import app.knock.api.models.recipients.RecipientReference

/**
* An element of an array query parameter: either a scalar (e.g. a user ID) or a set of fields (e.g.
* an object reference's `id` and `collection`).
*/
internal sealed class QueryArrayElement {

internal class Value(val value: String) : QueryArrayElement()

internal class Fields(val fields: List<Pair<String, String>>) : QueryArrayElement()
}

/**
* Serializes an array query parameter in the forms the Knock API accepts.
*
* Arrays of scalars use the bracket form (`key[]=a&key[]=b`). Any array containing an object uses
* the indexed form for every element (`key[0]=a&key[1][id]=b&key[1][collection]=c`), because the
* API rejects `key[][id]=...` with a 422.
*/
internal fun QueryParams.Builder.putQueryArray(key: String, elements: List<QueryArrayElement>) {
if (elements.all { it is QueryArrayElement.Value }) {
elements.forEach { put("$key[]", (it as QueryArrayElement.Value).value) }
return
}
elements.forEachIndexed { index, element ->
when (element) {
is QueryArrayElement.Value -> put("$key[$index]", element.value)
is QueryArrayElement.Fields ->
element.fields.forEach { (field, value) -> put("$key[$index][$field]", value) }
}
}
}

/** Serializes a single object-valued query parameter as `key[field]=value`. */
internal fun QueryParams.Builder.putQueryObject(key: String, element: QueryArrayElement) {
when (element) {
is QueryArrayElement.Value -> put(key, element.value)
is QueryArrayElement.Fields ->
element.fields.forEach { (field, value) -> put("$key[$field]", value) }
}
}

internal fun RecipientReference.toQueryArrayElement(): QueryArrayElement =
accept(
object : RecipientReference.Visitor<QueryArrayElement> {
override fun visitUser(user: String) = QueryArrayElement.Value(user)

override fun visitObjectReference(
objectReference: RecipientReference.ObjectReference
): QueryArrayElement {
val fields = mutableListOf<Pair<String, String>>()
objectReference.id().ifPresent { fields.add("id" to it) }
objectReference.collection().ifPresent { fields.add("collection" to it) }
objectReference._additionalProperties().forEach { (key, value) ->
fields.add(key to value.toString())
}
return QueryArrayElement.Fields(fields)
}
}
)
Loading
Loading