Revoking Badges After a Stripe Refund or Chargeback: Fixing Stale Credentials & Double-Revocation

Symptom Statement Link to this section

An attendee was refunded — or filed a dispute Stripe honored — and their badge still scans green at the door because it printed before anyone told the access system the money went back. Or the opposite: a single refund fires the revocation twice, the credential is invalidated once and then invalidated again against a now-empty record, and the second pass throws or corrupts the state so a legitimate re-registration can no longer be admitted. This page addresses that exact pair at the Stripe reversal boundary — a stale credential that outlives its payment, and a double-revocation that fires more than once for one reversal. It is the provider-specific implementation of the refund & chargeback reconciliation stage, which owns the reverse-then-revoke contract for the broader Registration Ingestion & Payment Reconciliation pipeline. The observable tells are consistent: Stripe’s dashboard shows a refund or a lost dispute, the scanner still admits the holder, the access-control audit log shows either zero revoke events for the payment_intent or two of them milliseconds apart, and a queued print job for a now-refunded ticket is still sitting on the print floor.

Idempotent revocation flow for a Stripe refund or dispute, versus the two failure paths that leave a stale or double-revoked credential A Stripe charge.refunded or charge.dispute.created event enters a decision. First, is there a reversal handler at all? If no handler is subscribed, the flow dead-ends at a stale credential that still scans. If a handler exists, it resolves the payment_intent to a credential id; if that link is missing, it also dead-ends at a stale credential. With a link, it claims an idempotency key on the event id: a first delivery proceeds to revoke the credential and cancel the queued print job, then asserts the credential is inactive; a redelivery whose key is already claimed short-circuits to a single no-op acknowledgment, preventing double-revocation. charge.refunded / charge.dispute.created → idempotent revoke Stripe reversal signed event handler subscribed? map intent → credential claim event id SET NX EX first vs redelivery revoke credential cancel queued print job assert inactive exactly once door denies credential inactive redelivery → single no-op ACK (no double-revoke) stale credential — still scans green no handler subscribed, or no payment_intent → credential link

Root Cause Analysis Link to this section

Four concrete failures produce a stale or double-revoked credential. They are independent; triage means naming which before touching code.

  • No reversal webhook handling. The endpoint subscribes to checkout.session.completed and payment_intent.succeeded but never to charge.refunded or charge.dispute.created. Stripe delivers the reversal, the endpoint 200s a generic “unhandled event”, and nothing revokes anything — the credential is stale by omission.
  • Non-idempotent revoke. The handler revokes on every delivery with no deduplication. Stripe retries any delivery it does not see a prompt 2xx for, so a slow first pass plus a retry fires the revocation twice; the second pass runs against an already-revoked record and either throws or double-writes an audit entry, corrupting the credential’s history.
  • Race between the refund and the print job. The refund arrives while the badge print job is already queued or mid-print. The revocation cancels a job that no longer exists (or fails to cancel one that does), and a physical badge lands on the floor for a refunded attendee because cancellation and revocation were not treated as one atomic outcome.
  • Missing payment_intent → credential link. Refunds carry a charge/payment_intent, not a badge id. If nothing binds the intent to the issued credential, the handler cannot find what to revoke. It either no-ops silently or guesses by email and revokes the wrong person’s badge.

Symptom-to-Resolution Matrix Link to this section

No Reversal Webhook Subscribed Link to this section

Symptoms

  • Stripe shows a refund or lost dispute; the access log shows zero revoke events for that payment_intent.
  • Forward payments provision correctly, so the endpoint “works” — only reversals vanish.

Root cause. The handler branches only on the success event types and treats reversals as unhandled, returning 200 without acting.

Fix

  1. Subscribe the endpoint to charge.refunded and charge.dispute.created in the Stripe dashboard (or via stripe.WebhookEndpoint.modify) so reversals are actually delivered.
  2. Branch on event["type"] and route both reversal types into a single revoke_for_reversal path, mapping each to its terminal state.
  3. Add a synthetic monitor: alert if count(stripe_refunds) > count(revocations) over any rolling window, so a silent drop is visible before the door catches it.

Non-Idempotent Double-Revocation Link to this section

Symptoms

  • The audit log shows two revoke entries for one payment_intent, milliseconds apart.
  • A retry throws AlreadyRevoked and Stripe keeps redelivering because it never got a clean 200.

Root cause. The revoke has no idempotency guard, so a Stripe retry re-runs the full side effect.

Fix

  1. Claim the event atomically before revoking: redis SET reversal:evt:{event_id} NX EX, TTL covering the ~13-month chargeback window.
  2. If the key is already claimed, return 200 immediately as a no-op — the revocation already happened.
  3. Make the revoke itself idempotent at the store: transition the credential to revoked only WHERE status != 'revoked', so even a bypassed guard cannot double-write.

Refund / Print-Job Race Link to this section

Symptoms

  • A physical badge for a refunded attendee is printed anyway, or a queued job is never pulled.
  • Revocation “succeeds” but the print floor disagrees.

Root cause. Credential revocation and print-job cancellation are separate, unordered operations, so one can complete while the other is still in flight.

Fix

  1. Treat cancellation as part of the revocation: emit a single revocation that both invalidates the credential and cancels the queued job. The queue-side cancellation is owned by print queue orchestration.
  2. Cancel the print job before acknowledging the revoke complete, so a crash leaves the job cancelled, not orphaned.
  3. If the job already printed, mark the credential revoked anyway and let the door-scan check — not the paper — be the source of truth.

Minimal Working Implementation Link to this section

A single self-contained handler: raw-body capture, construct_event verification against stripe.SignatureVerificationError, an atomic idempotency claim, a store-level conditional revoke that cannot double-write, print-job cancellation, and a runnable verification block proving a redelivery is a no-op.

PYTHON
import os
import logging
from typing import Optional

import stripe
import redis
from fastapi import FastAPI, Request, Response, status
from pydantic import BaseModel, ConfigDict, ValidationError

STRIPE_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET", "whsec_test_secret")
redis_client = redis.Redis.from_url(
    os.getenv("REDIS_URL", "redis://localhost:6379/0"), decode_responses=True
)
IDEMPOTENCY_TTL = 60 * 60 * 24 * 400  # cover the full chargeback window
REVERSAL_TYPES = {"charge.refunded", "charge.dispute.created"}
logger = logging.getLogger("stripe.revocation")
app = FastAPI()


class ReversalEvent(BaseModel):
    model_config = ConfigDict(extra="ignore")
    id: str
    type: str
    data: dict


class CredentialStore:
    """Stand-in for the access-control store; revoke is conditional and idempotent."""
    def __init__(self):
        self._by_intent = {"pi_abc": {"credential_id": "cred_1", "status": "active"}}

    def credential_for_intent(self, intent: str) -> Optional[dict]:
        return self._by_intent.get(intent)

    def revoke(self, credential_id: str) -> bool:
        for row in self._by_intent.values():
            if row["credential_id"] == credential_id and row["status"] != "revoked":
                row["status"] = "revoked"  # WHERE status != 'revoked'
                return True
        return False  # already revoked — no-op


class PrintQueue:
    def __init__(self):
        self.queued = {"cred_1"}

    def cancel(self, credential_id: str) -> None:
        self.queued.discard(credential_id)  # cancel before ACK; safe if absent


store, print_queue = CredentialStore(), PrintQueue()


def revoke_for_reversal(event: ReversalEvent) -> str:
    """Idempotently revoke the credential and cancel any queued print job."""
    if not redis_client.set(f"reversal:evt:{event.id}", "1", nx=True, ex=IDEMPOTENCY_TTL):
        return "duplicate"  # redelivery — the revoke already ran

    intent = event.data["object"]["payment_intent"]
    cred = store.credential_for_intent(intent)
    if cred is None:
        redis_client.delete(f"reversal:evt:{event.id}")  # let it retry once linked
        raise LookupError(f"no credential for {intent}")

    print_queue.cancel(cred["credential_id"])  # cancel BEFORE we claim done
    changed = store.revoke(cred["credential_id"])
    logger.info("credential_revoked", extra={"intent": intent, "changed": changed})
    return "revoked" if changed else "already_revoked"


@app.post("/webhooks/stripe/reversals")
async def handle_reversal(request: Request):
    raw_body = await request.body()
    sig = request.headers.get("stripe-signature", "")
    try:
        raw_event = stripe.Webhook.construct_event(raw_body, sig, STRIPE_SECRET)
    except ValueError:
        return Response(status_code=status.HTTP_400_BAD_REQUEST)
    except stripe.SignatureVerificationError:
        return Response(status_code=status.HTTP_401_UNAUTHORIZED)

    try:
        event = ReversalEvent.model_validate(dict(raw_event))
    except ValidationError:
        return Response(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)

    if event.type in REVERSAL_TYPES:
        revoke_for_reversal(event)
    return Response(status_code=status.HTTP_200_OK, content="ACK")


if __name__ == "__main__":
    evt = ReversalEvent.model_validate({
        "id": "evt_ref_1", "type": "charge.refunded",
        "data": {"object": {"payment_intent": "pi_abc"}},
    })
    first = revoke_for_reversal(evt)
    second = revoke_for_reversal(evt)  # Stripe retry / redelivery
    assert first == "revoked"          # acted exactly once
    assert second == "duplicate"       # redelivery is a clean no-op
    assert store._by_intent["pi_abc"]["status"] == "revoked"
    assert "cred_1" not in print_queue.queued  # queued job cancelled
    print("OK: revoked once, redelivery suppressed, print job cancelled")

The verification block is the fix’s own regression test: it proves the first delivery revokes and cancels the queued job, while a Stripe redelivery of the identical event returns duplicate without touching the store — the double-revocation path is closed structurally, not by hoping the retry never comes.

Memory & Performance Constraints Link to this section

Reversal traffic is sparse and bursty, so the constraints are idempotency-store lifetime and cancellation atomicity, not throughput.

Component Constraint Mitigation
Redis idempotency key Must outlive the full ~13-month dispute window or a late redelivery double-revokes Long EX TTL, maxmemory-policy noeviction; dispute volume is low so key count stays small
credential_for_intent lookup Hot indexed read during a batch-refund (cancelled event) storm Index on payment_intent; briefly cache the negative result so unlinkable-reversal storms do not hammer the primary
Print-queue cancellation A cancel that races an in-flight print can print anyway Cancel before ACK; make the door scan, not the paper, the source of truth
Conditional revoke write Concurrent retries racing the same credential UPDATE ... WHERE status != 'revoked' so the store enforces exactly-once even if the Redis guard is bypassed

Incident Triage & Rollback Link to this section

Fast path when “refunded attendee still scanned in” fires — target under 15 minutes.

  1. Confirm the event was delivered. Stripe Dashboard → the customer’s charge → Events; verify a charge.refunded or charge.dispute.created exists and shows a 200 from your endpoint. No delivery means the subscription is missing.
  2. Check the revoke fired exactly once. redis-cli EXISTS reversal:evt:{event_id} and grep the access log: grep credential_revoked app.log | grep pi_abc. Zero entries is a stale credential; two is a double-revoke.
  3. Verify the credential state. psql -c "SELECT credential_id, status FROM credentials WHERE payment_intent='pi_abc';" — expect revoked. An active status with a delivered event means the handler never resolved the link.
  4. Force-revoke to contain. Manually revoke the stale credential and cancel the print job now; reconciling the root cause can wait, an admitted refunded attendee cannot.

Rollback. If a bad deploy is double-revoking legitimate attendees, toggle REVERSAL_PROCESSING_ENABLED=false to park incoming reversals on the queue, then git revert HEAD~1 --no-edit && docker compose up -d --build. Because the revoke is idempotent on event_id and conditional at the store, replaying parked reversals through the restored handler can only revoke each credential once — never resurrect or double-invalidate one.

Post-rollback validation. Replay the reversal and confirm exactly one revocation and no stale credential:

BASH
stripe events resend evt_ref_1   # expect HTTP 200
psql -c "SELECT COUNT(*) FROM credentials c JOIN refunds r USING (payment_intent) \
         WHERE c.status='active';"   # expect 0 active credentials behind a refund

Frequently Asked Questions Link to this section

Should I revoke on charge.refunded or wait for charge.dispute.created? Both, but treat them differently. charge.refunded is a settled, voluntary reversal — revoke immediately. charge.dispute.created freezes the funds and opens a representment window you might win, so revoke the credential but transition to a disputed state you can reverse if you prevail, rather than the terminal refunded state. The refund & chargeback reconciliation stage models these as distinct states for exactly this reason.

How do I map a Stripe refund back to the specific badge to revoke? Persist the payment_intentcredential_id binding at issuance, when the forward payment_intent.succeeded first provisions the credential. Refund and dispute events carry the payment_intent, so the reversal handler joins on it directly. Guessing by email is how you revoke the wrong attendee — bind at issuance, never at reversal.

What stops a Stripe retry from revoking twice? Two layers. An atomic Redis SET ... NX EX on the event_id short-circuits a redelivery to a no-op 200, and the store-level UPDATE ... WHERE status != 'revoked' guarantees exactly-once even if the guard is ever bypassed. Belt and suspenders, because Stripe will retry and you cannot assume you saw the first delivery.