A few things I learned while building card-backed bids, automatic closing and anti-sniping for a small auction product.
I have been working on BidPage, a small tool for auctioning things that can be delivered online.
I wanted to keep the idea simple. A seller creates one auction page, shares the link with people who may be interested, and lets them bid. The winner is charged when the auction closes. Everyone else pays nothing.
On the screen, that is not a complicated product. There is an image, a description, a few rules, a list of bids and a countdown.
Then the countdown reaches zero.
Who is allowed to close the auction? What happens if two jobs try at the same time? What if a bid arrives during the final second? What if the highest bidder’s card fails? And what does “highest bidder” even mean when the winner does not always pay the highest bid?
Those questions took more thought than the page itself.
This is not meant to be a complete auction tutorial, and the snippets below are simplified rather than copied from production. These are just the decisions I would write down before building another one.
The browser does not close the auction
The countdown is useful for the person looking at the page, but it cannot be the authority. A browser can sleep, lose its connection or have the wrong local time. Someone can also change anything running on their own device.
The server clock and the database have to decide whether a bid is late.
A safer model is to treat closing as a real state between live and awaiting_payment. A worker first tries to claim the auction with an atomic update:
UPDATE auctions
SET status = 'closing'
WHERE id = :auction_id
AND status = 'live'
AND closes_at <= CURRENT_TIMESTAMP;
If one row changes, that worker owns the close. If zero rows change, it has nothing to do. Another worker may already have claimed it, or the closing time may have moved.
The second case matters because BidPage has anti-sniping. A valid bid during the final two minutes extends the auction so another bidder has time to respond.
That small feature ruined my first, neat mental model of “schedule one job for the closing time.” The original closing time may no longer be correct when that job runs.
A close worker therefore has to read the current state every time. If the auction was extended, the old job exits. A later job, or a periodic scan for auctions that are actually due, handles it instead.
Bid acceptance and time extension also belong in the same transaction. Otherwise one request can decide that the auction is open while another request closes it.
“Highest bid wins” is not a full rule
BidPage has three auction formats:
| Format | What bidders see | What the winner pays |
|---|---|---|
| Open bidding | The current leading bid | Their highest accepted bid |
| Private bids | Competing offers stay hidden | Their own offer |
| Fair Price | Competing offers stay hidden | The second-highest qualifying offer |
Fair Price is the one that needs explaining. If the two highest offers are $100 and $75, the person who offered $100 wins but pays $75, subject to the reserve price.
The outcome function itself should be boring. Give it an auction and a fixed set of valid bids, and it should always return the same result.
function determineOutcome(auction, bids) {
const ranked = bids
.filter((bid) => bid.status === "valid")
.sort((a, b) =>
b.amountCents - a.amountCents ||
a.acceptedSequence - b.acceptedSequence
);
const winner = ranked[0];
if (!winner || winner.amountCents < auction.reserveCents) {
return { sold: false };
}
const priceCents = auction.format === "fair_price"
? Math.max(
auction.reserveCents,
ranked[1]?.amountCents ?? auction.reserveCents
)
: winner.amountCents;
return { sold: true, winner, priceCents };
}
The example uses the earlier accepted bid as the tie-breaker. A different rule is possible, but it needs to be decided before two bidders submit the same amount.
Money should also be stored in cents rather than floating-point values. An auction is a particularly bad place to discover that the UI and backend round the same number differently.
The cases I found worth writing down were the awkward ones: no bids, one bidder in a Fair Price auction, two identical private offers, a bid exactly at the reserve, and a Buy Now action racing with a normal bid.
The public explanation of those rules is part of the specification. If the interface says one thing and the settlement function does another, the code may run perfectly and still produce the wrong outcome.
Putting a card on file is not collecting money
I did not want to charge every bidder and then refund all the losers. I also did not want an auction lasting several days to depend on a long card authorization.
The flow is instead split in two. The bidder gives permission to save a payment method when placing the bid. After the auction closes and the final price is known, the winner is charged off-session.
Stripe’s Setup Intents API is made for saving a payment method without creating a charge. Stripe also documents the later off-session payment flow.
The uncomfortable detail is that a saved card can still fail. The issuer may decline the payment, or it may ask the customer to authenticate again.
So winner_selected and paid cannot be the same state. Between them, the data model needs payment states such as pending, requires_action and failed.
The payment attempt must also be safe to retry. A stable idempotency key for each settlement attempt prevents a repeated job from creating another charge. A browser redirect should not count as proof of payment either. The final status should come from a verified webhook; Stripe recommends monitoring PaymentIntent status with webhooks.
I am still interested in how other people handle the failed-winner case. Automatically offering the item to the second bidder sounds reasonable until you ask what that bidder should pay, how long the original winner gets to authenticate, and whether the published rules actually allow it.
Payment is not the end either
Digital delivery is not one thing.
A domain has to be transferred. A consultation has to take place. A banner may need to stay online for seven days. A sponsored post may have an agreed publication date and duration.
That is why an auction needs clear delivery terms before anyone bids. I also ask the seller for a proof URL when it makes sense, so a buyer can check that the seller controls what is being offered.
After payment, the auction still moves through delivery and confirmation before it is complete. Calling it complete as soon as the card is charged would make the payment system happy, but not necessarily the buyer.
This is partly code and partly policy. How long does the buyer have to confirm? What evidence can the seller provide? What happens if the two sides disagree? Even a small product eventually has to answer those questions.
The remaining problem is not technical
The same auction engine can handle a domain, a newsletter slot, a consultation, a digital product or a homepage banner.
That flexibility is useful in the code. I am less certain that it is useful in the first sentence on the landing page.
“Auction anything digital” is accurate, but it may be too broad. I may get a clearer product by starting with one group, such as creators selling sponsorship placements or founders selling small digital assets, and expanding later.
That is the question I am testing now. The auction logic can tell me who won. It cannot tell me which first use case people will care about.
If you have built an auction or another delayed-charge system, I would genuinely like to hear how you approached closing races and failed winner payments.
The current version is at bidpage.app.
Disclosure: I used AI assistance to help organize and edit this article. I reviewed the final version and take responsibility for the technical content.
Top comments (0)