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.
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.completedandpayment_intent.succeededbut never tocharge.refundedorcharge.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
2xxfor, 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 acharge/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
revokeevents for thatpayment_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
- Subscribe the endpoint to
charge.refundedandcharge.dispute.createdin the Stripe dashboard (or viastripe.WebhookEndpoint.modify) so reversals are actually delivered. - Branch on
event["type"]and route both reversal types into a singlerevoke_for_reversalpath, mapping each to its terminal state. - 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
revokeentries for onepayment_intent, milliseconds apart. - A retry throws
AlreadyRevokedand Stripe keeps redelivering because it never got a clean200.
Root cause. The revoke has no idempotency guard, so a Stripe retry re-runs the full side effect.
Fix
- Claim the event atomically before revoking:
redis SET reversal:evt:{event_id} NX EX, TTL covering the ~13-month chargeback window. - If the key is already claimed, return
200immediately as a no-op — the revocation already happened. - Make the revoke itself idempotent at the store: transition the credential to
revokedonlyWHERE 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
- 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.
- Cancel the print job before acknowledging the revoke complete, so a crash leaves the job cancelled, not orphaned.
- If the job already printed, mark the credential
revokedanyway 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.
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.
- Confirm the event was delivered. Stripe Dashboard → the customer’s charge → Events; verify a
charge.refundedorcharge.dispute.createdexists and shows a200from your endpoint. No delivery means the subscription is missing. - 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. - Verify the credential state.
psql -c "SELECT credential_id, status FROM credentials WHERE payment_intent='pi_abc';"— expectrevoked. Anactivestatus with a delivered event means the handler never resolved the link. - 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:
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_intent → credential_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.
Related Link to this section
- Refund & Chargeback Reconciliation — the parent stage that defines the reverse → revoke contract this Stripe handler implements.
- Reconciling Partial Refunds Against Multi-Ticket Orders — the sibling guide for when only some tickets in an order are refunded and only some credentials should be revoked.
- Print Queue Orchestration — owns the queued-job cancellation that a revocation triggers on the print floor.
- Payment Webhook Handling — the forward stage that provisions the credential and persists the
payment_intentbinding this handler joins on.