From ba99f6faa5519fec9cdaf6b0abc358585c0f636e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Manciot?= Date: Fri, 19 Jun 2026 10:44:53 +0200 Subject: [PATCH 1/4] feat(audit): log failed Stripe webhook events to disk for replay --- .../payment/config/StripeApi.scala | 24 +++++++++++++++++-- .../payment/service/StripeEventHandler.scala | 3 +++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/stripe/src/main/scala/app/softnetwork/payment/config/StripeApi.scala b/stripe/src/main/scala/app/softnetwork/payment/config/StripeApi.scala index ce020ac..cb0d3fa 100644 --- a/stripe/src/main/scala/app/softnetwork/payment/config/StripeApi.scala +++ b/stripe/src/main/scala/app/softnetwork/payment/config/StripeApi.scala @@ -5,11 +5,10 @@ import app.softnetwork.payment.model.SoftPayAccount import app.softnetwork.payment.model.SoftPayAccount.Client.Provider import app.softnetwork.security.sha256 import com.stripe.Stripe -import com.stripe.model.WebhookEndpoint +import com.stripe.model.{Event, WebhookEndpoint} import com.stripe.net.RequestOptions import com.stripe.net.RequestOptions.RequestOptionsBuilder import com.stripe.param.{WebhookEndpointCreateParams, WebhookEndpointListParams} - import org.slf4j.{Logger, LoggerFactory} import java.nio.file.Paths @@ -246,4 +245,25 @@ object StripeApi { addSecret(hash, secret) } } + + /** Log a failed Stripe webhook payload to disk for replaying events that failed to be processed. + * @param payload + * - the raw JSON payload of the webhook event that failed to be processed + */ + def writeFailedEvent(payload: String): Unit = { + Try { + val dir = s"$STRIPE_SECRETS_DIR/failures" + Paths.get(dir).toFile.mkdirs() + val file = Paths.get(dir, s"failed-event-${System.currentTimeMillis()}.json").toFile + file.createNewFile() + val writer = new java.io.BufferedWriter(new java.io.FileWriter(file)) + writer.write(payload) + writer.close() + log.info(s"Wrote failed Stripe webhook event to ${file.getAbsolutePath}") + } match { + case Failure(f) => + log.warn(s"Failed to record Stripe event: ${f.getMessage}") + case _ => + } + } } diff --git a/stripe/src/main/scala/app/softnetwork/payment/service/StripeEventHandler.scala b/stripe/src/main/scala/app/softnetwork/payment/service/StripeEventHandler.scala index 5cc4368..d55a867 100644 --- a/stripe/src/main/scala/app/softnetwork/payment/service/StripeEventHandler.scala +++ b/stripe/src/main/scala/app/softnetwork/payment/service/StripeEventHandler.scala @@ -2,6 +2,7 @@ package app.softnetwork.payment.service import app.softnetwork.concurrent.Completion import app.softnetwork.payment.audit.PaymentAuditLog.audit +import app.softnetwork.payment.config.StripeApi import app.softnetwork.payment.handlers.PaymentHandler import app.softnetwork.payment.message.PaymentMessages.{ CreateOrUpdateKycDocument, @@ -73,6 +74,8 @@ trait StripeEventHandler extends Completion { _: BasicPaymentService with Paymen case Failure(f) => log.error(s"[Payment Hooks] Stripe Webhook verification failed: ${f.getMessage}", f) log.error(payload) + // Save the payload to a file for replaying events that failed to be processed. + StripeApi.writeFailedEvent(payload) None } } From 0ff0e92ebc83cc05032945008a19db7f248e26b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Manciot?= Date: Fri, 19 Jun 2026 12:48:31 +0200 Subject: [PATCH 2/4] feat(audit): update Stripe client configuration to use empty strings instead of null --- stripe/src/main/resources/reference.conf | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/stripe/src/main/resources/reference.conf b/stripe/src/main/resources/reference.conf index ad7b4b9..14761c9 100644 --- a/stripe/src/main/resources/reference.conf +++ b/stripe/src/main/resources/reference.conf @@ -1,8 +1,8 @@ payment { stripe { - client-id: null + client-id: "" client-id: ${?STRIPE_CLIENT_ID} - api-key: null + api-key: "" api-key: ${?STRIPE_API_KEY} baseUrl = "https://api.stripe.com" base-url = ${payment.stripe.baseUrl} From 597b0586e264b49ff8c459831c0036da40e1f0a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Manciot?= Date: Fri, 19 Jun 2026 12:49:46 +0200 Subject: [PATCH 3/4] feat(audit): enhance Stripe webhook management with structured endpoint handling --- .../payment/config/StripeApi.scala | 281 ++++++++++++------ .../payment/service/StripeEventHandler.scala | 9 +- .../service/StripeHooksDirectives.scala | 2 +- .../service/StripeHooksEndpoints.scala | 2 +- 4 files changed, 199 insertions(+), 95 deletions(-) diff --git a/stripe/src/main/scala/app/softnetwork/payment/config/StripeApi.scala b/stripe/src/main/scala/app/softnetwork/payment/config/StripeApi.scala index cb0d3fa..6a92b26 100644 --- a/stripe/src/main/scala/app/softnetwork/payment/config/StripeApi.scala +++ b/stripe/src/main/scala/app/softnetwork/payment/config/StripeApi.scala @@ -5,15 +5,24 @@ import app.softnetwork.payment.model.SoftPayAccount import app.softnetwork.payment.model.SoftPayAccount.Client.Provider import app.softnetwork.security.sha256 import com.stripe.Stripe -import com.stripe.model.{Event, WebhookEndpoint} +import com.stripe.model.WebhookEndpoint import com.stripe.net.RequestOptions import com.stripe.net.RequestOptions.RequestOptionsBuilder -import com.stripe.param.{WebhookEndpointCreateParams, WebhookEndpointListParams} +import com.stripe.param.{ + WebhookEndpointCreateParams, + WebhookEndpointListParams, + WebhookEndpointUpdateParams +} import org.slf4j.{Logger, LoggerFactory} import java.nio.file.Paths import scala.util.{Failure, Success, Try} +case class StripeWebHookEndpoint( + id: String, + secret: String +) + case class StripeApi( private val baseUrl: String, private val clientId: String, @@ -37,7 +46,7 @@ case class StripeApi( def requestOptions(stripeAccount: Option[String] = None): RequestOptions = requestOptionsBuilder(stripeAccount).build() - lazy val secret: Option[String] = StripeApi.loadSecret(hash) + lazy val secret: Option[String] = StripeApi.loadWebHook(hash).map(_.secret) } object StripeApi { @@ -80,35 +89,41 @@ object StripeApi { private[this] var stripeApis: Map[String, StripeApi] = Map.empty - private[this] var stripeWebHooks: Map[String, String] = Map.empty + private[this] var stripeWebHooks: Map[String, StripeWebHookEndpoint] = Map.empty private[this] lazy val STRIPE_SECRETS_DIR: String = s"${SoftPayClientSettings.SP_SECRETS}/stripe" // TODO migrate webhook secrets to encrypted storage (Sealed Secrets / Vault) - private[this] def addSecret(hash: String, secret: String): Unit = { + private[this] def addWebHook(hash: String, secret: String, id: String): Unit = { val dir = s"$STRIPE_SECRETS_DIR/$hash" Paths.get(dir).toFile.mkdirs() val file = Paths.get(dir, "webhook-secret").toFile file.createNewFile() val secretWriter = new java.io.BufferedWriter(new java.io.FileWriter(file)) - secretWriter.write(secret) + secretWriter.write(s"$id=$secret") secretWriter.close() - stripeWebHooks = stripeWebHooks.updated(hash, secret) + stripeWebHooks = stripeWebHooks.updated(hash, StripeWebHookEndpoint(id, secret)) } - private[StripeApi] def loadSecret(hash: String): Option[String] = { + private[StripeApi] def loadWebHook(hash: String): Option[StripeWebHookEndpoint] = { val dir = s"$STRIPE_SECRETS_DIR/$hash" val file = Paths.get(dir, "webhook-secret").toFile log.debug(s"Loading secret from: ${file.getAbsolutePath}") if (file.exists()) { import scala.io.Source val source = Source.fromFile(file) - val secret = source.getLines().mkString + val kv = source.getLines().mkString.trim.split("=") source.close() - stripeWebHooks = stripeWebHooks.updated(hash, secret) - Some(secret) + if (kv.length == 2) { + val id = kv(0).trim + val secret = kv(1).trim + Some(StripeWebHookEndpoint(id, secret)) + } else { + log.error(s"Invalid secret file format: ${file.getAbsolutePath}") + None + } } else { - None + stripeWebHooks.get(hash) } } @@ -121,7 +136,7 @@ object StripeApi { val clientId = provider.providerId val apiKey = provider.providerApiKey - // (re)create stripe webhook endpoint + // (re)create / update stripe webhook endpoint val hash = sha256(provider.clientId) @@ -132,95 +147,175 @@ object StripeApi { val url = s"${config.hooksBaseUrl}?hash=$hash" log.info( - s"Provisioning (delete + recreate) Stripe webhook endpoint for provider ${provider.providerId} at ${config.hooksBaseUrl}?hash=*****" + s"Provisioning (update or delete + recreate) Stripe webhook endpoint for provider ${provider.providerId} at ${config.hooksBaseUrl}?hash=*****" ) import scala.jdk.CollectionConverters._ - Try { - // Stripe returns a webhook endpoint's signing secret ONLY at creation time — never on - // list / retrieve / update, and stripe-java 26.12.0 exposes no roll-secret API. The only - // way to guarantee that the locally stored secret matches the one Stripe actually uses is - // therefore to (re)create the endpoint and capture the secret it returns. So we always - // delete any endpoint already registered for this provider's URL, then create a fresh one. - // The URL carries the per-client hash, so we only ever match — and delete — THIS - // provider's endpoint(s), never another client's. We materialize the matches before - // deleting so paging is not disturbed by the deletions. - WebhookEndpoint - .list( - WebhookEndpointListParams.builder().setLimit(100L).build(), - requestOptions - ) - .autoPagingIterable() - .asScala - .filter(endpoint => Option(endpoint.getUrl).exists(_.contains(url))) - .toList - .foreach { endpoint => + def createWebHookEndpoint(): StripeWebHookEndpoint = { + Try { + WebhookEndpoint + .create( + WebhookEndpointCreateParams + .builder() + .addEnabledEvent( + WebhookEndpointCreateParams.EnabledEvent.ACCOUNT__UPDATED + ) + .addEnabledEvent( + WebhookEndpointCreateParams.EnabledEvent.PERSON__UPDATED + ) + .addEnabledEvent( + WebhookEndpointCreateParams.EnabledEvent.INVOICE__PAYMENT_SUCCEEDED + ) + .addEnabledEvent( + WebhookEndpointCreateParams.EnabledEvent.INVOICE__PAYMENT_FAILED + ) + .addEnabledEvent( + WebhookEndpointCreateParams.EnabledEvent.CUSTOMER__SUBSCRIPTION__DELETED + ) + .addEnabledEvent( + WebhookEndpointCreateParams.EnabledEvent.CUSTOMER__SUBSCRIPTION__UPDATED + ) + .addEnabledEvent( + WebhookEndpointCreateParams.EnabledEvent.CUSTOMER__UPDATED + ) + .addEnabledEvent( + WebhookEndpointCreateParams.EnabledEvent.PAYMENT_METHOD__ATTACHED + ) + .addEnabledEvent( + WebhookEndpointCreateParams.EnabledEvent.PAYMENT_METHOD__DETACHED + ) + .setUrl(url) + .setApiVersion(WebhookEndpointCreateParams.ApiVersion.VERSION_2024_06_20) + // connect=true -> events from connected accounts only; connect=false -> events from + // the platform account (customer.updated, invoice.*, subscription.*, payment_method.*). + // Driven by payment.stripe.connected (default false). NOTE: `connect` is immutable + // after endpoint creation, so flipping this requires deleting the existing endpoint + // so it is recreated. FUTURE: support several webhook endpoints per provider (one per + // scope) — not on the roadmap and not needed by the license-server (single endpoint, + // connected=false, suffices to receive customer.updated for org sync). + .setConnect(config.connected) + .build(), + requestOptions + ) + } match { + case Success(endpoint) => log.info( - s"Deleting existing Stripe webhook endpoint ${endpoint.getId} to refresh its signing secret" + s"Stripe webhook endpoint created for provider ${provider.providerId} at ${config.hooksBaseUrl}?hash=*****" ) - Try(endpoint.delete(requestOptions)) match { - case Failure(f) => - log.warn( - s"Failed to delete Stripe webhook endpoint ${endpoint.getId}: ${f.getMessage}" + StripeWebHookEndpoint(endpoint.getId, endpoint.getSecret) + case Failure(e) => + log.error( + s"Failed to create Stripe webhook endpoint for provider ${provider.providerId} at ${config.hooksBaseUrl}?hash=*****: ${e.getMessage}", + e + ) + throw e + } + } + + (loadWebHook(hash) match { + case Some(stripeWebHook) => + Try { + WebhookEndpoint + .list( + WebhookEndpointListParams.builder().setLimit(100L).build(), + requestOptions + ) + .autoPagingIterable() + .asScala + .filter(endpoint => + Option(endpoint.getUrl).exists(_.contains(url)) && + endpoint.getId == stripeWebHook.id + ) + .toList + .headOption match { + case Some(endpoint) => + log.info( + s"Updating existing Stripe webhook endpoint ${endpoint.getId} for provider ${provider.providerId} at ${config.hooksBaseUrl}?hash=*****" + ) + endpoint.update( + WebhookEndpointUpdateParams + .builder() + .addEnabledEvent( + WebhookEndpointUpdateParams.EnabledEvent.ACCOUNT__UPDATED + ) + .addEnabledEvent( + WebhookEndpointUpdateParams.EnabledEvent.PERSON__UPDATED + ) + .addEnabledEvent( + WebhookEndpointUpdateParams.EnabledEvent.INVOICE__PAYMENT_SUCCEEDED + ) + .addEnabledEvent( + WebhookEndpointUpdateParams.EnabledEvent.INVOICE__PAYMENT_FAILED + ) + .addEnabledEvent( + WebhookEndpointUpdateParams.EnabledEvent.CUSTOMER__SUBSCRIPTION__DELETED + ) + .addEnabledEvent( + WebhookEndpointUpdateParams.EnabledEvent.CUSTOMER__SUBSCRIPTION__UPDATED + ) + .addEnabledEvent( + WebhookEndpointUpdateParams.EnabledEvent.CUSTOMER__UPDATED + ) + .addEnabledEvent( + WebhookEndpointUpdateParams.EnabledEvent.PAYMENT_METHOD__ATTACHED + ) + .addEnabledEvent( + WebhookEndpointUpdateParams.EnabledEvent.PAYMENT_METHOD__DETACHED + ) + .setUrl(url) + .build(), + requestOptions ) + stripeWebHook.copy(id = endpoint.getId) case _ => + createWebHookEndpoint() } } - WebhookEndpoint - .create( - WebhookEndpointCreateParams - .builder() - .addEnabledEvent( - WebhookEndpointCreateParams.EnabledEvent.ACCOUNT__UPDATED - ) - .addEnabledEvent( - WebhookEndpointCreateParams.EnabledEvent.PERSON__UPDATED - ) - .addEnabledEvent( - WebhookEndpointCreateParams.EnabledEvent.INVOICE__PAYMENT_SUCCEEDED - ) - .addEnabledEvent( - WebhookEndpointCreateParams.EnabledEvent.INVOICE__PAYMENT_FAILED - ) - .addEnabledEvent( - WebhookEndpointCreateParams.EnabledEvent.CUSTOMER__SUBSCRIPTION__DELETED - ) - .addEnabledEvent( - WebhookEndpointCreateParams.EnabledEvent.CUSTOMER__SUBSCRIPTION__UPDATED - ) - .addEnabledEvent( - WebhookEndpointCreateParams.EnabledEvent.CUSTOMER__UPDATED + case _ => + Try { + // Stripe returns a webhook endpoint's signing secret ONLY at creation time — never on + // list / retrieve / update, and stripe-java 26.12.0 exposes no roll-secret API. The only + // way to guarantee that the locally stored secret matches the one Stripe actually uses is + // therefore to (re)create the endpoint and capture the secret it returns. So we always + // delete any endpoint already registered for this provider's URL, then create a fresh one. + // The URL carries the per-client hash, so we only ever match — and delete — THIS + // provider's endpoint(s), never another client's. We materialize the matches before + // deleting so paging is not disturbed by the deletions. + WebhookEndpoint + .list( + WebhookEndpointListParams.builder().setLimit(100L).build(), + requestOptions ) - .addEnabledEvent( - WebhookEndpointCreateParams.EnabledEvent.PAYMENT_METHOD__ATTACHED - ) - .addEnabledEvent( - WebhookEndpointCreateParams.EnabledEvent.PAYMENT_METHOD__DETACHED - ) - .setUrl(url) - .setApiVersion(WebhookEndpointCreateParams.ApiVersion.VERSION_2024_06_20) - // connect=true -> events from connected accounts only; connect=false -> events from - // the platform account (customer.updated, invoice.*, subscription.*, payment_method.*). - // Driven by payment.stripe.connected (default false). NOTE: `connect` is immutable - // after endpoint creation, so flipping this requires deleting the existing endpoint - // so it is recreated. FUTURE: support several webhook endpoints per provider (one per - // scope) — not on the roadmap and not needed by the license-server (single endpoint, - // connected=false, suffices to receive customer.updated for org sync). - .setConnect(config.connected) - .build(), - requestOptions - ) - .getSecret + .autoPagingIterable() + .asScala + .filter(endpoint => Option(endpoint.getUrl).exists(_.contains(url))) + .toList + .foreach { endpoint => + log.info( + s"Deleting existing Stripe webhook endpoint ${endpoint.getId} to refresh its signing secret" + ) + Try(endpoint.delete(requestOptions)) match { + case Failure(f) => + log.warn( + s"Failed to delete Stripe webhook endpoint ${endpoint.getId}: ${f.getMessage}" + ) + case _ => + } + } + createWebHookEndpoint() + } - } match { - case Success(secret) => + }) match { + case Success(stripeWebHook) => + log.info( + s"Stripe webhook endpoint for provider ${provider.providerId} already exists and was updated at ${config.hooksBaseUrl}?hash=*****" + ) stripeApis = stripeApis.updated(provider.providerId, stripeApi) - addSecret(hash, secret) + addWebHook(hash, stripeWebHook.secret, stripeWebHook.id) stripeApi case Failure(f) => - Console.err.println(s"Error creating stripe webhook endpoint: ${f.getMessage}") if (apiKey.startsWith("sk_test_")) { // In test mode, we can proceed without a webhook endpoint stripeApis = stripeApis.updated(provider.providerId, stripeApi) stripeApi @@ -228,11 +323,15 @@ object StripeApi { throw f } } + } } + def webHookId(hash: String): Option[String] = + stripeWebHooks.get(hash).orElse(loadWebHook(hash)).map(_.id) + def webHookSecret(hash: String): Option[String] = - stripeWebHooks.get(hash).orElse(loadSecret(hash)) + stripeWebHooks.get(hash).orElse(loadWebHook(hash)).map(_.secret) /** Override the cached webhook secret for a given hash. Only allowed for test API keys * (sk_test_*) — used by test kit when Stripe CLI provides its own signing secret. @@ -241,8 +340,8 @@ object StripeApi { provider: SoftPayAccount.Client.Provider ): Unit = { if (provider.providerApiKey.startsWith("sk_test_")) { - stripeWebHooks = stripeWebHooks.updated(hash, secret) - addSecret(hash, secret) + stripeWebHooks = stripeWebHooks.updated(hash, StripeWebHookEndpoint("we_test", secret)) + addWebHook(hash, secret, "we_test") } } @@ -250,9 +349,9 @@ object StripeApi { * @param payload * - the raw JSON payload of the webhook event that failed to be processed */ - def writeFailedEvent(payload: String): Unit = { + def writeFailedEvent(hash: String, payload: String): Unit = { Try { - val dir = s"$STRIPE_SECRETS_DIR/failures" + val dir = s"$STRIPE_SECRETS_DIR/$hash/failures" Paths.get(dir).toFile.mkdirs() val file = Paths.get(dir, s"failed-event-${System.currentTimeMillis()}.json").toFile file.createNewFile() diff --git a/stripe/src/main/scala/app/softnetwork/payment/service/StripeEventHandler.scala b/stripe/src/main/scala/app/softnetwork/payment/service/StripeEventHandler.scala index d55a867..43fcda2 100644 --- a/stripe/src/main/scala/app/softnetwork/payment/service/StripeEventHandler.scala +++ b/stripe/src/main/scala/app/softnetwork/payment/service/StripeEventHandler.scala @@ -65,7 +65,12 @@ trait StripeEventHandler extends Completion { _: BasicPaymentService with Paymen } } - def toStripeEvent(payload: String, sigHeader: String, secret: String): Option[Event] = { + def toStripeEvent( + hash: String, + payload: String, + sigHeader: String, + secret: String + ): Option[Event] = { Try { Webhook.constructEvent(payload, sigHeader, secret) } match { @@ -75,7 +80,7 @@ trait StripeEventHandler extends Completion { _: BasicPaymentService with Paymen log.error(s"[Payment Hooks] Stripe Webhook verification failed: ${f.getMessage}", f) log.error(payload) // Save the payload to a file for replaying events that failed to be processed. - StripeApi.writeFailedEvent(payload) + StripeApi.writeFailedEvent(hash, payload) None } } diff --git a/stripe/src/main/scala/app/softnetwork/payment/service/StripeHooksDirectives.scala b/stripe/src/main/scala/app/softnetwork/payment/service/StripeHooksDirectives.scala index 29152e9..919620f 100644 --- a/stripe/src/main/scala/app/softnetwork/payment/service/StripeHooksDirectives.scala +++ b/stripe/src/main/scala/app/softnetwork/payment/service/StripeHooksDirectives.scala @@ -17,7 +17,7 @@ trait StripeHooksDirectives extends HooksDirectives with PaymentHandler with Str if (log.isDebugEnabled) { log.debug(s"[Payment Hooks] Stripe Webhook received: $payload") } - toStripeEvent(payload, signature, secret) match { + toStripeEvent(hash, payload, signature, secret) match { case Some(event) => handleStripeEvent(event) log.info( diff --git a/stripe/src/main/scala/app/softnetwork/payment/service/StripeHooksEndpoints.scala b/stripe/src/main/scala/app/softnetwork/payment/service/StripeHooksEndpoints.scala index 1427b84..9c82d2f 100644 --- a/stripe/src/main/scala/app/softnetwork/payment/service/StripeHooksEndpoints.scala +++ b/stripe/src/main/scala/app/softnetwork/payment/service/StripeHooksEndpoints.scala @@ -26,7 +26,7 @@ trait StripeHooksEndpoints extends HooksEndpoints with PaymentHandler with Strip if (log.isDebugEnabled) { log.debug(s"[Payment Hooks] Stripe Webhook received: $payload") } - toStripeEvent(payload, signature, secret) match { + toStripeEvent(hash, payload, signature, secret) match { case Some(event) => handleStripeEvent(event) Future.successful(Right(())) From 124fa0c48173aa707301ee3518b16c98da2754c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Manciot?= Date: Fri, 19 Jun 2026 13:10:18 +0200 Subject: [PATCH 4/4] feat(audit): improve Stripe webhook logging and enhance key-value parsing --- .../main/scala/app/softnetwork/payment/config/StripeApi.scala | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/stripe/src/main/scala/app/softnetwork/payment/config/StripeApi.scala b/stripe/src/main/scala/app/softnetwork/payment/config/StripeApi.scala index 6a92b26..ff84fbe 100644 --- a/stripe/src/main/scala/app/softnetwork/payment/config/StripeApi.scala +++ b/stripe/src/main/scala/app/softnetwork/payment/config/StripeApi.scala @@ -112,7 +112,7 @@ object StripeApi { if (file.exists()) { import scala.io.Source val source = Source.fromFile(file) - val kv = source.getLines().mkString.trim.split("=") + val kv = source.getLines().mkString.trim.split("=", 2) source.close() if (kv.length == 2) { val id = kv(0).trim @@ -310,7 +310,7 @@ object StripeApi { }) match { case Success(stripeWebHook) => log.info( - s"Stripe webhook endpoint for provider ${provider.providerId} already exists and was updated at ${config.hooksBaseUrl}?hash=*****" + s"Stripe webhook endpoint ${stripeWebHook.id} ready for provider ${provider.providerId} at ${config.hooksBaseUrl}?hash=*****" ) stripeApis = stripeApis.updated(provider.providerId, stripeApi) addWebHook(hash, stripeWebHook.secret, stripeWebHook.id)