Apache ActiveMQ and Stripe Integration: Restricted API Keys, Spend Caps, and Agent Governance
Apache ActiveMQ is a mature JMS broker built around at-least-once message delivery: every message is guaranteed to be delivered to a consumer, but not exactly once. When a billing consumer reads usage events from an ActiveMQ queue and calls stripe.charges.create(), three ActiveMQ-specific behaviors — JMSXDeliveryCount included in the idempotency key, transacted session rollback() re-delivering already-charged messages, and Dead Letter Queue admin retry assigning a new JMSMessageID — introduce Stripe double-charge failure modes that standard redelivery backoff and maximumRedeliveries configuration alone cannot prevent.
This post covers those three failure modes with Java (JMS API) code examples, content-hash idempotency keys that exclude all JMS session metadata, pre-flight PostgreSQL checks with ON CONFLICT DO NOTHING, per-billing-period vault keys via a spend-cap proxy, and write-before-acknowledge ordering — the two-layer governance pattern that closes all three failure modes without modifying ActiveMQ broker redelivery policy or queue configuration.
Failure mode 1: JMSXDeliveryCount increments on every redelivery; developer includes it in the Stripe idempotency key to “version” retry attempts; each redelivery produces a different key and Stripe creates ch_B, ch_C, up to maximumRedeliveries
When a JMS consumer fails to acknowledge a message — because the consumer process crashes, throws an uncaught exception, or calls session.recover() — the ActiveMQ broker redelivers the message. The JMSXDeliveryCount property is a standard JMS extended property that ActiveMQ sets on every message: its value is 1 on the first delivery and increments by 1 on each subsequent redelivery. A developer who consults JMSXDeliveryCount as part of their idempotency strategy — perhaps to distinguish “first attempt” from “retry” or to implement exponential backoff with per-attempt Stripe keys — introduces a key that changes with every redelivery.
The failure scenario: the consumer receives a billing message. It calls stripe.charges.create() and receives ch_A from Stripe. Before calling message.acknowledge(), the consumer’s write to a secondary audit database fails with a connection timeout. The developer calls session.recover() to signal that the message should be redelivered. ActiveMQ increments JMSXDeliveryCount from 1 to 2 and redelivers the message. The billing consumer runs again. If the idempotency key includes JMSXDeliveryCount, the key on the second delivery is different from the key on the first delivery. Stripe sees a new unique key and creates ch_B for the same customer and billing period.
// Java — JMS consumer with ActiveMQ (unsafe: JMSXDeliveryCount in key)
import org.apache.activemq.ActiveMQConnectionFactory;
import javax.jms.*;
import com.stripe.Stripe;
import com.stripe.model.Charge;
import java.util.HashMap;
import java.util.Map;
public class BillingConsumer implements MessageListener {
@Override
public void onMessage(Message message) {
try {
TextMessage textMessage = (TextMessage) message;
String customerId = message.getStringProperty("customer_id");
String billingPeriod = message.getStringProperty("billing_period");
int amountCents = message.getIntProperty("amount_cents");
// JMSXDeliveryCount: 1 on first delivery, 2 on first redelivery,
// 3 on second redelivery, etc. ActiveMQ sets this on every message.
int deliveryCount = message.getIntProperty("JMSXDeliveryCount");
// UNSAFE: includes JMSXDeliveryCount.
// Each redelivery increments the count → different idempotency key
// → Stripe creates ch_B, ch_C, ch_D for the same customer and period.
String idempotencyKey = "activemq-billing-" + deliveryCount
+ ":" + customerId + ":" + billingPeriod;
Map<String, Object> params = new HashMap<>();
params.put("amount", amountCents);
params.put("currency", "usd");
params.put("customer", message.getStringProperty("stripe_customer_id"));
params.put("idempotency_key", idempotencyKey);
Charge charge = Charge.create(params);
// Write to audit log — if this fails, session.recover() is called
// and JMSXDeliveryCount increments on next delivery.
writeAuditLog(charge.getId(), customerId, billingPeriod);
message.acknowledge();
} catch (Exception e) {
try {
// Recover the session — all unacknowledged messages are redelivered.
// JMSXDeliveryCount increments on each recovered delivery.
message.getSession().recover();
} catch (JMSException jmse) {
throw new RuntimeException(jmse);
}
}
}
}
The JMSXDeliveryCount property has a specific and narrow valid use: detecting redeliveries for logging and dead-letter routing logic. Its value changes on every delivery attempt by design. Including it in an idempotency key — which must be stable across all delivery attempts for the same business event — creates a key that is guaranteed to change on every redelivery.
A second variant of this failure mode occurs when the developer uses the JMSMessageID correctly as the idempotency key but appends the delivery count as a “uniqueness discriminator” to handle the case where the same customer is billed for the same period twice from two different messages. The logic is: “if the delivery count is 1, it’s the first billing attempt; if it’s 2, it might be a legitimate re-bill.” This reasoning conflates delivery count (a transport-layer retry counter) with billing-period versioning (a business-level concept). The result is the same: a different idempotency key on redelivery and a Stripe duplicate charge.
With ActiveMQ’s default maximumRedeliveries=6, a single failed billing message can result in up to 6 distinct idempotency keys submitted to Stripe — one per delivery attempt — before the message is moved to the Dead Letter Queue. All 6 may produce distinct Stripe charges.
Failure mode 2: transacted JMS session rollback() after a batch error re-delivers all messages in the transaction, including those whose stripe.charges.create() already completed; batch-start timestamp in the idempotency key changes on the rollback-replay pass
JMS transacted sessions (createSession(true, Session.SESSION_TRANSACTED)) treat all messages received in a session as an atomic unit: either all are acknowledged by session.commit() or all are redelivered by session.rollback(). Developers use transacted sessions to batch billing messages — processing 50 usage events in a single transaction and committing once the batch is complete — for throughput and atomicity.
The failure scenario: the consumer reads 50 billing messages in a transacted session. It processes messages 1–47, calling stripe.charges.create() for each and receiving ch_A through ch_47. At message 48, the Stripe API returns stripe.error.CardError (the customer’s card was declined). The developer’s error handling catches the CardError and calls session.rollback() to reset the entire batch. ActiveMQ redelivers all 50 messages. The consumer processes them again — including messages 1–47, which already have successful Stripe charges.
The specific idempotency-key failure: a developer who included the batch start timestamp (recorded at the start of the transaction loop) in the idempotency key to “namespace” billing runs now produces different keys on the rollback-replay pass. The batch was originally processed starting at 2026-07-30T10:00:00.123Z. After session.rollback() and redelivery, the new batch starts at 2026-07-30T10:00:02.456Z. The idempotency keys for messages 1–47 are different. Stripe creates ch_B’ through ch_47’ for all 47 customers already charged in the first pass.
// Java — JMS transacted session consumer (unsafe: batch timestamp in key)
import org.apache.activemq.ActiveMQConnectionFactory;
import javax.jms.*;
import com.stripe.Stripe;
import com.stripe.model.Charge;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class BatchBillingConsumer {
private static final int BATCH_SIZE = 50;
public void processBatch(Session session, Queue queue) throws JMSException {
MessageConsumer consumer = session.createConsumer(queue);
List<Message> batch = new ArrayList<>();
// Record the batch start time — used in idempotency key.
// UNSAFE: this timestamp changes on rollback-replay.
long batchStartMs = Instant.now().toEpochMilli();
try {
for (int i = 0; i < BATCH_SIZE; i++) {
Message message = consumer.receive(5000);
if (message == null) break;
batch.add(message);
String customerId = message.getStringProperty("customer_id");
String billingPeriod = message.getStringProperty("billing_period");
// UNSAFE: includes batchStartMs.
// After session.rollback(), a new batch starts at a different
// millisecond. All messages from the first batch get new keys.
// Stripe creates ch_B for each already-charged customer.
String idempotencyKey = "batch-" + batchStartMs
+ ":" + customerId + ":" + billingPeriod;
Map<String, Object> params = new HashMap<>();
params.put("amount", message.getIntProperty("amount_cents"));
params.put("currency", "usd");
params.put("customer", message.getStringProperty("stripe_customer_id"));
params.put("idempotency_key", idempotencyKey);
// If stripe.error.CardError thrown here for message 48,
// charges for messages 1-47 already completed on Stripe.
Charge charge = Charge.create(params);
}
session.commit();
} catch (Exception e) {
// Rollback redelivers ALL 50 messages — including the 47 already charged.
// New batchStartMs → different idempotency keys → ch_B for 47 customers.
session.rollback();
throw new RuntimeException("Batch failed, rolled back", e);
}
}
}
The core problem is that session.rollback() operates at the transport layer: it signals the broker that the consumer did not process these messages and they should be redelivered. It has no knowledge of which messages already had side effects (Stripe charges) and which did not. From the broker’s perspective, all 50 messages are equally unprocessed.
A developer who attempts to fix this by catching CardError separately and not rolling back the entire session runs into a different problem: in a transacted session, you cannot selectively acknowledge individual messages without committing the entire session. The correct fix is to move the Stripe call outside the rollback-able transaction scope and use a pre-flight check against an external durable store instead.
A subtler variant occurs when the developer uses the JMS Session object’s hash code (session.hashCode()) or a session-level UUID generated at consumer startup as part of the idempotency key. After session.rollback(), if the session is closed and a new one created (a common error-recovery pattern), the new session’s hash code or UUID is different. All redelivered messages get new idempotency keys and Stripe creates duplicate charges.
Failure mode 3: Dead Letter Queue admin retry via ActiveMQ web console or JMX assigns a new JMSMessageID; idempotency key derived from the original JMSMessageID produces a different key on the retried message; Stripe creates ch_B for a customer charged during one of the original delivery attempts
When a billing message fails all maximumRedeliveries delivery attempts (default: 6), ActiveMQ moves it to the Dead Letter Queue (ActiveMQ.DLQ). The original message is removed from the source queue. The billing consumer never sees it again unless an administrator manually retries the DLQ message.
ActiveMQ provides several mechanisms for DLQ retry: the web console (Hawtio-based management interface), JMX via QueueViewMBean.moveMessageTo() or retryMessages(), and the activemq-admin CLI. When a DLQ message is retried using any of these mechanisms, ActiveMQ re-enqueues it as a new message with a new JMSMessageID generated by the broker at re-enqueue time. The original JMSMessageID is attached as an internal metadata property but is not the active JMSMessageID that the consumer’s message.getJMSMessageID() call returns.
The failure scenario: a billing consumer reads a usage event. The first five delivery attempts all fail with stripe.error.APIConnectionError — a transient network outage between the consumer and Stripe. On attempt 3, Stripe actually processed the charge and assigned ch_A, but the network outage prevented the response from reaching the consumer. The consumer’s error handler caught the APIConnectionError (which covers both “Stripe unreachable” and “response lost in transit” cases) and let the message redeliver. On attempts 4 and 5, the same JMSMessageID-based idempotency key returned ch_A from Stripe’s 24-hour idempotency cache — no duplicate. Attempt 6 also fails (the outage window is long). The message goes to the DLQ.
An administrator inspects the DLQ and retries the message. ActiveMQ assigns a new JMSMessageID:
// Java — JMS consumer with ActiveMQ (unsafe: JMSMessageID in idempotency key)
public class BillingConsumer implements MessageListener {
@Override
public void onMessage(Message message) {
try {
String customerId = message.getStringProperty("customer_id");
String billingPeriod = message.getStringProperty("billing_period");
// JMSMessageID is stable across ActiveMQ's normal redelivery:
// the same message being redelivered carries the same JMSMessageID.
// This IS a valid component of an idempotency key for normal redeliveries.
//
// UNSAFE for DLQ retry: when an admin retries a DLQ message via
// web console or JMX, ActiveMQ assigns a NEW JMSMessageID.
//
// Original JMSMessageID: "ID:broker-host-12345-1753858800000-1:1:1:1:1"
// DLQ retry JMSMessageID: "ID:broker-host-12345-1753945200000-1:1:1:2:1"
// ↑ new sequence
//
// The original JMSMessageID is stored in the property
// "JMSXOriginalDestination" metadata, but getJMSMessageID() returns
// the new broker-assigned ID. The idempotency key changes.
String jmsMessageId = message.getJMSMessageID();
String idempotencyKey = "activemq-" + jmsMessageId.replaceAll("[^a-zA-Z0-9]", "")
+ ":" + customerId + ":" + billingPeriod;
Map<String, Object> params = new HashMap<>();
params.put("amount", message.getIntProperty("amount_cents"));
params.put("currency", "usd");
params.put("customer", message.getStringProperty("stripe_customer_id"));
params.put("idempotency_key", idempotencyKey);
// DLQ-retried message has new JMSMessageID → new idempotency key.
// Stripe does not find ch_A in its 24-hour cache (different key).
// Stripe creates ch_B for a customer already charged in attempt 3.
Charge charge = Charge.create(params);
message.acknowledge();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
The failure is compounded by the timing: the DLQ message may sit unretried for hours or days while the administrator investigates. If the retry happens more than 24 hours after the original charge in attempt 3, Stripe’s idempotency cache for the original key has expired. Even if the developer corrects the key back to the original JMSMessageID, Stripe would create a new charge because the 24-hour window has closed. Only a pre-flight check against an external durable store (PostgreSQL, in this pattern) can close the failure regardless of how much time has elapsed.
A second variant: the developer uses ActiveMQ’s IndividualAcknowledgeSession extension (ActiveMQSession.INDIVIDUAL_ACKNOWLEDGE) to selectively acknowledge messages without a transacted session. Individual acknowledgment mode is an ActiveMQ extension not available in standard JMS. When a DLQ retry arrives, the consumer code correctly reads message.getJMSMessageID() — which is the new broker-assigned ID — and generates a new idempotency key. The same ch_B is created.
The governance pattern that closes all three failure modes
1. Content-hash idempotency key from stable business fields only
The idempotency key must be derived exclusively from business-level fields that are stable across all ActiveMQ delivery scenarios — normal redeliveries, transacted session rollbacks, and DLQ admin retries:
import java.security.MessageDigest;
import java.nio.charset.StandardCharsets;
public class IdempotencyKeyUtil {
public static String make(String customerId, String billingPeriod) {
// Stable across all ActiveMQ delivery scenarios.
// Must NOT include: JMSXDeliveryCount (increments on every redelivery),
// JMSMessageID (new value assigned on DLQ retry re-enqueue),
// batch start timestamp (changes on session.rollback() and new batch),
// session.hashCode() or session UUID (changes on session close/reopen),
// JMSTimestamp (broker-set delivery timestamp, changes on redelivery),
// JMSExpiration (changes when broker re-enqueues DLQ message),
// JMSRedelivered flag (boolean — not useful for key uniqueness),
// consumer thread ID (changes on redelivery to a different thread).
String raw = customerId + ":" + billingPeriod + ":activemq-billing";
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] hash = md.digest(raw.getBytes(StandardCharsets.UTF_8));
StringBuilder hex = new StringBuilder();
for (int i = 0; i < 16; i++) {
hex.append(String.format("%02x", hash[i]));
}
return hex.toString();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
2. Pre-flight PostgreSQL check with ON CONFLICT DO NOTHING, written before stripe.charges.create()
Write to billing_records before calling stripe.charges.create(). This closes all three failure modes regardless of ActiveMQ configuration:
// Java — JMS consumer (safe version)
import org.apache.activemq.ActiveMQConnectionFactory;
import javax.jms.*;
import com.stripe.Stripe;
import com.stripe.model.Charge;
import com.stripe.exception.*;
import java.sql.*;
import java.util.HashMap;
import java.util.Map;
public class BillingConsumer implements MessageListener {
private final Connection dbConn;
public BillingConsumer(Connection dbConn) {
this.dbConn = dbConn;
}
@Override
public void onMessage(Message message) {
try {
String customerId = message.getStringProperty("customer_id");
String billingPeriod = message.getStringProperty("billing_period");
int amountCents = message.getIntProperty("amount_cents");
String stripeCustomerId = message.getStringProperty("stripe_customer_id");
String idempotencyKey = IdempotencyKeyUtil.make(customerId, billingPeriod);
// Pre-flight check: write before calling Stripe.
// External, durable, shared across all ActiveMQ consumers on all hosts.
// Closes failure mode 1 (JMSXDeliveryCount), failure mode 2
// (transacted batch rollback), failure mode 3 (DLQ retry).
PreparedStatement ps = dbConn.prepareStatement(
"INSERT INTO billing_records (customer_id, billing_period, idempotency_key) " +
"VALUES (?, ?, ?) ON CONFLICT (customer_id, billing_period) DO NOTHING"
);
ps.setString(1, customerId);
ps.setString(2, billingPeriod);
ps.setString(3, idempotencyKey);
int inserted = ps.executeUpdate();
dbConn.commit();
if (inserted == 0) {
// Already billed — redelivered, rolled-back batch retry, or DLQ retry.
// Skip Stripe call. Acknowledge to prevent further redelivery.
message.acknowledge();
return;
}
try {
Map<String, Object> params = new HashMap<>();
params.put("amount", amountCents);
params.put("currency", "usd");
params.put("customer", stripeCustomerId);
params.put("idempotency_key", idempotencyKey);
Charge charge = Charge.create(params);
message.acknowledge();
} catch (CardException e) {
// Card declined: mark billing_records row so this message is not
// retried via redelivery. Acknowledge to clear from queue.
PreparedStatement update = dbConn.prepareStatement(
"UPDATE billing_records SET status = 'card_declined' " +
"WHERE customer_id = ? AND billing_period = ?"
);
update.setString(1, customerId);
update.setString(2, billingPeriod);
update.executeUpdate();
dbConn.commit();
message.acknowledge();
} catch (ApiConnectionException e) {
// Network error: Stripe may or may not have processed the charge.
// Do NOT acknowledge — let ActiveMQ redeliver.
// On redelivery, the pre-flight billing_records row (written before
// the Stripe call) causes the consumer to skip the Stripe call.
// This is safe: if Stripe created the charge before the network
// failure, the pre-flight skip is correct (charge exists).
// If Stripe did not create the charge, the pre-flight row will
// block all future attempts — so delete the orphaned row only
// after confirming via Stripe API that no charge was created.
throw new RuntimeException("Stripe connection error — redelivering", e);
}
} catch (JMSException | SQLException e) {
throw new RuntimeException(e);
}
}
}
Note the write-before-Stripe ordering: billing_records is inserted before stripe.charges.create() is called. If the consumer is killed between the INSERT and the Stripe call, the redelivered message’s pre-flight check finds the row and skips without a Stripe call. If Stripe returned ApiConnectionException after creating the charge server-side (network error on response path), the content-hash idempotency key returns ch_A on the next delivery (within 24 hours) and the pre-flight row is in place to skip the call at the database layer beyond that window.
The DDL for the billing_records table with the required unique constraint:
-- PostgreSQL DDL
-- ON CONFLICT DO NOTHING handles concurrent consumers racing on the same
-- redelivered message (can happen briefly during ActiveMQ consumer failover).
CREATE TABLE billing_records (
id BIGSERIAL PRIMARY KEY,
customer_id TEXT NOT NULL,
billing_period TEXT NOT NULL,
idempotency_key TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'billed',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (customer_id, billing_period)
);
3. Per-billing-period vault key capped at expected_total × 1.10
A Keybrake vault key per customer per billing period, capped at the expected billing amount times 1.10, provides a hard backstop for cases that the pre-flight check cannot fully close:
- The DLQ retry arrives more than 24 hours after the original charge, Stripe’s idempotency window has closed, and the
billing_recordstable was cleared by a data retention policy or restored from a backup that predates the original charge. Both the content-hash key and the pre-flight check fail to protect. The vault cap absorbs the second charge at the proxy layer before it reaches Stripe’s payment network. - Two concurrent DLQ retry operations are submitted by two administrators simultaneously (two console tabs, two JMX clients). Both generate new
JMSMessageIDvalues. Both messages arrive at a consumer cluster simultaneously. Two consumer threads both pass the pre-flightINSERTin the same millisecond before either commits.ON CONFLICT DO NOTHINGhandles this correctly at the database layer, but the vault cap is the backstop if the constraint is not configured. - A transacted session rollback triggers after Stripe’s 24-hour idempotency window has closed for earlier messages in the batch (billing run interrupted by a multi-day outage). The pre-flight check prevents the duplicate Stripe calls, but the vault cap is the final backstop for environments where the pre-flight row was deleted.
Protection level comparison
| Protection level | JMSXDeliveryCount key (FM1) | Transacted batch rollback (FM2) | DLQ admin retry (FM3) |
|---|---|---|---|
| No protection | ch_B on every redelivery up to maximumRedeliveries | ch_B for every message in the rolled-back batch | ch_B on every DLQ admin retry |
| JMSXDeliveryCount-based key only | ch_B (key changes by design) | ch_B (new batch timestamp changes key) | ch_B (new JMSMessageID changes key) |
| JMSMessageID-based key only (no pre-flight) | Safe within 24 h (stable ID); ch_B after idempotency window | Safe within 24 h if no timestamp in key; ch_B after window | ch_B (new JMSMessageID on DLQ retry) |
| Content-hash key only (no pre-flight) | Safe within 24 h; ch_B after idempotency window closes | Safe within 24 h; ch_B after multi-day outage + rollback | Safe within 24 h; ch_B after 24 h DLQ delay |
| Full pattern: content-hash + pre-flight + vault cap | Safe always | Safe always | Safe always |
Gap analysis: four additional ActiveMQ failure modes
1. ActiveMQ prefetch buffer delivers multiple messages to a consumer before any are acknowledged
ActiveMQ’s prefetch mechanism pushes up to prefetchSize messages (default: 1000 for queues) to a consumer’s local buffer before any are acknowledged. If the consumer process is killed after acknowledging message #1 through #499 but before acknowledging message #500, messages #500 through #999 are still in the consumer’s prefetch buffer — unacknowledged from the broker’s perspective. After consumer.close() or a crash, the broker requeues all 500 unacknowledged messages. Depending on the redelivery delay and consumer pool size, multiple consumer instances may receive these 500 messages simultaneously. The pre-flight ON CONFLICT DO NOTHING constraint handles concurrent billing_records inserts from multiple consumers racing on the same redelivered message: one insert wins, all others are silently no-oped.
2. RedeliveryPolicy exponential backoff resets on consumer reconnect
ActiveMQ’s RedeliveryPolicy tracks the delivery count and backoff delay on the broker side (for queue-based redelivery) and on the consumer side (for session-level redelivery). When a consumer disconnects and reconnects, the redelivery backoff counter is reset to initial values for some redelivery policy implementations. A consumer that has already retried a billing message 4 times with increasing delays may receive it again immediately on reconnect with JMSXDeliveryCount appearing lower than expected (the broker’s count and the consumer’s count can diverge). Any idempotency logic that compares JMSXDeliveryCount against a threshold (“if delivery count > 3, skip billing”) may incorrectly process the message again. The pre-flight check is unaffected by delivery count reset and provides the correct dedup gate.
3. Topic durable subscriptions deliver messages to multiple subscriber instances on failover
ActiveMQ durable topic subscriptions allow a subscriber to receive messages published while the subscriber was offline. When a durable subscriber reconnects after a period of absence, ActiveMQ delivers all messages published during the absence. If the billing consumer uses a durable topic subscription and a consumer instance was briefly offline (maintenance, crash), it receives all messages published during the offline window on reconnect. If another consumer instance was processing some of those messages during the offline window and wrote pre-flight rows to billing_records, the reconnected instance’s pre-flight check correctly skips those messages. Without the pre-flight check, both instances independently call stripe.charges.create() for the same billing events and Stripe creates duplicates.
4. ActiveMQ Virtual Destinations fan-out delivers messages to all consumers in a virtual topic
ActiveMQ Virtual Destinations allow a message published to a virtual topic (VirtualTopic.billing) to be automatically routed to physical queues (Consumer.A.VirtualTopic.billing, Consumer.B.VirtualTopic.billing) for each consumer group. If the billing topology uses a virtual topic and a queue configuration error causes two consumer groups to both subscribe to the same physical queue, both consume and acknowledge messages from the same queue independently. The pre-flight ON CONFLICT DO NOTHING handles this correctly: whichever consumer group inserts first wins; the other’s insert is silently no-oped and the billing message is skipped without a Stripe call. This is the same correctness guarantee as the transacted-session race scenario.
FAQ
Is it safe to use JMSMessageID as the Stripe idempotency key for standard ActiveMQ redeliveries (not DLQ retries)?
For standard ActiveMQ redeliveries (same message being redelivered after consumer failure, session.recover(), or session.rollback()), JMSMessageID is stable — the same ID is reused across redeliveries. A JMSMessageID-based key would protect against failure modes 1 and 2 within Stripe’s 24-hour idempotency window. The failure is specific to DLQ admin retry, which assigns a new JMSMessageID. A content-hash key derived from business fields is safer than a JMSMessageID-based key because it remains stable across all delivery scenarios including DLQ retry, and it works correctly even if the same billing event arrives via a different message (e.g., a retry system that republishes failed events as new messages).
How should a transacted session consumer handle stripe.error.CardError without rolling back the entire batch?
Process card errors at the individual message level inside the transaction loop, not at the transaction level. When a CardError is caught, write a status = 'card_declined' row to billing_records (the pre-flight INSERT was already done before the Stripe call), then continue processing the next message in the batch. Commit the transaction after all 50 messages are processed, regardless of individual outcomes. This approach avoids session.rollback() entirely and processes the full batch in a single transaction commit. Card declines are logged in billing_records and can be retried separately via a different mechanism (a retry queue, a cron job, a webhook from Stripe’s invoice retry system) without re-queueing the original JMS message.
Does the pre-flight check handle the case where the consumer crashes between the billing_records INSERT and stripe.charges.create(), leaving an orphaned pre-flight row for a customer who was never actually charged?
Yes, with a specific caveat. The orphaned billing_records row causes all subsequent deliveries of the same message to skip the Stripe call. If the charge was never created (consumer crashed before calling Stripe), the orphaned row creates a permanent skip: the customer is never billed for that billing period. The correct handling is a reconciliation job that periodically scans for billing_records rows with status = 'billed' and no corresponding Stripe charge ID, then either calls stripe.charges.create() to complete the charge or marks the row as status = 'orphaned' for manual review. For the common case (consumer crashes and is restarted quickly), the ActiveMQ redelivery will deliver the message again, the pre-flight check will find the row (with no Stripe charge ID, depending on your schema), and you can branch on that to complete the Stripe call. Alternatively, write the Stripe charge ID into billing_records after the charge completes: an orphaned row has no charge ID and can be retried safely.
Can I use ActiveMQ’s built-in RedeliveryPlugin or a custom BrokerPlugin to prevent duplicate deliveries instead of a pre-flight check?
ActiveMQ’s RedeliveryPlugin controls redelivery scheduling and dead-letter routing but does not prevent the broker from delivering a message more than once to the consumer layer. It configures the policy for redelivery (delays, max attempts, DLQ routing), not the dedup guarantee. A BrokerPlugin could theoretically track message processing state, but it runs in the broker JVM, not in the consumer, so it cannot know whether stripe.charges.create() succeeded before the consumer acknowledged. The authoritative dedup gate must be external to the broker and external to the consumer JVM — a shared PostgreSQL table with a unique constraint is the correct architecture for cross-consumer, cross-crash deduplication.
If I set maximumRedeliveries=0 (no redelivery), do I still need the pre-flight check?
With maximumRedeliveries=0, ActiveMQ moves unacknowledged messages directly to the DLQ without any redelivery attempt. This prevents failure mode 1 (JMSXDeliveryCount incrementing) and reduces the surface area for failure mode 2 (no rollback-based redelivery). However, DLQ admin retry (failure mode 3) still applies — any message retried from the DLQ gets a new JMSMessageID. Additionally, maximumRedeliveries=0 means that a transient Stripe network error (consumer fails to acknowledge due to ApiConnectionException) immediately moves the billing message to the DLQ instead of retrying. The message may have been partially charged at Stripe. A DLQ retry creates a new JMSMessageID and, without the pre-flight check, Stripe creates ch_B. The pre-flight check is necessary regardless of maximumRedeliveries setting.
Put the brakes on your agent’s Stripe key
Keybrake proxies your agent’s Stripe calls with per-period spend caps, endpoint allowlists, and a full audit log. If an ActiveMQ consumer fires twice — redelivery, batch rollback, or DLQ retry — the vault key cap absorbs the second charge before it reaches Stripe.