How to Verify a Card Is Real with a $1 Test Payment Using Stripe Elements (and Why Hosted Checkout Can't Do It)

You've seen it as a customer: you add a card to an app, a $1 charge appears on your statement, and a few days later it vanishes. No money ever actually moved. That $1 was a card verification: a temporary authorization the merchant placed and immediately voided, purely to prove the card is real, active, and able to authorize.
This post walks through exactly how to build that flow with Stripe Elements using a manual-capture PaymentIntent, and explains why Stripe's hosted Checkout page is the wrong tool for the job.
What the $1 "test payment" actually is
The $1 charge is not a payment. It's an authorization without capture:
Your server asks the card network to authorize $1 on the card.
The issuing bank checks the card number, expiry, CVC, available funds, and fraud signals, then places a $1 hold.
Your server immediately cancels (voids) the authorization instead of capturing it.
The hold drops off the cardholder's statement, typically within a few business days depending on the issuing bank.
Because the issuer fully evaluated the authorization, a successful hold tells you the card genuinely works, which is a much stronger signal than a format check or a tokenization success. Merchants use this before saving a card for future billing, before starting a free trial, or before releasing goods where the real charge happens later.
A quick note on the $0 alternative. Stripe also supports zero-amount verification through a SetupIntent, where supported networks run a $0 account verification. That's the lighter-touch option when you only need to know the card is valid. The $1 auth-and-void is the classic approach when you specifically want the issuer to confirm the account can actually authorize a positive amount, and it's the flow customers recognize from their statements.
The flow at a glance
Browser → your server: the page requests a card verification.
Your server → Stripe: create a PaymentIntent with
amount: 100($1.00) andcapture_method: 'manual', and return itsclient_secretto the browser.Browser → Stripe: Stripe Elements confirms the payment. The issuing bank authorizes and places a $1 hold, and the PaymentIntent lands in
requires_capture. Nothing is captured.Browser → your server: the page reports the card as verified, and your server immediately cancels the PaymentIntent, which voids the $1 hold.
The key detail is capture_method: 'manual'. A normal PaymentIntent authorizes and captures in one step, which would take the customer's dollar for real. With manual capture, confirmation only places the hold, and the PaymentIntent sits in requires_capture until you either capture it or cancel it. For verification, you always cancel.
Step 1: Create a manual-capture PaymentIntent on your server
// server.js (Node)
const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY);
app.post("/create-verification-intent", async (req, res) => {
const paymentIntent = await stripe.paymentIntents.create({
amount: 100, // $1.00 in cents
currency: "usd",
capture_method: "manual", // authorize only, never auto-capture
payment_method_types: ["card"],
metadata: { purpose: "card_verification" },
});
res.json({ clientSecret: paymentIntent.client_secret });
});
The metadata.purpose tag isn't required, but it makes these intents easy to identify in the Dashboard and in webhook handlers so nobody on your team captures one by accident.
Step 2: Collect and confirm the card with Stripe Elements
Stripe Elements renders the card fields inside your own page, which is what makes this flow possible: the customer never leaves your UI, never sees a "Pay $1.00" purchase page, and you keep full control of what happens after authorization.
<form id="verify-form">
<div id="payment-element"></div>
<button type="submit">Verify card</button>
</form>
// client.js
const stripe = Stripe("pk_live_...");
const { clientSecret } = await fetch("/create-verification-intent", {
method: "POST",
}).then((r) => r.json());
const elements = stripe.elements({ clientSecret });
const paymentElement = elements.create("payment");
paymentElement.mount("#payment-element");
document.getElementById("verify-form").addEventListener("submit", async (e) => {
e.preventDefault();
const { error, paymentIntent } = await stripe.confirmPayment({
elements,
redirect: "if_required", // stay on-page unless the issuer forces 3DS redirect
});
if (error) {
// The issuer declined: card is not good to go
showMessage(error.message);
return;
}
if (paymentIntent.status === "requires_capture") {
// $1 hold placed successfully: the card is real and can authorize
await fetch("/release-verification-hold", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ paymentIntentId: paymentIntent.id }),
});
showMessage("Card verified. The $1 hold will disappear from your statement shortly.");
}
});
Because confirmation runs through the real authorization rails, this step also handles 3D Secure automatically when the issuer requires it. A card that can't complete authentication won't reach requires_capture, which is exactly the signal you want.
Step 3: Immediately void the hold
// server.js
app.post("/release-verification-hold", async (req, res) => {
const { paymentIntentId } = req.body;
const intent = await stripe.paymentIntents.retrieve(paymentIntentId);
// Only cancel uncaptured verification holds, never a real payment
if (
intent.metadata.purpose === "card_verification" &&
intent.status === "requires_capture"
) {
await stripe.paymentIntents.cancel(paymentIntentId);
}
res.json({ ok: true });
});
Canceling an uncaptured PaymentIntent voids the authorization. No funds were ever captured, so there's nothing to refund; the issuer simply releases the hold. The customer sees the $1 as "pending" and then it disappears. How fast it disappears is up to the issuing bank (often same-day, sometimes up to a week), which is worth stating in your UI so support doesn't get "why did you charge me $1?" tickets.
As a safety net, add a webhook or scheduled job that cancels any requires_capture intent tagged card_verification that's more than a few minutes old. If the customer closes the tab between confirmation and your cancel call, the hold would otherwise sit until it expires on its own (typically around 7 days for cards).
Why hosted Checkout can't do this
Stripe's hosted Checkout is a purchase page, and everything about it is built around completing a sale. That's precisely what disqualifies it for an auth-and-void verification:
1. It must present a real purchase. Checkout in payment mode requires line items with names and prices, then shows the customer a Stripe-branded page that says they are paying $1.00 for something. There is no way to frame it as "temporary hold, instantly released." For a verification step, that UX is somewhere between confusing and trust-destroying.
2. It enforces a minimum charge amount. Checkout requires the total to meet Stripe's minimum charge (around $0.50 USD equivalent), because it exists to take payments. You're forced into pretending a verification is a product.
3. It takes the customer off your page. Checkout redirects to a Stripe-hosted URL (or shows a full-page embedded frame). A card verification is usually one step inside a bigger flow: signup, trial start, account settings. Elements slots into that page; Checkout hijacks it.
4. You don't control the intent lifecycle inline. With Elements, your code confirms the intent and can cancel the hold in the very next request, all invisible to the user. With Checkout, Stripe creates and manages the PaymentIntent inside the session, and you only get control after the session completes and the customer has sat through a full checkout ceremony. You'd be voiding a "purchase" the customer believes they just made.
Checkout does have a mode: 'setup' for saving cards without charging, but that's the $0 SetupIntent flow on Stripe's page, not the $1 auth-and-void, and it still redirects the customer away from your product. If the requirement is "place a small hold, void it instantly, inside my own UI," Stripe Elements with a manual-capture PaymentIntent is the only clean way to build it.
Testing it before you go live
In test mode, use Stripe's standard test cards to exercise both outcomes:
4242 4242 4242 4242: authorizes successfully, lands inrequires_capture, cancels cleanly.4000 0000 0000 0002: always declines, so you can verify your failure path.4000 0025 0000 3155: requires 3D Secure, so you can verify the authentication path.
Watch the PaymentIntent in the Dashboard as you test: you should see it go requires_payment_method → requires_capture → canceled, with the amount showing as uncaptured at the middle step and no charge ever created.
Keep it legitimate
One important boundary: this technique is for verifying your own customer's card, with their knowledge, inside your product. Running authorizations against lists of card numbers is card testing fraud, and Stripe Radar actively blocks and flags accounts doing it. Keep verifications tied to a real signup or billing action, rate-limit the endpoint, and require authentication before it can be called.
Wrapping up
The mysterious $1 charge is just an authorization with manual capture that gets canceled instead of captured. With Stripe Elements you can run the whole thing inside your own page: create a PaymentIntent with capture_method: 'manual', confirm it with the Payment Element, and cancel it the moment it reaches requires_capture. Hosted Checkout can't offer this because it's structurally a purchase page: minimum amounts, product framing, a redirect, and no inline control over the authorization. When you need to prove a card is good to go without taking a cent, Elements is the tool.