Solace PubSub+ and Stripe Integration: Restricted API Keys, Spend Caps, and Agent Governance

Solace PubSub+ is an enterprise-grade event broker used in financial services, logistics, and large-scale agent pipelines for guaranteed, at-least-once message delivery via durable queues, configurable acknowledgment semantics, and high-availability failover pairs. When billing workers consume from a Solace Guaranteed queue and call stripe.charges.create() per usage event, three Solace-specific behaviors introduce Stripe double-charge failure modes that Solace’s queue statistics, delivery count metrics, and consumer flow state do not surface as billing risk.

This post covers those three failure modes with Python solace-pubsubplus SDK code, content-hash idempotency keys stable across session disconnects and message replays, per-billing-period vault keys via a spend-cap proxy, and pre-flight database checks — the two-layer governance pattern that eliminates all three without restructuring your Solace topics, queue topology, or consumer configuration.

Failure mode 1: Guaranteed Message flow rebind after session disconnect while the Stripe HTTP call is still in-flight — the billing event is redelivered before the first charge is acknowledged

Solace PubSub+ Guaranteed Messaging tracks message delivery through a concept called a flow — a binding between a consumer and a queue endpoint. The broker delivers each Guaranteed message to exactly one active flow on a non-exclusive queue (or to the primary flow on an exclusive queue). The message remains unacknowledged in the broker’s tracking until the consumer sends an explicit acknowledgment via msg.ack(). If the consumer’s Solace session disconnects — due to a network partition, container restart, Solace HA failover, or transport keepalive timeout — the flow is torn down. The broker marks all in-flight unacknowledged messages as eligible for redelivery. When the consumer’s session reconnects and a new flow binds to the queue, the broker redelivers those messages.

The billing failure: a billing worker receives a usage event at T=0 and calls stripe.charges.create() at T=1. The Stripe API call completes at T=4, returning ch_A. At T=5, before the worker executes msg.ack(), the Solace session disconnects — a network partition or transport keepalive timeout (configured via transport_layer_properties.KEEP_ALIVE_INTERVAL, default 3,000 ms) causes the broker to declare the session dead. During the reconnect window (governed by transport_layer_properties.RECONNECTION_ATTEMPTS and RECONNECTION_ATTEMPTS_WAIT_INTERVAL), the broker marks the unacknowledged billing event as eligible for redelivery. At T=8, the session reconnects and the consumer’s flow rebinds. The broker redelivers the billing event. The worker calls stripe.charges.create() a second time without an idempotency key and Stripe creates ch_B for the same customer in the same billing period.

Nothing in the Solace monitoring surface distinguishes this redelivery from a new message. The queue’s delivery-count metric increments by one. The redelivered flag on the message object is True on the redelivered copy — but many billing handlers do not check it, and checking it alone is not a safe guard: suppressing all redelivered messages would cause genuine retry events (worker crashed before Stripe was called) to be silently dropped. The Max Redelivery Count queue setting (default: 0, meaning unlimited) does not protect against duplicate charges either — each redelivery creates another Stripe charge.

import os, json, stripe
from solace.messaging.messaging_service import MessagingService
from solace.messaging.resources.queue import Queue
from solace.messaging.config.solace_properties import (
    authentication_properties, transport_layer_properties, service_properties
)

STRIPE_KEY = os.environ["STRIPE_KEY"]
stripe.api_key = STRIPE_KEY

broker_props = {
    transport_layer_properties.HOST: os.environ["SOLACE_HOST"],
    service_properties.VPN_NAME: os.environ["SOLACE_VPN"],
    authentication_properties.SCHEME_BASIC_USER_NAME: os.environ["SOLACE_USER"],
    authentication_properties.SCHEME_BASIC_PASSWORD: os.environ["SOLACE_PASSWORD"],
}

# UNSAFE: no idempotency key
# Session disconnect between stripe.charges.create() and msg.ack()
# → flow rebind → message redelivered → ch_B created
class UnsafeBillingHandler:
    def on_message(self, msg):
        payload = json.loads(msg.get_payload_as_string())
        customer_id  = payload["customer_id"]
        billing_period = payload["billing_period"]
        amount_cents = payload["amount_cents"]

        charge = stripe.Charge.create(
            amount=amount_cents,
            currency="usd",
            customer=customer_id,
            # no idempotency_key — ch_B on redelivery
        )
        db.execute(
            "INSERT INTO billing_records VALUES (%s, %s, %s)",
            (customer_id, billing_period, charge.id)
        )
        msg.ack()  # never reached if session disconnects above

The handler calls stripe.charges.create(), saves ch_A to the database, then calls msg.ack(). If the session disconnects between the database write and the ack call — a window of typically one to three milliseconds — the message is redelivered. The worker calls stripe.charges.create() again. Without an idempotency key, Stripe has no record of ch_A being “the authoritative charge for this billing intent” and creates ch_B. The customer is charged twice.

The fix for failure mode 1

A content-hash idempotency key derived exclusively from stable business fields closes this failure. The key must produce the same value on the original delivery and on the redelivered copy. Fields that must NOT appear in the key: the Solace message ID (msg.get_message_id() — redelivered messages may carry a new internal ID), the Solace sequence number (msg.get_sequence_number() — incremented by the broker on redelivery in some configurations), the flow ID or session ID from the consumer object, the redelivery count from msg.get_redelivery_count(), and any timestamp captured at message receipt. The key must be derived solely from the billing intent: customer ID, billing period, and a stable service namespace.

A pre-flight PostgreSQL check using ON CONFLICT DO NOTHING closes the concurrent-consumer race condition: two consumer flows on a non-exclusive queue race to process the same redelivered message, both pass the Stripe idempotency check (which is only guaranteed within 24 hours), but only one wins the database unique constraint insert.

import hashlib, psycopg2

def make_idempotency_key(customer_id: str, billing_period: str) -> str:
    # Stable fields only — same value on first delivery and on flow-rebind redelivery
    # Must NOT include: msg.get_message_id(), msg.get_sequence_number(),
    # flow ID, session ID, redelivery count, msg.get_receive_time()
    payload = f"{customer_id}:{billing_period}:solace-billing"
    return hashlib.sha256(payload.encode()).hexdigest()[:32]

class SafeBillingHandler:
    def __init__(self, db_conn):
        self.db = db_conn

    def on_message(self, msg):
        payload      = json.loads(msg.get_payload_as_string())
        customer_id  = payload["customer_id"]
        billing_period = payload["billing_period"]
        amount_cents = payload["amount_cents"]

        key = make_idempotency_key(customer_id, billing_period)

        # Pre-flight: claim the slot before calling Stripe
        # ON CONFLICT DO NOTHING returns rowcount=0 if already claimed
        with self.db.cursor() as cur:
            cur.execute(
                """INSERT INTO billing_records
                       (customer_id, billing_period, idempotency_key, status)
                   VALUES (%s, %s, %s, 'pending')
                   ON CONFLICT (idempotency_key) DO NOTHING""",
                (customer_id, billing_period, key)
            )
            self.db.commit()
            if cur.rowcount == 0:
                # Already processed (original delivery or concurrent worker won the race)
                msg.ack()
                return

        charge = stripe.Charge.create(
            amount=amount_cents,
            currency="usd",
            customer=customer_id,
            idempotency_key=key,  # Stripe-side dedup for the concurrent-call window
        )

        with self.db.cursor() as cur:
            cur.execute(
                "UPDATE billing_records SET charge_id=%s, status='charged' "
                "WHERE idempotency_key=%s",
                (charge.id, key)
            )
            self.db.commit()

        msg.ack()  # safe to ack — charge is committed to DB

Write-before-call ordering matters here: the pre-flight insert happens before stripe.charges.create(). If the session disconnects between the insert and the Stripe call, the redelivered message finds the billing_records row already present (rowcount=0) and acks without calling Stripe. The charge was never created, so this is correct — the billing event is effectively dropped. To handle this case (pre-flight insert succeeded but Stripe was never called), a background reconciliation job checks status='pending' rows older than five minutes and re-issues the Stripe call with the same idempotency key.

Failure mode 2: Direct delivery fan-out to all billing pods simultaneously — the billing event is processed once per pod, not once per cluster

Solace PubSub+ supports two distinct delivery modes that work fundamentally differently: Direct and Persistent/Guaranteed. Persistent messages are enqueued and delivered to one consumer per message on a non-exclusive queue — this is the correct delivery mode for billing. Direct messages are published once and delivered to all active direct subscribers simultaneously — there is no queue, no persistence, no acknowledgment, and no single-consumer guarantee. Direct delivery is appropriate for low-latency, high-volume event streams where occasional loss is acceptable (metrics, telemetry, live dashboards). It is catastrophic for billing.

The failure scenario emerges from a common pattern in Solace-based systems: a team builds their billing pipeline using a PersistentMessageReceiver bound to a durable queue (correct). During development, a developer adds a DirectMessageReceiver with a matching topic subscription to each billing pod for real-time debugging — they want to see raw event payloads as they arrive without waiting for the guaranteed delivery path to process them. In staging, the publisher uses Direct delivery by default (many Solace SDK examples, including the official Python quickstart, use direct publishing). The billing service appears to work correctly in staging — the direct subscriber fires and shows payloads in the console, the persistent receiver fires and charges Stripe. What the developer does not realize is that both receivers are calling stripe.charges.create().

In production, the upstream publisher is correctly configured with Persistent delivery mode. The DirectMessageReceiver in each billing pod receives nothing for Persistent messages (direct subscribers do not receive persistent/guaranteed messages). The billing pipeline works correctly — until a pipeline engineer adds a test harness that publishes a batch of billing events using the default Direct publisher to “simulate load.” All three billing pods’ direct subscribers fire simultaneously for every Direct message. Three pods, three calls to stripe.charges.create(), three charges for the same customer.

from solace.messaging.resources.topic_subscription import TopicSubscription

# DANGEROUS: both a PersistentMessageReceiver and a DirectMessageReceiver
# are active in the same billing pod, both calling stripe.charges.create()
class BillingService:
    def __init__(self, messaging_service):
        self.ms = messaging_service

        # Correct path: Persistent receiver bound to durable queue
        queue = Queue.durable_exclusive_queue("billing-events-q1")
        self.persistent_rcvr = (
            self.ms.create_persistent_message_receiver_builder()
               .build(queue)
        )
        self.persistent_rcvr.start()
        self.persistent_rcvr.receive_async(self._on_persistent)  # ✓ calls Stripe

        # Dangerous: DirectMessageReceiver also present in billing pod
        # Added for debugging — receives Direct messages on the same topic
        # If any publisher uses Direct delivery mode, ALL pods' handlers fire
        self.direct_rcvr = (
            self.ms.create_direct_message_receiver_builder()
               .with_subscriptions(TopicSubscription.of("billing/events/v1"))
               .build()
        )
        self.direct_rcvr.start()
        self.direct_rcvr.receive_async(self._on_direct)  # ✗ ALSO calls Stripe

    def _on_persistent(self, msg):
        self._charge(msg)
        msg.ack()

    def _on_direct(self, msg):
        self._charge(msg)   # ← second call to Stripe for same customer

    def _charge(self, msg):
        p = json.loads(msg.get_payload_as_string())
        stripe.Charge.create(amount=p["amount_cents"], customer=p["customer_id"])

The second variant: the publisher is a third-party service that occasionally publishes billing events as Direct messages when its own persistent queue is full or when it downgrades delivery mode under load. The billing pods’ direct subscribers pick up these downgraded Direct messages while the persistent receiver also queues them (if the Solace queue has a topic subscription and the message was published to the matching topic). The result is two calls to Stripe for the same customer — once from the Direct path and once from the Guaranteed path.

The fix for failure mode 2

Billing pods must not contain a DirectMessageReceiver that calls payment APIs. The rule is absolute: direct receivers in billing pods are banned. Monitoring, logging, and debugging should be handled by a separate non-billing service that subscribes as a direct receiver but writes to a log file or observability platform, not to Stripe. Audit your Solace publisher configurations to verify that all billing-event publishers use PersistentMessagePublisher with DeliveryMode.PERSISTENT. Add an assertion at startup that verifies no direct receivers are registered in the billing pod:

class SafeBillingService:
    def __init__(self, messaging_service):
        self.ms = messaging_service

        # Only a PersistentMessageReceiver — no direct receivers in billing pods
        queue = Queue.durable_exclusive_queue("billing-events-q1")
        self.persistent_rcvr = (
            self.ms.create_persistent_message_receiver_builder()
               .build(queue)
        )
        self.persistent_rcvr.start()
        self.persistent_rcvr.receive_async(SafeBillingHandler(db_conn).on_message)

    # No DirectMessageReceiver — direct subscribers live in a separate monitoring service

If a publisher may use Direct delivery (because it does not control its delivery mode, or because a test harness bypasses guaranteed delivery), deploy a Solace router filter or DMQ rule that rejects Direct messages on the billing topic and raises an alert. Never silently ignore the Direct message — the upstream publisher has a configuration error that must be fixed at the source.

The content-hash idempotency key and pre-flight database check from failure mode 1 provide a second layer of protection here: even if a Direct message and a Persistent message for the same billing event both arrive in the same billing pod, the first caller wins the ON CONFLICT DO NOTHING insert and the second caller exits early. This is not an excuse to leave both receivers active — it is a fallback for cases where the publisher configuration is outside your control.

Failure mode 3: Solace Message Replay triggered during incident recovery — historical billing events are redelivered to customers already charged

Solace PubSub+ Event Brokers support a Message Replay feature: when replay is enabled on a queue or durable topic endpoint, the broker maintains a replay log of messages received within a configurable time window (hours to days, depending on broker storage). An operator or a consumer with replay credentials can trigger replay from three positions: the beginning of the replay log (ReplayStartLocationFactory.create_replay_start_from_beginning()), a specific RFC 3339 timestamp, or a Replication Group Message ID (RGMID) saved from a prior delivery.

The incident recovery scenario: the billing service crashes at 14:00 UTC during a large monthly billing run. The Solace queue’s consumer flow goes down. Messages accumulate in the queue. At 14:30 UTC, the engineering team restores the billing service. The billing service reconnects, binds a new flow, and begins processing the queued messages. This part works correctly — the messages that arrived between 14:00 and 14:30 are delivered in order and processed by the safe handler.

But the on-call engineer is not sure whether all messages from the billing run were enqueued before the crash — perhaps some were published at 13:45 UTC and the producer also crashed, so they were never enqueued at all. The engineer triggers a replay from 13:30 UTC in the Solace broker admin UI. The replay delivers all messages from 13:30 UTC through the current end of the replay log — including the messages that were already processed by the billing service between 13:30 and 14:00 before the crash. These replayed messages are indistinguishable from new messages in the consumer’s on_message handler. The billing service calls stripe.charges.create() for each replayed event. Without a pre-flight database check, every customer charged between 13:30 and 14:00 is charged again.

from solace.messaging.config.replay_start_location_factory import ReplayStartLocationFactory
from datetime import datetime, timezone

# DANGEROUS: operator-triggered timestamp replay without pre-flight guard
# Consumer that processes all messages from replay start unconditionally
class UnsafeReplayConsumer:
    def start(self, messaging_service, replay_from: datetime):
        queue = Queue.durable_exclusive_queue("billing-events-q1")
        replay_loc = ReplayStartLocationFactory.create_replay_start_from_date(
            replay_from  # e.g. datetime(2026, 8, 1, 13, 30, tzinfo=timezone.utc)
        )
        self.receiver = (
            messaging_service
            .create_persistent_message_receiver_builder()
            .with_message_replay(replay_loc)
            .build(queue)
        )
        self.receiver.start()
        self.receiver.receive_async(self._on_message_unsafe)

    def _on_message_unsafe(self, msg):
        p = json.loads(msg.get_payload_as_string())
        # No pre-flight check — customers charged at 13:40 are charged again
        charge = stripe.Charge.create(
            amount=p["amount_cents"],
            customer=p["customer_id"],
            # No idempotency_key — or if present, key is non-deterministic (time-based)
        )
        db.execute("INSERT INTO billing_records VALUES (%s, %s)", (p["customer_id"], charge.id))
        msg.ack()

The replay scenario also occurs when a consumer is coded to start from the beginning of the replay log on every startup — a pattern found in systems that want to “catch up on any missed events.” If a billing service pod is restarted during a billing run (Kubernetes rolling deploy, OOM kill, node drain), the new pod with ReplayStartLocation.BEGINNING starts from the first message in the replay log and reprocesses billing events from days or weeks ago. Every customer charged since the replay log was first populated is charged again.

# ALSO DANGEROUS: ReplayStartLocation.BEGINNING on consumer startup
# Reprocesses ALL historical billing events every time the pod restarts
class BillingConsumerRestartUnsafe:
    def start(self, messaging_service):
        queue = Queue.durable_exclusive_queue("billing-events-q1")
        self.receiver = (
            messaging_service
            .create_persistent_message_receiver_builder()
            .with_message_replay(
                ReplayStartLocationFactory.create_replay_start_from_beginning()
                # Replay log may contain 7 days of billing events
                # All redelivered on every pod restart without pre-flight guard
            )
            .build(queue)
        )
        self.receiver.start()
        self.receiver.receive_async(self._charge_stripe_unsafe)

The fix for failure mode 3

Two independent fixes: eliminate the with_message_replay() call on the default consumer, and add the pre-flight database check that catches replayed messages even when an operator triggers replay manually.

For the default consumer, start from the current queue position — no replay configuration at all. The consumer receives only messages that arrive after the flow binds. Messages accumulated during downtime are already in the queue and delivered in order without replay. Reserve with_message_replay() for explicit disaster-recovery runbooks, behind an operational gate that requires the on-call engineer to acknowledge that the pre-flight dedup check is active.

# SAFE: no replay on default consumer startup
# Messages accumulated during downtime are already in the queue — no replay needed
class SafeBillingConsumer:
    def start(self, messaging_service):
        queue = Queue.durable_exclusive_queue("billing-events-q1")
        self.receiver = (
            messaging_service
            .create_persistent_message_receiver_builder()
            # No .with_message_replay() — starts from current queue position
            .build(queue)
        )
        self.receiver.start()
        # SafeBillingHandler has the pre-flight ON CONFLICT DO NOTHING check
        self.receiver.receive_async(SafeBillingHandler(db_conn).on_message)

The pre-flight check from failure mode 1 (the ON CONFLICT DO NOTHING insert keyed on make_idempotency_key(customer_id, billing_period)) serves as the second line of defense. Replayed messages for customers whose charges were already recorded in billing_records find the row present, return rowcount=0, and ack without calling Stripe. This guard is effective for both operator-triggered replays and accidental BEGINNING replays — and critically, it works even when the replay includes messages from billing periods predating the dedup guard’s deployment, because the billing_records table was populated during the original successful run.

The complete fix: content-hash idempotency key + vault key + pre-flight check

All three failure modes above share the same root cause: the billing handler calls stripe.charges.create() without a stable idempotency key and without verifying that the charge has not already been created. The content-hash idempotency key and pre-flight check pattern closes all three independently of which failure mode is active. The vault key adds a third layer: even if the first two fail, the per-billing-period spend cap prevents the damage from compounding.

import hashlib, stripe, psycopg2, os
from solace.messaging.messaging_service import MessagingService
from solace.messaging.resources.queue import Queue

VAULT_KEY  = os.environ["KEYBRAKE_VAULT_KEY"]  # scoped per billing period, capped at expected_total * 1.10
STRIPE_KEY = os.environ["STRIPE_SECRET_KEY"]
stripe.api_key = STRIPE_KEY

def make_idempotency_key(customer_id: str, billing_period: str) -> str:
    # Content hash of stable business fields only
    # Identical on: first delivery, flow-rebind redelivery, and operator replay
    return hashlib.sha256(
        f"{customer_id}:{billing_period}:solace-billing".encode()
    ).hexdigest()[:32]

class FullySafeBillingHandler:
    def __init__(self, db_conn):
        self.db = db_conn

    def on_message(self, msg):
        p = json.loads(msg.get_payload_as_string())
        customer_id    = p["customer_id"]
        billing_period = p["billing_period"]
        amount_cents   = p["amount_cents"]
        key = make_idempotency_key(customer_id, billing_period)

        # Layer 1: pre-flight claim — if this row already exists, skip
        with self.db.cursor() as cur:
            cur.execute(
                """INSERT INTO billing_records
                       (customer_id, billing_period, idempotency_key, status)
                   VALUES (%s, %s, %s, 'pending')
                   ON CONFLICT (idempotency_key) DO NOTHING""",
                (customer_id, billing_period, key)
            )
            self.db.commit()
            if cur.rowcount == 0:
                msg.ack()
                return  # already charged (original run or earlier redelivery)

        # Layer 2: Stripe idempotency key — broker-side dedup for concurrent-call window
        # Uses VAULT_KEY (not STRIPE_KEY) — vault enforces spend cap, logs every call
        charge = stripe.Charge.create(
            amount=amount_cents,
            currency="usd",
            customer=customer_id,
            idempotency_key=key,
            api_key=VAULT_KEY,  # proxied through spend-cap proxy
        )

        with self.db.cursor() as cur:
            cur.execute(
                "UPDATE billing_records SET charge_id=%s, status='charged' "
                "WHERE idempotency_key=%s",
                (charge.id, key)
            )
            self.db.commit()

        msg.ack()


def run_billing_service(broker_props, db_conn):
    ms = MessagingService.builder().from_properties(broker_props).build()
    ms.connect()
    queue = Queue.durable_exclusive_queue("billing-events-q1")
    # No .with_message_replay() — queue accumulates messages during downtime
    receiver = ms.create_persistent_message_receiver_builder().build(queue)
    receiver.start()
    receiver.receive_async(FullySafeBillingHandler(db_conn).on_message)
    return ms

The vault key (VAULT_KEY) is issued per billing period via a spend-cap proxy such as Keybrake. The policy attached to the vault key caps the total spend at expected_total × 1.10 — ten percent above the expected billing run amount. If any combination of the three failure modes above produces duplicate Stripe calls that bypass the idempotency and pre-flight layers (for example, a second consumer pod issues a Stripe call within the 24-hour idempotency window with the same key while the first call is still pending), the vault key cap triggers and the proxy returns a 402 before the duplicate charge reaches Stripe. The audit log from the proxy shows the exact vault key, endpoint, timestamp, and response for every call — surfacing the duplicate attempt without waiting for a Stripe Dashboard review.

Comparison: which pattern covers which failure mode

Pattern Flow rebind redelivery Direct fan-out Operator replay
No protection ch_B on disconnect ch_B × N pods on Direct publish ch_B per customer in replay window
Solace redelivered flag suppression Suppresses ch_B but silently drops genuine retries No effect (Direct messages have no redelivered flag) Partially effective — replayed messages carry redelivered=True only for unacked messages, not for messages replayed via the Replay API from an acknowledged offset
Stripe idempotency key only (time-based) Different key on reconnect if timestamp used in key Different key per pod if pod ID in key Same key if key is stable, but Stripe 24-hour window may expire for old replays
Content-hash idempotency key only Protects within Stripe 24-hour window Protects if all pods call Stripe concurrently (idempotency cache dedup) Protects within Stripe 24-hour window; fails for replays of billing periods older than 24h
Pre-flight DB check only Effective — rowcount=0 on redelivery Effective — only one pod wins the unique constraint insert Effective — replayed message finds existing row
Content-hash key + vault cap + pre-flight check Full protection Full protection Full protection (pre-flight handles >24h replay)

Gap analysis: four additional Solace patterns that warrant attention

1. Solace HA failover during an active Stripe call

In a Solace Active/Standby HA pair, failover triggers when the active broker fails. All consumer flows disconnect from the failed active and reconnect to the newly promoted standby. Unacknowledged messages in the active broker’s delivery queue at the moment of failover are replicated to the standby and redelivered to consumers that reconnect. This is operationally identical to failure mode 1 — the session disconnect is caused by the HA event rather than a network partition, but the redelivery path is the same. The content-hash key and pre-flight check handle HA failover redeliveries without any additional configuration. Teams sometimes incorrectly believe that Solace HA prevents message duplication — HA prevents message loss, not message redelivery to already-processing consumers. The two properties are independent.

2. DMQ (Dead Message Queue) reprocessing

Solace queues can be configured with a Max Redelivery Count and a Dead Message Queue (DMQ). Messages that exceed the max redelivery count are moved to the DMQ rather than being dropped. When an operations engineer manually moves messages from the DMQ back to the original queue — to recover from a billing service bug that caused repeated processing failures — those messages are redelivered to the billing service. If the original billing event was processed successfully (the service failed on a downstream write, not on the Stripe call), the re-processed message creates ch_B. The pre-flight check catches this case: the charge was recorded in billing_records during the first successful Stripe call, and the DMQ replay finds rowcount=0.

3. Non-exclusive queue with multiple consumers and idempotency key stability

Non-exclusive Solace queues deliver each message to one consumer (round-robin or load-balanced, depending on the consumer bind configuration). This is safe for at-most-one-delivery per message. However, if two consumers are running with different billing period derivation logic — one uses the message timestamp to derive the billing period, the other uses a field from the payload — they may produce different idempotency keys for the same billing intent. The content-hash key must be derived consistently by all consumers from stable payload fields. Pin the billing period field name and format in a shared schema and enforce it at consumer startup.

4. Transacted Session rollback and Stripe call atomicity

Solace supports Transacted Sessions where consuming a Guaranteed message and publishing output messages are committed atomically. A billing worker using a Transacted Session may call stripe.charges.create() inside the transaction, then commit. If the transaction commit fails and is retried, the Stripe call is not retried as part of the transaction — the Transacted Session controls only Solace message commits, not external HTTP calls. The Stripe call from the first (failed) attempt may have completed successfully, and the retry attempt calls Stripe again. The content-hash idempotency key covers this case: the retry uses the same key and Stripe returns the cached ch_A.

FAQ

Does Solace Guaranteed Messaging’s at-least-once delivery guarantee mean my billing will have duplicates?

At-least-once delivery means that Solace guarantees every Guaranteed message is delivered to some consumer at least once, even in the face of broker restarts and HA failovers. It does not mean your billing handler will be called more than once for a given billing intent — that depends on whether a session disconnect happens during processing. Under normal conditions (no disconnects, no replays), each message is processed exactly once. The failure modes in this post are triggered by specific operational events: network partitions, HA failovers, operator replays. Implementing the content-hash key and pre-flight check makes your billing handler correct under all Solace delivery conditions, not just the happy path.

Can I use the Solace message ID (msg.get_message_id()) as the Stripe idempotency key?

No. The Solace message ID is not guaranteed to be stable across redeliveries in all SDK versions and broker configurations. More fundamentally, even if it were stable, the message ID is a transport-layer identifier, not a billing-intent identifier. Two separate billing events for the same customer in the same billing period should share an idempotency key — but they will have different message IDs. The content-hash key from sha256(customer_id + billing_period + namespace) correctly collapses all delivery attempts for the same billing intent into a single Stripe charge, regardless of how many times the message is delivered or replayed.

Does Solace’s Max Redelivery Count protect against duplicate Stripe charges?

No. Max Redelivery Count limits how many times Solace will redeliver a message before moving it to the DMQ — it does not prevent the duplicate calls that happen during the redelivery window. If Max Redelivery Count is 3 and the billing handler calls Stripe without a pre-flight check, a message that causes session disconnects on every delivery will create up to 3 Stripe charges before being moved to the DMQ. Setting Max Redelivery Count to 1 prevents more than one redelivery, but it also means the message is dead-lettered if the first delivery fails before the Stripe call (due to a database timeout or network error), causing a missed charge rather than a duplicate. The content-hash key and pre-flight check are the correct solution: they allow unlimited redeliveries while guaranteeing exactly one Stripe charge per billing intent.

How should vault keys be issued for a Solace-based billing pipeline?

Issue one vault key per billing period per billing run, not per consumer instance. A vault key with a policy of {vendor: "stripe", daily_usd_cap: expected_total * 1.10, allowed_endpoints: ["/v1/charges"], expires_at: billing_period_end} caps the total Stripe spend for this run at ten percent above the expected amount. If duplicate charges somehow bypass both the Stripe idempotency key and the pre-flight check — for example, a new consumer pod starts up before the prior pod’s flow is declared dead, and both pods race to call Stripe within the same 24-hour window with the same key — the vault cap stops the excess spend at the policy limit. After the cap is hit, the proxy returns a 402 and logs the exact vault key, endpoint, customer ID, and timestamp, giving you the forensic trail to identify the duplicate-call root cause.

Does removing the DirectMessageReceiver from billing pods require changes to the Solace broker configuration?

No. The DirectMessageReceiver is instantiated in your application code via the solace-pubsubplus SDK. Removing the create_direct_message_receiver_builder() call and all associated topic subscriptions from your billing service codebase is sufficient. No Solace broker queue, topic, or ACL changes are needed. If you want to enforce at the broker level that billing pods cannot use direct receivers, Solace supports client-profile restrictions and topic ACLs that can block direct subscriptions for the billing service’s client username — but code removal is the primary fix.

Put the brakes on your agent’s Stripe key

Keybrake issues scoped vault keys for the Stripe API your agent calls — with per-billing-period spend caps, allowed-endpoint allowlists, and an audit log of every proxied request. One vault key per Solace consumer flow. Kill it in one click if a replay goes wrong.

Related: NATS JetStream and Stripe integrationRabbitMQ and Stripe integrationApache Pulsar and Stripe integrationAzure Service Bus and Stripe integrationGoogle Cloud Pub/Sub and Stripe integration