diff --git a/.release-please-manifest.json b/.release-please-manifest.json
index a7230d955..92be4f76e 100644
--- a/.release-please-manifest.json
+++ b/.release-please-manifest.json
@@ -1,3 +1,3 @@
{
- ".": "0.424.0"
+ ".": "0.425.0"
}
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 25d8b3fb0..fbcb1d502 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,13 @@
# Changelog
+## 0.425.0 (2026-02-06)
+
+Full Changelog: [v0.424.0...v0.425.0](https://github.com/Increase/increase-java/compare/v0.424.0...v0.425.0)
+
+### Features
+
+* **api:** add webhook signature verification ([ede3f4c](https://github.com/Increase/increase-java/commit/ede3f4cc63647edcebf4426df3198a6c2a27eda6))
+
## 0.424.0 (2026-02-06)
Full Changelog: [v0.423.0...v0.424.0](https://github.com/Increase/increase-java/compare/v0.423.0...v0.424.0)
diff --git a/README.md b/README.md
index c816b1153..9c832c37c 100644
--- a/README.md
+++ b/README.md
@@ -2,8 +2,8 @@
-[](https://central.sonatype.com/artifact/com.increase.api/increase-java/0.424.0)
-[](https://javadoc.io/doc/com.increase.api/increase-java/0.424.0)
+[](https://central.sonatype.com/artifact/com.increase.api/increase-java/0.425.0)
+[](https://javadoc.io/doc/com.increase.api/increase-java/0.425.0)
@@ -13,7 +13,7 @@ The Increase Java SDK is similar to the Increase Kotlin SDK but with minor diffe
-The REST API documentation can be found on [increase.com](https://increase.com/documentation). Javadocs are available on [javadoc.io](https://javadoc.io/doc/com.increase.api/increase-java/0.424.0).
+The REST API documentation can be found on [increase.com](https://increase.com/documentation). Javadocs are available on [javadoc.io](https://javadoc.io/doc/com.increase.api/increase-java/0.425.0).
@@ -24,7 +24,7 @@ The REST API documentation can be found on [increase.com](https://increase.com/d
### Gradle
```kotlin
-implementation("com.increase.api:increase-java:0.424.0")
+implementation("com.increase.api:increase-java:0.425.0")
```
### Maven
@@ -33,7 +33,7 @@ implementation("com.increase.api:increase-java:0.424.0")
com.increase.api
increase-java
- 0.424.0
+ 0.425.0
```
diff --git a/build.gradle.kts b/build.gradle.kts
index de8a27201..1ccde6b35 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -8,7 +8,7 @@ repositories {
allprojects {
group = "com.increase.api"
- version = "0.424.0" // x-release-please-version
+ version = "0.425.0" // x-release-please-version
}
subprojects {
diff --git a/increase-java-core/build.gradle.kts b/increase-java-core/build.gradle.kts
index e5f90d187..8f3caebe6 100644
--- a/increase-java-core/build.gradle.kts
+++ b/increase-java-core/build.gradle.kts
@@ -22,6 +22,7 @@ dependencies {
api("com.fasterxml.jackson.core:jackson-core:2.18.2")
api("com.fasterxml.jackson.core:jackson-databind:2.18.2")
api("com.google.errorprone:error_prone_annotations:2.33.0")
+ api("com.standardwebhooks:standardwebhooks:1.1.0")
implementation("com.fasterxml.jackson.core:jackson-annotations:2.18.2")
implementation("com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.18.2")
diff --git a/increase-java-core/src/main/kotlin/com/increase/api/core/UnwrapWebhookParams.kt b/increase-java-core/src/main/kotlin/com/increase/api/core/UnwrapWebhookParams.kt
new file mode 100644
index 000000000..57e03c068
--- /dev/null
+++ b/increase-java-core/src/main/kotlin/com/increase/api/core/UnwrapWebhookParams.kt
@@ -0,0 +1,102 @@
+// File generated from our OpenAPI spec by Stainless.
+
+package com.increase.api.core
+
+import com.increase.api.core.http.Headers
+import java.util.Objects
+import java.util.Optional
+import kotlin.jvm.optionals.getOrNull
+
+class UnwrapWebhookParams
+private constructor(
+ private val body: String,
+ private val headers: Headers?,
+ private val secret: String?,
+) {
+
+ /** The raw JSON body of the webhook request. */
+ fun body(): String = body
+
+ /** The headers from the webhook request. */
+ fun headers(): Optional = Optional.ofNullable(headers)
+
+ /** The secret used to verify the webhook signature. */
+ fun secret(): Optional = Optional.ofNullable(secret)
+
+ fun toBuilder() = Builder().from(this)
+
+ companion object {
+
+ /**
+ * Returns a mutable builder for constructing an instance of [UnwrapWebhookParams].
+ *
+ * The following fields are required:
+ * ```java
+ * .body()
+ * ```
+ */
+ @JvmStatic fun builder() = Builder()
+ }
+
+ /** A builder for [UnwrapWebhookParams]. */
+ class Builder internal constructor() {
+
+ private var body: String? = null
+ private var headers: Headers? = null
+ private var secret: String? = null
+
+ @JvmSynthetic
+ internal fun from(unwrapWebhookParams: UnwrapWebhookParams) = apply {
+ body = unwrapWebhookParams.body
+ headers = unwrapWebhookParams.headers
+ secret = unwrapWebhookParams.secret
+ }
+
+ /** The raw JSON body of the webhook request. */
+ fun body(body: String) = apply { this.body = body }
+
+ /** The headers from the webhook request. */
+ fun headers(headers: Headers?) = apply { this.headers = headers }
+
+ /** Alias for calling [Builder.headers] with `headers.orElse(null)`. */
+ fun headers(headers: Optional) = headers(headers.getOrNull())
+
+ /** The secret used to verify the webhook signature. */
+ fun secret(secret: String?) = apply { this.secret = secret }
+
+ /** Alias for calling [Builder.secret] with `secret.orElse(null)`. */
+ fun secret(secret: Optional) = secret(secret.getOrNull())
+
+ /**
+ * Returns an immutable instance of [UnwrapWebhookParams].
+ *
+ * Further updates to this [Builder] will not mutate the returned instance.
+ *
+ * The following fields are required:
+ * ```java
+ * .body()
+ * ```
+ *
+ * @throws IllegalStateException if any required field is unset.
+ */
+ fun build(): UnwrapWebhookParams =
+ UnwrapWebhookParams(checkRequired("body", body), headers, secret)
+ }
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) {
+ return true
+ }
+
+ return other is UnwrapWebhookParams &&
+ body == other.body &&
+ headers == other.headers &&
+ secret == other.secret
+ }
+
+ private val hashCode: Int by lazy { Objects.hash(body, headers, secret) }
+
+ override fun hashCode(): Int = hashCode
+
+ override fun toString() = "UnwrapWebhookParams{body=$body, headers=$headers, secret=$secret}"
+}
diff --git a/increase-java-core/src/main/kotlin/com/increase/api/errors/IncreaseWebhookException.kt b/increase-java-core/src/main/kotlin/com/increase/api/errors/IncreaseWebhookException.kt
new file mode 100644
index 000000000..71c91e06b
--- /dev/null
+++ b/increase-java-core/src/main/kotlin/com/increase/api/errors/IncreaseWebhookException.kt
@@ -0,0 +1,5 @@
+package com.increase.api.errors
+
+class IncreaseWebhookException
+@JvmOverloads
+constructor(message: String? = null, cause: Throwable? = null) : IncreaseException(message, cause)
diff --git a/increase-java-core/src/main/kotlin/com/increase/api/services/async/EventServiceAsync.kt b/increase-java-core/src/main/kotlin/com/increase/api/services/async/EventServiceAsync.kt
index 6dbc606a4..73a7b74b7 100644
--- a/increase-java-core/src/main/kotlin/com/increase/api/services/async/EventServiceAsync.kt
+++ b/increase-java-core/src/main/kotlin/com/increase/api/services/async/EventServiceAsync.kt
@@ -4,8 +4,10 @@ package com.increase.api.services.async
import com.increase.api.core.ClientOptions
import com.increase.api.core.RequestOptions
+import com.increase.api.core.UnwrapWebhookParams
import com.increase.api.core.http.HttpResponseFor
import com.increase.api.errors.IncreaseInvalidDataException
+import com.increase.api.errors.IncreaseWebhookException
import com.increase.api.models.events.Event
import com.increase.api.models.events.EventListPageAsync
import com.increase.api.models.events.EventListParams
@@ -85,6 +87,14 @@ interface EventServiceAsync {
*/
fun unwrap(body: String): UnwrapWebhookEvent
+ /**
+ * Unwraps a webhook event from its JSON representation.
+ *
+ * @throws IncreaseInvalidDataException if the body could not be parsed.
+ * @throws IncreaseWebhookException if the webhook signature could not be verified
+ */
+ fun unwrap(unwrapParams: UnwrapWebhookParams): UnwrapWebhookEvent
+
/** A view of [EventServiceAsync] that provides access to raw HTTP responses for each method. */
interface WithRawResponse {
diff --git a/increase-java-core/src/main/kotlin/com/increase/api/services/async/EventServiceAsyncImpl.kt b/increase-java-core/src/main/kotlin/com/increase/api/services/async/EventServiceAsyncImpl.kt
index 3c4229241..8bc419796 100644
--- a/increase-java-core/src/main/kotlin/com/increase/api/services/async/EventServiceAsyncImpl.kt
+++ b/increase-java-core/src/main/kotlin/com/increase/api/services/async/EventServiceAsyncImpl.kt
@@ -4,6 +4,7 @@ package com.increase.api.services.async
import com.increase.api.core.ClientOptions
import com.increase.api.core.RequestOptions
+import com.increase.api.core.UnwrapWebhookParams
import com.increase.api.core.checkRequired
import com.increase.api.core.handlers.errorBodyHandler
import com.increase.api.core.handlers.errorHandler
@@ -15,7 +16,6 @@ import com.increase.api.core.http.HttpResponse.Handler
import com.increase.api.core.http.HttpResponseFor
import com.increase.api.core.http.parseable
import com.increase.api.core.prepareAsync
-import com.increase.api.errors.IncreaseInvalidDataException
import com.increase.api.models.events.Event
import com.increase.api.models.events.EventListPageAsync
import com.increase.api.models.events.EventListPageResponse
@@ -53,14 +53,12 @@ class EventServiceAsyncImpl internal constructor(private val clientOptions: Clie
// get /events
withRawResponse().list(params, requestOptions).thenApply { it.parse() }
- /**
- * Unwraps a webhook event from its JSON representation.
- *
- * @throws IncreaseInvalidDataException if the body could not be parsed.
- */
override fun unwrap(body: String): UnwrapWebhookEvent =
EventServiceImpl(clientOptions).unwrap(body)
+ override fun unwrap(unwrapParams: UnwrapWebhookParams): UnwrapWebhookEvent =
+ EventServiceImpl(clientOptions).unwrap(unwrapParams)
+
class WithRawResponseImpl internal constructor(private val clientOptions: ClientOptions) :
EventServiceAsync.WithRawResponse {
diff --git a/increase-java-core/src/main/kotlin/com/increase/api/services/blocking/EventService.kt b/increase-java-core/src/main/kotlin/com/increase/api/services/blocking/EventService.kt
index 737406268..ed0f3edfa 100644
--- a/increase-java-core/src/main/kotlin/com/increase/api/services/blocking/EventService.kt
+++ b/increase-java-core/src/main/kotlin/com/increase/api/services/blocking/EventService.kt
@@ -5,8 +5,10 @@ package com.increase.api.services.blocking
import com.google.errorprone.annotations.MustBeClosed
import com.increase.api.core.ClientOptions
import com.increase.api.core.RequestOptions
+import com.increase.api.core.UnwrapWebhookParams
import com.increase.api.core.http.HttpResponseFor
import com.increase.api.errors.IncreaseInvalidDataException
+import com.increase.api.errors.IncreaseWebhookException
import com.increase.api.models.events.Event
import com.increase.api.models.events.EventListPage
import com.increase.api.models.events.EventListParams
@@ -79,6 +81,14 @@ interface EventService {
*/
fun unwrap(body: String): UnwrapWebhookEvent
+ /**
+ * Unwraps a webhook event from its JSON representation.
+ *
+ * @throws IncreaseInvalidDataException if the body could not be parsed.
+ * @throws IncreaseWebhookException if the webhook signature could not be verified
+ */
+ fun unwrap(unwrapParams: UnwrapWebhookParams): UnwrapWebhookEvent
+
/** A view of [EventService] that provides access to raw HTTP responses for each method. */
interface WithRawResponse {
diff --git a/increase-java-core/src/main/kotlin/com/increase/api/services/blocking/EventServiceImpl.kt b/increase-java-core/src/main/kotlin/com/increase/api/services/blocking/EventServiceImpl.kt
index 59c35d52b..67b8ae9a7 100644
--- a/increase-java-core/src/main/kotlin/com/increase/api/services/blocking/EventServiceImpl.kt
+++ b/increase-java-core/src/main/kotlin/com/increase/api/services/blocking/EventServiceImpl.kt
@@ -5,6 +5,7 @@ package com.increase.api.services.blocking
import com.fasterxml.jackson.module.kotlin.jacksonTypeRef
import com.increase.api.core.ClientOptions
import com.increase.api.core.RequestOptions
+import com.increase.api.core.UnwrapWebhookParams
import com.increase.api.core.checkRequired
import com.increase.api.core.handlers.errorBodyHandler
import com.increase.api.core.handlers.errorHandler
@@ -17,12 +18,15 @@ import com.increase.api.core.http.HttpResponseFor
import com.increase.api.core.http.parseable
import com.increase.api.core.prepare
import com.increase.api.errors.IncreaseInvalidDataException
+import com.increase.api.errors.IncreaseWebhookException
import com.increase.api.models.events.Event
import com.increase.api.models.events.EventListPage
import com.increase.api.models.events.EventListPageResponse
import com.increase.api.models.events.EventListParams
import com.increase.api.models.events.EventRetrieveParams
import com.increase.api.models.events.UnwrapWebhookEvent
+import com.standardwebhooks.Webhook
+import com.standardwebhooks.exceptions.WebhookVerificationException
import java.util.function.Consumer
import kotlin.jvm.optionals.getOrNull
@@ -46,11 +50,6 @@ class EventServiceImpl internal constructor(private val clientOptions: ClientOpt
// get /events
withRawResponse().list(params, requestOptions).parse()
- /**
- * Unwraps a webhook event from its JSON representation.
- *
- * @throws IncreaseInvalidDataException if the body could not be parsed.
- */
override fun unwrap(body: String): UnwrapWebhookEvent =
try {
clientOptions.jsonMapper.readValue(body, jacksonTypeRef())
@@ -58,6 +57,28 @@ class EventServiceImpl internal constructor(private val clientOptions: ClientOpt
throw IncreaseInvalidDataException("Error parsing body", e)
}
+ override fun unwrap(unwrapParams: UnwrapWebhookParams): UnwrapWebhookEvent {
+ val headers = unwrapParams.headers().getOrNull()
+ if (headers != null) {
+ try {
+ val webhookSecret =
+ checkRequired(
+ "webhookSecret",
+ unwrapParams.secret().getOrNull()
+ ?: clientOptions.webhookSecret().getOrNull(),
+ )
+ val headersMap =
+ headers.names().associateWith { name -> headers.values(name) }.toMap()
+
+ val webhook = Webhook(webhookSecret)
+ webhook.verify(unwrapParams.body(), headersMap)
+ } catch (e: WebhookVerificationException) {
+ throw IncreaseWebhookException("Could not verify webhook event signature", e)
+ }
+ }
+ return unwrap(unwrapParams.body())
+ }
+
class WithRawResponseImpl internal constructor(private val clientOptions: ClientOptions) :
EventService.WithRawResponse {
diff --git a/increase-java-core/src/test/kotlin/com/increase/api/services/async/EventServiceAsyncTest.kt b/increase-java-core/src/test/kotlin/com/increase/api/services/async/EventServiceAsyncTest.kt
index 8cb73f7e3..4c761a5b8 100644
--- a/increase-java-core/src/test/kotlin/com/increase/api/services/async/EventServiceAsyncTest.kt
+++ b/increase-java-core/src/test/kotlin/com/increase/api/services/async/EventServiceAsyncTest.kt
@@ -4,7 +4,13 @@ package com.increase.api.services.async
import com.increase.api.TestServerExtension
import com.increase.api.client.okhttp.IncreaseOkHttpClientAsync
+import com.increase.api.core.UnwrapWebhookParams
+import com.increase.api.core.http.Headers
+import com.increase.api.errors.IncreaseWebhookException
+import com.standardwebhooks.Webhook
+import java.time.Instant
import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.assertThrows
import org.junit.jupiter.api.extension.ExtendWith
@ExtendWith(TestServerExtension::class)
@@ -39,4 +45,84 @@ internal class EventServiceAsyncTest {
val page = pageFuture.get()
page.response().validate()
}
+
+ @Test
+ fun unwrap() {
+ val client =
+ IncreaseOkHttpClientAsync.builder()
+ .baseUrl(TestServerExtension.BASE_URL)
+ .apiKey("My API Key")
+ .build()
+ val eventServiceAsync = client.events()
+
+ val payload =
+ "{\"id\":\"event_001dzz0r20rzr4zrhrr1364hy80\",\"associated_object_id\":\"account_in71c4amph0vgo2qllky\",\"associated_object_type\":\"account\",\"category\":\"account.created\",\"created_at\":\"2020-01-31T23:59:59Z\",\"type\":\"event\"}"
+ val webhookSecret = "whsec_c2VjcmV0Cg=="
+ val messageId = "1"
+ val timestampSeconds = Instant.now().epochSecond
+ val webhook = Webhook(webhookSecret)
+ val signature = webhook.sign(messageId, timestampSeconds, payload)
+ val headers =
+ Headers.builder()
+ .putAll(
+ mapOf(
+ "webhook-signature" to listOf(signature),
+ "webhook-id" to listOf(messageId),
+ "webhook-timestamp" to listOf(timestampSeconds.toString()),
+ )
+ )
+ .build()
+
+ eventServiceAsync.unwrap(payload).validate()
+
+ // Wrong key should throw
+ assertThrows {
+ val wrongKey = "whsec_aaaaaaaaaa"
+ eventServiceAsync.unwrap(
+ UnwrapWebhookParams.builder()
+ .body(payload)
+ .headers(headers)
+ .secret(wrongKey)
+ .build()
+ )
+ }
+
+ // Bad signature should throw
+ assertThrows {
+ val badSig = webhook.sign(messageId, timestampSeconds, "some other payload")
+ val badHeaders =
+ headers.toBuilder().replace("webhook-signature", listOf(badSig)).build()
+ eventServiceAsync.unwrap(
+ UnwrapWebhookParams.builder()
+ .body(payload)
+ .headers(badHeaders)
+ .secret(webhookSecret)
+ .build()
+ )
+ }
+
+ // Old timestamp should throw
+ assertThrows {
+ val oldHeaders = headers.toBuilder().replace("webhook-timestamp", listOf("5")).build()
+ eventServiceAsync.unwrap(
+ UnwrapWebhookParams.builder()
+ .body(payload)
+ .headers(oldHeaders)
+ .secret(webhookSecret)
+ .build()
+ )
+ }
+
+ // Wrong message ID should throw
+ assertThrows {
+ val wrongIdHeaders = headers.toBuilder().replace("webhook-id", listOf("wrong")).build()
+ eventServiceAsync.unwrap(
+ UnwrapWebhookParams.builder()
+ .body(payload)
+ .headers(wrongIdHeaders)
+ .secret(webhookSecret)
+ .build()
+ )
+ }
+ }
}
diff --git a/increase-java-core/src/test/kotlin/com/increase/api/services/blocking/EventServiceTest.kt b/increase-java-core/src/test/kotlin/com/increase/api/services/blocking/EventServiceTest.kt
index b3cabcbbb..f263fd80c 100644
--- a/increase-java-core/src/test/kotlin/com/increase/api/services/blocking/EventServiceTest.kt
+++ b/increase-java-core/src/test/kotlin/com/increase/api/services/blocking/EventServiceTest.kt
@@ -4,7 +4,13 @@ package com.increase.api.services.blocking
import com.increase.api.TestServerExtension
import com.increase.api.client.okhttp.IncreaseOkHttpClient
+import com.increase.api.core.UnwrapWebhookParams
+import com.increase.api.core.http.Headers
+import com.increase.api.errors.IncreaseWebhookException
+import com.standardwebhooks.Webhook
+import java.time.Instant
import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.assertThrows
import org.junit.jupiter.api.extension.ExtendWith
@ExtendWith(TestServerExtension::class)
@@ -37,4 +43,84 @@ internal class EventServiceTest {
page.response().validate()
}
+
+ @Test
+ fun unwrap() {
+ val client =
+ IncreaseOkHttpClient.builder()
+ .baseUrl(TestServerExtension.BASE_URL)
+ .apiKey("My API Key")
+ .build()
+ val eventService = client.events()
+
+ val payload =
+ "{\"id\":\"event_001dzz0r20rzr4zrhrr1364hy80\",\"associated_object_id\":\"account_in71c4amph0vgo2qllky\",\"associated_object_type\":\"account\",\"category\":\"account.created\",\"created_at\":\"2020-01-31T23:59:59Z\",\"type\":\"event\"}"
+ val webhookSecret = "whsec_c2VjcmV0Cg=="
+ val messageId = "1"
+ val timestampSeconds = Instant.now().epochSecond
+ val webhook = Webhook(webhookSecret)
+ val signature = webhook.sign(messageId, timestampSeconds, payload)
+ val headers =
+ Headers.builder()
+ .putAll(
+ mapOf(
+ "webhook-signature" to listOf(signature),
+ "webhook-id" to listOf(messageId),
+ "webhook-timestamp" to listOf(timestampSeconds.toString()),
+ )
+ )
+ .build()
+
+ eventService.unwrap(payload).validate()
+
+ // Wrong key should throw
+ assertThrows {
+ val wrongKey = "whsec_aaaaaaaaaa"
+ eventService.unwrap(
+ UnwrapWebhookParams.builder()
+ .body(payload)
+ .headers(headers)
+ .secret(wrongKey)
+ .build()
+ )
+ }
+
+ // Bad signature should throw
+ assertThrows {
+ val badSig = webhook.sign(messageId, timestampSeconds, "some other payload")
+ val badHeaders =
+ headers.toBuilder().replace("webhook-signature", listOf(badSig)).build()
+ eventService.unwrap(
+ UnwrapWebhookParams.builder()
+ .body(payload)
+ .headers(badHeaders)
+ .secret(webhookSecret)
+ .build()
+ )
+ }
+
+ // Old timestamp should throw
+ assertThrows {
+ val oldHeaders = headers.toBuilder().replace("webhook-timestamp", listOf("5")).build()
+ eventService.unwrap(
+ UnwrapWebhookParams.builder()
+ .body(payload)
+ .headers(oldHeaders)
+ .secret(webhookSecret)
+ .build()
+ )
+ }
+
+ // Wrong message ID should throw
+ assertThrows {
+ val wrongIdHeaders = headers.toBuilder().replace("webhook-id", listOf("wrong")).build()
+ eventService.unwrap(
+ UnwrapWebhookParams.builder()
+ .body(payload)
+ .headers(wrongIdHeaders)
+ .secret(webhookSecret)
+ .build()
+ )
+ }
+ }
}