IBM MQ and Stripe Integration: Restricted API Keys, Spend Caps, and Agent Governance
IBM MQ — the enterprise message broker deployed in financial services, healthcare, and logistics for its transactional guarantees and guaranteed delivery — introduces three Stripe billing failure modes that differ entirely from open-source messaging systems. The most surprising exploits a field IBM MQ exposes directly to native applications: MQMD.BackoutCount. When a consumer rolls back a message (via MQBACK), IBM MQ increments BackoutCount in the message descriptor and requeues the message. Developers who include BackoutCount in the Stripe idempotency key — reasoning that each delivery attempt should map to a separate, isolated Stripe request — get a different key on every rollback and stripe creates a new charge for each one. The second failure mode is specific to IBM MQ’s Dead Letter Queue tooling: retry tools like rfhutil and IBM MQ Explorer call MQPUT to requeue dead-lettered messages, and IBM MQ assigns a new 24-byte MQMD.MsgId on every MQPUT — no tool preserves the original. A key derived from MsgId is different for the DLQ-retried message and Stripe creates ch_B for a customer who was charged on the original delivery. The third is specific to IBM MQ’s automatic client reconnect: when the queue manager connection drops during a stripe.charges.create() call and the MQ client reconnects, IBM MQ rolls back the unit of work and re-delivers the billing message — developers who captured a connection handle or timestamp at MQCONN time and included it in the idempotency key get a different key after reconnect and Stripe charges the customer a second time.
This post covers all three failure modes with Java code using the IBM MQ classes (com.ibm.mq.MQQueueManager, MQMessage, MQGetMessageOptions), content-hash idempotency keys that exclude all IBM MQ delivery and session metadata (BackoutCount, MsgId, CorrelId, connection handle, connection timestamp, reconnect counter), pre-flight PostgreSQL checks with ON CONFLICT DO NOTHING, per-billing-period vault keys via a spend-cap proxy, and write-before-commit ordering — the two-layer governance pattern that closes all three failure modes without modifying IBM MQ queue configuration.
Failure mode 1: MQMD.BackoutCount increments on every MQBACK rollback — developer who includes BackoutCount in the Stripe idempotency key to “version” delivery attempts produces a different key on each redelivery and Stripe creates a new charge for every rollback
IBM MQ’s Message Descriptor (MQMD) is the fixed-format envelope that accompanies every IBM MQ message. It contains fields that open-source MQ systems handle in higher-level abstractions: MsgId (a 24-byte binary unique identifier), CorrelId (24-byte correlation identifier for request-reply patterns), Expiry (time-to-live), Persistence (persistent vs. non-persistent), and BackoutCount (a 4-byte integer that the queue manager increments each time the message is returned to its queue following a rollback). The BackoutCount field is IBM MQ’s mechanism for tracking “how many times has this delivery attempt failed.” Applications read it to implement backout threshold logic of their own — for example, routing a message to a hold queue after three failed attempts rather than relying on the queue manager’s BOTHRESH attribute.
The failure pattern: a billing developer working with the native IBM MQ Java API reads about BackoutCount and decides to include it in the Stripe idempotency key. The reasoning sounds sound: “if the message is being processed for the first time, BackoutCount is 0 and the idempotency key is key_v0; if it’s the first retry, BackoutCount is 1 and the key is key_v1; Stripe treats each as a distinct request, which means a partial failure on the first attempt doesn’t prevent a fresh attempt on the retry.” This reasoning is wrong in both directions: including a counter in the Stripe idempotency key does not isolate retry attempts from partial failures — Stripe’s idempotency cache is designed to handle exactly that case without a counter — and it actively causes duplicate charges when the billing consumer throws an exception after stripe.charges.create() has completed and returned ch_A. The consumer rolls back via MQBACK; IBM MQ increments BackoutCount to 1 and requeues the message; the next consumer picks it up, computes a key with BackoutCount=1, calls stripe.charges.create(), and Stripe creates ch_B. The same customer is charged twice.
The failure repeats on every subsequent rollback. With a BOTHRESH of 5 (a common setting in financial services IBM MQ environments), the same billing message can produce five Stripe charges — one per BackoutCount value from 0 to 4 — before the queue manager moves the message to the backout queue (BOQNAME). All five charges are created silently: Stripe does not detect duplicates across idempotency keys, and IBM MQ does not report the billing consequence of the BackoutCount increment.
// Java — IBM MQ native API (com.ibm.mq.*) with BackoutCount in idempotency key (unsafe)
import com.ibm.mq.MQException;
import com.ibm.mq.MQGetMessageOptions;
import com.ibm.mq.MQMessage;
import com.ibm.mq.MQQueue;
import com.ibm.mq.MQQueueManager;
import com.ibm.mq.constants.CMQC;
import com.stripe.Stripe;
import com.stripe.model.Charge;
import com.stripe.param.ChargeCreateParams;
public class BillingConsumer {
private final MQQueueManager qm;
private final MQQueue billingQueue;
public void processMessages() throws MQException {
MQGetMessageOptions gmo = new MQGetMessageOptions();
gmo.options = CMQC.MQGMO_SYNCPT // get within syncpoint (transaction)
| CMQC.MQGMO_WAIT
| CMQC.MQGMO_FAIL_IF_QUIESCING;
gmo.waitInterval = 5000;
while (true) {
MQMessage message = new MQMessage();
try {
billingQueue.get(message, gmo);
String customerId = message.readUTF();
String billingPeriod = message.readUTF();
int amountCents = message.readInt();
int backoutCount = message.backoutCount; // read from MQMD
// Developer intent: "version" each delivery attempt in Stripe's cache.
// Actual effect: each MQBACK produces a different key and a new charge.
String idempotencyKey = sha256(
customerId + ":" + billingPeriod + ":v" + backoutCount
);
ChargeCreateParams params = ChargeCreateParams.builder()
.setAmount((long) amountCents)
.setCurrency("usd")
.setCustomer(message.readUTF())
.build();
// stripe.charges.create() succeeds; ch_A is created for BackoutCount=0.
// Next line throws a secondary write exception.
Charge charge = Charge.create(params, requestOptions(idempotencyKey));
writeAuditRecord(charge.getId(), customerId, billingPeriod); // throws
qm.commit(); // never reached — audit write threw
} catch (Exception e) {
// MQBACK: IBM MQ increments message.backoutCount to 1 and requeues.
// Next MQGET on this message: backoutCount=1 → key="...v1" → ch_B.
qm.backout();
log.error("Billing error, message backed out", e);
}
}
}
}
A second variant: the developer uses BackoutCount to gate whether to call Stripe at all. The logic: “if BackoutCount is 0, this is the first attempt, so call Stripe; if BackoutCount is greater than 0, we already called Stripe on a previous attempt, so skip it and just re-commit.” This is closer to the right idea but still breaks in a specific scenario: if stripe.charges.create() was called but the consumer crashed before the response was received (network timeout, JVM OOM kill), BackoutCount on the redelivered message is 1, the consumer skips the Stripe call entirely, and the billing message is committed without verifying whether ch_A was actually created. The customer either goes uncharged (if Stripe never processed the request) or charged without the charge being recorded. The pre-flight database check described below closes both cases without any BackoutCount conditional logic.
BackoutCount is also unreliable as a gate when BOTHRESH is set on the queue: if BOTHRESH is 3 and BackoutCount reaches 3, IBM MQ moves the message to the backout queue before the consumer can read it again. The consumer’s “if BackoutCount > 0, skip Stripe” logic never executes for BackoutCount=3; the billing message goes to the backout queue uncharged (or with an unknown charge status) and waits for operator review.
Failure mode 2: IBM MQ Dead Letter Queue retry via rfhutil, IBM MQ Explorer, or custom MQPUT scripts assigns a new 24-byte MQMD.MsgId — the original MsgId is not preserved; a developer whose Stripe idempotency key is derived from MsgId gets a completely different key on DLQ retry and Stripe creates ch_B for a customer charged on any of the original delivery attempts
When an IBM MQ message exceeds its queue’s BOTHRESH (backout threshold) attribute, the queue manager moves it to the queue named in the BOQNAME attribute. If BOQNAME is empty or unset, the queue manager moves the message to SYSTEM.DEAD.LETTER.QUEUE. In both cases, the queue manager prepends an MQDLH (Message Queue Dead Letter Header) to the original message data. The MQDLH contains the reason code that caused the dead-lettering, the original destination queue and queue manager name, a timestamp, and the original message put date and time. Critically, the original MQMD.MsgId is preserved in the dead-lettered message’s MQMD — the same 24-byte value that was assigned when the message was first enqueued to the original queue. This gives administrators the ability to track the message from the DLQ back to its origin.
The failure happens when an administrator retries the dead-lettered message. IBM MQ provides several tools for this: rfhutil (the IBM-supplied MQ utility for browsing, putting, and getting messages — standard on every IBM MQ installation), IBM MQ Explorer (the Eclipse-based administrative GUI), and custom applications that browse the DLQ and re-enqueue messages to their original destination. All of these tools follow the same pattern: they read the message from the DLQ, strip or keep the MQDLH, and call MQPUT to place the message on the processing queue. When they call MQPUT without explicitly copying the original MsgId from the DLQ message to the MQMD.MsgId field of the MQPUT call — which none of these tools do by default, and which rfhutil’s default “put” mode does not do — IBM MQ generates a new 24-byte MsgId for the re-enqueued message. The 24-byte binary field is set to a new unique value; no reference to the original MsgId is automatically maintained in the re-enqueued message’s MQMD.
// Java — IBM MQ native API, unsafe: MsgId-derived Stripe idempotency key
import com.ibm.mq.MQMessage;
import com.ibm.mq.constants.CMQC;
import java.nio.charset.StandardCharsets;
public class BillingConsumer {
private String buildIdempotencyKey(MQMessage message, String customerId, String billingPeriod) {
// MQMD.messageId is a byte[24] assigned by IBM MQ at MQPUT time.
// When an admin retries from the DLQ via rfhutil or MQ Explorer MQPUT,
// a new 24-byte MsgId is generated. The key below is different
// for the DLQ-retried message even though the customer and billing period are the same.
String msgIdHex = bytesToHex(message.messageId);
return sha256(msgIdHex + ":" + customerId + ":" + billingPeriod);
}
// Safe alternative: content-hash key derived from stable business fields only
private String buildSafeIdempotencyKey(String customerId, String billingPeriod) {
// Does NOT include: messageId (new on DLQ retry MQPUT), backoutCount (increments on MQBACK),
// correlId (may change on DLQ re-enqueue), putDateTime (changes on every MQPUT),
// expiry (changes if admin tool adjusts TTL on re-enqueue),
// MQHCONN handle (changes on every MQQueueManager() constructor call).
return sha256(customerId + ":" + billingPeriod + ":ibmmq-billing").substring(0, 32);
}
}
// What the DLQ retry looks like at the MQMD level:
//
// Original message at MQPUT time:
// MQMD.messageId = 414D512042494C4C494E47... (IBM MQ-generated unique 24 bytes)
// MQMD.backoutCount = 0
// MQMD.putDateTime = 2026-07-28 09:15:22
//
// After BOTHRESH exceeded → moved to SYSTEM.DEAD.LETTER.QUEUE:
// MQMD.messageId = 414D512042494C4C494E47... (same — preserved in DLQ message)
// MQDLH prepended: {reason=MQRC_BACK_OUT_THRESHOLD_REACHED, destQName="BILLING.QUEUE"}
//
// DLQ retry via rfhutil/MQ Explorer (default MQPUT):
// MQMD.messageId = 51A2B3C4D5E6F70819... (new 24-byte MsgId — IBM MQ-generated)
// MQMD.backoutCount = 0 (reset to 0 on new MQPUT)
// MQMD.putDateTime = 2026-07-31 14:30:07 (new put timestamp)
// No MQDLH on re-enqueued message (rfhutil strips it by default)
//
// If stripe.charges.create() succeeded at BackoutCount=0 (2026-07-28, ch_A):
// Original key: sha256("414D51..." + "cust_123" + "Q3-2026") → "a8f3bc..."
// DLQ retry key: sha256("51A2B3..." + "cust_123" + "Q3-2026") → "f94c21..." ← different
// If DLQ retry is >24h after ch_A: Stripe's idempotency cache expired → ch_B created.
The 24-hour window makes this failure particularly dangerous for IBM MQ deployments. DLQ messages in enterprise environments are not typically retried immediately: the DLQ is reviewed by operations teams on a regular schedule, messages accumulate over weekends or holidays, and retries are batched. A billing message that failed on a Friday evening and is retried Monday morning is almost certainly outside Stripe’s 24-hour idempotency window. The customer was charged on Friday (ch_A on BackoutCount=0 before the secondary failure that caused the rollback) and charged again on Monday (ch_B on DLQ retry with a new MsgId). Both charges appear in Stripe’s dashboard under different charge IDs with no indication of duplication.
A variant exists for IBM MQ Managed File Transfer (MFT) or IBM Integration Bus (IIB/ACE) pipelines that use MQ as a transport layer: message flows that call DLQ re-enqueue logic via an IBM App Connect Enterprise node, a custom Java Compute node, or an MFT Transfer Definition also issue MQPUT without explicitly preserving the original MsgId. The failure mode is identical; only the tooling that triggers it changes.
Failure mode 3: IBM MQ automatic client reconnect (MQRC_RECONNECTED) rolls back the in-progress unit of work and re-delivers billing messages that were obtained but not yet committed — developers who include the MQHCONN connection handle or connection timestamp in the Stripe idempotency key get a different key after reconnect and stripe.charges.create() creates ch_B
IBM MQ 7.1 introduced automatic client reconnect: client applications that connect to a queue manager using the MQ client (as opposed to local bindings) can configure their MQCONNX call with the MQCNO_RECONNECT or MQCNO_RECONNECT_Q_MGR option. When the connection drops — due to a network partition, a queue manager restart, a HA failover to a standby queue manager, or a multi-instance queue manager switchover — the IBM MQ client library automatically attempts to reconnect without the application needing to handle the MQRC_CONNECTION_BROKEN or MQRC_Q_MGR_NOT_AVAILABLE reason code explicitly. The reconnect is transparent in many cases: after reconnection, applications can resume issuing MQ API calls as if nothing happened.
During reconnect, IBM MQ rolls back the in-progress unit of work. Any MQGET calls issued within the current syncpoint (started via MQBEGIN, or automatically when using MQGMO_SYNCPT without explicit begin) that have not been committed via MQCMIT are undone. Those messages are returned to their queues and become available for re-delivery. When the consumer reconnects and issues MQGET again, it receives the same billing messages that were in-flight at the time of the disconnection.
The failure happens when the developer captures a session-level identifier at connection time and includes it in the Stripe idempotency key. Common patterns in native IBM MQ code:
- Connection timestamp:
long sessionStart = System.currentTimeMillis();captured once perMQQueueManagerconstructor call, then included in the Stripe key assha256(sessionStart + ":" + customerId + ":" + billingPeriod). After reconnect, a newMQQueueManagerobject is created (or the existing one re-initializes internally), and the consumer captures a newsessionStarttimestamp. The idempotency key is different for the re-delivered messages. - Reconnect counter: the developer registers an event handler (via
MQCNO.eventHandleror IBM MQ’sMQAsyncStatuscallback) that increments a reconnect counter each timeMQRC_RECONNECTEDfires. The counter is included in the Stripe key as a “generation” suffix. Each reconnect produces a different key for re-delivered messages, so all messages in-flight at the time of the connection drop are billed again. MQHCONN-derived value: in native C or older Java MQ code, the connection handle (MQHCONN) is a 4-byte integer assigned by the queue manager at connect time. Some developers convert it to a string and use it as a “session identifier” in idempotency keys. After reconnect, the new connection receives a newMQHCONNvalue. The IBM MQ Java classes (com.ibm.mq.MQQueueManager) expose internal handle information indirectly via object identity (System.identityHashCode(qm)or similar), and developers who use object identity as a session discriminator change their key after reconnect.
// Java — IBM MQ native API with reconnect-enabled connection and unsafe session timestamp
import com.ibm.mq.MQEnvironment;
import com.ibm.mq.MQException;
import com.ibm.mq.MQGetMessageOptions;
import com.ibm.mq.MQMessage;
import com.ibm.mq.MQQueue;
import com.ibm.mq.MQQueueManager;
import com.ibm.mq.constants.CMQC;
public class ReconnectingBillingConsumer {
// sessionStart captured at connection time — unsafe to include in Stripe key
private long sessionStart;
private MQQueueManager qm;
private MQQueue queue;
public void connect() throws MQException {
MQEnvironment.hostname = "mqbroker.prod.internal";
MQEnvironment.port = 1414;
MQEnvironment.channel = "BILLING.SVRCONN";
// Enable automatic reconnect (IBM MQ 7.1+ client feature)
MQEnvironment.properties.put(CMQC.TRANSPORT_PROPERTY, CMQC.TRANSPORT_MQSERIES_CLIENT);
// Client reconnect configured via CCDT or mqclient.ini reconnectTimeout / reconnectDelay
sessionStart = System.currentTimeMillis(); // captured here — changes on reconnect
qm = new MQQueueManager("PROD.QM1");
queue = qm.accessQueue("BILLING.QUEUE",
CMQC.MQOO_INPUT_SHARED | CMQC.MQOO_FAIL_IF_QUIESCING);
}
public void processMessages() {
MQGetMessageOptions gmo = new MQGetMessageOptions();
gmo.options = CMQC.MQGMO_SYNCPT | CMQC.MQGMO_WAIT | CMQC.MQGMO_FAIL_IF_QUIESCING;
gmo.waitInterval = 5000;
while (true) {
MQMessage message = new MQMessage();
try {
queue.get(message, gmo);
String customerId = readField(message, "customer_id");
String billingPeriod = readField(message, "billing_period");
int amountCents = readField(message, "amount_cents");
// Unsafe: sessionStart changes after reconnect.
// On reconnect + re-delivery: same message, different sessionStart,
// different idempotency key, Stripe creates ch_B.
String idempotencyKey = sha256(sessionStart + ":" + customerId + ":" + billingPeriod);
Charge charge = Charge.create(buildParams(amountCents, customerId),
requestOptions(idempotencyKey));
writeAuditRecord(charge.getId(), customerId, billingPeriod);
qm.commit();
} catch (MQException mqe) {
if (mqe.reasonCode == CMQC.MQRC_CONNECTION_BROKEN
|| mqe.reasonCode == CMQC.MQRC_Q_MGR_NOT_AVAILABLE) {
// IBM MQ client reconnect fires here or raises MQRC_RECONNECTED.
// The in-progress UOW has been rolled back by the QM.
// In-flight billing messages are back in the queue.
// On reconnect, the consumer calls connect() again, resetting sessionStart.
reconnectWithBackoff();
} else {
try { qm.backout(); } catch (MQException ignored) {}
}
} catch (Exception e) {
try { qm.backout(); } catch (MQException ignored) {}
}
}
}
// Safe: content-hash key derived from stable business fields only.
// Does NOT include: sessionStart (changes per connect()), System.identityHashCode(qm)
// (changes on reconnect), backoutCount (increments on MQBACK), message.messageId
// (new on DLQ retry MQPUT), reconnect counter (increments on MQRC_RECONNECTED),
// putDateTime (different if message was re-enqueued by a retry tool).
private String safeIdempotencyKey(String customerId, String billingPeriod) {
return sha256(customerId + ":" + billingPeriod + ":ibmmq-billing").substring(0, 32);
}
}
The failure is most common in IBM MQ environments that rely on multi-instance queue managers (active/standby pairs) for high availability. When the active queue manager fails, the standby takes over and connected clients experience a brief disconnection during which their in-progress units of work are rolled back. In a billing pipeline that processes billing messages in batches — one MQBEGIN per batch of 50 customers, one MQCMIT after all 50 are charged — a failover mid-batch means all 50 billing messages are re-delivered. If the developer used sessionStart in the Stripe key and the failover caused a reconnect that captured a new sessionStart, all 50 customers are charged twice. The Stripe dashboard shows 50 ch_A charges (from before the failover) and 50 ch_B charges (from after reconnect), all looking legitimate, all within 24 hours of each other.
What doesn’t prevent duplicate Stripe charges in IBM MQ
| Technique | FM1: BackoutCount key | FM2: DLQ MsgId reset | FM3: Reconnect key change |
|---|---|---|---|
| BOTHRESH + BOQNAME (backout threshold) | Limits how many times FM1 repeats but does not prevent it within the threshold window | Determines when the message goes to DLQ — triggers FM2 rather than preventing it | No effect on reconnect re-delivery |
MQMD.MsgId as Stripe key component |
Stable within a BackoutCount series — prevents FM1 if BackoutCount is NOT included; broken by FM2 | New MsgId on DLQ retry MQPUT — FM2 still fires | Stable across reconnect re-delivery — prevents FM3 if MsgId alone is the key, but FM2 breaks the same key |
IBM MQ persistence (MQPER_PERSISTENT) |
Ensures the message survives QM restart — does not affect idempotency key stability or Stripe call dedup | No effect on MsgId reset at DLQ retry | Ensures the message is not lost during failover — does not prevent re-delivery after reconnect rollback |
Consumer-local in-memory set of processed MsgIds |
Works if the same consumer processes the redelivery — does not survive JVM restart or reconnect | New MsgId on DLQ retry bypasses the local set | In-memory set is empty after reconnect (new JVM, new set) — reconnect re-delivery bypasses it |
| IBM MQ exactly-once messaging (IMS bridge, CICS bridge) | Scoped to IBM subsystem integration — does not cover stripe.charges.create() side effects |
No effect on DLQ retry external tool behavior | No effect on HTTP outbound calls from the MQ consumer application |
The fix: content-hash idempotency key + pre-flight database check + vault key spend cap
Three changes close all three failure modes without modifying IBM MQ queue attributes, channel configuration, or BOTHRESH settings.
1. Content-hash idempotency key derived exclusively from stable business fields
The Stripe idempotency key must be derived from fields that are stable across every IBM MQ delivery path: the initial delivery (BackoutCount=0), all rollback redeliveries (BackoutCount=1, 2, 3...), DLQ retry via any tool (rfhutil, MQ Explorer, custom MQPUT), and client reconnect re-delivery. The content of the billing message — customer ID, billing period, and a service-level namespace — meets all four conditions. IBM MQ routing and delivery metadata — BackoutCount, MsgId, CorrelId, putDateTime, expiry, and any session-level identifiers (MQHCONN, connection timestamp, reconnect counter) — does not.
// Java — IBM MQ native API with safe content-hash idempotency key
import com.ibm.mq.MQException;
import com.ibm.mq.MQGetMessageOptions;
import com.ibm.mq.MQMessage;
import com.ibm.mq.MQQueue;
import com.ibm.mq.MQQueueManager;
import com.ibm.mq.constants.CMQC;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.sql.Connection;
import java.sql.PreparedStatement;
public class SafeBillingConsumer {
private final MQQueueManager qm;
private final MQQueue queue;
private final Connection pgConn; // external PostgreSQL — shared across all consumers
public void processMessages() throws Exception {
MQGetMessageOptions gmo = new MQGetMessageOptions();
gmo.options = CMQC.MQGMO_SYNCPT | CMQC.MQGMO_WAIT | CMQC.MQGMO_FAIL_IF_QUIESCING;
gmo.waitInterval = 5000;
while (true) {
MQMessage message = new MQMessage();
try {
queue.get(message, gmo);
String customerId = readField(message, "customer_id");
String billingPeriod = readField(message, "billing_period");
int amountCents = readIntField(message, "amount_cents");
String stripeCustomer = readField(message, "stripe_customer_id");
// Safe idempotency key: excludes ALL IBM MQ metadata.
// BackoutCount, MsgId, CorrelId, putDateTime, expiry, MQHCONN —
// none of these are included. Key is stable across all delivery paths.
String idempotencyKey = contentHashKey(customerId, billingPeriod);
// Pre-flight check: INSERT into external PostgreSQL before calling Stripe.
// ON CONFLICT DO NOTHING: if this pair is already in the table (from a
// previous delivery that completed after the Stripe call but before MQCMIT),
// the INSERT is silently skipped. rowsAffected=0 → skip Stripe call.
// This closes FM2 (DLQ retry >24h after ch_A) and FM1/FM3 (reconnect/rollback
// after Stripe responds but before MQCMIT).
int rowsAffected;
try (PreparedStatement ps = pgConn.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);
pgConn.setAutoCommit(false);
rowsAffected = ps.executeUpdate();
pgConn.commit();
}
if (rowsAffected == 0) {
// Pre-flight row already exists: previous delivery called Stripe.
// Skip the Stripe call. Commit the MQ message to remove it from the queue.
qm.commit();
continue;
}
// Write-before-call ordering: billing_records row committed to PostgreSQL
// before stripe.charges.create() is called. If the consumer is killed
// between the PostgreSQL commit and the Stripe call, the pre-flight row
// exists on the next delivery and the Stripe call is skipped safely.
// (The Stripe call was never made — the customer is not charged.)
// If stripe.charges.create() succeeds but MQCMIT fails (reconnect), the
// pre-flight row prevents a second Stripe call on re-delivery.
ChargeCreateParams params = ChargeCreateParams.builder()
.setAmount((long) amountCents)
.setCurrency("usd")
.setCustomer(stripeCustomer)
.build();
Charge charge = Charge.create(params, requestOptions(idempotencyKey));
// Update billing_records with the charge ID (non-critical — pre-flight
// row already exists; this update is for audit completeness only).
updateBillingRecord(customerId, billingPeriod, charge.getId());
qm.commit();
} catch (MQException mqe) {
handleMQException(mqe);
} catch (Exception e) {
try { qm.backout(); } catch (MQException ignored) {}
}
}
}
private String contentHashKey(String customerId, String billingPeriod) throws Exception {
// Inputs: stable business fields only.
// Excluded (all IBM MQ metadata): message.backoutCount, message.messageId (byte[24]),
// message.correlationId (byte[24]), message.putDateTime (GregorianCalendar),
// message.expiry, sessionStart timestamp, reconnect counter, MQHCONN handle.
String raw = customerId + ":" + billingPeriod + ":ibmmq-billing";
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] digest = md.digest(raw.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder();
for (byte b : digest) sb.append(String.format("%02x", b));
return sb.substring(0, 32);
}
}
2. Pre-flight database check with ON CONFLICT DO NOTHING
The content-hash idempotency key closes failure modes 1 and 3 when the re-delivery happens within Stripe’s 24-hour idempotency window: Stripe returns ch_A on every call with the same key within 24 hours. Failure mode 2 (DLQ retry) often happens outside the 24-hour window; the content-hash key is the same on the DLQ retry as on the original, but Stripe’s cache has expired and stripe.charges.create() creates ch_B.
The pre-flight check closes failure mode 2 regardless of the 24-hour window. Before calling stripe.charges.create(), the consumer executes a PostgreSQL INSERT INTO billing_records (customer_id, billing_period) VALUES (?, ?) ON CONFLICT (customer_id, billing_period) DO NOTHING and checks rowsAffected. If the row was inserted (rowsAffected=1), the consumer calls Stripe and commits. If the row already existed (rowsAffected=0), the consumer skips Stripe and commits the MQ message. The PostgreSQL table is external to IBM MQ — it survives queue manager failovers, consumer restarts, and DLQ retry gaps that span days or weeks. The unique constraint on (customer_id, billing_period) is the durable source of truth for “has this customer been charged for this period.”
Write-before-call ordering is critical: the PostgreSQL INSERT must commit before stripe.charges.create() is called. If the consumer is killed between the PostgreSQL commit and the Stripe call, the pre-flight row exists on the next delivery and the Stripe call is safely skipped — the customer is not charged, which is correct (Stripe never processed the request). If the consumer is killed between the Stripe call completing and MQCMIT, the pre-flight row exists on the next delivery and the Stripe call is skipped — the customer was already charged (ch_A exists in Stripe), and the skip prevents ch_B. Both cases are handled correctly by the same pre-flight row.
3. Per-billing-period vault key capped at expected_total × 1.10
A Keybrake vault key issued per billing period with a spend cap of expected_total × 1.10 provides a hard backstop for DLQ retry scenarios where the pre-flight row is unavailable: PostgreSQL is temporarily unreachable, or the pre-flight INSERT succeeded but the read-your-writes guarantee is violated by a replica failover between the INSERT and the rowsAffected check. The vault key refuses any stripe.charges.create() call that would push the period total beyond the 110% cap. Within normal operation, duplicate charge attempts are blocked at the Keybrake proxy layer before they reach Stripe, without the pre-flight row needing to be available.
The three layers — content-hash key, pre-flight row, and vault cap — provide defense in depth: any single layer failing (Stripe idempotency cache expired, PostgreSQL temporarily unreachable, vault key not configured) leaves two others intact. All three failing simultaneously requires a correlated failure across two infrastructure systems (PostgreSQL and Keybrake) during a DLQ retry that happens to be outside the 24-hour Stripe idempotency window, which is a scenario IBM MQ’s own reliability guarantees make extremely unlikely when both systems are healthy.
Gap analysis: IBM MQ-specific scenarios not covered by simpler approaches
IBM MQ client reconnect fires MQRC_RECONNECTED during an active stripe.charges.create() HTTP call in a separate thread
IBM MQ client reconnect is asynchronous: the MQ client library detects the dropped connection on the next MQ API call (MQGET, MQCMIT, MQBACK). If the consumer application makes the stripe.charges.create() HTTP call on a separate thread while the main thread is blocked on MQGET waiting for the next message, the reconnect fires on the MQGET thread. The Stripe call thread completes successfully and stores the result. The main thread gets MQRC_RECONNECTED on the MQGET call, rolls back the in-progress UOW, and re-delivers the billing messages that were mid-flight. The consumer now has the Stripe charge stored in memory but the MQ message is back in the queue. When the same message is processed again, the in-memory Stripe result is gone (if the consumer restarted) and the consumer calls Stripe again. The pre-flight PostgreSQL row prevents the second Stripe call regardless of the in-memory state.
IBM MQ multi-instance queue manager failover and the gap between active/standby switch and client reconnect
In IBM MQ multi-instance queue manager configurations (active/standby pair sharing a network file system), the standby takes over the active role when the active queue manager fails. Client applications connected to the active receive MQRC_CONNECTION_BROKEN and begin reconnecting. The standby, now active, restores its queue state from the shared file system — including in-flight persistent messages that were in syncpoint on the failed active. Persistent messages not yet committed are placed back in their queues. When the consumer reconnects and issues MQGET, it receives these messages again. A content-hash key is stable across the failover (the consumer reconnects to a different queue manager instance, but the billing message content is the same). The pre-flight PostgreSQL row is readable from the standby consumer because it is in an external database not bound to either QM instance. Both guards work correctly through the active/standby switch without any change to the consumer application.
IBM MQ MQGET with MQGMO_BROWSE followed by a destructive get in a two-phase scan pattern
Some IBM MQ consumer patterns browse the queue first (to inspect the message without removing it) and then issue a destructive MQGET with a message selector targeting the browsed message’s MsgId. The browse-then-get pattern creates a window where a competing consumer can also browse and destructively get the same message. If both consumers reach stripe.charges.create() simultaneously, both calls use the same content-hash key, and Stripe’s idempotency cache ensures both receive ch_A — no duplicate charge. However, only one of the two MQGET calls actually removes the message (first one wins; the second gets MQRC_NO_MSG_AVAILABLE). The pre-flight PostgreSQL ON CONFLICT DO NOTHING handles the race at the database layer: the second consumer’s INSERT finds the row already exists (rowsAffected=0) and skips the Stripe call. Both guards work correctly.
IBM MQ Connector for Apache Kafka (MQ Source Connector) and offset replay
The IBM MQ Source Connector for Kafka (part of Kafka Connect) reads messages from an IBM MQ queue and produces them to Kafka topics. If the connector fails before committing the Kafka offset and committing the MQ syncpoint, both the Kafka offset and the MQ message are restored to their pre-failure positions on restart. The billing pipeline downstream of the Kafka topic sees duplicate records. If the Kafka consumer uses a content-hash idempotency key (derived from the original MQ message’s business fields, preserved through the MQ-to-Kafka transit) and a pre-flight PostgreSQL check, duplicate Stripe charges are prevented. The IBM MQ-specific failure mode here is the dual-commit problem at the MQ/Kafka boundary: MQ syncpoint and Kafka offset commit are not atomic. The fix is at the Kafka layer (Kafka idempotent producer + exactly-once semantics, or the same content-hash key + pre-flight check applied at the Kafka consumer).
Frequently asked questions
Can I use MQMD.MsgId as the Stripe idempotency key component without risking FM2?
Yes, with a caveat. MQMD.MsgId is stable across BackoutCount increments (FM1) and across client reconnect re-delivery (FM3): the queue manager does not change the MsgId when it requeues a message after MQBACK or when it re-delivers messages after reconnect rollback. An idempotency key derived from MsgId alone closes FM1 and FM3 when within Stripe’s 24-hour window. The problem is FM2: DLQ retry via rfhutil or IBM MQ Explorer issues a new MQPUT with a new MsgId. If the DLQ retry happens more than 24 hours after the original charge, the content-hash key (which does not include MsgId) allows Stripe’s idempotency cache to match; the MsgId-based key does not. Use a content-hash key (stable across all three failure paths, including DLQ retry with tool-generated new MsgId) plus the pre-flight PostgreSQL check. This closes all three failure modes without any dependency on MsgId stability.
Does IBM MQ’s MQPMO_NEW_CORREL_ID / MQPMO_NEW_MSG_ID put message option control whether DLQ retry tools preserve the original MsgId?
MQPMO_NEW_MSG_ID tells IBM MQ to generate a new MsgId for the message being put. MQPMO_PASS_ALL_CONTEXT tells IBM MQ to preserve the MsgId, CorrelId, and other context fields from the input message. DLQ retry tools that call MQPUT with MQPMO_PASS_ALL_CONTEXT and copy the original message’s MQMD.MsgId to the put message’s MQMD.MsgId will produce a re-enqueued message with the same MsgId. rfhutil and IBM MQ Explorer do not do this by default: rfhutil’s “put message” function generates a new MsgId unless the user explicitly edits the MQMD fields; IBM MQ Explorer’s Move Messages function does not expose MQMD editing in its default flow. Custom retry applications can use MQPMO_PASS_ALL_CONTEXT to preserve the MsgId — but relying on consistent operator behavior across every retry tool used in the organization is operationally fragile. A content-hash key that does not depend on MsgId stability is safer.
If I set BOTHRESH=1 on the billing queue, does that prevent FM1?
Setting BOTHRESH=1 means IBM MQ moves the message to the backout queue (BOQNAME) or SYSTEM.DEAD.LETTER.QUEUE after the first backout (BackoutCount=1). This prevents FM1 from repeating beyond BackoutCount=0 and BackoutCount=1 — only one rollback is possible. However, if stripe.charges.create() succeeded on the BackoutCount=0 delivery and the consumer rolled back for an unrelated reason, FM2 now applies: the message is in the DLQ after one rollback, the DLQ retry assigns a new MsgId, and the content-hash key (which is the same for the DLQ-retried message) prevents the duplicate only if Stripe’s 24-hour window has not expired. BOTHRESH=1 makes FM2 more likely (every rollback immediately triggers DLQ behavior) rather than less likely. The pre-flight check is the correct solution regardless of BOTHRESH value.
Does IBM MQ exactly-once delivery (using IBM MQ with XA transactions and a transactional database) prevent duplicate Stripe charges?
IBM MQ supports XA two-phase commit when used with an XA-capable transaction manager (JTA, IBM WebSphere Liberty, IBM App Connect Enterprise). An XA transaction coordinates an MQGET from an IBM MQ queue with a database write in a single atomic transaction: either both complete or both roll back. This eliminates the gap between the MQ commit and the database write — the two that are in scope for the XA coordinator. stripe.charges.create() is an HTTP call to an external system. It cannot participate in an XA transaction: Stripe does not implement the XA two-phase commit protocol. The gap between stripe.charges.create() completing and the XA transaction committing is outside the XA scope. A stripe failure between the Stripe response and the XA commit causes rollback of the MQ MQGET and the database write but not the Stripe charge. On redelivery, the XA database write is attempted again; if the billing logic does not check first whether a Stripe charge already exists, stripe.charges.create() is called again. XA transactions solve MQ-to-database atomicity, not Stripe-to-MQ atomicity. The content-hash key plus pre-flight check are still required.
Can the pre-flight PostgreSQL row become stale if the billing period definition changes (month vs. quarter vs. custom period)?
The pre-flight row uses a composite unique constraint on (customer_id, billing_period). The billing_period value is a business-defined string from the billing message content — "2026-Q3", "2026-07", "2026-W31", or whatever the billing system uses. If the billing period definition changes — for example, from monthly to quarterly — the billing_period values in new messages are different strings ("2026-07" vs. "2026-Q3"). The pre-flight row for (customer_id, "2026-Q3") is a different row from (customer_id, "2026-07"). This is correct: a customer billed for 2026-07 has not been billed for 2026-Q3. The uniqueness constraint correctly allows both charges. The pre-flight row does not become stale due to period definition changes; it only prevents duplicate charges for identical (customer_id, billing_period) pairs.
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 IBM MQ consumer fires twice — BackoutCount-keyed duplicate, DLQ retry after 24 hours, or reconnect re-delivery — the vault key cap absorbs the second charge before it reaches Stripe.