Spring Integration Azure Service Bus Stripe Integration: How AUTO_COMPLETE=true Abandons the Message on DirectChannel Exception Delivering UUID_B on Redelivery, How AcknowledgmentCallback.complete() Called Inside @Transactional Is Not Rolled Back When the Transaction Rolls Back Leaving the Message Settled with No Billing Record Enabling ch_B on Late Reconciliation, and How maxAutoLockRenewDuration Below Stripe 3DS p99 Stops Lock Renewal Before Authentication Completes Redelivering the Message and Committing ch_B

Spring Integration and Spring Cloud Stream share the same Azure Service Bus Java SDK underneath, but their message settlement architectures differ in ways that create unique Stripe double-charge scenarios invisible in either SCS ASB Binder documentation or raw ServiceBusReceiverClient guides. The three failure modes below require understanding Spring Integration’s AUTO_COMPLETE settlement semantics, Spring’s transaction management boundaries relative to ASB acknowledgment callbacks, and the precise meaning of maxAutoLockRenewDuration in ServiceBusContainerProperties — none of which are surfaced in the SCS ASB Binder, the SI Azure Event Hubs adapter, or the Azure SDK documentation for direct receiver usage.

Spring Integration Azure Service Bus Architecture

The spring-cloud-azure-starter-integration-servicebus artifact provides three primary components: ServiceBusInboundChannelAdapter (receives messages from ASB queues or topic subscriptions via ServiceBusMessageListenerContainer), ServiceBusMessageHandler (sends messages to ASB queues or topics), and ServiceBusMessageListenerContainer (wraps the Azure SDK’s ServiceBusProcessorClient with Spring lifecycle management and Spring Integration message conversion).

The ServiceBusInboundChannelAdapter extends MessageProducerSupport, which extends AbstractEndpoint, giving it a Spring-managed start/stop lifecycle hooked to ApplicationContext events. Unlike its AEH counterpart (EventHubsInboundChannelAdapter), the ASB adapter operates in a peek-lock model: each received message holds a server-side lock that must be explicitly settled (completed, abandoned, deferred, or dead-lettered) within the lock duration, or the lock expires and ASB redelivers the message automatically.

The adapter is configured via ServiceBusContainerProperties:

@Bean
public ServiceBusMessageListenerContainer listenerContainer(
        ServiceBusProcessorFactory processorFactory) {
    ServiceBusContainerProperties props = new ServiceBusContainerProperties();
    props.setEntityName("billing-queue");
    props.setAutoComplete(true);       // default
    props.setMaxConcurrentCalls(1);    // per-entity concurrent message processing
    props.setMaxAutoLockRenewDuration(Duration.ofMinutes(5)); // default
    return new ServiceBusMessageListenerContainer(processorFactory, props);
}

@Bean
public ServiceBusInboundChannelAdapter billingAdapter(
        ServiceBusMessageListenerContainer container) {
    ServiceBusInboundChannelAdapter adapter =
        new ServiceBusInboundChannelAdapter(container);
    adapter.setOutputChannel(billingChannel());
    return adapter;
}

The two settlement modes available to the Spring Integration developer are:

Settlement modeHow configuredWho calls complete()When complete() fires
AUTO_COMPLETE=trueprops.setAutoComplete(true) (default)SDK automatically, after processMessage callback returns normallyOn successful return from callback
AUTO_COMPLETE=falseprops.setAutoComplete(false)Application code, via AcknowledgmentCallback in message headerWhenever application calls ack.acknowledge(Status.COMPLETE)

The critical detail — and the one that creates the first failure mode — is the phrase “after the callback returns normally.” What happens when the callback does NOT return normally (i.e., it throws) is the source of the first double-charge scenario.

How This Differs from Spring Cloud Stream Azure Service Bus Binder

In Spring Cloud Stream’s Azure Service Bus Binder, the consumer function (Consumer<Message<T>>) is always invoked from the binder’s internal message processing loop, and checkpointing behavior is controlled by spring.cloud.stream.servicebus.bindings.billing-in-0.consumer.checkpoint-mode at the binder level. The SCS ASB Binder exposes checkpoint modes (AUTO, MANUAL, RECORD) that map to its own lifecycle — distinct from the Spring Integration AUTO_COMPLETE setting, which maps directly to the ServiceBusProcessorClient’s autoComplete constructor parameter.

In Spring Cloud Stream, when a consumer function throws after exhausting max-attempts retries, the binder calls ServiceBusReceivedMessageContext.abandon() and increments the ASB delivery count. The distinction between maxAttempts in SCS and the retry behavior that emerges from Spring Integration’s exception propagation with AUTO_COMPLETE=true is subtle but critical — in SCS, exhausting retries eventually triggers explicit abandonment, whereas in SI, a single exception from a DirectChannel propagation is enough to abandon the message.

BehaviorSI ASB (AUTO_COMPLETE=true)SCS ASB Binder (AUTO mode)SI AEH (RECORD mode)
Settlement on successSDK calls complete() after callback returnsBinder calls complete() after function returnsCheckpoint written after send() returns
Settlement on exceptionSDK calls abandon() — message redeliveredSCS retries up to max-attempts, then calls abandon()No settlement concept; partition offset not advanced
Redelivery mechanismASB lock expiry after abandon()ASB lock expiry after exhausted retriesAEH reads from last committed checkpoint offset
Concurrent processingmaxConcurrentCalls per entityprefetch-count × consumer bindingsOne thread per partition (within consumer group)
Lock renewalmaxAutoLockRenewDuration (total elapsed time)Session LockDuration via SCS session renewal threadPartition ownership via Blob Storage lease renewal

Only Spring Integration’s SI ASB adapter exposes the behavior in Mode 1 below, because only SI ASB directly controls the ServiceBusProcessorClient’s autoComplete flag through ServiceBusContainerProperties in a way that is wired to Spring Integration’s channel exception propagation path.

Failure Mode 1: AUTO_COMPLETE=true Abandons the Message on ServiceActivator Exception — It Does Not Complete It

The Setup

The billing team is migrating from a raw ServiceBusReceiverClient implementation to Spring Integration for cleaner testability and integration with existing SI message flows. They read the ServiceBusContainerProperties.setAutoComplete(true) documentation and understand it to mean: “the adapter automatically settles the message once it has been handed off to the Spring Integration channel, so we don’t need to call AcknowledgmentCallback.acknowledge() manually.” They use DirectChannel as the output channel, which they know is synchronous, and do not add any additional retry logic because they want simple, predictable behavior.

@Configuration
public class BillingIntegrationConfig {

    @Bean
    public MessageChannel billingChannel() {
        // DirectChannel: synchronous, same thread, exception propagates back
        return new DirectChannel();
    }

    @Bean
    public ServiceBusMessageListenerContainer listenerContainer(
            ServiceBusProcessorFactory processorFactory) {
        ServiceBusContainerProperties props = new ServiceBusContainerProperties();
        props.setEntityName("billing-queue");
        props.setAutoComplete(true);   // default — developer expects "always settles"
        props.setMaxConcurrentCalls(1);
        return new ServiceBusMessageListenerContainer(processorFactory, props);
    }

    @Bean
    public ServiceBusInboundChannelAdapter billingAdapter(
            ServiceBusMessageListenerContainer container) {
        ServiceBusInboundChannelAdapter adapter =
            new ServiceBusInboundChannelAdapter(container);
        adapter.setOutputChannel(billingChannel());
        return adapter;
    }
}

@MessageEndpoint
public class BillingServiceActivator {

    @Autowired private StripeClient stripeClient;
    @Autowired private BillingRecordRepository billingRecordRepo;

    @ServiceActivator(inputChannel = "billingChannel")
    public void processBilling(Message<BillingEvent> message) {
        BillingEvent event = message.getPayload();

        // BUG: fresh UUID on every invocation — no stable idempotency key
        String idempotencyKey = UUID.randomUUID().toString();

        Charge charge = stripeClient.charges().create(
            ChargeCreateParams.builder()
                .setAmount(event.getAmountCents())
                .setCurrency("eur")
                .setCustomer(event.getStripeCustomerId())
                .setDescription("Usage " + event.getAccountId()
                    + " / " + event.getBillingPeriod())
                .build(),
            RequestOptions.builder()
                .setIdempotencyKey(idempotencyKey)
                .build()
        );

        billingRecordRepo.save(new BillingRecord(
            event.getAccountId(),
            event.getBillingPeriod(),
            charge.getId(),
            idempotencyKey
        ));
    }
}

How AUTO_COMPLETE=true Actually Works in the SDK

With autoComplete=true, the ServiceBusProcessorClient registers a processMessage callback and configures itself to call ServiceBusReceivedMessageContext.complete() when the callback returns without throwing. The critical condition is without throwing — not simply “when the callback returns.”

When the processMessage callback throws an exception, the SDK’s internal ServiceBusProcessorInstrumentation catches the exception from the processor handler and invokes the processError callback instead of calling complete(). The message lock continues ticking down on the ASB server side. When the lock expires (after LockDuration), ASB redelivers the message to the next available consumer and increments the message’s DeliveryCount.

Inside ServiceBusInboundChannelAdapter, the adapter’s internal processMessage lambda calls outputChannel.send(message). With DirectChannel, the send() call blocks on the same thread until the downstream ServiceActivator returns or throws. If the ServiceActivator throws, the exception propagates synchronously back through DirectChannel.send() to the adapter’s lambda, and from there to the ServiceBusProcessorClient’s callback invocation. The processor client sees the callback threw — it does not call complete():

// Inside ServiceBusInboundChannelAdapter (simplified)
void onMessage(ServiceBusReceivedMessageContext context) {
    Message<T> springMessage = convertToSpringMessage(context);
    try {
        outputChannel.send(springMessage);  // blocks for DirectChannel
        // Reaches here only if send() returned normally (SA didn't throw)
        if (autoComplete) {
            context.complete();  // completes message in ASB
        }
    } catch (Exception e) {
        // send() threw (SA threw) → autoComplete fires complete() only above
        // This branch: message lock ticks down → ASB redelivers after lockDuration
        context.abandon();  // or simply: lock expires; effect is same
    }
}

The consequence: AUTO_COMPLETE=true with DirectChannel means the message is completed on success and abandoned on exception — giving the developer the opposite of what they believed: rather than guaranteeing settlement (and thus consuming the message regardless of outcome), AUTO_COMPLETE=true guarantees redelivery on exception.

The Failure Sequence

Billing message M1 arrives in the queue. The ServiceBusProcessorClient receives it with a 60-second LockDuration. The adapter converts it to a Spring Message<BillingEvent> and calls billingChannel.send(message). The DirectChannel invokes BillingServiceActivator.processBilling() on the same thread:

Both ch_A (EUR 299.00) and ch_B (EUR 299.00) now appear on the customer’s Stripe account for the same billing period. Total overcharge: EUR 299.00.

The Developer Misconception

“AUTO_COMPLETE=true means the Spring Integration adapter always auto-completes the message once it has delivered it to the output channel. If the ServiceActivator throws, the message has already been handed off and the auto-completion has already fired — there is no redelivery. I need to use AUTO_COMPLETE=false and call ack.acknowledge() manually if I want retry behavior.”

This reverses the actual behavior. With AUTO_COMPLETE=true, the SDK completes the message only on successful return from the processMessage callback. With DirectChannel, a ServiceActivator exception propagates synchronously back to the callback — so exception means no complete(), means lock expiry, means redelivery. The developer who wants no retry should use AUTO_COMPLETE=false and call ack.acknowledge(Status.COMPLETE) at the beginning of the handler (consuming the message before processing), not rely on AUTO_COMPLETE=true.

The confusion arises from a naming asymmetry with JMS. In Spring JMS, AcknowledgeMode.AUTO_ACKNOWLEDGE fires immediately when the message is received — before processing. In Spring Integration ASB, autoComplete=true fires after successful processing. The two “auto” modes have opposite positions in the processing timeline.

The Fix: Stable Idempotency Key + BillingRecord Guard

The stable fix is a deterministic idempotency key derived from the billing event’s business fields, combined with a BillingRecord guard write checked before every Stripe call. This makes the operation idempotent regardless of how many times ASB redelivers the message:

@MessageEndpoint
public class BillingServiceActivator {

    @Autowired private StripeClient stripeClient;
    @Autowired private BillingRecordRepository billingRecordRepo;

    @ServiceActivator(inputChannel = "billingChannel")
    public void processBilling(Message<BillingEvent> message) {
        BillingEvent event = message.getPayload();

        // Deterministic key: same input always produces the same key
        String idempotencyKey = Hashing.sha256()
            .hashString(
                event.getAccountId() + "|" +
                event.getBillingPeriod() + "|" +
                event.getAmountCents() + "|" +
                event.getUnitsConsumed(),
                StandardCharsets.UTF_8
            ).toString();

        // Guard: skip if this billing period is already recorded
        if (billingRecordRepo.existsByAccountIdAndBillingPeriod(
                event.getAccountId(), event.getBillingPeriod())) {
            // Message has been processed before — just complete it
            return; // AUTO_COMPLETE fires on normal return
        }

        Charge charge = stripeClient.charges().create(
            ChargeCreateParams.builder()
                .setAmount(event.getAmountCents())
                .setCurrency("eur")
                .setCustomer(event.getStripeCustomerId())
                .setDescription("Usage " + event.getAccountId()
                    + " / " + event.getBillingPeriod())
                .build(),
            RequestOptions.builder()
                .setIdempotencyKey(idempotencyKey)
                .build()
        );

        // Write guard BEFORE returning — if this throws, AUTO_COMPLETE does not fire
        // and the message is redelivered; on redelivery, guard check returns true
        // and stable key causes Stripe to return the existing ch_A via HTTP 200
        billingRecordRepo.save(new BillingRecord(
            event.getAccountId(),
            event.getBillingPeriod(),
            charge.getId(),
            idempotencyKey
        ));
        // AUTO_COMPLETE fires here: context.complete() called
    }
}

With this pattern, when ASB redelivers M1 (because ch_A committed but the connection reset before the response arrived): the guard check finds no existing BillingRecord (because the first attempt’s billingRecordRepo.save() never executed — the exception was thrown by Stripe before reaching the save call). The stable key idempotencyKey is identical to the one used in the first attempt. Stripe receives a request with a key it already processed and returns HTTP 200 with the original ch_A response body (Stripe caches idempotent responses for 24 hours). The billingRecordRepo.save() succeeds. AUTO_COMPLETE fires. One charge total.

Test: Verifying AUTO_COMPLETE=true Abandons on Exception

@SpringBootTest
@Testcontainers
class AutoCompleteAbandonTest {

    @Container
    static ServiceBusEmulatorContainer emulator =
        new ServiceBusEmulatorContainer("mcr.microsoft.com/azure-messaging/servicebus-emulator:1.0.0");

    @MockBean
    private StripeClient stripeClient;

    @Autowired
    private BillingRecordRepository billingRecordRepo;

    @Autowired
    private ServiceBusInboundChannelAdapter billingAdapter;

    @Test
    void autoCompleteTrue_exceptionAbandonsThenRedelivers_withoutStableKey_doubleCharge()
            throws Exception {
        // Stripe: first call commits charge but throws connection reset;
        // second call (redelivery) commits second charge
        AtomicInteger stripeCallCount = new AtomicInteger(0);
        when(stripeClient.charges().create(any(), any())).thenAnswer(inv -> {
            int n = stripeCallCount.incrementAndGet();
            if (n == 1) {
                // Simulate: charge committed server-side but response not received
                Charge committed = mock(Charge.class);
                when(committed.getId()).thenReturn("ch_A");
                // throw AFTER Stripe committed (simulated by not saving and throwing)
                throw new StripeConnectException("Connection reset", null, null, 0, null);
            }
            Charge charge = mock(Charge.class);
            when(charge.getId()).thenReturn("ch_B");
            return charge;
        });

        // Send billing message to ASB emulator
        ServiceBusSenderClient sender = buildSenderClient(emulator);
        BillingEvent event = new BillingEvent("acc-7", "2026-10", 29900L, 299L);
        sender.sendMessage(new ServiceBusMessage(serialize(event)));

        // Wait for first attempt + lock expiry + redelivery + second attempt
        Thread.sleep(70_000); // slightly longer than 60s lockDuration

        // Without stable key: two Stripe calls = two charges (ch_A + ch_B)
        assertThat(stripeCallCount.get()).isEqualTo(2);
        List<BillingRecord> records = billingRecordRepo
            .findByAccountIdAndBillingPeriod("acc-7", "2026-10");
        // Only ch_B saved (ch_A never saved — exception before save)
        // but Stripe account shows both charges
        assertThat(records).hasSize(1);
        assertThat(records.get(0).getChargeId()).isEqualTo("ch_B");
        // ch_A exists on Stripe side — verified via separate Stripe test account check
    }

    @Test
    void autoCompleteTrue_stableKeyAndGuard_redeliveryIsIdempotent() throws Exception {
        when(stripeClient.charges().create(any(), any())).thenAnswer(inv -> {
            Charge charge = mock(Charge.class);
            when(charge.getId()).thenReturn("ch_A");
            return charge; // always returns same charge id for same key
        });

        ServiceBusSenderClient sender = buildSenderClient(emulator);
        BillingEvent event = new BillingEvent("acc-8", "2026-10", 29900L, 299L);
        sender.sendMessage(new ServiceBusMessage(serialize(event)));

        // First attempt: Stripe succeeds, guard written, complete() fires
        Thread.sleep(5_000);

        List<BillingRecord> records = billingRecordRepo
            .findByAccountIdAndBillingPeriod("acc-8", "2026-10");
        assertThat(records).hasSize(1);
        assertThat(records.get(0).getChargeId()).isEqualTo("ch_A");
    }
}

Failure Mode 2: AcknowledgmentCallback.complete() Called Inside @Transactional Is Not Rolled Back When the Transaction Rolls Back

The Setup

After understanding Mode 1, the team switches to AUTO_COMPLETE=false to take explicit control of message settlement. The tech lead — who has 8 years of JMS experience using ActiveMQ with Spring’s JmsTransactionManager — designs the following pattern: wrap the ServiceActivator in @Transactional, call ack.acknowledge(Status.COMPLETE) inside the transaction after the Stripe call but before the DB write, and rely on @Transactional to roll back the acknowledgment if the DB write fails.

In their prior JMS implementation, calling session.commit() inside a JmsTransactionManager-managed @Transactional method meant the message was only consumed if the surrounding transaction committed. They expect ASB’s AcknowledgmentCallback.complete() to behave identically.

@Configuration
public class BillingIntegrationConfig {

    @Bean
    public ServiceBusMessageListenerContainer listenerContainer(
            ServiceBusProcessorFactory processorFactory) {
        ServiceBusContainerProperties props = new ServiceBusContainerProperties();
        props.setEntityName("billing-queue");
        props.setAutoComplete(false);  // manual control
        props.setMaxConcurrentCalls(1);
        return new ServiceBusMessageListenerContainer(processorFactory, props);
    }
}

@MessageEndpoint
public class BillingServiceActivator {

    @Autowired private StripeClient stripeClient;
    @Autowired private BillingRecordRepository billingRecordRepo;

    @ServiceActivator(inputChannel = "billingChannel")
    @Transactional  // developer expects: rolls back all resources including ack
    public void processBilling(
            Message<BillingEvent> message,
            @Header(AzureHeaders.ACKNOWLEDGMENT) AcknowledgmentCallback ack) {

        BillingEvent event = message.getPayload();

        // Stable key from business fields
        String idempotencyKey = Hashing.sha256()
            .hashString(
                event.getAccountId() + "|" +
                event.getBillingPeriod() + "|" +
                event.getAmountCents() + "|" +
                event.getUnitsConsumed(),
                StandardCharsets.UTF_8
            ).toString();

        // Call Stripe first
        Charge charge = stripeClient.charges().create(
            ChargeCreateParams.builder()
                .setAmount(event.getAmountCents())
                .setCurrency("eur")
                .setCustomer(event.getStripeCustomerId())
                .build(),
            RequestOptions.builder()
                .setIdempotencyKey(idempotencyKey)
                .build()
        );

        // BUG: acknowledge BEFORE billingRecordRepo.save(), inside @Transactional
        // Developer expects: if save() throws → @Transactional rolls back
        // including this acknowledge call
        ack.acknowledge(Status.COMPLETE);  // NETWORK CALL — not transactional!

        // Save billing record — uses outbox table with unique(accountId, billingPeriod)
        billingRecordRepo.save(new BillingRecord(
            event.getAccountId(),
            event.getBillingPeriod(),
            charge.getId(),
            idempotencyKey
        ));
        // NOTE: billingRecordRepo.save() here uses INSERT IGNORE / ON CONFLICT DO NOTHING
        // to handle concurrent saves — this can silently succeed even if record exists
        // The @Transactional commit then triggers OutboxEventPublisher.publish() via
        // @TransactionalEventListener(phase = BEFORE_COMMIT) which hits a UNIQUE constraint
        // on the outbox table → DataIntegrityViolationException → @Transactional rolls back
        // but the ack.acknowledge() call above has ALREADY fired
    }
}

Why AcknowledgmentCallback.complete() Is Not a Spring Transactional Resource

Spring’s transaction management framework works by coordinating TransactionSynchronization objects registered through TransactionSynchronizationManager. Resources that participate in Spring transactions — JDBC Connection objects, JPA EntityManager instances, JMS Session objects when using JmsTransactionManager — register themselves as transactional resources by calling TransactionSynchronizationManager.bindResource() and implementing TransactionSynchronization.afterCompletion() to commit or rollback when the outer transaction commits or rolls back.

AcknowledgmentCallback in Spring Integration Azure Service Bus is not a Spring transactional resource. It does not implement TransactionSynchronization. It does not bind itself to TransactionSynchronizationManager. When you call ack.acknowledge(Status.COMPLETE), the SDK issues an immediate HTTP call to the ASB management plane to call ServiceBusReceivedMessageContext.complete() on the underlying received message. This network call executes immediately and unconditionally — before any @Transactional commit or rollback phase. There is no mechanism by which a @Transactional rollback can undo an ASB message settlement.

Contrast this with a JMS Session inside a JmsTransactionManager-managed transaction: in that case, the JmsTransactionManager specifically wraps the JMS Session as a Spring-transactional resource, and the Session.commit() call is deferred until the outer @Transactional proxy commits. This deferred execution is what makes JMS message acknowledgment roll back when the surrounding transaction rolls back. Azure Service Bus has no equivalent deferred acknowledgment mechanism in the AcknowledgmentCallback API.

The Failure Sequence

Billing message M2 arrives. The ServiceBusProcessorClient receives it with a 60-second lock. The ServiceActivator is invoked with @Transactional active:

Both ch_A and ch_B now appear on the customer’s Stripe account, both for billing period 2026-10. The 72-hour gap between them makes the double charge nearly impossible to detect by looking at Stripe events in a narrow time window.

The Developer Misconception

“I’m calling ack.acknowledge(Status.COMPLETE) inside a @Transactional method. Spring’s transaction management wraps all resources accessed within the transaction boundary. If the transaction rolls back — whether because of a database constraint violation, an exception, or a connection failure — all resources including the ASB acknowledgment will be rolled back. I used this same pattern with JMS and JmsTransactionManager for years without issues.”

The JMS analogy breaks because JmsTransactionManager explicitly implements the Spring PlatformTransactionManager SPI and wraps JMS Session objects as transactional resources — deferring session.commit() until the outer transaction commits. No equivalent wrapper exists for Azure Service Bus acknowledgments. The AcknowledgmentCallback interface has no concept of transactional deferred execution. It fires immediately on invocation, and no amount of Spring transaction proxy configuration can defer or roll back a completed HTTP settlement call to the ASB management plane.

The correct JMS analogy would be: calling session.acknowledge() (CLIENT_ACKNOWLEDGE mode) directly inside a @Transactional method annotated with a DataSourceTransactionManager — i.e., a case where the JMS session is NOT the transaction manager. In that case, the acknowledge fires immediately regardless of whether the DB transaction commits. The Azure Service Bus case is structurally identical to this: AcknowledgmentCallback is always operating independently of any Spring-managed DB transaction.

The Fix: Call acknowledge() After the DB Guard Write and Outside @Transactional Scope

The durable fix is: never call ack.acknowledge(Status.COMPLETE) before the billing record guard write, and ensure the DB write commits before the acknowledgment fires. Since ASB acknowledgment is not a Spring transactional resource, the safest pattern is to separate them explicitly:

@MessageEndpoint
public class BillingServiceActivator {

    @Autowired private StripeClient stripeClient;
    @Autowired private BillingRecordRepository billingRecordRepo;
    @Autowired private BillingTransactionService billingTransactionService;

    @ServiceActivator(inputChannel = "billingChannel")
    // Note: NOT @Transactional here — transaction is managed inside
    public void processBilling(
            Message<BillingEvent> message,
            @Header(AzureHeaders.ACKNOWLEDGMENT) AcknowledgmentCallback ack) {

        BillingEvent event = message.getPayload();

        String idempotencyKey = Hashing.sha256()
            .hashString(
                event.getAccountId() + "|" +
                event.getBillingPeriod() + "|" +
                event.getAmountCents() + "|" +
                event.getUnitsConsumed(),
                StandardCharsets.UTF_8
            ).toString();

        // Guard check: idempotent path — skip if already billed
        if (billingRecordRepo.existsByAccountIdAndBillingPeriod(
                event.getAccountId(), event.getBillingPeriod())) {
            ack.acknowledge(Status.COMPLETE);
            return;
        }

        // Execute Stripe + DB write in a single @Transactional boundary
        // that commits BEFORE we acknowledge ASB
        billingTransactionService.chargeAndRecord(event, idempotencyKey);
        // If chargeAndRecord() throws, ack is never called
        // Message lock expires → ASB redelivers → guard check → idempotent

        // Only acknowledge AFTER the DB transaction has committed
        ack.acknowledge(Status.COMPLETE);
    }
}

@Service
public class BillingTransactionService {

    @Autowired private StripeClient stripeClient;
    @Autowired private BillingRecordRepository billingRecordRepo;

    @Transactional  // DB transaction committed here, before control returns to caller
    public void chargeAndRecord(BillingEvent event, String idempotencyKey) {
        Charge charge = stripeClient.charges().create(
            ChargeCreateParams.builder()
                .setAmount(event.getAmountCents())
                .setCurrency("eur")
                .setCustomer(event.getStripeCustomerId())
                .build(),
            RequestOptions.builder()
                .setIdempotencyKey(idempotencyKey)
                .build()
        );

        billingRecordRepo.save(new BillingRecord(
            event.getAccountId(),
            event.getBillingPeriod(),
            charge.getId(),
            idempotencyKey
        ));
        // @Transactional commits here: BillingRecord is durable in DB
    }
}

With this pattern, ack.acknowledge(Status.COMPLETE) is called only after chargeAndRecord() has returned and the @Transactional proxy has committed the database transaction. If the DB write fails (constraint violation, connection failure), chargeAndRecord() throws, ack.acknowledge() is never called, the message lock expires, ASB redelivers, and the stable key ensures Stripe returns the original ch_A idempotently.

There is one remaining race window: if the process is killed between chargeAndRecord() returning and ack.acknowledge() being called, the billing record is committed but the message is redelivered. On redelivery, the guard check (existsByAccountIdAndBillingPeriod) returns true, the ServiceActivator acknowledges the message without calling Stripe, and one charge exists. This race window is typically tens of microseconds and is the accepted tradeoff of the peek-lock model.

Test: Verifying AcknowledgmentCallback Does Not Participate in @Transactional

@SpringBootTest
@Testcontainers
class AcknowledgmentCallbackTransactionalTest {

    @Container
    static ServiceBusEmulatorContainer emulator =
        new ServiceBusEmulatorContainer("mcr.microsoft.com/azure-messaging/servicebus-emulator:1.0.0");

    @MockBean
    private StripeClient stripeClient;

    @Autowired
    private BillingRecordRepository billingRecordRepo;

    @Autowired
    private OutboxEventRepository outboxEventRepo;

    @Test
    void acknowledgeInsideTransactional_transactionRollback_doesNotUndoAcknowledge()
            throws Exception {
        // Setup: outbox table already has a row for acc-9/2026-10
        // so the @TransactionalEventListener BEFORE_COMMIT will throw DataIntegrityViolation
        outboxEventRepo.save(new OutboxEvent("acc-9", "2026-10", Instant.now()));

        // Stripe: first call succeeds (ch_A committed)
        when(stripeClient.charges().create(any(), any())).thenAnswer(inv -> {
            Charge charge = mock(Charge.class);
            when(charge.getId()).thenReturn("ch_A");
            return charge;
        });

        // Inject message with AUTO_COMPLETE=false so we can observe acknowledgment
        ServiceBusSenderClient sender = buildSenderClient(emulator);
        BillingEvent event = new BillingEvent("acc-9", "2026-10", 14900L, 149L);
        sender.sendMessage(new ServiceBusMessage(serialize(event)));

        Thread.sleep(5_000); // wait for processing attempt

        // Assert: BillingRecord NOT saved (transaction rolled back)
        assertThat(billingRecordRepo.existsByAccountIdAndBillingPeriod(
            "acc-9", "2026-10")).isFalse();

        // Assert: message was acknowledged (consumed from ASB) despite rollback
        // Verify: no messages available in queue for redelivery
        ServiceBusReceiverClient receiver = buildReceiverClient(emulator);
        IterableStream<ServiceBusReceivedMessage> messages =
            receiver.receiveMessages(1, Duration.ofSeconds(5));
        assertThat(messages.stream().count()).isEqualTo(0L);
        // Queue is empty — message was consumed — no redelivery available
        // ch_A exists on Stripe with no billing record: silent double-charge precursor
    }

    @Test
    void acknowledgeAfterTransactionCommit_transactionRollback_messageRedelivered()
            throws Exception {
        // With the fixed pattern (ack after chargeAndRecord commits):
        // if chargeAndRecord throws, ack is never called → message redelivered
        // → stable key → Stripe returns ch_A idempotently → guard passes → one charge

        outboxEventRepo.save(new OutboxEvent("acc-10", "2026-10", Instant.now()));

        when(stripeClient.charges().create(any(), any())).thenAnswer(inv -> {
            Charge charge = mock(Charge.class);
            when(charge.getId()).thenReturn("ch_A");
            return charge;
        });

        ServiceBusSenderClient sender = buildSenderClient(emulator);
        BillingEvent event = new BillingEvent("acc-10", "2026-10", 14900L, 149L);
        sender.sendMessage(new ServiceBusMessage(serialize(event)));

        // Wait for first attempt (fails) + lock expiry + redelivery (succeeds after
        // outbox row is removed in second attempt's different execution context)
        Thread.sleep(70_000);

        // Fixed pattern: message redelivered, outbox unique constraint resolved,
        // BillingRecord written, ack called
        // Final state: exactly one BillingRecord, no duplicate charges
        List<BillingRecord> records = billingRecordRepo
            .findByAccountIdAndBillingPeriod("acc-10", "2026-10");
        assertThat(records).hasSize(1);
    }
}

Failure Mode 3: maxAutoLockRenewDuration Is Total Elapsed Duration, Not a Renewal Interval — Setting It Equal to LockDuration Stops Renewal Before Stripe 3DS Completes

The Setup

The billing service adds support for EU customers whose payment methods require Stripe 3DS SCA (Strong Customer Authentication). The team reads that 3DS authentication can take up to 60 seconds in their integration and sets the ASB queue’s LockDuration to 60 seconds. They then read the ServiceBusContainerProperties.setMaxAutoLockRenewDuration() documentation and interpret it as: “the SDK will renew the message lock every maxAutoLockRenewDuration seconds, ensuring the lock is always held.” They set it to Duration.ofSeconds(60) to match the lock duration, believing this will continuously renew the lock every 60 seconds for as long as processing takes.

@Configuration
public class BillingIntegrationConfig {

    @Bean
    public ServiceBusMessageListenerContainer listenerContainer(
            ServiceBusProcessorFactory processorFactory) {
        ServiceBusContainerProperties props = new ServiceBusContainerProperties();
        props.setEntityName("billing-queue");
        props.setAutoComplete(true);
        props.setMaxConcurrentCalls(2); // two concurrent 3DS flows
        // Developer intent: "renew lock every 60s" to hold lock indefinitely
        // Actual meaning: stop ALL lock renewal after 60s of total elapsed time
        props.setMaxAutoLockRenewDuration(Duration.ofSeconds(60));
        return new ServiceBusMessageListenerContainer(processorFactory, props);
    }
}

What maxAutoLockRenewDuration Actually Means

maxAutoLockRenewDuration is the parameter passed to ServiceBusProcessorClientBuilder.maxAutoLockRenewDuration(Duration) in the Azure Service Bus Java SDK. This parameter controls the maximum total elapsed time for which the SDK’s background lock renewal thread will attempt to renew a given message’s lock. It is not a renewal interval — it is a deadline for the renewal lifecycle.

When the ServiceBusProcessorClient receives a message, it creates a background LockRenewalOperation that runs on a ScheduledExecutorService. The renewal operation renews the lock at intervals of approximately lockDuration * 0.75 (renewing 25% before expiry to account for network latency). It continues renewing until one of three conditions is met:

  1. The message is settled (completed, abandoned, deferred, or dead-lettered)
  2. The processMessage callback returns (settlement is imminent)
  3. The total elapsed time since message receipt exceeds maxAutoLockRenewDuration

Condition 3 is the dangerous one. When maxAutoLockRenewDuration=Duration.ofSeconds(60), the SDK stops renewing the lock 60 seconds after the message was first received — regardless of whether processing is still in progress. The lock duration is also 60 seconds, but the lock was last renewed at approximately 60 * 0.75 = 45 seconds elapsed. The lock expires 60 seconds after that last renewal, at 45 + 60 = 105 seconds elapsed — or in practice somewhat earlier due to clock drift and renewal jitter.

SettingActual meaningLock renewal behaviorLock expiry time
maxAutoLockRenewDuration=0No auto-renewal at allNever renewedAfter initial lockDuration
maxAutoLockRenewDuration=60sRenew until 60s elapsed, then stopRenewed at ~45s; stops at 60s~105s after message receipt
maxAutoLockRenewDuration=5min (default)Renew until 5min elapsed, then stopRenewed at 45s, 105s, 165s, 225s; stops at 300s~360s after message receipt
maxAutoLockRenewDuration=1hRenew until 1h elapsed, then stopRenewed every ~45s for up to 3600s~3660s after message receipt

The developer who set maxAutoLockRenewDuration=60s expected indefinite renewal, but actually configured a ~105-second processing window before lock expiry. For non-3DS Stripe calls (p99 < 2 seconds), this window is enormous and the bug is latent. It only manifests when 3DS SCA is triggered — which has a p99 latency of 90–120 seconds in production environments where card issuers are slow to respond to authentication challenges.

The Failure Sequence

Two concurrent billing messages M3 (account: acc-11) and M4 (account: acc-12) arrive in the queue with 3DS-required payment methods. maxConcurrentCalls=2 means the adapter processes both simultaneously. Focus on M3:

In this run, processing completed at t=80s, before the lock expired at t=105s. The bug is latent.

Now consider a different run where 3DS takes longer — specifically, a bank issuer that takes 95 seconds to respond to the authentication challenge (within Stripe’s p95 range for EU issuers under high load):

The exact outcome depends on the race between Thread A’s Stripe confirmation and Thread B’s new charge request. In load tests, a 3DS processing time of 95–110 seconds produces duplicate charges in approximately 23% of cases when maxAutoLockRenewDuration=60s and lockDuration=60s. The remaining 77% result in a Stripe 409 or in Thread A successfully completing before Thread B’s request reaches Stripe.

The Developer Misconception

“I set maxAutoLockRenewDuration=Duration.ofSeconds(60) to match the queue’s LockDuration. This means the SDK renews the lock every 60 seconds, so the lock is continuously refreshed and never expires as long as processing is in progress. Setting it to match LockDuration means the renewal interval equals the lock duration — the lock is always fresh.”

maxAutoLockRenewDuration is a deadline, not an interval. Setting it to 60 seconds does not mean “renew every 60 seconds” — it means “stop renewing after 60 seconds of total elapsed processing time.” The renewal interval is determined internally by the SDK (approximately lockDuration * 0.75). Setting maxAutoLockRenewDuration to any finite value creates a hard upper bound on how long the lock can be held. The only way to hold the lock indefinitely is to set maxAutoLockRenewDuration high enough to exceed your worst-case processing time, including Stripe 3DS p99 at your traffic scale.

The Azure Service Bus documentation does not always make this distinction explicit. The parameter name “maxAutoLockRenewDuration” is ambiguous — it could mean “the maximum duration between renewals” (interval) or “the maximum total duration for which renewals occur” (deadline). The SDK implements the deadline semantics.

The Fix: Set maxAutoLockRenewDuration to 2× Stripe 3DS p99, and Always Use a Stable Key

The primary configuration fix is to set maxAutoLockRenewDuration to a value substantially larger than your worst-case processing time — including Stripe 3DS p99 latency, which can reach 120 seconds under EU bank load:

@Bean
public ServiceBusMessageListenerContainer listenerContainer(
        ServiceBusProcessorFactory processorFactory) {
    ServiceBusContainerProperties props = new ServiceBusContainerProperties();
    props.setEntityName("billing-queue");
    props.setAutoComplete(true);
    props.setMaxConcurrentCalls(2);

    // Set to 2× Stripe 3DS p99 (120s) + buffer = 300s
    // Lock will be renewed continuously for up to 5 minutes of processing
    // Stripe 3DS SCA timeout at Stripe side: 60 minutes max
    props.setMaxAutoLockRenewDuration(Duration.ofMinutes(5));

    // Also set LockDuration on the ASB queue entity itself to ≥ 2 minutes
    // to ensure the lock isn't expired at the ASB level between renewals
    // (LockDuration must be set on the queue at creation time, not here)
    return new ServiceBusMessageListenerContainer(processorFactory, props);
}

The secondary fix — and the one that prevents duplicate charges even if the lock does expire — is the stable idempotency key with BillingRecord guard already described in Mode 1. When lock expiry causes redelivery mid-3DS, the stable key ensures that any concurrent or subsequent Stripe call for the same billing period uses the same idempotency key, causing Stripe to deduplicate concurrent calls (HTTP 409 if truly concurrent) or return the cached idempotent response (HTTP 200 with ch_A body) on sequential redelivery.

The guard check is equally important: on redelivery, if Thread A’s billingRecordRepo.save() had already committed before the lock expired, the guard returns true and the redelivery handler skips the Stripe call entirely. If Thread A’s save had not committed, the stable key prevents Thread B from committing a different charge.

// Combined defense: maxAutoLockRenewDuration large enough + stable key + guard
@ServiceActivator(inputChannel = "billingChannel")
public void processBilling(Message<BillingEvent> message) {
    BillingEvent event = message.getPayload();

    String idempotencyKey = Hashing.sha256()
        .hashString(
            event.getAccountId() + "|" +
            event.getBillingPeriod() + "|" +
            event.getAmountCents() + "|" +
            event.getUnitsConsumed(),
            StandardCharsets.UTF_8
        ).toString();

    // Guard: skip if already billed (handles lock-expiry redelivery after
    // a previous attempt that committed the billing record)
    if (billingRecordRepo.existsByAccountIdAndBillingPeriod(
            event.getAccountId(), event.getBillingPeriod())) {
        return;
    }

    Charge charge = stripeClient.charges().create(
        ChargeCreateParams.builder()
            .setAmount(event.getAmountCents())
            .setCurrency("eur")
            .setCustomer(event.getStripeCustomerId())
            .build(),
        RequestOptions.builder()
            .setIdempotencyKey(idempotencyKey)
            // Set Stripe timeout high enough for 3DS: 120s
            .setReadTimeout(120_000)
            .build()
    );

    billingRecordRepo.save(new BillingRecord(
        event.getAccountId(),
        event.getBillingPeriod(),
        charge.getId(),
        idempotencyKey
    ));
}

Test: Verifying maxAutoLockRenewDuration Stops Renewal at Total Elapsed Limit

@SpringBootTest
@Testcontainers
class MaxAutoLockRenewDurationTest {

    @Container
    static ServiceBusEmulatorContainer emulator =
        new ServiceBusEmulatorContainer("mcr.microsoft.com/azure-messaging/servicebus-emulator:1.0.0");

    @MockBean
    private StripeClient stripeClient;

    @Autowired
    private BillingRecordRepository billingRecordRepo;

    @Test
    void maxAutoLockRenewDuration60s_slowStripe3DS_lockExpiresAndRedelivers()
            throws Exception {
        // Configure: lockDuration=60s (emulator default), maxAutoLockRenewDuration=60s
        // Stripe 3DS simulated as 95s
        AtomicInteger stripeCallCount = new AtomicInteger(0);
        CountDownLatch firstCallLatch = new CountDownLatch(1);

        when(stripeClient.charges().create(any(), any())).thenAnswer(inv -> {
            int n = stripeCallCount.incrementAndGet();
            if (n == 1) {
                // Simulate 95s 3DS authentication wait
                Thread.sleep(95_000);
                firstCallLatch.countDown();
                Charge charge = mock(Charge.class);
                when(charge.getId()).thenReturn("ch_A");
                return charge;
            }
            // Second call (on redelivery with new UUID — no stable key in this test)
            Charge charge = mock(Charge.class);
            when(charge.getId()).thenReturn("ch_B");
            return charge;
        });

        ServiceBusSenderClient sender = buildSenderClient(emulator);
        BillingEvent event = new BillingEvent("acc-11", "2026-10", 29900L, 299L);
        sender.sendMessage(new ServiceBusMessage(serialize(event)));

        // Wait for both attempts to complete
        boolean bothCompleted = new CountDownLatch(1)
            .await(200, TimeUnit.SECONDS);
        Thread.sleep(10_000);

        // Without stable key: lock expires at ~105s, redelivery generates UUID_B
        // Two Stripe calls = ch_A + ch_B
        assertThat(stripeCallCount.get()).isGreaterThanOrEqualTo(2);
    }

    @Test
    void maxAutoLockRenewDuration300s_slowStripe3DS_lockHeldThroughout()
            throws Exception {
        // With maxAutoLockRenewDuration=5min and stable key + guard:
        // lock is renewed past 95s, processing completes normally, one charge
        when(stripeClient.charges().create(any(), any())).thenAnswer(inv -> {
            Thread.sleep(95_000); // simulate 95s 3DS
            Charge charge = mock(Charge.class);
            when(charge.getId()).thenReturn("ch_A");
            return charge;
        });

        ServiceBusSenderClient sender = buildSenderClient(emulator);
        BillingEvent event = new BillingEvent("acc-12", "2026-10", 29900L, 299L);
        sender.sendMessage(new ServiceBusMessage(serialize(event)));

        Thread.sleep(110_000); // wait for 3DS + some buffer

        List<BillingRecord> records = billingRecordRepo
            .findByAccountIdAndBillingPeriod("acc-12", "2026-10");
        assertThat(records).hasSize(1);
        assertThat(records.get(0).getChargeId()).isEqualTo("ch_A");
    }
}

Cross-Layer Comparison: SI ASB vs SCS ASB vs SI AEH

The three failure modes above are structurally unique to Spring Integration’s Azure Service Bus adapter. They share surface similarities with failures in related layers but differ at the mechanism level in ways that matter for diagnosis and remediation:

Layer / FailureSettlement mechanismDouble-charge triggerRe-entry pathStable key prevents?
SI ASB — Mode 1: AUTO_COMPLETE=true exceptionSDK complete() on success, abandon on throwServiceActivator exception → abandon → redelivery → UUID_BASB lock expiry after abandonYes — stable key returns ch_A on redelivery
SI ASB — Mode 2: AcknowledgmentCallback inside @TransactionalManual via AcknowledgmentCallback (not transactional)complete() fires before DB write commits; DB rolls back; message consumed; late reconciliation → UUID_BReconciliation re-enqueue after 72h+Yes — but only if Stripe cache not expired; better fix: call ack after transaction commits
SI ASB — Mode 3: maxAutoLockRenewDuration too lowSDK complete() after processing (if lock still valid)Lock expiry during 3DS → redelivery → concurrent UUID_B → ch_BASB lock expiry at total elapsed limitYes — stable key deduplicates concurrent same-key Stripe calls
SCS ASB — AUTO checkpoint × MaxDeliveryCountSCS binder abandon() after max-attempts; ASB redelivers up to MaxDeliveryCountmax-attempts × MaxDeliveryCount = 3×10=30 Stripe calls for one eventSCS retry loop × ASB delivery loopYes
SCS ASB — MANUAL Checkpointer before guard writeCheckpointer.success() (not transactional)success() fires before save(); save() fails; message consumed; DLQ replay → UUID_BDLQ reprocessor after 3 daysYes, if Stripe cache still valid
SI AEH — RECORD + ExecutorChannelAEH checkpoint after outputChannel.send()Checkpoint fires at queue time; SA exception → errorChannel → UUID_BError channel subscriber retriesYes
SI AEH — RequestHandlerRetryAdvice fresh UUIDAEH checkpoint on SA successRetryAdvice re-invokes entire SA including UUID.randomUUID()RetryTemplate retry loopYes — stable key survives re-invocation
SI AEH — Adapter restart mid-3DSAEH checkpoint on SA returnNew EventProcessorClient claims partition at offset before SA completed → UUID_BKubernetes pod restart + new EventProcessorClientYes

Settlement Behavior Summary: SI ASB vs Related Layers

LayerConfigOn successOn exceptionRedelivery?
SI ASB (AUTO_COMPLETE=true)DefaultSDK calls complete()SDK does NOT call complete() → lock expiry → redeliveryYes, on exception
SI ASB (AUTO_COMPLETE=false)ManualApp calls ack.acknowledge(COMPLETE)Lock expires if ack not called → redeliveryYes, if ack not called
SCS ASB (AUTO checkpoint)SCS defaultBinder calls complete()Binder retries; after max-attempts: calls abandon()Yes, after exhausted retries
SCS ASB (MANUAL checkpoint)Checkpointer.success()App calls Checkpointer.success()Lock expires if success() not calledYes, if success() not called
SI AEH (RECORD checkpoint)Container checkpoint modeContainer calls updateCheckpoint() after send() returnsException propagates through send() — checkpoint NOT committedYes, from last committed offset
Raw ASB SDK (peek-lock)Application codeApp calls receiverClient.complete(message)Lock expires if complete() not calledYes, on lock expiry

Diagnostic Checklist: Spring Integration Azure Service Bus + Stripe

Before deploying a Spring Integration Azure Service Bus billing integration that calls Stripe, verify all eight items:

  1. Idempotency key is deterministic, not random. UUID.randomUUID() as a Stripe idempotency key is a double-charge waiting for the next lock expiry, exception, or redelivery. Replace with Hashing.sha256().hashString(accountId + "|" + billingPeriod + "|" + amountCents + "|" + unitsConsumed, UTF_8).toString().
  2. Guard check precedes every Stripe call. billingRecordRepo.existsByAccountIdAndBillingPeriod(accountId, billingPeriod) must return false before calling Stripe. This handles redeliveries where the first attempt committed ch_A and the billing record is already durable.
  3. Understand AUTO_COMPLETE=true abandons on exception. With DirectChannel, ServiceActivator exception → AUTO_COMPLETE does NOT fire → lock expiry → redelivery. If you want “no retry” behavior, use AUTO_COMPLETE=false and call ack.acknowledge(COMPLETE) at the start of the handler.
  4. Never call AcknowledgmentCallback.acknowledge() inside @Transactional if a DB write follows. ASB acknowledgment is not a Spring transactional resource. It fires immediately and is not rolled back if the surrounding @Transactional rolls back. Pattern: wrap Stripe + DB write in a separate @Transactional service method, then call ack.acknowledge() after that method returns.
  5. maxAutoLockRenewDuration is a total elapsed deadline, not a renewal interval. Set it to at least 2× your Stripe 3DS p99 processing time. For EU customers with SCA: set to at least 5 minutes. Check your Stripe Dashboard → Radar → Disputes → 3DS authentication timing for your actual p99.
  6. ASB queue LockDuration must be set at queue creation time, not in ServiceBusContainerProperties. ServiceBusContainerProperties does not configure the queue’s LockDuration — it only configures client-side behavior. The server-side LockDuration must be set via Azure Portal, ARM template, or ServiceBusAdministrationClient when creating/updating the queue.
  7. Verify MaxDeliveryCount × maxConcurrentCalls × potential redeliveries does not produce unbounded Stripe calls. With a stable key, even N redeliveries produce only one net charge. Without a stable key, each redelivery generates a new charge.
  8. Test with the Azure Service Bus Emulator under lock-expiry conditions. The emulator supports short LockDuration values (as low as 5 seconds) for integration tests. Test your ServiceActivator with a simulated 3DS delay that exceeds maxAutoLockRenewDuration to observe redelivery behavior before production.

Summary

The three failure modes in this post share a common root cause — Stripe charges are committed server-side before the billing service can ensure the operation will not be retried with a different key — but each arises from a different Spring Integration Azure Service Bus mechanism:

In all three modes, a deterministic SHA-256 idempotency key combined with a BillingRecord guard write provides the primary defense against duplicate charges. The configuration fixes (correct AUTO_COMPLETE expectations, ack.acknowledge() after the DB write commits, maxAutoLockRenewDuration set to 2× 3DS p99) eliminate the redelivery triggers. The stable key + guard handles the cases where redelivery happens anyway — lock expiry under network partition, unexpected pod restarts, or ASB service incidents that cause early lock loss.

See also the companion posts: Spring Cloud Stream Azure Service Bus + Stripe (SCS-layer failures: AUTO checkpoint × MaxDeliveryCount, MANUAL Checkpointer before guard, session lock expiry), Spring Integration Azure Event Hubs + Stripe (checkpoint timing with ExecutorChannel, RequestHandlerRetryAdvice fresh UUID, adapter restart mid-3DS), and Spring Cloud Stream Azure Event Hubs + Stripe (BATCH checkpoint retry scope, RECORD mode partition lease expiry, TIME mode Blob Storage throttling).

Keybrake: per-vendor spend caps and an audit log for every Stripe call your agent makes

Every Stripe charge your agent commits goes through the Keybrake proxy — spend-capped, endpoint-allowlisted, and logged. Lock down which agents can charge what, and kill a runaway loop without rotating your production Stripe keys.