Portreeve
how-to · 7 Sept 202611 min read

How to detect card testing on Stripe before the PaymentIntent is created

Detect card testing on Stripe with a server-side check on the card fingerprint before you create the PaymentIntent, plus the webhook that refunds a deny.

You can detect card testing on Stripe before a PaymentIntent exists: screen the attempt server-side on the card fingerprint, decline probes with a generic message, and refund the ones a reviewer denies later.

A probe never becomes an authorization request. A real customer gets charged with no extra friction, and anything the engine is unsure about is charged now and refunded automatically when the review resolves.

The reason to do this before the PaymentIntent, rather than reacting to charge.failed, is that the decline itself is the thing hurting you. Stripe's own guidance is blunt about it: a high decline rate "might damage the reputation of your business with card issuers and card networks, which makes all of your transactions appear riskier," and the effect outlives the attack, since it "can result in an increased decline rate for legitimate payments, even after card testing ceases," per Stripe's card testing page. An attempt you never send is never counted against you.

What you need before starting:

  • Node 18.17 or later, and an existing Stripe integration where your server creates the PaymentIntent. If you use hosted Checkout Sessions with no server-side card step, this pattern does not apply to you; Stripe controls that form.
  • Stripe test keys (sk_test_, pk_test_) and the stripe npm package.
  • A Portreeve account on the free tier, and its test keys. Test mode is fully isolated from live.
  • A checkout page using Stripe Elements, so you can create the PaymentMethod in the browser and post its id to your server.

Examples are Express and TypeScript. The Stripe calls are the same anywhere.

Step 1: keys, the browser snippet, and a device token at checkout

Install both SDKs. portreeve has zero runtime dependencies.

npm install portreeve @portreeve/browser stripe

Four keys in your environment: STRIPE_SECRET_KEY, PORTREEVE_SECRET_KEY (sk_test_... to start), and the two publishable keys your browser bundle needs.

On the checkout page, collect a device token alongside the PaymentMethod. collectDeviceToken resolves to a token or null and never throws, so it does not need a try/catch and cannot break your checkout if the network is bad.

import { collectDeviceToken } from "@portreeve/browser";

const { paymentMethod, error } = await stripe.createPaymentMethod({ elements });
if (error) return showError(error.message);

const device_token = await collectDeviceToken(
  process.env.NEXT_PUBLIC_PORTREEVE_PK!,
);

await fetch("/api/checkout", {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({
    payment_method_id: paymentMethod.id,
    attempt_id: crypto.randomUUID(),
    device_token,
  }),
});

Tokens live 15 minutes. Collect one per checkout attempt, not once per session, or a customer who leaves a tab open will arrive with an expired token. An expired or malformed token is silently dropped and the verdict still returns 200, so the failure mode is a weaker signal rather than an error. The mechanics are in the device fingerprinting docs.

Step 2: run the PaymentIntent fraud check on the card fingerprint

Retrieve the PaymentMethod on your server before you create anything. You want two fields off it. card.fingerprint "uniquely identifies this particular card number," which is what lets you "check whether two customers who've signed up with you are using the same card number." card.funding is credit, debit, prepaid, or unknown. Both are documented on the Card object. Raw numbers and CVVs stay with Stripe.

One caveat from the same page: for Apple Pay and Google Pay, "the tokenized number might be provided instead of the underlying card number," so a wallet attempt will not always fingerprint to the same value as the raw card. Card testers rarely bother with wallets, but it explains gaps when you go looking.

import Stripe from "stripe";
import { Portreeve } from "portreeve";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const portreeve = new Portreeve(process.env.PORTREEVE_SECRET_KEY!);

app.post("/api/checkout", async (req, res) => {
  const { payment_method_id, device_token, attempt_id } = req.body;
  const amount = 4900; // minor units

  const pm = await stripe.paymentMethods.retrieve(payment_method_id);
  if (pm.type !== "card" || !pm.card) {
    return res.status(400).json({ error: "Card required." });
  }

  const result = await portreeve.verdict(
    {
      event_type: "checkout_attempt",
      external_user_id: req.user.id,
      ip: req.ip,
      device_token,
      email: req.user.email,
      payment: {
        card_fingerprint: pm.card.fingerprint,
        card_funding: pm.card.funding,
        amount,
        currency: "usd",
      },
    },
    { dedupeKey: `checkout:${attempt_id}` },
  );

  if (result.verdict === "block") {
    // Generic message. Never name the reason.
    return res.status(402).json({ error: "Your card was declined." });
  }
  // ... step 3
});

dedupeKey is the option you want here rather than idempotencyKey. One logical checkout attempt should produce one verdict; if your own retry path screens the same click twice, a repeat with the same key returns the original verdict instead of a 409.

The card fingerprint is what makes this different from a rate limit. It links the attempt to every other account and device that has presented the same card, and the device token links this browser to every card it has presented. Six distinct cards from one device in ten minutes is the shape of a card checker regardless of which IPs they came from. IP is a soft signal that never links accounts on its own, because shared egress makes per-IP counts untrustworthy.

Portreeve is the abuse firewall I built for this: one call at signup, trial_start, trial_convert, checkout_attempt, or login returns allow, review, or block with reason codes in under 100 ms, and the SDK fails open on timeout, returning allow with degraded: true and a *_failopen reason inside a 400 ms budget. It is in open beta with no uptime SLA yet, which is the main reason fail-open is the default.

Log result.reasons from day one. When someone asks why a customer was declined, the reason codes are the only answer you will have.

Step 3: create the PaymentIntent and store the event id

allow and review take the same path. A review verdict never blocks the user: the charge goes through, the event lands in a queue in the dashboard, and a signed webhook tells you later if it was denied. That is what lets the engine flag aggressively without costing you real customers, and it is why block can stay reserved for high-confidence shapes.

Put the Portreeve event id in metadata so the deny handler can find this charge later.

  const intent = await stripe.paymentIntents.create(
    {
      amount,
      currency: "usd",
      payment_method: payment_method_id,
      confirm: true,
      automatic_payment_methods: { enabled: true, allow_redirects: "never" },
      metadata: {
        portreeve_event_id: result.id,
        external_user_id: req.user.id,
      },
    },
    { idempotencyKey: `pi:${attempt_id}` },
  );

  await db.charges.insert({
    portreeve_event_id: result.id,
    payment_intent_id: intent.id,
    user_id: req.user.id,
  });

  res.json({ status: intent.status });
});

Write that row. Stripe's Search API is the obvious way to find the intent later, but it is an index, and Stripe tells you not to use it here: "Don't use search for read-after-write flows," because data is searchable "in under 1 minute" under normal conditions and can be delayed during an outage, per the search docs. A review resolved thirty seconds after a charge is exactly the case that breaks. Your own table is the primary lookup; search is the fallback.

Step 4: refund on deny

Portreeve posts a signed webhook when a reviewer resolves an event. Verify the raw body, then act only on a denial.

app.post(
  "/webhooks/portreeve",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    let event;
    try {
      event = Portreeve.verifyWebhook(
        req.body,
        req.get("portreeve-signature"),
        process.env.PORTREEVE_WEBHOOK_SECRET!,
      );
    } catch {
      return res.status(400).send("bad signature");
    }

    if (event.type === "review.resolved" && event.resolution === "denied") {
      const rows = await db.charges.where({
        portreeve_event_id: event.event_id,
      });

      const ids = rows.length
        ? rows.map((r) => r.payment_intent_id)
        : (
            await stripe.paymentIntents.search({
              query: `metadata["portreeve_event_id"]:"${event.event_id}"`,
              limit: 10,
            })
          ).data.map((pi) => pi.id);

      for (const id of ids) {
        const pi = await stripe.paymentIntents.retrieve(id);
        if (pi.status !== "succeeded") continue;
        await stripe.refunds.create(
          { payment_intent: id, reason: "fraudulent" },
          { idempotencyKey: `pv-deny:${event.event_id}:${id}` },
        );
      }

      await revokeAccess(event.external_user_id);
    }

    res.sendStatus(200);
  },
);

Three things in that handler earn their place. Deliveries retry until they get a 2xx, so the Stripe idempotency key is what stops a retried delivery from attempting a second refund. The retrieve before refunding avoids an error on an intent that never succeeded. And reason: "fraudulent" is not cosmetic: Stripe's refund API docs say that specifying it "will add the associated card and email to your block lists," so the deny propagates into Radar as well.

The same handler shape works for revoking a free account after a denied signup review, which is covered in more detail in the review webhook post.

Step 5: verify that you stopped card testing at checkout

Use sk_test_ and pk_test_ keys on both sides. Test mode is fully isolated from live, so nothing you do here touches your live counters.

Fire one normal checkout with 4242 4242 4242 4242. Expect allow, a succeeded intent, and the event visible in the dashboard with your external_user_id attached.

Then run the probe. From the same browser, so the same device token is in play, submit six different test cards within a few minutes: 4242 4242 4242 4242, 5555 5555 5555 4444, 4000 0025 0000 3155, 4000 0000 0000 9995, and any two more from Stripe's test card numbers. Watch the verdict change as the device-keyed counter climbs. You should see review and then block, with card and device reason codes, and no PaymentIntent created for the blocked attempt.

Last, resolve one of the review events as denied in the dashboard and confirm your webhook fires, finds the intent, and creates the refund. Use the Stripe CLI or the dashboard to confirm the refund object exists. The test mode and feedback guide covers driving these states deliberately.

What the device token changes, and the $0 SetupIntent case

Two of the block-strength card rules read a device-keyed counter. Without a device token, card rotation at ordinary amounts tops out at review. Your reviewer still sees it and can deny, but the charge already happened and you are refunding rather than declining. Skip the browser snippet and you have built a slower version of this.

The $0 authorization is the case the checkout path misses entirely. Stripe calls card setup "a method preferred by fraudulent actors" precisely because validations and authorizations during setup "don't typically show up on cardholder statements," which is why nobody reports them. If you save cards with a SetupIntent, screen that too: retrieve the PaymentMethod after confirmation, call verdict() with event_type: "trial_start" or checkout_attempt and payment.amount: 0, and detach the PaymentMethod on block. The mechanics of that probe shape are in the card testing on Stripe explainer.

Before you turn on live keys

Behind a proxy, req.ip is your load balancer unless you call app.set("trust proxy", true). Getting x-forwarded-for wrong means every attempt shares one IP and your velocity signals are noise.

Do not tell the attacker anything. One generic decline string for every block, the same one you use for a real issuer decline. A specific error message is free feedback for tuning the next run.

Do not rotate your publishable key and call it done. On Hacker News, chrisdkemper wrote that "I've cycled my keys multiple times and it goes any time from 6 weeks to less than a week till the attacks start again." In the same thread tinyprojects, who had tried the alternative, wrote that Radar "wasn't as effective as I liked" and "turned out to be very pricey paying £0.04/screened transaction," and ended up hand-rolling IP bans and alerts on repeated card failures. Key rotation moves the crawler on for a while. It does not change what happens when it comes back.

Send feedback. When a charge you allowed turns into a dispute, report it: portreeve.feedback({ kind: "external_user_id", value: userId }, "chargeback", "stripe dispute du_...") from your charge.dispute.created handler marks the whole linked cluster, not the single event. Feedback is idempotent per event and outcome, so a retried webhook is harmless.

Finally, decide your posture on outages before one happens. The SDK fails open by default and returns allow with degraded: true. If you would rather refuse checkouts than let them through unscreened, fail-closed is a client-side option, but for most people a few unscreened minutes beats a checkout that is down.

Start on the free tier at dashboard.portreeve.com: 1,000 screened events a month, no card, test keys available immediately.

← Back to all posts