Spring Integration and Stripe Integration: How RequestHandlerRetryAdvice Retries a UUID.randomUUID() Keyed ServiceActivator After Stripe Commits ch_A and Throws SocketTimeoutException Generating UUID_B Producing ch_B, a JdbcChannelMessageStore Transactional Poller Rollback Re-queues the Billing Message After Stripe Commits ch_A Causing the Next Poll to Generate UUID_B Producing ch_B, and a JdbcMessageGroupStore Aggregator Group Crash Before completeGroup() Causes the Release to Fire Twice with UUID_B

This post is part of the ongoing series on Stripe idempotency failure modes in JVM frameworks. Earlier entries covered Spring Data JPA (JDBC transaction boundary and Stripe commit ordering), Spring Batch (step retry, chunk rollback, and JobRepository state divergence), Spring Integration Batch (the integration layer that wraps Spring Batch jobs in message channels), Spring Data Cassandra (WriteTimeoutException treated as write failure, LWT IF NOT EXISTS timeout, ConsistencyLevel.ONE guard read after LOCAL_QUORUM write), and Spring Cloud Kubernetes (ConfigMap Watch cluster-wide @RefreshScope refresh, Fabric8 GC pause dual-active leader, rolling deployment key-formula split-brain). This post covers Spring Integration — the enterprise integration patterns framework built around MessageChannel, ServiceActivator, RetryAdvice, and durable message stores — and specifically the three failure modes that arise from the way Spring Integration re-invokes handlers, manages transactional message delivery, and tracks aggregator group state across JVM restarts.

The three failure modes are: a developer who adds RequestHandlerRetryAdvice to a @ServiceActivator that calls Stripe, where the service activator generates UUID.randomUUID() on each invocation to produce the Stripe idempotency key, and where a SocketTimeoutException fires on the HTTP response read after Stripe has already committed the charge — meaning ch_A was created on the Stripe side but the response acknowledgment was lost in transit — causing RequestHandlerRetryAdvice to re-invoke the handler, generating a new UUID_B, and Stripe to commit an independent ch_B for the same billing intent; a developer who uses JdbcChannelMessageStore to back a QueueChannel and configures transactional polling so that the Stripe call and a downstream billing_records database write are both wrapped in the same transaction, where the database write throws DataIntegrityViolationException, the transaction rolls back, the message is returned to the store and made available for the next poll cycle, and the next poll invokes the service activator again, which generates a new UUID_B and produces ch_B; and a developer who uses JdbcMessageGroupStore to back an aggregator that collects per-line-item billing events into a complete order aggregate before calling Stripe, where the aggregator releases the group and the service activator commits ch_A, but a JVM crash occurs in the window between the Stripe call returning and JdbcMessageGroupStore.completeGroup() persisting the completion marker, so that on restart the group appears incomplete, is re-evaluated, and the service activator generates UUID_B and commits ch_B.

Stack for examples: Spring Boot 3.x, Spring Integration 6.x (spring-boot-starter-integration), Spring Integration JDBC (spring-integration-jdbc), Spring Retry (spring-retry), Stripe Java SDK v24+, PostgreSQL 15 via Testcontainers, WireMock 3.x, JUnit 5.

Background: what Spring Integration adds and why it interacts with billing idempotency differently

Spring Integration is an implementation of the enterprise integration patterns described by Hohpe and Woolf: MessageChannel, MessageHandler, Aggregator, Splitter, Router, and the various advice mechanisms that wrap handler invocations. When a developer builds a billing pipeline in Spring Integration, they are assembling a directed graph of channels and handlers rather than writing imperative request-handling code. This architectural difference creates idempotency failure surfaces that simply do not exist in a synchronous Spring Boot REST controller calling Stripe directly.

The first distinct surface is handler re-invocation by advice. In a standard REST controller, if the HTTP call to Stripe throws a SocketTimeoutException, the exception propagates up the call stack to the controller method, which may catch it, log it, and return a 500. Nothing re-invokes the method automatically. In Spring Integration, RequestHandlerRetryAdvice wraps the handler invocation and will re-call the handler method as many times as the configured RetryTemplate allows. Each re-invocation is a full, fresh call into the handler method with no state shared from the previous attempt — meaning any local variable computed inside the method, such as UUID.randomUUID(), is computed fresh on each attempt. The message object passed to the handler is the same object (headers and payload are preserved), but the method body runs from the top.

The second distinct surface is transactional message delivery semantics. When a poller is configured with a TransactionInterceptor wrapping the polling operation, the message receive and the handler processing happen inside the same database transaction. If that transaction rolls back, the message is effectively un-received — it reappears in the JDBC message store and will be picked up by the next poll. From the perspective of the service activator, this looks indistinguishable from the first invocation: the same message arrives, the handler runs, and any state computed in the previous (rolled-back) handler invocation is invisible. A fresh UUID.randomUUID() call produces a new key, and a fresh Stripe API call produces a new charge.

The third distinct surface is message group store durability and completion semantics. Spring Integration's aggregator persists incomplete message groups to a MessageGroupStore so that groups survive application restarts. The completion of a group is recorded in the store by completeGroup() or by removing the group from the store entirely. But the Stripe API call that fires when a group is released happens in application code — it is not part of the store's own transactional write. The window between a successful Stripe API return and the persistence of group completion is a crash window. If the JVM dies in that window, the store still shows the group as incomplete and Spring Integration will re-release it on restart. The service activator generates a new UUID and Stripe sees an independent charge request.

Mode 1: RequestHandlerRetryAdvice retries UUID.randomUUID() keyed ServiceActivator after SocketTimeoutException on response path — UUID_B — ch_B

Setup

The developer is building a subscription billing pipeline. Billing messages arrive on a DirectChannel from a scheduler or upstream event source. A @ServiceActivator bean receives the message, extracts the subscription context, generates a Stripe idempotency key with UUID.randomUUID(), and calls the Stripe Charges (or PaymentIntents) API. To handle transient network errors — DNS resolution failures, TCP connection timeouts during connection establishment, brief Stripe API outages — the developer adds RequestHandlerRetryAdvice to the service activator's advice chain. The retry template is configured to retry on IOException and on ApiConnectionException (the Stripe SDK’s network-level exception), with three maximum attempts and a two-second fixed backoff.

The buggy implementation looks like this:

// BUGGY — UUID.randomUUID() inside the service activator
// generates a new key on every retry attempt

@Configuration
@EnableIntegration
public class BillingIntegrationConfig {

    @Bean
    public DirectChannel billingRequestChannel() {
        return new DirectChannel();
    }

    @Bean
    @ServiceActivator(inputChannel = "billingRequestChannel",
                      adviceChain = "retryAdvice")
    public MessageHandler billingServiceActivator(StripeClient stripeClient) {
        return message -> {
            BillingRequest req = (BillingRequest) message.getPayload();

            // BUG: UUID generated fresh on every invocation.
            // RequestHandlerRetryAdvice re-invokes this lambda on each retry.
            // A SocketTimeoutException after ch_A commits will trigger a retry
            // with UUID_B, creating ch_B.
            String idempotencyKey = UUID.randomUUID().toString();

            ChargeCreateParams params = ChargeCreateParams.builder()
                .setAmount(req.getAmountCents())
                .setCurrency("usd")
                .setCustomer(req.getStripeCustomerId())
                .setDescription("Subscription billing: " + req.getSubscriptionId())
                .build();

            RequestOptions options = RequestOptions.builder()
                .setIdempotencyKey(idempotencyKey)
                .build();

            try {
                Charge charge = Charge.create(params, options);
                // downstream processing with charge.getId()...
            } catch (StripeException e) {
                throw new RuntimeException("Stripe charge failed", e);
            }
        };
    }

    @Bean
    public RequestHandlerRetryAdvice retryAdvice() {
        RequestHandlerRetryAdvice advice = new RequestHandlerRetryAdvice();
        RetryTemplate retryTemplate = RetryTemplate.builder()
            .maxAttempts(3)
            .fixedBackoff(2000)
            .retryOn(IOException.class)
            .retryOn(ApiConnectionException.class)
            .build();
        advice.setRetryTemplate(retryTemplate);
        return advice;
    }
}

This code follows a pattern that looks correct from a Spring Integration tutorial perspective. The adviceChain attribute on @ServiceActivator registers the retry advice to wrap the handler, and the RetryTemplate correctly identifies the exception types that warrant a retry. The developer has read the Spring Integration documentation on RequestHandlerRetryAdvice and believes they have robust error handling in place.

The failure path

At t=0, a billing message for subscription sub_123 arrives on billingRequestChannel. Spring Integration dispatches it to the service activator. The handler lambda begins executing. UUID.randomUUID() produces UUID_A. ChargeCreateParams is assembled. The Stripe SDK transmits the HTTP POST to api.stripe.com/v1/charges, including the Idempotency-Key: UUID_A header. The request bytes reach Stripe’s servers. Stripe validates the charge, runs fraud checks, communicates with the card network, and commits ch_A to its internal ledger. Stripe writes its HTTP response (200 OK with the charge object JSON) back into the TCP connection.

At this point — after ch_A is committed but before the response bytes are delivered to the JVM — the TCP socket times out. This can happen because a NAT gateway or load balancer in the network path has a shorter idle timeout than the Stripe API response latency under the current load. The Stripe SDK’s HTTP client throws a SocketTimeoutException (or wraps it in an ApiConnectionException, which is an IOException subtype). The exception bubbles up through the Stripe SDK, through the handler lambda, and is caught by RequestHandlerRetryAdvice.

RequestHandlerRetryAdvice records the first attempt as failed. The RetryTemplate evaluates the exception against its retry policy: ApiConnectionException matches the retry-on list. Two seconds later, the second attempt begins. RequestHandlerRetryAdvice re-invokes the handler lambda with the same message object. The lambda body begins executing again from the top. UUID.randomUUID() executes again — this time producing UUID_B. The Stripe API call goes out with Idempotency-Key: UUID_B. Stripe has no record of UUID_B. It processes the charge as a new, independent request and commits ch_B. The customer is charged twice.

The critical point is the nature of SocketTimeoutException. A SocketTimeoutException on the response read path is not the same as a connection refused error or a DNS resolution failure. Those errors mean the request never reached Stripe. A SocketTimeoutException on the response read means the TCP connection was established, the request was transmitted, and Stripe’s response was in transit — but the socket’s read timeout expired before all response bytes arrived at the JVM. Stripe may have already committed the charge. Retrying with a different idempotency key is therefore not safe.

Developer misconception

The developer’s mental model is: “I have idempotency keys — if Stripe gets the request twice it will deduplicate.” This is the correct understanding of how Stripe idempotency keys work in isolation. Stripe’s deduplication logic is: if a request arrives with an idempotency key that matches a request processed within the last 24 hours, return the cached response from the original request rather than processing the charge again. The mental model breaks down on the word “same.” Stripe deduplicates when the same key is sent both times. UUID_A and UUID_B are different keys. Stripe has no mechanism to know that UUID_B is intended as a retry of UUID_A.

The secondary misconception is about what SocketTimeoutException means in terms of request delivery. Many developers reason: “an exception was thrown, so the operation didn’t complete, so it’s safe to retry.” This reasoning is correct for purely local operations like a failed database insert (where the exception indicates the write did not commit). It is not correct for network calls to external systems, where the exception indicates a failure in the communication path, not necessarily a failure in the remote system’s processing. The remote system may have processed the request successfully; only the response delivery failed.

The third misconception is about RequestHandlerRetryAdvice’s execution model. The developer may believe that RequestHandlerRetryAdvice retries “the operation” in some abstract sense, perhaps reusing state from the first attempt. In fact, RequestHandlerRetryAdvice retries by re-invoking the handler method. Any value computed inside the handler method — including UUID.randomUUID() — is computed fresh. The advice has no ability to intercept or reuse the UUID computed in the prior attempt, because it never had access to that value; it only sees the method call as a whole, not the variables inside it.

The fix

The correct approach is to compute the idempotency key before the message enters the service activator and store it as a message header. Message headers in Spring Integration are part of the Message<T> envelope and are preserved across RequestHandlerRetryAdvice retry attempts, because the same Message object is passed to the handler on every invocation. The key should be derived deterministically from the billing intent’s identifying fields — subscription ID, billing period, amount in cents — so that even if the message is re-delivered from a durable channel (covered in Mode 2), the same key is used.

// FIXED — stable idempotency key computed before channel entry,
// stored as a message header, read inside service activator

// Step 1: Enrich the message with the idempotency key header
// before it enters the billing channel. This can be a transformer,
// a header enricher, or the message source itself.

@Bean
@Transformer(inputChannel = "rawBillingChannel",
             outputChannel = "billingRequestChannel")
public MessageHandler billingKeyEnricher() {
    return message -> {
        BillingRequest req = (BillingRequest) message.getPayload();
        String stableKey = computeIdempotencyKey(
            req.getSubscriptionId(),
            req.getBillingPeriod(),
            req.getAmountCents()
        );
        MessageBuilder.fromMessage(message)
            .setHeader("stripe-idempotency-key", stableKey)
            .build();
        // Spring Integration @Transformer should return the new message;
        // returning from a MessageHandler means using a HeaderEnricher instead.
    };
}

// Better: use HeaderEnricher directly in the integration flow

@Bean
public IntegrationFlow billingFlow(StripeClient stripeClient) {
    return IntegrationFlow
        .from("rawBillingChannel")
        .enrichHeaders(h -> h.headerFunction(
            "stripe-idempotency-key",
            message -> {
                BillingRequest req = (BillingRequest) message.getPayload();
                return computeIdempotencyKey(
                    req.getSubscriptionId(),
                    req.getBillingPeriod(),
                    req.getAmountCents()
                );
            }
        ))
        .handle(billingServiceActivator(stripeClient),
                e -> e.advice(retryAdvice()))
        .get();
}

// Step 2: Service activator reads the key from headers — same key on every retry

@Bean
public MessageHandler billingServiceActivator(StripeClient stripeClient) {
    return message -> {
        BillingRequest req = (BillingRequest) message.getPayload();

        // Read the pre-computed stable key from the message header.
        // RequestHandlerRetryAdvice passes the same Message object on every
        // retry attempt, so this header value is identical across all attempts.
        String idempotencyKey = (String) message.getHeaders()
            .get("stripe-idempotency-key");

        if (idempotencyKey == null) {
            throw new IllegalStateException(
                "stripe-idempotency-key header missing — " +
                "message must be enriched before reaching billing activator"
            );
        }

        ChargeCreateParams params = ChargeCreateParams.builder()
            .setAmount(req.getAmountCents())
            .setCurrency("usd")
            .setCustomer(req.getStripeCustomerId())
            .setDescription("Subscription billing: " + req.getSubscriptionId())
            .build();

        RequestOptions options = RequestOptions.builder()
            .setIdempotencyKey(idempotencyKey)
            .build();

        try {
            Charge charge = Charge.create(params, options);
            // downstream processing...
        } catch (StripeException e) {
            throw new RuntimeException("Stripe charge failed", e);
        }
    };
}

// Step 3: Stable key derivation — SHA-256 of billing intent fields

private static String computeIdempotencyKey(
        String subscriptionId, String billingPeriod, long amountCents) {
    try {
        MessageDigest digest = MessageDigest.getInstance("SHA-256");
        String input = subscriptionId + "|" + billingPeriod + "|" + amountCents;
        byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8));
        return HexFormat.of().formatHex(hash);
    } catch (NoSuchAlgorithmException e) {
        throw new IllegalStateException("SHA-256 unavailable", e);
    }
}

With this fix, the header enricher computes the idempotency key once, before the message enters the retry-adviced handler. RequestHandlerRetryAdvice re-invokes billingServiceActivator with the same Message object, which carries the same stripe-idempotency-key header on every attempt. If Stripe committed ch_A on the first attempt and the second attempt arrives with the same key, Stripe returns the cached response from ch_A without creating a new charge. The customer is not double-charged.

The key derivation uses SHA-256(subscriptionId + billingPeriod + amountCents) rather than a random UUID. This means the key is stable not only across retry attempts within a single RequestHandlerRetryAdvice session, but also across message redelivery scenarios (Mode 2), application restarts, and any other situation where the same billing intent needs to be presented to Stripe more than once. Stripe’s 24-hour idempotency window applies to the key regardless of how it was derived; the stability of the key is what guarantees deduplication works correctly.

Note that the retry advice should also be configured to not retry on all StripeException subtypes indiscriminately. CardException (the card was declined) should not be retried. Only genuinely transient exceptions like ApiConnectionException (network-level errors) and RateLimitException (Stripe rate limiting, retry with backoff) warrant automatic retries.

Mode 2: JdbcChannelMessageStore-backed QueueChannel with transactional PollingConsumer — Stripe commits ch_A, downstream DB write rollback re-queues billing message, next poll generates UUID_B, Stripe commits ch_B

Setup

The developer wants durable billing message processing: if the application restarts before a billing message is fully processed, the message should not be lost. They use a QueueChannel backed by JdbcChannelMessageStore, which persists messages to the application’s relational database (the same database used for billing_records). A PollingConsumer polls the channel at a configured rate. To achieve atomicity between message consumption and downstream effects, the developer configures the poller with a TransactionInterceptor: the polling operation, the message receive from the JDBC store, the service activator logic (including the Stripe call), and the write to the billing_records table are all wrapped in a single Spring-managed database transaction. The intent is “either everything succeeds and the message is consumed, or everything fails and the message is re-queued.”

The buggy implementation:

// BUGGY — UUID.randomUUID() inside service activator
// + transactional poller wrapping Stripe call + DB write
// When DB write fails and transaction rolls back,
// message re-queues and next poll generates UUID_B.

@Configuration
@EnableIntegration
public class DurableBillingConfig {

    @Bean
    public JdbcChannelMessageStore jdbcChannelMessageStore(DataSource dataSource) {
        JdbcChannelMessageStore store = new JdbcChannelMessageStore(dataSource);
        store.setChannelMessageStoreQueryProvider(
            new PostgresChannelMessageStoreQueryProvider()
        );
        return store;
    }

    @Bean
    public QueueChannel durableBillingChannel(
            JdbcChannelMessageStore store) {
        MessageGroupQueue queue = new MessageGroupQueue(store, "billing-queue");
        return new QueueChannel(queue);
    }

    @Bean
    public IntegrationFlow durableBillingFlow(
            QueueChannel durableBillingChannel,
            BillingServiceActivator billingActivator,
            PlatformTransactionManager transactionManager) {
        return IntegrationFlow
            .from(durableBillingChannel,
                  c -> c.poller(Pollers.fixedDelay(1000)
                      .transactional(transactionManager)))
            .handle(billingActivator)
            .get();
    }
}

@Component
public class BillingServiceActivator {

    private final StripeClient stripeClient;
    private final BillingRecordRepository billingRecordRepository;

    public BillingServiceActivator(StripeClient stripeClient,
                                   BillingRecordRepository billingRecordRepository) {
        this.stripeClient = stripeClient;
        this.billingRecordRepository = billingRecordRepository;
    }

    @ServiceActivator
    public void handle(Message<BillingRequest> message) throws StripeException {
        BillingRequest req = message.getPayload();

        // BUG: UUID generated fresh on each invocation.
        // When the transaction below rolls back, this message is re-queued.
        // The next invocation generates UUID_B.
        String idempotencyKey = UUID.randomUUID().toString();

        ChargeCreateParams params = ChargeCreateParams.builder()
            .setAmount(req.getAmountCents())
            .setCurrency("usd")
            .setCustomer(req.getStripeCustomerId())
            .build();

        RequestOptions options = RequestOptions.builder()
            .setIdempotencyKey(idempotencyKey)
            .build();

        // Stripe call committed ch_A with UUID_A.
        Charge charge = Charge.create(params, options);

        // This write may fail with DataIntegrityViolationException
        // if billing_records.subscription_id + billing_period has a unique constraint
        // and the record was already written by a concurrent instance.
        // When this exception propagates, the @Transactional boundary rolls back
        // the entire transaction including the JdbcChannelMessageStore receive.
        BillingRecord record = new BillingRecord(
            req.getSubscriptionId(),
            req.getBillingPeriod(),
            charge.getId(),
            req.getAmountCents()
        );
        billingRecordRepository.save(record); // throws DataIntegrityViolationException
    }
}

The developer’s reasoning is sound on the surface: by wrapping everything in a transaction, they ensure consistency between the JDBC message store state and the billing records database. But the Stripe API call is not part of the database transaction. The transaction controls only local database operations. The Stripe call is a non-transactional side effect — it commits a charge on Stripe’s servers regardless of what happens to the local database transaction.

The failure path

A billing message for subscription sub_456, billing period 2026-10, arrives in the JDBC-backed queue. The poller fires and begins a database transaction. Within the transaction: the JdbcChannelMessageStore marks the message as being processed (it does not delete it yet; the delete happens on transaction commit). The service activator’s handle() method is called. UUID.randomUUID() produces UUID_A. The Stripe API is called with Idempotency-Key: UUID_A. Stripe processes the charge and commits ch_A. The Stripe API returns the charge object. The service activator proceeds to the billingRecordRepository.save(record) call.

Simultaneously, a second application instance (perhaps a canary pod in a Kubernetes rolling deployment) processes the same billing period for the same subscription. It wins the race: its billingRecordRepository.save(record) commits first, and the unique constraint on (subscription_id, billing_period) is now satisfied by that row. The current instance’s save(record) throws DataIntegrityViolationException. The exception propagates out of the handle() method, out of the service activator, and up to the transactional poller’s transaction boundary. Spring’s transaction management rolls back the transaction.

Rolling back the transaction has these effects on the local database: the JdbcChannelMessageStore message deletion (which was part of the transaction) is reversed, and the message reappears in the queue as available for polling. The billing record insert (which threw the exception) was never committed. Crucially, the Stripe charge ch_A is unaffected by the rollback: Stripe’s servers committed the charge before the exception was thrown, and Stripe’s state is not managed by the local database transaction.

The next poll cycle fires. The same billing message is received from the queue again. The service activator’s handle() method runs from the beginning. UUID.randomUUID() produces UUID_B. The Stripe API is called with Idempotency-Key: UUID_B. Because UUID_B ≠ UUID_A, Stripe has no deduplication record for UUID_B. It processes the charge as a new, independent request and commits ch_B. The customer is now charged twice: ch_A and ch_B.

This failure mode is particularly insidious because the developer intended transactional polling to prevent exactly this kind of duplication. The rollback mechanism works correctly for purely local operations. The flaw is the assumption that “rolling back the transaction undoes the Stripe call” — which it does not and cannot, because Stripe’s internal state is not within the scope of the local database transaction.

Developer misconception

The developer’s mental model is: “JdbcChannelMessageStore with transactional polling gives me exactly-once processing — if the processing fails and the transaction rolls back, the message is retried with the same state.” The phrase “same state” is the source of the error. The message payload and headers are indeed identical on the retry — they have not changed. But “same state” is conflated with “fresh invocation of the handler, producing the same observable behavior as the first invocation,” which is only true for idempotent handlers. A handler that calls UUID.randomUUID() is not idempotent. Neither is a handler that calls Stripe without a stable idempotency key.

The deeper misconception is about what transactional message processing guarantees in the presence of external, non-transactional side effects. Transactional message processing with a JDBC message store provides at-least-once delivery with local atomicity. It guarantees that if the local transaction commits, the message was consumed and the local database writes happened atomically. It does not provide exactly-once delivery for external API calls. Achieving exactly-once semantics for external APIs requires idempotency on the external API side (which Stripe provides via idempotency keys) combined with stable, deterministic idempotency key selection (which the application must provide).

Developers who are familiar with message queue systems like JMS or RabbitMQ with acknowledgment-based delivery sometimes carry over the mental model that “if I don’t ack the message, it will be redelivered with the same content and I can process it identically.” This is correct for idempotent operations (writing a fixed record to a database with an upsert) but not for operations that generate new state on each invocation (like generating a new random UUID).

The fix

There are two complementary fixes. The first is the same as Mode 1: pre-compute a stable idempotency key and store it as a message header before the message enters the durable channel. The second is to check for an existing BillingRecord before calling Stripe, so that if the previous attempt’s Stripe call succeeded but the DB write failed, the retry detects the existing record and does not call Stripe again.

// FIX APPROACH 1 — stable idempotency key in message header
// Pre-compute the key before the message is sent to the durable channel.
// The header is persisted with the message in JdbcChannelMessageStore
// and survives transaction rollbacks and application restarts.

// When creating the billing message (before it enters the queue):
public Message<BillingRequest> createBillingMessage(BillingRequest req) {
    String idempotencyKey = computeIdempotencyKey(
        req.getSubscriptionId(),
        req.getBillingPeriod(),
        req.getAmountCents()
    );
    return MessageBuilder.withPayload(req)
        .setHeader("stripe-idempotency-key", idempotencyKey)
        .build();
}

// Service activator reads the key from the header — same key on every retry
@ServiceActivator
public void handle(Message<BillingRequest> message) throws StripeException {
    BillingRequest req = message.getPayload();
    String idempotencyKey = (String) message.getHeaders()
        .get("stripe-idempotency-key");

    ChargeCreateParams params = ChargeCreateParams.builder()
        .setAmount(req.getAmountCents())
        .setCurrency("usd")
        .setCustomer(req.getStripeCustomerId())
        .build();

    RequestOptions options = RequestOptions.builder()
        .setIdempotencyKey(idempotencyKey)
        .build();

    Charge charge = Charge.create(params, options);

    BillingRecord record = new BillingRecord(
        req.getSubscriptionId(),
        req.getBillingPeriod(),
        charge.getId(),
        req.getAmountCents()
    );
    billingRecordRepository.save(record);
}
// FIX APPROACH 2 — idempotency guard: check for existing BillingRecord
// before calling Stripe. If a record exists with a committed charge ID,
// skip the Stripe call entirely.

@ServiceActivator
public void handle(Message<BillingRequest> message) throws StripeException {
    BillingRequest req = message.getPayload();
    String idempotencyKey = (String) message.getHeaders()
        .get("stripe-idempotency-key");

    // Check for an existing billing record with this idempotency key.
    // This handles the case where a previous attempt committed ch_A to Stripe
    // but failed to write the BillingRecord before the transaction rolled back.
    // In that scenario, the previous attempt's BillingRecord was rolled back too,
    // so this check alone does not prevent the double charge.
    // It must be combined with the stable idempotency key (Approach 1).
    Optional<BillingRecord> existing =
        billingRecordRepository.findByIdempotencyKey(idempotencyKey);
    if (existing.isPresent()) {
        // Previous attempt already committed a charge for this billing intent.
        // Stripe will deduplicate the call (same key), but skip to avoid
        // unnecessary API calls and log noise.
        log.info("Skipping Stripe call — BillingRecord already exists for key {}",
                 idempotencyKey);
        return;
    }

    ChargeCreateParams params = ChargeCreateParams.builder()
        .setAmount(req.getAmountCents())
        .setCurrency("usd")
        .setCustomer(req.getStripeCustomerId())
        .build();

    RequestOptions options = RequestOptions.builder()
        .setIdempotencyKey(idempotencyKey)
        .build();

    Charge charge = Charge.create(params, options);

    // Store the idempotency key with the billing record for future guard checks.
    BillingRecord record = new BillingRecord(
        req.getSubscriptionId(),
        req.getBillingPeriod(),
        charge.getId(),
        req.getAmountCents(),
        idempotencyKey  // store the key for future lookups
    );
    billingRecordRepository.save(record);
}

Approach 1 is sufficient to prevent the double charge, because Stripe’s idempotency deduplication will return the cached ch_A response when the stable key is presented again. Approach 2 is a defense-in-depth measure that avoids redundant API calls and handles edge cases where the Stripe idempotency window has expired (Stripe’s 24-hour deduplication window does not cover billing retries that span multiple days, but a durable BillingRecord in your own database has no expiry). The two approaches are complementary and should both be used in production billing systems.

One additional concern with this setup: wrapping a Stripe API call inside a database transaction is semantically problematic even when idempotency keys are stable. The Stripe call is a non-transactional side effect that commits regardless of the database transaction’s outcome. The preferred pattern for systems that need both durability and correctness is the outbox pattern: write a pending BillingAttempt record within the transaction (instead of calling Stripe directly), and have a separate background process read from the outbox, call Stripe with the stable key stored in the outbox record, and update the outbox record to completed. This fully decouples Stripe call execution from the transactional message consumption. The same stable key derivation applies. This pattern is also discussed in the context of Spring Data JPA and Stripe.

Mode 3: JdbcMessageGroupStore aggregator group crash before completeGroup() — group released twice — UUID_B — ch_B

Setup

The developer is building an order billing pipeline where an order consists of multiple line items, each of which arrives as a separate billing event message. Before billing the customer, the application needs to aggregate all line items for an order into a complete order total. The developer uses Spring Integration’s Aggregator component (AggregatorSpec in the DSL) with a ReleaseStrategy that fires when all N line-item messages for a given orderId correlation key have arrived. The aggregator uses JdbcMessageGroupStore for durability, so that if the application restarts mid-aggregation, the partially-collected group is not lost. The output channel of the aggregator feeds a service activator that calls Stripe with the order total.

The aggregator flow:

// BUGGY — UUID.randomUUID() in service activator downstream of aggregator
// JVM crash between Stripe commit and completeGroup() causes double release

@Configuration
@EnableIntegration
public class OrderBillingAggregatorConfig {

    @Bean
    public JdbcMessageGroupStore jdbcMessageGroupStore(DataSource dataSource) {
        JdbcMessageGroupStore store = new JdbcMessageGroupStore(dataSource);
        store.setRegion("order-billing");
        return store;
    }

    @Bean
    public IntegrationFlow orderBillingFlow(
            JdbcMessageGroupStore jdbcMessageGroupStore,
            OrderBillingServiceActivator orderBillingActivator) {
        return IntegrationFlow
            .from("lineItemChannel")
            .aggregate(a -> a
                .messageStore(jdbcMessageGroupStore)
                .correlationStrategy(
                    m -> ((LineItemEvent) m.getPayload()).getOrderId()
                )
                .releaseStrategy(group -> {
                    // Release when the group contains the expected number of items
                    LineItemEvent first = (LineItemEvent)
                        group.getMessages().iterator().next().getPayload();
                    return group.size() == first.getTotalLineItemCount();
                })
                .expireGroupsUponCompletion(true)
                .sendPartialResultOnExpiry(false)
            )
            .handle(orderBillingActivator)
            .get();
    }
}

@Component
public class OrderBillingServiceActivator {

    private final StripeClient stripeClient;

    public OrderBillingServiceActivator(StripeClient stripeClient) {
        this.stripeClient = stripeClient;
    }

    @ServiceActivator
    public void handle(Message<List<LineItemEvent>> message) throws StripeException {
        List<LineItemEvent> lineItems =
            (List<LineItemEvent>) message.getPayload();

        String orderId = lineItems.get(0).getOrderId();
        long totalAmountCents = lineItems.stream()
            .mapToLong(LineItemEvent::getAmountCents)
            .sum();

        // BUG: UUID generated fresh on each invocation.
        // If the JVM crashes after Stripe commits ch_A but before
        // JdbcMessageGroupStore marks the group complete, the group
        // will be re-released on restart and this will generate UUID_B.
        String idempotencyKey = UUID.randomUUID().toString();

        ChargeCreateParams params = ChargeCreateParams.builder()
            .setAmount(totalAmountCents)
            .setCurrency("usd")
            .setCustomer(lineItems.get(0).getStripeCustomerId())
            .setDescription("Order billing: " + orderId)
            .build();

        RequestOptions options = RequestOptions.builder()
            .setIdempotencyKey(idempotencyKey)
            .build();

        // ch_A committed here. If JVM crashes immediately after this returns,
        // JdbcMessageGroupStore still shows the group as active.
        Charge charge = Charge.create(params, options);

        log.info("Order {} billed: charge {}", orderId, charge.getId());
        // completeGroup() is called by Spring Integration internally
        // after this handler returns, as part of expireGroupsUponCompletion logic.
        // That persistence happens AFTER this method returns — it is not atomic
        // with the Stripe call.
    }
}

The developer has set expireGroupsUponCompletion(true), which instructs Spring Integration to remove the group from the JdbcMessageGroupStore after the release strategy fires and the aggregated message is sent to the output channel. This removal is the mechanism that prevents the group from being released twice — once the group is removed from the store, it can never be re-released. But the removal happens after the output channel message is delivered, which means it happens after the service activator returns. The Stripe call is inside the service activator. The JVM crash window is between the Stripe call returning and the group removal being written to the JDBC store.

The failure path

An order with orderId=ORDER_789 has three line items. Over a period of seconds, all three line-item messages arrive on lineItemChannel. The aggregator stores each in the JdbcMessageGroupStore under the correlation key ORDER_789. When the third message arrives, the release strategy returns true. Spring Integration builds the aggregated message containing all three LineItemEvent payloads and sends it to the output channel. The OrderBillingServiceActivator.handle() method is called.

Inside handle(): UUID.randomUUID() produces UUID_A. The total order amount is computed as 14999 cents. The Stripe API is called with Idempotency-Key: UUID_A. Stripe commits ch_A. The Stripe SDK returns the charge object. handle() logs the charge ID and returns. Spring Integration’s aggregator completion logic begins executing: it calls jdbcMessageGroupStore.completeGroup("ORDER_789") (or removes the group, depending on configuration). This requires a database write to the INT_MESSAGE_GROUP or equivalent table.

The JVM is killed (OOM killer, SIGKILL from orchestrator, hardware failure) in the nanosecond-to-millisecond window between handle() returning and the database write completing. The database write never executes. The JDBC store retains the group for ORDER_789 with all three line-item messages and no completion marker.

On application restart, Spring Integration initializes. It reads the JdbcMessageGroupStore and finds an active group for ORDER_789 with three messages. Depending on the aggregator’s configuration and the Spring Integration version’s restart behavior, one of two things happens: either the aggregator re-evaluates the group and the release strategy fires again immediately (because the group already has the required number of messages), or a timeout or re-delivery of one of the original line-item messages causes the group to be re-evaluated. In either case, OrderBillingServiceActivator.handle() is called again.

UUID.randomUUID() produces UUID_B. The Stripe API is called with Idempotency-Key: UUID_B. Stripe has no record of UUID_B. It processes the charge as an independent request and commits ch_B. The customer is charged twice for ORDER_789.

The crash window is real and observable in production. Kubernetes pods are killed by the OOM killer when memory pressure spikes. Java applications on heap-constrained containers commonly hit OOM conditions during high-load billing runs. A single JVM kill after Stripe commits but before the JDBC write is all that is needed to trigger the double charge. The fact that the JDBC store makes the group durable is precisely what causes the double charge: without durability, the group would simply be lost on restart (no double charge, but a missed charge). With durability but without idempotency, the group is re-processed and Stripe is called again with a fresh UUID.

Developer misconception

The developer’s mental model is: “If I use JdbcMessageGroupStore, my aggregator is crash-safe — the group won’t be processed twice because JDBC is durable.” This conflates two distinct properties: durability (the data survives a crash) and exactly-once execution (the downstream effect happens at most once). JdbcMessageGroupStore provides durability. It does not provide exactly-once execution of the handlers downstream of the aggregator. The JDBC store’s durability prevents the loss of the group, but it cannot prevent the re-execution of non-idempotent side effects that happen outside the store’s transactional boundary.

A related misconception is about the atomicity of the aggregator’s release-and-complete sequence. Developers sometimes assume that when the aggregator releases a group, the entire sequence — build aggregate message, send to output channel, process downstream, mark group complete — is atomic. In Spring Integration, it is not. The group is marked complete (or removed) after the output channel message is processed by the downstream handler. If the downstream handler calls an external API (Stripe), that API call is a side effect that occurs in the middle of the sequence, after the group’s release decision but before its completion is persisted. This window is the crash vulnerability.

The third misconception is that expireGroupsUponCompletion(true) eliminates the double-processing risk. It reduces the window (by removing the group from the store rather than leaving it with a completion flag that could potentially be re-evaluated), but it does not close it. The group removal is still a database write that happens after the downstream handler returns, and the JVM can die in the interval between the handler’s return and the write’s commit.

The fix

There are two complementary approaches. The first is stable key derivation: if the idempotency key for the aggregated charge is deterministically derived from the order identity, then even if the aggregator releases the group twice, the service activator generates the same key both times and Stripe deduplicates on its side. The second is a pre-call idempotency guard: before calling Stripe, write a BillingAttempt record to the database; on restart, the service activator checks for an existing BillingAttempt for the order and skips the Stripe call if one exists. Both approaches must use a stable key derived from the order identity, not from UUID.randomUUID().

// FIX APPROACH 1 — stable idempotency key derived from order identity
// Even if the group is released twice, the same key is generated
// and Stripe deduplicates on its side.

@Component
public class OrderBillingServiceActivator {

    private final StripeClient stripeClient;

    @ServiceActivator
    public void handle(Message<List<LineItemEvent>> message) throws StripeException {
        List<LineItemEvent> lineItems =
            (List<LineItemEvent>) message.getPayload();

        String orderId = lineItems.get(0).getOrderId();
        String billingPeriod = lineItems.get(0).getBillingPeriod();
        long totalAmountCents = lineItems.stream()
            .mapToLong(LineItemEvent::getAmountCents)
            .sum();

        // FIXED: stable key derived from order identity.
        // This key is identical on every invocation for the same orderId.
        // If the aggregator releases the group twice due to a crash,
        // the second invocation generates the same key and Stripe deduplicates.
        String idempotencyKey = computeOrderIdempotencyKey(
            orderId, billingPeriod, totalAmountCents
        );

        ChargeCreateParams params = ChargeCreateParams.builder()
            .setAmount(totalAmountCents)
            .setCurrency("usd")
            .setCustomer(lineItems.get(0).getStripeCustomerId())
            .setDescription("Order billing: " + orderId)
            .build();

        RequestOptions options = RequestOptions.builder()
            .setIdempotencyKey(idempotencyKey)
            .build();

        Charge charge = Charge.create(params, options);
        log.info("Order {} billed: charge {}", orderId, charge.getId());
    }

    private static String computeOrderIdempotencyKey(
            String orderId, String billingPeriod, long totalAmountCents) {
        try {
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            String input = orderId + "|" + billingPeriod + "|" + totalAmountCents;
            byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8));
            return HexFormat.of().formatHex(hash);
        } catch (NoSuchAlgorithmException e) {
            throw new IllegalStateException("SHA-256 unavailable", e);
        }
    }
}
// FIX APPROACH 2 — BillingAttempt guard record + stable key
// Write a BillingAttempt record BEFORE calling Stripe,
// check for it on each invocation to skip duplicate calls.

@Component
public class OrderBillingServiceActivator {

    private final StripeClient stripeClient;
    private final BillingAttemptRepository billingAttemptRepository;

    @ServiceActivator
    @Transactional  // Wraps the BillingAttempt write only — NOT the Stripe call
    public void handle(Message<List<LineItemEvent>> message) throws StripeException {
        List<LineItemEvent> lineItems =
            (List<LineItemEvent>) message.getPayload();

        String orderId = lineItems.get(0).getOrderId();
        String billingPeriod = lineItems.get(0).getBillingPeriod();
        long totalAmountCents = lineItems.stream()
            .mapToLong(LineItemEvent::getAmountCents)
            .sum();

        String idempotencyKey = computeOrderIdempotencyKey(
            orderId, billingPeriod, totalAmountCents
        );

        // Check for an existing completed BillingAttempt.
        // This handles the crash window: if the JVM crashed after ch_A
        // committed but before completeGroup(), the BillingAttempt record
        // (written after Stripe returned) may or may not have committed.
        // If it committed, this check short-circuits correctly.
        // If it did not commit (JVM crashed during BillingAttempt write),
        // the stable key ensures Stripe deduplicates on the next call.
        Optional<BillingAttempt> existing =
            billingAttemptRepository.findByIdempotencyKey(idempotencyKey);
        if (existing.isPresent() && existing.get().isCompleted()) {
            log.info("Skipping Stripe call — BillingAttempt already completed " +
                     "for order {} key {}", orderId, idempotencyKey);
            return;
        }

        // Record the attempt before calling Stripe.
        // Using INSERT ... ON CONFLICT DO NOTHING (or equivalent upsert)
        // to handle concurrent attempts gracefully.
        BillingAttempt attempt = billingAttemptRepository.save(
            new BillingAttempt(orderId, billingPeriod, idempotencyKey,
                               BillingAttemptStatus.IN_PROGRESS)
        );

        ChargeCreateParams params = ChargeCreateParams.builder()
            .setAmount(totalAmountCents)
            .setCurrency("usd")
            .setCustomer(lineItems.get(0).getStripeCustomerId())
            .setDescription("Order billing: " + orderId)
            .build();

        RequestOptions options = RequestOptions.builder()
            .setIdempotencyKey(idempotencyKey)
            .build();

        // Stripe call — outside the transaction scope if using
        // REQUIRES_NEW propagation for the BillingAttempt write.
        Charge charge = Charge.create(params, options);

        // Mark the attempt as completed with the charge ID.
        attempt.setStripeChargeId(charge.getId());
        attempt.setStatus(BillingAttemptStatus.COMPLETED);
        billingAttemptRepository.save(attempt);

        log.info("Order {} billed: charge {}", orderId, charge.getId());
    }
}

Approach 1 is the minimum viable fix: it closes the double-charge window by ensuring Stripe’s own idempotency deduplication activates correctly when the group is re-released. As long as the Stripe call happens within the 24-hour idempotency window from the first attempt, Stripe returns the cached ch_A response and no second charge is created. Approach 2 adds defense-in-depth via a durable BillingAttempt record that persists beyond Stripe’s 24-hour deduplication window and is queryable by your own tooling for support and reconciliation purposes. Both approaches require the stable key derivation — without it, Approach 2’s guard check uses a different key on each invocation and the check can never find the existing record.

For teams using the aggregator pattern extensively, it is also worth examining the aggregator’s completion transaction boundary. Spring Integration 6.x allows the aggregator’s output message send and group completion to be wrapped in a transaction using TransactionInterceptor on the aggregator’s output channel. This reduces but does not eliminate the crash window: if the transaction wraps both the group removal and the output channel send (which includes the downstream handler invocation), then either both happen or neither happens. But the Stripe call inside the downstream handler is still outside the database transaction’s scope. The only complete solution is to make the downstream Stripe call idempotent via a stable key, combined with a durable guard record as described above. The aggregator transaction configuration is a useful operational improvement that reduces the likelihood of the crash window being hit, but it is not a substitute for application-level idempotency. See also the discussion of outbox patterns in Spring Integration Batch and Stripe.

Diagnostic checklist

Use this checklist to audit a Spring Integration billing pipeline for idempotency vulnerabilities before deploying to production.

Check Pass Fail
Idempotency key location: Where is UUID.randomUUID() (or equivalent key generation) called? Key is computed once, before the message enters the pipeline, and stored as a message header or in the message payload itself. Key is computed inside a @ServiceActivator, @Transformer, or any handler method. Any re-invocation of that method (from RequestHandlerRetryAdvice, transactional requeue, or aggregator re-release) generates a new key.
RetryAdvice configuration: Is RequestHandlerRetryAdvice, RequestHandlerCircuitBreakerAdvice, or any custom AbstractRequestHandlerAdvice configured on a handler that calls Stripe? Retry advice is present, and the idempotency key is sourced from the message header (not computed inside the handler). The retry-on exception list excludes non-transient Stripe exceptions like CardException. Retry advice is present, and the idempotency key is computed inside the handler. Any retry attempt generates a new key.
Transactional polling scope: Does the polling transaction boundary include both a Stripe API call and a downstream database write? Either (a) the Stripe call uses a stable idempotency key, so rollback-and-retry does not produce a new charge, or (b) the outbox pattern is used and the Stripe call is outside the polling transaction. The polling transaction wraps both a Stripe call and a DB write. If the DB write fails, the transaction rolls back, the message re-queues, and the next poll invokes the Stripe call again with a new UUID.
JdbcChannelMessageStore headers: Are message headers that carry billing state (including the idempotency key) serialized and persisted by the message store? The JdbcChannelMessageStore serializer (Java serialization or custom) correctly persists all relevant headers. The idempotency key header survives a serialize/deserialize round-trip and application restart. Headers are lost on serialization or restart. The service activator reads a null header and falls back to UUID.randomUUID().
Aggregator completion atomicity: Is there a crash window between the aggregator’s output channel send and the JdbcMessageGroupStore group completion write? The idempotency key for the aggregated charge is derived from the correlation key (e.g., SHA-256(orderId + billingPeriod)), not from UUID.randomUUID(). A JVM crash and restart causes a stable re-attempt, not a new charge. UUID.randomUUID() is called in the handler downstream of the aggregator. A JVM crash between the Stripe call and completeGroup() will cause the group to be re-released with a new UUID.
Aggregator group expiry: Is expireGroupsUponCompletion(true) set, and is the expiry mechanism tested under crash scenarios? expireGroupsUponCompletion(true) is set. There is an integration test (using Testcontainers) that verifies group removal after completion. The stable key fix is also in place, so group removal is a performance optimization rather than the sole correctness mechanism. expireGroupsUponCompletion(false) or not set. Groups remain in the store indefinitely after completion and may be re-evaluated by timeout policies or manual re-injection of messages.
BillingAttempt guard record: Is there a durable record (database row) that records a successful Stripe API call for each billing intent, queryable by a stable key that is also passed to Stripe? A BillingAttempt or billing_records table stores the Stripe charge ID alongside the deterministic idempotency key. The service activator checks this table before calling Stripe and skips the call if a completed record exists. No guard record exists. The only idempotency mechanism is the Stripe-side deduplication, which requires the same key to be sent both times and expires after 24 hours.
Exception type granularity: Does the billing pipeline distinguish between retriable and non-retriable Stripe exceptions? CardException (decline), InvalidRequestException (bad parameter), and AuthenticationException are treated as terminal failures and not retried. ApiConnectionException (network error) and RateLimitException are retried with stable idempotency keys and exponential backoff. All StripeException subtypes are caught and either all retried or all rejected. Retrying CardException is semantically incorrect (the card was declined). Retrying ApiConnectionException without a stable key causes double charges.

Summary

The three Spring Integration failure modes described in this post share a common root cause: an idempotency key that is computed inside a handler method, which Spring Integration’s durability and retry mechanisms cause to be invoked more than once for the same billing intent. RequestHandlerRetryAdvice re-invokes the handler on each retry attempt. Transactional polling with JdbcChannelMessageStore re-queues the message on rollback, causing a fresh handler invocation on the next poll. JdbcMessageGroupStore’s group durability causes a crash-interrupted group to be re-released on restart, re-invoking the downstream handler. In all three cases, UUID.randomUUID() inside the handler produces a new key, Stripe sees an independent request, and ch_B is committed alongside ch_A.

The fix is consistent across all three modes: move idempotency key computation out of the handler and into the message creation point, using a deterministic derivation from the billing intent’s identifying fields. In Spring Integration’s message model, this means storing the key as a message header set before the message enters the pipeline. Message headers are part of the Message<T> envelope and are preserved across RequestHandlerRetryAdvice retry attempts, across JDBC message store serialization and deserialization, and across aggregator group persistence and re-release. A stable header-carried key ensures that however many times Spring Integration invokes the handler for a given message, Stripe receives the same key and applies its deduplication logic correctly. The same principle applies in the aggregation scenario, where the key can be derived from the correlation key used by the aggregator itself rather than from any per-invocation random value. This pattern also appears in the Spring Batch and Spring Data Cassandra posts in this series, where the idempotency key must survive the framework’s retry and recovery mechanisms unchanged.

Audit-logging the idempotency key alongside every Stripe API call is an operational necessity for diagnosing double-charge incidents after the fact. When a customer reports a double charge, the first question is: “were two different idempotency keys sent to Stripe for this billing intent?” If the key is derived deterministically from the billing intent fields, you can reconstruct the expected key from your own database and compare it to the key recorded in your Stripe Dashboard event log. If the keys differ, the cause is UUID regeneration during a retry or re-release. If the keys are identical, the cause is something else — perhaps a duplicate message at the ingestion point, or a configuration error in the deduplication logic. Keybrake’s proxy layer records the idempotency key for every proxied Stripe call in an immutable audit log that is independent of the application’s own logging infrastructure, providing a tamper-evident record for reconciliation even when the application’s logs have been rotated or lost.

Stop Spring Integration → Stripe double charges at the infrastructure layer

Keybrake is an API key proxy for the SaaS APIs your agents and services call — Stripe, Twilio, Resend. Every proxied request gets a stable, durable idempotency key derived from the request fingerprint and stored in the audit log. RequestHandlerRetryAdvice UUID regeneration, JdbcChannelMessageStore rollback requeue, and aggregator crash re-release cannot rotate the key that reaches Stripe. Early access is open.