diff --git a/CHANGELOG.md b/CHANGELOG.md index f97e9df9cc7..765a923ac62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,46 @@ This release changes the pinned API version to 2026-07-29.preview. * Add support for `payout` on `v2.moneymanagement.ReceivedCredit.balance_transfer` * ⚠️ Remove support for `payoutV1` on `v2.moneymanagement.ReceivedCredit.balance_transfer` +## 33.2.0 - 2026-07-29 +This release changes the pinned API version to 2026-07-29.dahlia. + +* [#2257](https://github.com/stripe/stripe-java/pull/2257) Update generated code + * Add support for new resource `financialconnections.Authorization` + * Add support for `unreject` method on resource `Account` + * Add support for `list` method on resource `PaymentRecord` + * Add support for `smartDisputesManagement` on `AccountSession.components.disputes_list.features`, `AccountSession.components.payment_details.features`, `AccountSession.components.payment_disputes.features`, `AccountSession.components.payments.features`, `AccountSessionCreateParams.components.disputes_list.features`, `AccountSessionCreateParams.components.payment_details.features`, `AccountSessionCreateParams.components.payment_disputes.features`, and `AccountSessionCreateParams.components.payments.features` + * Add support for `administrativeAddress` and `principalPlaceOfBusiness` on `Account.company`, `AccountCreateParams.company`, `AccountUpdateParams.company`, and `TokenCreateParams.account.company` + * Add support for `sepaDebitPayments` on `AccountUpdateParams.settings` + * Remove support for `proofOfRegistration` on `AccountCreateParams.documents`. This field was limited-use and is being deprecated. + * Add support for `payoutsAction` on `AccountRejectParams` + * Remove support for `dynamicTaxRates` on `checkout.SessionCreateParams.line_items[]`. This field was limited-use and is being deprecated. + * Add support for `setupFutureUsage` on `PaymentIntent.payment_method_options.payco`, `PaymentIntent.payment_method_options.samsung_pay`, `PaymentIntentConfirmParams.payment_method_options.payco`, `PaymentIntentConfirmParams.payment_method_options.samsung_pay`, `PaymentIntentCreateParams.payment_method_options.payco`, `PaymentIntentCreateParams.payment_method_options.samsung_pay`, `PaymentIntentUpdateParams.payment_method_options.payco`, `PaymentIntentUpdateParams.payment_method_options.samsung_pay`, `PaymentLinkUpdateParams.payment_intent_data`, `checkout.Session.payment_method_options.payco`, `checkout.Session.payment_method_options.samsung_pay`, `checkout.SessionCreateParams.payment_method_options.payco`, and `checkout.SessionCreateParams.payment_method_options.samsung_pay` + * Add support for new values `bnp_paribas`, `citibank`, and `mbsb_bank` on enums `ConfirmationTokenCreateParams.payment_method_data.fpx.bank`, `PaymentIntentConfirmParams.payment_method_data.fpx.bank`, `PaymentIntentCreateParams.payment_method_data.fpx.bank`, `PaymentIntentUpdateParams.payment_method_data.fpx.bank`, `PaymentMethodCreateParams.fpx.bank`, `SetupIntentConfirmParams.payment_method_data.fpx.bank`, `SetupIntentCreateParams.payment_method_data.fpx.bank`, and `SetupIntentUpdateParams.payment_method_data.fpx.bank` + * Add support for new value `ic_nif` on enums `CustomerCreateParams.tax_id_data[].type`, `InvoiceCreatePreviewParams.customer_details.tax_ids[].type`, `TaxIdCreateParams.type`, and `tax.CalculationCreateParams.customer_details.tax_ids[].type` + * Add support for `network` on `Dispute.payment_method_details.card` + * Add support for `limits` and `manualEntry` on `financialconnections.SessionCreateParams` and `financialconnections.Session` + * Add support for `requirePaymentMethodSupport` on `financialconnections.Session.filters` and `financialconnections.SessionCreateParams.filters` + * Add support for `bankAccountToken` on `financialconnections.Session` + * Add support for new values `alipay` and `mb_way` on enums `InvoiceCreateParams.payment_settings.paymentMethodTypes`, `InvoiceUpdateParams.payment_settings.paymentMethodTypes`, `SubscriptionCreateParams.payment_settings.paymentMethodTypes`, and `SubscriptionUpdateParams.payment_settings.paymentMethodTypes` + * Add support for new values `mass_transit_parking_tax` and `parking_tax` on enums `InvoiceAddLinesParams.lines[].tax_amounts[].tax_rate_data.taxType`, `InvoiceLineItemUpdateParams.tax_amounts[].tax_rate_data.taxType`, `InvoiceUpdateLinesParams.lines[].tax_amounts[].tax_rate_data.taxType`, `TaxRateCreateParams.taxType`, and `TaxRateUpdateParams.taxType` + * Add support for `metadata` on `InvoiceCreatePreviewParams.subscription_details` + * Add support for `businessName` on `issuing.Card.shipping`, `issuing.CardCreateParams.shipping`, and `issuing.CardUpdateParams.shipping` + * Add support for `allowedPaymentMethodTypes` on `PaymentIntentConfirmParams`, `PaymentIntentCreateParams`, `PaymentIntentUpdateParams`, `PaymentIntent`, `SetupIntentConfirmParams`, `SetupIntentCreateParams`, `SetupIntentUpdateParams`, and `SetupIntent` + * Add support for `referrer` on `PaymentIntentConfirmParams.radar_options` and `PaymentIntentCreateParams.radar_options` + * Add support for `consentCollection` and `shippingOptions` on `PaymentLinkUpdateParams` + * Add support for `customFields`, `description`, and `footer` on `Quote.invoice_settings`, `QuoteCreateParams.invoice_settings`, `QuoteUpdateParams.invoice_settings`, `SubscriptionSchedule.default_settings.invoice_settings`, `SubscriptionSchedule.phases[].invoice_settings`, `SubscriptionScheduleCreateParams.default_settings.invoice_settings`, `SubscriptionScheduleCreateParams.phases[].invoice_settings`, `SubscriptionScheduleUpdateParams.default_settings.invoice_settings`, and `SubscriptionScheduleUpdateParams.phases[].invoice_settings` + * Add support for `customerAccount` and `customer` on `Refund` + * Add support for `paymentMethod` on `Refund` and `Topup` + * Add support for `trial` on `SubscriptionSchedule.phases[]` + * Add support for `massTransitParkingTax` and `parkingTax` on `tax.Registration.country_options.us` and `tax.RegistrationCreateParams.country_options.us` + * Add support for new values `mass_transit_parking_tax` and `parking_tax` on enum `tax.RegistrationCreateParams.country_options.us.type` + * Add support for `initiatedBy` and `paymentMethodOptions` on `Topup` + * Add support for new values `financial_connections.account.expected_deactivation_date_updated`, `financial_connections.account.supported_payment_method_types_updated`, `financial_connections.account.upcoming_deactivation`, `financial_connections.authorization.expected_deactivation_date_updated`, and `financial_connections.authorization.upcoming_deactivation` on enums `WebhookEndpointCreateParams.enabledEvents` and `WebhookEndpointUpdateParams.enabledEvents` + * Add support for new value `2026-07-29.dahlia` on enum `WebhookEndpointCreateParams.apiVersion` + * Add support for `additionalAddresses` on `v2.core.Account.identity.business_details`, `v2.core.AccountCreateParams.identity.business_details`, `v2.core.AccountTokenCreateParams.identity.business_details`, and `v2.core.AccountUpdateParams.identity.business_details` + * Add support for snapshot events `financial_connections.account.expected_deactivation_date_updated`, `financial_connections.account.supported_payment_method_types_updated`, and `financial_connections.account.upcoming_deactivation` with resource `financialconnections.Account` + * Add support for snapshot events `financial_connections.authorization.expected_deactivation_date_updated` and `financial_connections.authorization.upcoming_deactivation` with resource `financialconnections.Authorization` + ## 33.2.0-beta.1 - 2026-06-24 This release changes the pinned API version to 2026-06-24.preview. diff --git a/CODEGEN_VERSION b/CODEGEN_VERSION index ea710b3c849..46dccda447e 100644 --- a/CODEGEN_VERSION +++ b/CODEGEN_VERSION @@ -1 +1 @@ -44014016ec56efb57a8cf6d0ee0771c2e6b4eebe \ No newline at end of file +7b0e14a8a4b606fa2cc641579a18e077e25aaac9 \ No newline at end of file diff --git a/OPENAPI_VERSION b/OPENAPI_VERSION index 5ea23263dda..d7096ed4ff2 100644 --- a/OPENAPI_VERSION +++ b/OPENAPI_VERSION @@ -1 +1 @@ -v2349 \ No newline at end of file +v2391 \ No newline at end of file diff --git a/src/main/java/com/stripe/ApiVersion.java b/src/main/java/com/stripe/ApiVersion.java index 927c6ed2138..42f0bb9f638 100644 --- a/src/main/java/com/stripe/ApiVersion.java +++ b/src/main/java/com/stripe/ApiVersion.java @@ -3,4 +3,5 @@ final class ApiVersion { public static final String CURRENT = "2026-07-29.preview"; + public static final String CURRENT_MAJOR = ""; } diff --git a/src/main/java/com/stripe/Stripe.java b/src/main/java/com/stripe/Stripe.java index 3dbc17c2b5b..05bd86bb0c4 100644 --- a/src/main/java/com/stripe/Stripe.java +++ b/src/main/java/com/stripe/Stripe.java @@ -11,6 +11,12 @@ public abstract class Stripe { public static final int DEFAULT_READ_TIMEOUT = 80 * 1000; public static final String API_VERSION = ApiVersion.CURRENT; + /** + * The major API version that this SDK uses. Objects retrieved using the same major version are + * compatible. Is an empty string in preview versions of the SDK. + */ + public static final String MAJOR_API_VERSION = ApiVersion.CURRENT_MAJOR; + public static final String CONNECT_API_BASE = "https://connect.stripe.com"; public static final String LIVE_API_BASE = "https://api.stripe.com"; public static final String UPLOAD_API_BASE = "https://files.stripe.com"; diff --git a/src/main/java/com/stripe/StripeClient.java b/src/main/java/com/stripe/StripeClient.java index 40120655009..2aafbb09770 100644 --- a/src/main/java/com/stripe/StripeClient.java +++ b/src/main/java/com/stripe/StripeClient.java @@ -2,6 +2,7 @@ import com.stripe.exception.SignatureVerificationException; import com.stripe.exception.StripeException; +import com.stripe.model.Event; import com.stripe.model.StripeObject; import com.stripe.model.v2.core.EventNotification; import com.stripe.net.*; @@ -115,9 +116,10 @@ public EventNotification parseEventNotification(String payload, String sigHeader } /** - * Returns an StripeEvent instance using the provided JSON payload. Throws a JsonSyntaxException - * if the payload is not valid JSON, and a SignatureVerificationException if the signature - * verification fails for any reason. + * Constructs a thin event + * notification from an incoming webhook after verifying its authenticity. To work with a + * webhook that has already been verified (i.e. one from a cloud provider, an asynchronous queue, + * or during testing), see {@code parseEventNotificationWithoutVerification}. * * @param payload the payload sent by Stripe. * @param sigHeader the contents of the signature header sent by Stripe. @@ -174,6 +176,45 @@ public com.stripe.model.Event constructEvent( return event; } + /** + * Constructs a snapshot + * event from an incoming webhook without first verifying its authenticity. Should be used + * after calling {@code Webhook.Signature.verifyHeader(...)} or with input from a trusted source + * (such as AWS EventBridge, + * or Azure Event Grid + * payload). Or, to verify & construct in a single call, use {@code constructEvent(...)} + * instead. + * + * @param payload the JSON payload: a raw Stripe Event or an AWS EventBridge/Azure Event Grid + * envelope. + * @return the Event instance. + * @throws IllegalArgumentException if the payload is a thin event notification, or if the format + * is not recognized. + */ + public com.stripe.model.Event constructEventWithoutVerification(String payload) { + Event event = Webhook.constructEventWithoutVerification(payload); + event.setResponseGetter(this.getResponseGetter()); + return event; + } + + /** + * Constructs a thin event + * notification from an incoming webhook without first verifying its authenticity. Should be + * used after calling {@code Webhook.Signature.verifyHeader(...)} or with input from a trusted + * source (such as AWS + * EventBridge, or Azure Event + * Grid payload). Or, to verify & parse in a single call, use {@code + * parseEventNotification(...)} instead. + * + * @param payload the JSON payload: a raw Stripe Event, or an AWS EventBridge/Azure Event Grid + * envelope. + * @return the EventNotification instance. + * @throws IllegalArgumentException if the payload format is not recognized. + */ + public EventNotification parseEventNotificationWithoutVerification(String payload) { + return EventNotification.fromJson(Webhook.maybeExtractFromCloudProviderEnvelope(payload), this); + } + // The beginning of the section generated from our OpenAPI spec public com.stripe.service.V1Services v1() { return new com.stripe.service.V1Services(this.getResponseGetter()); diff --git a/src/main/java/com/stripe/exception/AlreadyCanceledException.java b/src/main/java/com/stripe/exception/AlreadyCanceledException.java index 8afb343d196..35fd91cd5bc 100644 --- a/src/main/java/com/stripe/exception/AlreadyCanceledException.java +++ b/src/main/java/com/stripe/exception/AlreadyCanceledException.java @@ -18,9 +18,8 @@ private AlreadyCanceledException( static AlreadyCanceledException parse( JsonObject body, int statusCode, String requestId, StripeResponseGetter responseGetter) { AlreadyCanceledException.AlreadyCanceledError error = - (AlreadyCanceledException.AlreadyCanceledError) - StripeObject.deserializeStripeObject( - body, AlreadyCanceledException.AlreadyCanceledError.class, responseGetter); + StripeObject.deserializeStripeObject( + body, AlreadyCanceledException.AlreadyCanceledError.class, responseGetter); AlreadyCanceledException exception = new AlreadyCanceledException( error.getMessage(), requestId, error.getCode(), statusCode, null); diff --git a/src/main/java/com/stripe/exception/AlreadyExistsException.java b/src/main/java/com/stripe/exception/AlreadyExistsException.java index 317c4407b9b..84923868085 100644 --- a/src/main/java/com/stripe/exception/AlreadyExistsException.java +++ b/src/main/java/com/stripe/exception/AlreadyExistsException.java @@ -18,9 +18,8 @@ private AlreadyExistsException( static AlreadyExistsException parse( JsonObject body, int statusCode, String requestId, StripeResponseGetter responseGetter) { AlreadyExistsException.AlreadyExistsError error = - (AlreadyExistsException.AlreadyExistsError) - StripeObject.deserializeStripeObject( - body, AlreadyExistsException.AlreadyExistsError.class, responseGetter); + StripeObject.deserializeStripeObject( + body, AlreadyExistsException.AlreadyExistsError.class, responseGetter); AlreadyExistsException exception = new AlreadyExistsException( error.getMessage(), requestId, error.getCode(), statusCode, null); diff --git a/src/main/java/com/stripe/exception/BlockedByStripeException.java b/src/main/java/com/stripe/exception/BlockedByStripeException.java index 02181d693c8..b5f3d1d1edf 100644 --- a/src/main/java/com/stripe/exception/BlockedByStripeException.java +++ b/src/main/java/com/stripe/exception/BlockedByStripeException.java @@ -18,9 +18,8 @@ private BlockedByStripeException( static BlockedByStripeException parse( JsonObject body, int statusCode, String requestId, StripeResponseGetter responseGetter) { BlockedByStripeException.BlockedByStripeError error = - (BlockedByStripeException.BlockedByStripeError) - StripeObject.deserializeStripeObject( - body, BlockedByStripeException.BlockedByStripeError.class, responseGetter); + StripeObject.deserializeStripeObject( + body, BlockedByStripeException.BlockedByStripeError.class, responseGetter); BlockedByStripeException exception = new BlockedByStripeException( error.getMessage(), requestId, error.getCode(), statusCode, null); diff --git a/src/main/java/com/stripe/exception/CannotProceedException.java b/src/main/java/com/stripe/exception/CannotProceedException.java index 089992b1865..bbc55f76fad 100644 --- a/src/main/java/com/stripe/exception/CannotProceedException.java +++ b/src/main/java/com/stripe/exception/CannotProceedException.java @@ -27,9 +27,8 @@ private CannotProceedException( static CannotProceedException parse( JsonObject body, int statusCode, String requestId, StripeResponseGetter responseGetter) { CannotProceedException.CannotProceedError error = - (CannotProceedException.CannotProceedError) - StripeObject.deserializeStripeObject( - body, CannotProceedException.CannotProceedError.class, responseGetter); + StripeObject.deserializeStripeObject( + body, CannotProceedException.CannotProceedError.class, responseGetter); CannotProceedException exception = new CannotProceedException( error.getMessage(), requestId, error.getCode(), statusCode, null, error.getReason()); diff --git a/src/main/java/com/stripe/exception/ControlledByAlternateResourceException.java b/src/main/java/com/stripe/exception/ControlledByAlternateResourceException.java index a21b9f0eb35..07e2a29b81a 100644 --- a/src/main/java/com/stripe/exception/ControlledByAlternateResourceException.java +++ b/src/main/java/com/stripe/exception/ControlledByAlternateResourceException.java @@ -21,11 +21,10 @@ private ControlledByAlternateResourceException( static ControlledByAlternateResourceException parse( JsonObject body, int statusCode, String requestId, StripeResponseGetter responseGetter) { ControlledByAlternateResourceException.ControlledByAlternateResourceError error = - (ControlledByAlternateResourceException.ControlledByAlternateResourceError) - StripeObject.deserializeStripeObject( - body, - ControlledByAlternateResourceException.ControlledByAlternateResourceError.class, - responseGetter); + StripeObject.deserializeStripeObject( + body, + ControlledByAlternateResourceException.ControlledByAlternateResourceError.class, + responseGetter); ControlledByAlternateResourceException exception = new ControlledByAlternateResourceException( error.getMessage(), requestId, error.getCode(), statusCode, null); diff --git a/src/main/java/com/stripe/exception/ControlledByDashboardException.java b/src/main/java/com/stripe/exception/ControlledByDashboardException.java index cac7b66e44b..a65bf8542ef 100644 --- a/src/main/java/com/stripe/exception/ControlledByDashboardException.java +++ b/src/main/java/com/stripe/exception/ControlledByDashboardException.java @@ -21,11 +21,8 @@ private ControlledByDashboardException( static ControlledByDashboardException parse( JsonObject body, int statusCode, String requestId, StripeResponseGetter responseGetter) { ControlledByDashboardException.ControlledByDashboardError error = - (ControlledByDashboardException.ControlledByDashboardError) - StripeObject.deserializeStripeObject( - body, - ControlledByDashboardException.ControlledByDashboardError.class, - responseGetter); + StripeObject.deserializeStripeObject( + body, ControlledByDashboardException.ControlledByDashboardError.class, responseGetter); ControlledByDashboardException exception = new ControlledByDashboardException( error.getMessage(), requestId, error.getCode(), statusCode, null); diff --git a/src/main/java/com/stripe/exception/FeatureNotEnabledException.java b/src/main/java/com/stripe/exception/FeatureNotEnabledException.java index a1e24a0e4d1..1da536ba497 100644 --- a/src/main/java/com/stripe/exception/FeatureNotEnabledException.java +++ b/src/main/java/com/stripe/exception/FeatureNotEnabledException.java @@ -18,9 +18,8 @@ private FeatureNotEnabledException( static FeatureNotEnabledException parse( JsonObject body, int statusCode, String requestId, StripeResponseGetter responseGetter) { FeatureNotEnabledException.FeatureNotEnabledError error = - (FeatureNotEnabledException.FeatureNotEnabledError) - StripeObject.deserializeStripeObject( - body, FeatureNotEnabledException.FeatureNotEnabledError.class, responseGetter); + StripeObject.deserializeStripeObject( + body, FeatureNotEnabledException.FeatureNotEnabledError.class, responseGetter); FeatureNotEnabledException exception = new FeatureNotEnabledException( error.getMessage(), requestId, error.getCode(), statusCode, null); diff --git a/src/main/java/com/stripe/exception/FinancialAccountNotOpenException.java b/src/main/java/com/stripe/exception/FinancialAccountNotOpenException.java index 66a745010d4..a4feb652832 100644 --- a/src/main/java/com/stripe/exception/FinancialAccountNotOpenException.java +++ b/src/main/java/com/stripe/exception/FinancialAccountNotOpenException.java @@ -17,11 +17,10 @@ private FinancialAccountNotOpenException( static FinancialAccountNotOpenException parse( JsonObject body, int statusCode, String requestId, StripeResponseGetter responseGetter) { FinancialAccountNotOpenException.FinancialAccountNotOpenError error = - (FinancialAccountNotOpenException.FinancialAccountNotOpenError) - StripeObject.deserializeStripeObject( - body, - FinancialAccountNotOpenException.FinancialAccountNotOpenError.class, - responseGetter); + StripeObject.deserializeStripeObject( + body, + FinancialAccountNotOpenException.FinancialAccountNotOpenError.class, + responseGetter); FinancialAccountNotOpenException exception = new FinancialAccountNotOpenException( error.getMessage(), requestId, error.getCode(), statusCode, null); diff --git a/src/main/java/com/stripe/exception/InsufficientFundsException.java b/src/main/java/com/stripe/exception/InsufficientFundsException.java index e28c0065d0d..a640ffd962f 100644 --- a/src/main/java/com/stripe/exception/InsufficientFundsException.java +++ b/src/main/java/com/stripe/exception/InsufficientFundsException.java @@ -21,9 +21,8 @@ private InsufficientFundsException( static InsufficientFundsException parse( JsonObject body, int statusCode, String requestId, StripeResponseGetter responseGetter) { InsufficientFundsException.InsufficientFundsError error = - (InsufficientFundsException.InsufficientFundsError) - StripeObject.deserializeStripeObject( - body, InsufficientFundsException.InsufficientFundsError.class, responseGetter); + StripeObject.deserializeStripeObject( + body, InsufficientFundsException.InsufficientFundsError.class, responseGetter); InsufficientFundsException exception = new InsufficientFundsException( error.getMessage(), requestId, error.getCode(), statusCode, null); diff --git a/src/main/java/com/stripe/exception/InvalidPaymentMethodException.java b/src/main/java/com/stripe/exception/InvalidPaymentMethodException.java index 320cee6e260..49cfb87a998 100644 --- a/src/main/java/com/stripe/exception/InvalidPaymentMethodException.java +++ b/src/main/java/com/stripe/exception/InvalidPaymentMethodException.java @@ -30,11 +30,8 @@ private InvalidPaymentMethodException( static InvalidPaymentMethodException parse( JsonObject body, int statusCode, String requestId, StripeResponseGetter responseGetter) { InvalidPaymentMethodException.InvalidPaymentMethodError error = - (InvalidPaymentMethodException.InvalidPaymentMethodError) - StripeObject.deserializeStripeObject( - body, - InvalidPaymentMethodException.InvalidPaymentMethodError.class, - responseGetter); + StripeObject.deserializeStripeObject( + body, InvalidPaymentMethodException.InvalidPaymentMethodError.class, responseGetter); InvalidPaymentMethodException exception = new InvalidPaymentMethodException( error.getMessage(), diff --git a/src/main/java/com/stripe/exception/InvalidPayoutMethodException.java b/src/main/java/com/stripe/exception/InvalidPayoutMethodException.java index 1b92d726743..d25634cc672 100644 --- a/src/main/java/com/stripe/exception/InvalidPayoutMethodException.java +++ b/src/main/java/com/stripe/exception/InvalidPayoutMethodException.java @@ -18,9 +18,8 @@ private InvalidPayoutMethodException( static InvalidPayoutMethodException parse( JsonObject body, int statusCode, String requestId, StripeResponseGetter responseGetter) { InvalidPayoutMethodException.InvalidPayoutMethodError error = - (InvalidPayoutMethodException.InvalidPayoutMethodError) - StripeObject.deserializeStripeObject( - body, InvalidPayoutMethodException.InvalidPayoutMethodError.class, responseGetter); + StripeObject.deserializeStripeObject( + body, InvalidPayoutMethodException.InvalidPayoutMethodError.class, responseGetter); InvalidPayoutMethodException exception = new InvalidPayoutMethodException( error.getMessage(), requestId, error.getCode(), statusCode, null); diff --git a/src/main/java/com/stripe/exception/NonZeroBalanceException.java b/src/main/java/com/stripe/exception/NonZeroBalanceException.java index e9ffb6bc220..0fe7bcff5ad 100644 --- a/src/main/java/com/stripe/exception/NonZeroBalanceException.java +++ b/src/main/java/com/stripe/exception/NonZeroBalanceException.java @@ -18,9 +18,8 @@ private NonZeroBalanceException( static NonZeroBalanceException parse( JsonObject body, int statusCode, String requestId, StripeResponseGetter responseGetter) { NonZeroBalanceException.NonZeroBalanceError error = - (NonZeroBalanceException.NonZeroBalanceError) - StripeObject.deserializeStripeObject( - body, NonZeroBalanceException.NonZeroBalanceError.class, responseGetter); + StripeObject.deserializeStripeObject( + body, NonZeroBalanceException.NonZeroBalanceError.class, responseGetter); NonZeroBalanceException exception = new NonZeroBalanceException( error.getMessage(), requestId, error.getCode(), statusCode, null); diff --git a/src/main/java/com/stripe/exception/NotCancelableException.java b/src/main/java/com/stripe/exception/NotCancelableException.java index d1a93541b2e..79e5e6027f1 100644 --- a/src/main/java/com/stripe/exception/NotCancelableException.java +++ b/src/main/java/com/stripe/exception/NotCancelableException.java @@ -18,9 +18,8 @@ private NotCancelableException( static NotCancelableException parse( JsonObject body, int statusCode, String requestId, StripeResponseGetter responseGetter) { NotCancelableException.NotCancelableError error = - (NotCancelableException.NotCancelableError) - StripeObject.deserializeStripeObject( - body, NotCancelableException.NotCancelableError.class, responseGetter); + StripeObject.deserializeStripeObject( + body, NotCancelableException.NotCancelableError.class, responseGetter); NotCancelableException exception = new NotCancelableException( error.getMessage(), requestId, error.getCode(), statusCode, null); diff --git a/src/main/java/com/stripe/exception/QuotaExceededException.java b/src/main/java/com/stripe/exception/QuotaExceededException.java index 8428fe11abb..99f019e54a3 100644 --- a/src/main/java/com/stripe/exception/QuotaExceededException.java +++ b/src/main/java/com/stripe/exception/QuotaExceededException.java @@ -18,9 +18,8 @@ private QuotaExceededException( static QuotaExceededException parse( JsonObject body, int statusCode, String requestId, StripeResponseGetter responseGetter) { QuotaExceededException.QuotaExceededError error = - (QuotaExceededException.QuotaExceededError) - StripeObject.deserializeStripeObject( - body, QuotaExceededException.QuotaExceededError.class, responseGetter); + StripeObject.deserializeStripeObject( + body, QuotaExceededException.QuotaExceededError.class, responseGetter); QuotaExceededException exception = new QuotaExceededException( error.getMessage(), requestId, error.getCode(), statusCode, null); diff --git a/src/main/java/com/stripe/exception/RateLimitException.java b/src/main/java/com/stripe/exception/RateLimitException.java index b8476ea4ee5..95b2fe6990b 100644 --- a/src/main/java/com/stripe/exception/RateLimitException.java +++ b/src/main/java/com/stripe/exception/RateLimitException.java @@ -26,9 +26,8 @@ public RateLimitException( static RateLimitException parse( JsonObject body, int statusCode, String requestId, StripeResponseGetter responseGetter) { RateLimitException.RateLimitError error = - (RateLimitException.RateLimitError) - StripeObject.deserializeStripeObject( - body, RateLimitException.RateLimitError.class, responseGetter); + StripeObject.deserializeStripeObject( + body, RateLimitException.RateLimitError.class, responseGetter); RateLimitException exception = new RateLimitException( error.getMessage(), error.getParam(), requestId, error.getCode(), statusCode, null); diff --git a/src/main/java/com/stripe/exception/RecipientNotNotifiableException.java b/src/main/java/com/stripe/exception/RecipientNotNotifiableException.java index c6e7d2f6d93..5432d5caf95 100644 --- a/src/main/java/com/stripe/exception/RecipientNotNotifiableException.java +++ b/src/main/java/com/stripe/exception/RecipientNotNotifiableException.java @@ -21,11 +21,10 @@ private RecipientNotNotifiableException( static RecipientNotNotifiableException parse( JsonObject body, int statusCode, String requestId, StripeResponseGetter responseGetter) { RecipientNotNotifiableException.RecipientNotNotifiableError error = - (RecipientNotNotifiableException.RecipientNotNotifiableError) - StripeObject.deserializeStripeObject( - body, - RecipientNotNotifiableException.RecipientNotNotifiableError.class, - responseGetter); + StripeObject.deserializeStripeObject( + body, + RecipientNotNotifiableException.RecipientNotNotifiableError.class, + responseGetter); RecipientNotNotifiableException exception = new RecipientNotNotifiableException( error.getMessage(), requestId, error.getCode(), statusCode, null); diff --git a/src/main/java/com/stripe/exception/TemporarySessionExpiredException.java b/src/main/java/com/stripe/exception/TemporarySessionExpiredException.java index 376c73a885b..917383cb01d 100644 --- a/src/main/java/com/stripe/exception/TemporarySessionExpiredException.java +++ b/src/main/java/com/stripe/exception/TemporarySessionExpiredException.java @@ -18,11 +18,10 @@ private TemporarySessionExpiredException( static TemporarySessionExpiredException parse( JsonObject body, int statusCode, String requestId, StripeResponseGetter responseGetter) { TemporarySessionExpiredException.TemporarySessionExpiredError error = - (TemporarySessionExpiredException.TemporarySessionExpiredError) - StripeObject.deserializeStripeObject( - body, - TemporarySessionExpiredException.TemporarySessionExpiredError.class, - responseGetter); + StripeObject.deserializeStripeObject( + body, + TemporarySessionExpiredException.TemporarySessionExpiredError.class, + responseGetter); TemporarySessionExpiredException exception = new TemporarySessionExpiredException( error.getMessage(), requestId, error.getCode(), statusCode, null); diff --git a/src/main/java/com/stripe/model/StripeObject.java b/src/main/java/com/stripe/model/StripeObject.java index 68e4e7ed059..ad339c127aa 100644 --- a/src/main/java/com/stripe/model/StripeObject.java +++ b/src/main/java/com/stripe/model/StripeObject.java @@ -133,6 +133,12 @@ public static T deserializeStripeObject( return (T) deserializeStripeObject(payload, (Type) type, responseGetter); } + @SuppressWarnings("unchecked") + public static T deserializeStripeObject( + JsonObject payload, Class type, StripeResponseGetter responseGetter) { + return (T) deserializeStripeObject(payload, (Type) type, responseGetter); + } + public static StripeObject deserializeStripeObject( String payload, Type type, StripeResponseGetter responseGetter) { StripeObject object = ApiResource.INTERNAL_GSON.fromJson(payload, type); diff --git a/src/main/java/com/stripe/net/Webhook.java b/src/main/java/com/stripe/net/Webhook.java index 09505f70873..e5b8b5d0681 100644 --- a/src/main/java/com/stripe/net/Webhook.java +++ b/src/main/java/com/stripe/net/Webhook.java @@ -1,5 +1,6 @@ package com.stripe.net; +import com.google.gson.JsonObject; import com.stripe.exception.SignatureVerificationException; import com.stripe.model.Event; import com.stripe.model.StripeObject; @@ -53,9 +54,10 @@ public static Event constructEvent( } /** - * Returns an Event instance using the provided JSON payload. Throws a JsonSyntaxException if the - * payload is not valid JSON, a SignatureVerificationException if the signature verification fails - * for any reason, and an IllegalArgumentException if you pass the wrong type of input. + * Constructs a snapshot + * event from an incoming webhook after verifying its authenticity. To work with a webhook + * that has already been verified (i.e. one from a cloud provider, an asynchronous queue, or + * during testing), see {@code constructEventWithoutVerification}. * * @param payload the payload sent by Stripe. * @param sigHeader the contents of the signature header sent by Stripe. @@ -69,28 +71,90 @@ public static Event constructEvent( public static Event constructEvent( String payload, String sigHeader, String secret, long tolerance, Clock clock) throws SignatureVerificationException { - Event event = - StripeObject.deserializeStripeObject( - payload, Event.class, ApiResource.getGlobalResponseGetter()); + Signature.verifyHeader(payload, sigHeader, secret, tolerance, clock); + + return buildV1Event(payload); + } + + /** + * Constructs a snapshot + * event from an incoming webhook without first verifying its authenticity. Should be used + * after calling {@code Webhook.Signature.verifyHeader(...)} or with input from a trusted source + * (such as AWS EventBridge, + * or Azure Event Grid + * payload). Or, to verify & construct in a single call, use {@code + * Webhook.constructEvent(...)} instead. + * + * @param payload the payload sent by Stripe, or a cloud provider envelope wrapping it. + * @return the Event instance + * @throws IllegalArgumentException if the payload is a v2 thin event notification. + */ + public static Event constructEventWithoutVerification(String payload) { + return buildV1Event(maybeExtractFromCloudProviderEnvelope(payload)); + } - if ("v2.core.event".equals(event.getObject())) { + private static Event buildV1Event(String payload) { + return buildV1Event(ApiResource.GSON.fromJson(payload, JsonObject.class)); + } + + private static Event buildV1Event(JsonObject jsonObject) { + if (jsonObject.has("object") + && "v2.core.event".equals(jsonObject.get("object").getAsString())) { throw new IllegalArgumentException( - "You passed an event notification to Webhook.constructEvent, which expects a webhook payload." - + " Use StripeClient.parseEventNotification instead."); + "You passed an event notification to Webhook method, which expects a webhook payload. Use the corresponding parseEventNotification method instead."); } - Signature.verifyHeader(payload, sigHeader, secret, tolerance, clock); + Event event = + StripeObject.deserializeStripeObject( + jsonObject, Event.class, ApiResource.getGlobalResponseGetter()); + // StripeObjects source their raw JSON object from their last response, but constructed webhooks // don't have that // in order to make the raw object available on parsed events, we fake the response. if (event.getLastResponse() == null) { event.setLastResponse( - new StripeResponse(200, HttpHeaders.of(Collections.emptyMap()), payload)); + new StripeResponse(200, HttpHeaders.of(Collections.emptyMap()), jsonObject.toString())); } return event; } + /** + * Parses a JSON payload (or cloud provider envelope) and returns the inner Stripe event JSON + * object. If the payload is already a raw Stripe event (object is "event" or "v2.core.event"), it + * is returned as-is. If it is an AWS EventBridge or Azure Event Grid envelope, the inner event is + * extracted. Throws {@link IllegalArgumentException} for unrecognized formats. + * + * @param payload the raw JSON string. + * @return the inner event as a {@link JsonObject}. + */ + public static JsonObject maybeExtractFromCloudProviderEnvelope(String payload) { + JsonObject root = ApiResource.GSON.fromJson(payload, JsonObject.class); + + // AWS + // https://docs.stripe.com/event-destinations/eventbridge#event-structure + if (root.has("detail")) { + return root.get("detail").getAsJsonObject(); + } + + // Azure + // https://docs.stripe.com/event-destinations/eventgrid#event-structure + if (root.has("specversion") && root.has("data")) { + return root.get("data").getAsJsonObject(); + } + + // Raw Stripe event passed directly: pass through as-is + if (root.has("object") && root.get("object").isJsonPrimitive()) { + String object = root.get("object").getAsString(); + if ("event".equals(object) || "v2.core.event".equals(object)) { + return root; + } + } + + throw new IllegalArgumentException( + "Unrecognized event format. The payload must be an AWS EventBridge/Azure Event Grid event envelope or a Stripe webhook (thin event notification or snapshot)."); + } + public static final class Signature { public static final String EXPECTED_SCHEME = "v1"; @@ -112,8 +176,10 @@ public static boolean verifyHeader( } /** - * Verifies the signature header sent by Stripe. Throws a SignatureVerificationException if the - * verification fails for any reason. + * Verifies the authenticity (and recency) of a webhook, throwing a {@code + * SignatureVerificationException} if there's a mismatch. Useful for quickly validating incoming + * webhooks before storing them for later processing (at which time you can use the {@code + * *WithoutVerification} methods for parsing). * * @param payload the payload sent by Stripe. * @param sigHeader the contents of the signature header sent by Stripe. @@ -171,6 +237,35 @@ public static boolean verifyHeader( return true; } + /** + * Generates a {@code Stripe-Signature} header for the given payload and secret using the + * current timestamp. + * + * @param payload the payload to sign. + * @param secret the webhook secret. + * @return the generated signature header string. + */ + public static String generateSignatureHeader(String payload, String secret) + throws NoSuchAlgorithmException, InvalidKeyException { + return generateSignatureHeader(payload, secret, Util.getTimeNow()); + } + + /** + * Compute the {@code Stripe-Signature} header for a given webhook body & secret. Useful for + * signing payloads in unit tests. + * + * @param payload the payload to sign. + * @param secret the webhook secret. + * @param timestamp the timestamp to use (seconds since epoch). + * @return the generated signature header string. + */ + public static String generateSignatureHeader(String payload, String secret, long timestamp) + throws NoSuchAlgorithmException, InvalidKeyException { + String payloadToSign = String.format("%d.%s", timestamp, payload); + String signature = computeSignature(payloadToSign, secret); + return String.format("t=%d,%s=%s", timestamp, EXPECTED_SCHEME, signature); + } + /** * Extracts the timestamp in a signature header. * diff --git a/src/test/java/com/stripe/StripeClientTest.java b/src/test/java/com/stripe/StripeClientTest.java index aa97216e5a4..d53110efe61 100644 --- a/src/test/java/com/stripe/StripeClientTest.java +++ b/src/test/java/com/stripe/StripeClientTest.java @@ -246,6 +246,7 @@ public void parsesEventNotificationWithRelatedObject() client.parseEventNotification(v2EventNotificationWithRelatedObject, signature, secret); assertNotNull(eventNotification); assertEquals("evt_234", eventNotification.getId()); + assertEquals("v2.core.event", eventNotification.getObject()); assertEquals("v1.billing.meter.error_report_triggered", eventNotification.getType()); assertEquals(Instant.parse("2022-02-15T00:27:45.330Z"), eventNotification.created); assertEquals("org_123", eventNotification.getContext().toString()); diff --git a/src/test/java/com/stripe/net/CloudProviderEventTest.java b/src/test/java/com/stripe/net/CloudProviderEventTest.java new file mode 100644 index 00000000000..11234e560df --- /dev/null +++ b/src/test/java/com/stripe/net/CloudProviderEventTest.java @@ -0,0 +1,236 @@ +package com.stripe.net; + +import static org.junit.jupiter.api.Assertions.*; + +import com.google.gson.JsonObject; +import com.google.gson.JsonSyntaxException; +import com.stripe.BaseStripeTest; +import com.stripe.StripeClient; +import com.stripe.model.Event; +import com.stripe.model.v2.core.EventNotification; +import org.junit.jupiter.api.Test; + +public class CloudProviderEventTest extends BaseStripeTest { + + private static final String EVENTBRIDGE_PAYLOAD = + "{\"version\":\"0\",\"id\":\"17e8dff5-d6cd-3770-ace9-aeac02b6ac3f\"," + + "\"detail-type\":\"customer.created\"," + + "\"source\":\"aws.partner/stripe.com/ed_123\"," + + "\"account\":\"506417113029\"," + + "\"time\":\"2024-03-07T18:27:56Z\"," + + "\"region\":\"us-west-2\"," + + "\"resources\":[]," + + "\"detail\":{" + + "\"id\":\"evt_test_123\"," + + "\"object\":\"event\"," + + "\"api_version\":\"2023-10-16\"," + + "\"created\":1709836076," + + "\"data\":{\"object\":{\"id\":\"cus_123\",\"object\":\"customer\"}}," + + "\"livemode\":true," + + "\"pending_webhooks\":0," + + "\"request\":{\"id\":\"req_123\",\"idempotency_key\":null}," + + "\"type\":\"customer.created\"}}"; + + private static final String EVENTGRID_PAYLOAD = + "{\"specversion\":\"1.0\"," + + "\"type\":\"customer.created\"," + + "\"source\":\"/providers/stripe/ed_test_123\"," + + "\"id\":\"9aeb0fdf-c01e-0131-0922-9eb54906e209\"," + + "\"time\":\"2025-07-11T14:30:00Z\"," + + "\"subject\":null," + + "\"dataContentType\":\"application/cloudevents+json\"," + + "\"data\":{" + + "\"id\":\"evt_test_456\"," + + "\"object\":\"event\"," + + "\"api_version\":\"2023-10-16\"," + + "\"created\":1709836076," + + "\"data\":{\"object\":{\"id\":\"cus_456\",\"object\":\"customer\"}}," + + "\"livemode\":false," + + "\"pending_webhooks\":0," + + "\"request\":{\"id\":\"req_456\",\"idempotency_key\":null}," + + "\"type\":\"customer.created\"}}"; + + private static final String EVENTBRIDGE_NOTIFICATION_PAYLOAD = + "{\"version\":\"0\",\"id\":\"17e8dff5-d6cd-3770-ace9-aeac02b6ac3f\"," + + "\"detail-type\":\"v2.core.event_destination.ping\"," + + "\"source\":\"aws.partner/stripe.com/ed_123\"," + + "\"detail\":{" + + "\"id\":\"evt_test_789\"," + + "\"object\":\"v2.core.event\"," + + "\"type\":\"v2.core.event_destination.ping\"," + + "\"created\":\"2024-03-07T18:27:56.000Z\"," + + "\"livemode\":true}}"; + + private static final String EVENTGRID_NOTIFICATION_PAYLOAD = + "{\"specversion\":\"1.0\"," + + "\"type\":\"v2.core.event_destination.ping\"," + + "\"source\":\"/providers/stripe/ed_test_123\"," + + "\"id\":\"9aeb0fdf-c01e-0131-0922-9eb54906e209\"," + + "\"data\":{" + + "\"id\":\"evt_test_789\"," + + "\"object\":\"v2.core.event\"," + + "\"type\":\"v2.core.event_destination.ping\"," + + "\"created\":\"2024-03-07T18:27:56.000Z\"," + + "\"livemode\":true}}"; + + private static final String RAW_EVENT_PAYLOAD = + "{\"id\":\"evt_test_123\"," + + "\"object\":\"event\"," + + "\"api_version\":\"2023-10-16\"," + + "\"created\":1709836076," + + "\"data\":{\"object\":{\"id\":\"cus_123\",\"object\":\"customer\"}}," + + "\"livemode\":true," + + "\"pending_webhooks\":0," + + "\"request\":{\"id\":\"req_123\",\"idempotency_key\":null}," + + "\"type\":\"customer.created\"}"; + + // constructEventWithoutVerification tests + + @Test + public void testEventBridgeViaClient() { + StripeClient client = new StripeClient("sk_test_fake"); + Event event = client.constructEventWithoutVerification(EVENTBRIDGE_PAYLOAD); + assertNotNull(event); + assertEquals("evt_test_123", event.getId()); + assertEquals("customer.created", event.getType()); + } + + @Test + public void testEventGridViaClient() { + StripeClient client = new StripeClient("sk_test_fake"); + Event event = client.constructEventWithoutVerification(EVENTGRID_PAYLOAD); + assertNotNull(event); + assertEquals("evt_test_456", event.getId()); + assertEquals("customer.created", event.getType()); + } + + @Test + public void testRawEventViaClient() { + StripeClient client = new StripeClient("sk_test_fake"); + Event event = client.constructEventWithoutVerification(RAW_EVENT_PAYLOAD); + assertNotNull(event); + assertEquals("evt_test_123", event.getId()); + assertEquals("customer.created", event.getType()); + } + + @Test + public void testInvalidJsonViaClient() { + StripeClient client = new StripeClient("sk_test_fake"); + assertThrows( + JsonSyntaxException.class, + () -> client.constructEventWithoutVerification("not valid json")); + } + + @Test + public void testUnrecognizedFormatViaClient() { + StripeClient client = new StripeClient("sk_test_fake"); + IllegalArgumentException ex = + assertThrows( + IllegalArgumentException.class, + () -> client.constructEventWithoutVerification("{\"foo\":\"bar\"}")); + assertTrue(ex.getMessage().contains("Unrecognized event format")); + } + + // parseEventNotificationWithoutVerification tests + + @Test + public void testEventBridgeNotificationViaClient() { + StripeClient client = new StripeClient("sk_test_fake"); + EventNotification notification = + client.parseEventNotificationWithoutVerification(EVENTBRIDGE_NOTIFICATION_PAYLOAD); + assertNotNull(notification); + assertEquals("evt_test_789", notification.getId()); + } + + @Test + public void testEventGridNotificationViaClient() { + StripeClient client = new StripeClient("sk_test_fake"); + EventNotification notification = + client.parseEventNotificationWithoutVerification(EVENTGRID_NOTIFICATION_PAYLOAD); + assertNotNull(notification); + assertEquals("evt_test_789", notification.getId()); + } + + @Test + public void testParseNotificationWithV1EventSuggestsConstructEventWithoutVerification() { + StripeClient client = new StripeClient("sk_test_fake"); + assertThrows( + IllegalArgumentException.class, + () -> client.parseEventNotificationWithoutVerification(EVENTBRIDGE_PAYLOAD)); + } + + @Test + public void testParseNotificationInvalidJsonThrows() { + StripeClient client = new StripeClient("sk_test_fake"); + assertThrows( + JsonSyntaxException.class, + () -> client.parseEventNotificationWithoutVerification("not valid json")); + } + + @Test + public void testParseNotificationUnrecognizedFormatThrows() { + StripeClient client = new StripeClient("sk_test_fake"); + IllegalArgumentException ex = + assertThrows( + IllegalArgumentException.class, + () -> client.parseEventNotificationWithoutVerification("{\"foo\":\"bar\"}")); + assertTrue(ex.getMessage().contains("Unrecognized event format")); + } + + @Test + public void testParseNotificationRawV2NotificationPassthrough() { + StripeClient client = new StripeClient("sk_test_fake"); + String rawV2Payload = + "{\"id\":\"evt_234\"," + + "\"object\":\"v2.core.event\"," + + "\"type\":\"v2.core.event_destination.ping\"," + + "\"created\":\"2024-03-07T18:27:56.000Z\"," + + "\"livemode\":true}"; + EventNotification notification = client.parseEventNotificationWithoutVerification(rawV2Payload); + assertNotNull(notification); + assertEquals("evt_234", notification.getId()); + } + + @Test + public void testConstructEventWithoutVerificationRejectsV2ThinEvent() { + StripeClient client = new StripeClient("sk_test_fake"); + String v2Payload = + "{\"id\":\"evt_234\"," + + "\"object\":\"v2.core.event\"," + + "\"type\":\"v2.core.event_destination.ping\"," + + "\"created\":\"2024-03-07T18:27:56.000Z\"," + + "\"livemode\":true}"; + IllegalArgumentException ex = + assertThrows( + IllegalArgumentException.class, + () -> client.constructEventWithoutVerification(v2Payload)); + assertTrue(ex.getMessage().contains("parseEventNotification")); + } + + @Test + public void testAzureEnvelopeMissingDataThrows() { + StripeClient client = new StripeClient("sk_test_fake"); + String payloadMissingData = + "{\"specversion\":\"1.0\"," + + "\"type\":\"customer.created\"," + + "\"source\":\"/providers/stripe/ed_test_123\"," + + "\"id\":\"test-missing-data\"}"; + IllegalArgumentException ex = + assertThrows( + IllegalArgumentException.class, + () -> client.constructEventWithoutVerification(payloadMissingData)); + assertTrue(ex.getMessage().contains("Unrecognized event format")); + } + + @Test + public void testFromJsonUnexpectedObjectTypeThrows() { + StripeClient client = new StripeClient("sk_test_fake"); + JsonObject jsonObject = new JsonObject(); + jsonObject.addProperty("object", "customer"); + jsonObject.addProperty("type", "customer.created"); + IllegalArgumentException ex = + assertThrows( + IllegalArgumentException.class, () -> EventNotification.fromJson(jsonObject, client)); + assertTrue(ex.getMessage().contains("Unexpected object type")); + } +} diff --git a/src/test/java/com/stripe/net/WebhookTest.java b/src/test/java/com/stripe/net/WebhookTest.java index 3e1357b6bf3..cf7a7c7d9c7 100644 --- a/src/test/java/com/stripe/net/WebhookTest.java +++ b/src/test/java/com/stripe/net/WebhookTest.java @@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import com.google.gson.JsonObject; import com.google.gson.JsonSyntaxException; import com.stripe.BaseStripeTest; import com.stripe.Stripe; @@ -76,7 +77,7 @@ public static String generateSigHeader(Map options) @Test public void testValidJsonAndHeader() throws SignatureVerificationException, NoSuchAlgorithmException, InvalidKeyException { - final String sigHeader = generateSigHeader(); + final String sigHeader = Webhook.Signature.generateSignatureHeader(payload, secret); final Event event = Webhook.constructEvent(payload, sigHeader, secret); @@ -86,10 +87,7 @@ public void testValidJsonAndHeader() @Test public void testValidJsonAndHeaderButOutsideTimeTolerance() throws NoSuchAlgorithmException, InvalidKeyException { - final Map options = new HashMap<>(); - options.put("timestamp", 1L); - - final String sigHeader = generateSigHeader(options); + final String sigHeader = Webhook.Signature.generateSignatureHeader(payload, secret, 1L); final Clock clock = Clock.fixed(Instant.ofEpochMilli(12000), ZoneId.of("UTC")); assertThrows( @@ -109,9 +107,7 @@ public void testValidJsonAndHeaderCanMakeRequestsOnDataObject() + "\"," + "\"object\": \"event\",\"data\": {\"object\": {\"id\": \"acct_123\",\"object\": \"account\"}}}"; - final Map options = new HashMap<>(); - options.put("payload", payload); - final String sigHeader = generateSigHeader(options); + final String sigHeader = Webhook.Signature.generateSignatureHeader(payload, secret); final Event event = Webhook.constructEvent(payload, sigHeader, secret); Account modelViaData = ((Account) event.getData().getObject()); @@ -125,9 +121,7 @@ public void testValidJsonAndHeaderCanMakeRequestsOnDataObject() public void testInvalidJson() throws SignatureVerificationException, NoSuchAlgorithmException, InvalidKeyException { final String payload = "this is not valid JSON"; - final Map options = new HashMap<>(); - options.put("payload", payload); - final String sigHeader = generateSigHeader(options); + final String sigHeader = Webhook.Signature.generateSignatureHeader(payload, secret); assertThrows( JsonSyntaxException.class, @@ -196,9 +190,8 @@ public void testNoValidSignatureForPayload() @Test public void testTimestampOutsideTolerance() throws SignatureVerificationException, NoSuchAlgorithmException, InvalidKeyException { - final Map options = new HashMap<>(); - options.put("timestamp", Webhook.Util.getTimeNow() - 15); - final String sigHeader = generateSigHeader(options); + final String sigHeader = + Webhook.Signature.generateSignatureHeader(payload, secret, Webhook.Util.getTimeNow() - 15); Throwable exception = assertThrows( @@ -212,7 +205,7 @@ public void testTimestampOutsideTolerance() @Test public void testValidHeaderAndSignature() throws SignatureVerificationException, NoSuchAlgorithmException, InvalidKeyException { - final String sigHeader = generateSigHeader(); + final String sigHeader = Webhook.Signature.generateSignatureHeader(payload, secret); assertTrue(Webhook.Signature.verifyHeader(payload, sigHeader, secret, 10, null)); } @@ -220,7 +213,9 @@ public void testValidHeaderAndSignature() @Test public void testHeaderContainsValidSignature() throws SignatureVerificationException, NoSuchAlgorithmException, InvalidKeyException { - final String sigHeader = String.format("%s,v1=bad_signature", generateSigHeader()); + final String sigHeader = + String.format( + "%s,v1=bad_signature", Webhook.Signature.generateSignatureHeader(payload, secret)); assertTrue(Webhook.Signature.verifyHeader(payload, sigHeader, secret, 10, null)); } @@ -228,9 +223,7 @@ public void testHeaderContainsValidSignature() @Test public void testTimestampOffButNoTolerance() throws SignatureVerificationException, NoSuchAlgorithmException, InvalidKeyException { - final Map options = new HashMap<>(); - options.put("timestamp", Long.valueOf(12345L)); - final String sigHeader = generateSigHeader(options); + final String sigHeader = Webhook.Signature.generateSignatureHeader(payload, secret, 12345L); assertTrue(Webhook.Signature.verifyHeader(payload, sigHeader, secret, 0, null)); } @@ -238,11 +231,7 @@ public void testTimestampOffButNoTolerance() @Test public void testTimestampWithClock() throws SignatureVerificationException, NoSuchAlgorithmException, InvalidKeyException { - - final Map options = new HashMap<>(); - options.put("timestamp", 11L); - - final String sigHeader = generateSigHeader(options); + final String sigHeader = Webhook.Signature.generateSignatureHeader(payload, secret, 11L); final Clock clock = Clock.fixed(Instant.ofEpochMilli(1), ZoneId.of("UTC")); assertTrue(Webhook.Signature.verifyHeader(payload, sigHeader, secret, 10, clock)); @@ -251,11 +240,7 @@ public void testTimestampWithClock() @Test public void testTimestampWithClockOutsideTolerance() throws SignatureVerificationException, NoSuchAlgorithmException, InvalidKeyException { - - final Map options = new HashMap<>(); - options.put("timestamp", 11L); - - final String sigHeader = generateSigHeader(options); + final String sigHeader = Webhook.Signature.generateSignatureHeader(payload, secret, 11L); final Clock clock = Clock.fixed(Instant.ofEpochMilli(12), ZoneId.of("UTC")); assertTrue(Webhook.Signature.verifyHeader(payload, sigHeader, secret, 10, clock)); @@ -282,9 +267,7 @@ public void testStripeClientConstructEvent() + "\"," + "\"object\": \"event\",\"data\": {\"object\": {\"id\": \"rdr_123\",\"object\": \"terminal.reader\"}}}"; - final Map options = new HashMap<>(); - options.put("payload", payload); - final String sigHeader = generateSigHeader(options); + final String sigHeader = Webhook.Signature.generateSignatureHeader(payload, secret); final Event event = client.constructEvent(payload, sigHeader, secret); @@ -315,9 +298,7 @@ public void testStripeClientConstructEventWithTolerance() + "\"," + "\"object\": \"event\",\"data\": {\"object\": {\"id\": \"rdr_123\",\"object\": \"terminal.reader\"}}}"; - final Map options = new HashMap<>(); - options.put("payload", payload); - final String sigHeader = generateSigHeader(options); + final String sigHeader = Webhook.Signature.generateSignatureHeader(payload, secret); final Event event = client.constructEvent(payload, sigHeader, secret, 500); @@ -331,19 +312,116 @@ public void testStripeClientConstructEventWithTolerance() public void testConstructEventWithRawJson() throws StripeException, NoSuchAlgorithmException, InvalidKeyException { - final Event event = Webhook.constructEvent(payload, generateSigHeader(), secret); + final Event event = + Webhook.constructEvent( + payload, Webhook.Signature.generateSignatureHeader(payload, secret), secret); assertNotNull(event.getRawJsonObject()); } + @Test + public void testGenerateSignatureHeaderWithTimestamp() + throws NoSuchAlgorithmException, InvalidKeyException, SignatureVerificationException { + final long timestamp = 1609459200L; + final String header = Webhook.Signature.generateSignatureHeader(payload, secret, timestamp); + + assertTrue(Webhook.Signature.verifyHeader(payload, header, secret, 0, null)); + assertTrue(header.startsWith(String.format("t=%d,v1=", timestamp))); + } + + @Test + public void testGenerateSignatureHeaderWithCurrentTimestamp() + throws NoSuchAlgorithmException, InvalidKeyException, SignatureVerificationException { + final String header = Webhook.Signature.generateSignatureHeader(payload, secret); + + assertTrue(Webhook.Signature.verifyHeader(payload, header, secret, 10, null)); + } + + @Test + public void testGenerateSignatureHeaderRoundtrip() + throws NoSuchAlgorithmException, InvalidKeyException, SignatureVerificationException { + String payload = "test_payload"; + String secret = "whsec_test_secret"; + String header = Webhook.Signature.generateSignatureHeader(payload, secret); + // Should not throw: + assertTrue(Webhook.Signature.verifyHeader(payload, header, secret, 300)); + } + + @Test + public void testParseEventNotification() + throws NoSuchAlgorithmException, InvalidKeyException, SignatureVerificationException { + StripeClient client = new StripeClient(new LiveStripeResponseGetter()); + + final String v2Payload = + "{\n" + + " \"id\": \"evt_test_webhook\",\n" + + " \"object\": \"v2.core.event\",\n" + + " \"type\": \"v1.billing.meter.no_meter_found\",\n" + + " \"livemode\": false,\n" + + " \"created\": \"2022-02-15T00:27:45.330Z\"\n" + + "}"; + + final String header = Webhook.Signature.generateSignatureHeader(v2Payload, secret); + + final com.stripe.model.v2.core.EventNotification notification = + client.parseEventNotification(v2Payload, header, secret); + + assertNotNull(notification); + assertEquals("evt_test_webhook", notification.getId()); + } + + @Test + public void testMaybeExtractPassesThroughV1Event() { + final String v1Payload = "{\"id\": \"evt_test_webhook\", \"object\": \"event\"}"; + final JsonObject result = Webhook.maybeExtractFromCloudProviderEnvelope(v1Payload); + assertEquals("event", result.get("object").getAsString()); + } + + @Test + public void testMaybeExtractPassesThroughV2Event() { + final String v2Payload = "{\"id\": \"evt_test_webhook\", \"object\": \"v2.core.event\"}"; + final JsonObject result = Webhook.maybeExtractFromCloudProviderEnvelope(v2Payload); + assertEquals("v2.core.event", result.get("object").getAsString()); + } + + @Test + public void testMaybeExtractThrowsForUnrecognizedFormat() { + final String unknownPayload = "{\"id\": \"evt_test_webhook\", \"object\": \"something_else\"}"; + assertThrows( + IllegalArgumentException.class, + () -> { + Webhook.maybeExtractFromCloudProviderEnvelope(unknownPayload); + }); + } + + @Test + public void testConstructEventWithoutVerification() { + final Event event = Webhook.constructEventWithoutVerification(payload); + + assertNotNull(event); + assertEquals("evt_test_webhook", event.getId()); + } + + @Test + public void testConstructEventWithoutVerificationRejectsV2Payload() { + final String v2Payload = + "{\n \"id\": \"evt_test_webhook\",\n \"object\": \"v2.core.event\"\n}"; + + Throwable exception = + assertThrows( + IllegalArgumentException.class, + () -> { + Webhook.constructEventWithoutVerification(v2Payload); + }); + assertTrue(exception.getMessage().contains("parseEventNotification")); + } + @Test public void testConstructEventRejectsV2Payload() throws NoSuchAlgorithmException, InvalidKeyException { final String v2Payload = "{\n \"id\": \"evt_test_webhook\",\n \"object\": \"v2.core.event\"\n}"; - final Map options = new HashMap<>(); - options.put("payload", v2Payload); - final String sigHeader = generateSigHeader(options); + final String sigHeader = Webhook.Signature.generateSignatureHeader(v2Payload, secret); Throwable exception = assertThrows( @@ -351,7 +429,7 @@ public void testConstructEventRejectsV2Payload() () -> { Webhook.constructEvent(v2Payload, sigHeader, secret); }); - assertTrue(exception.getMessage().contains("StripeClient.parseEventNotification")); + assertTrue(exception.getMessage().contains("parseEventNotification")); } @Test @@ -361,9 +439,7 @@ public void testClientConstructEventRejectsV2Payload() final String v2Payload = "{\n \"id\": \"evt_test_webhook\",\n \"object\": \"v2.core.event\"\n}"; - final Map options = new HashMap<>(); - options.put("payload", v2Payload); - final String sigHeader = generateSigHeader(options); + final String sigHeader = Webhook.Signature.generateSignatureHeader(v2Payload, secret); Throwable exception = assertThrows( @@ -371,6 +447,6 @@ public void testClientConstructEventRejectsV2Payload() () -> { client.constructEvent(v2Payload, sigHeader, secret); }); - assertTrue(exception.getMessage().contains("StripeClient.parseEventNotification")); + assertTrue(exception.getMessage().contains("parseEventNotification")); } }