DEV Community

Cover image for Building a Pay-to-Take Spot with PHP, MySQL and Stripe Manual Capture
Andreas
Andreas

Posted on

Building a Pay-to-Take Spot with PHP, MySQL and Stripe Manual Capture

In this guide, we will walk through the payment flow behind OneLinkWins, a site with a single paid spot that anyone can take by paying $1 more than the last buyer. We will build it with plain PHP, MySQL and Stripe Checkout, and focus on the one problem that shaped most of the design: two people paying for the same spot at the same time.

What is OneLinkWins

OneLinkWins has exactly one paid spot. Whoever holds it gets their startup's name, a short message and a link shown on the homepage and in an embeddable widget on other sites. The first takeover cost $1, and every takeover after that costs $1 more than the previous one. The holder keeps the spot until someone else takes it.

The Problem

The price is fixed at the moment a buyer starts checkout. If the spot costs $3 and two people click "Take the spot" a few seconds apart, both are sent to Stripe Checkout and both pay $3. Only one of them can get the spot. The other one now owes $4, but their card has already been charged $3.

There are a few ways out, and each has a downside:

  • Locking the spot during checkout - a Stripe Checkout session stays open for at least 30 minutes, so one buyer who walks away would block everyone else
  • Letting both win one after the other - breaks the +$1 rule
  • Charging both and refunding the loser - the loser sees a charge and a refund on their statement, and Stripe usually keeps the processing fee
  • Only authorizing both and charging the winner - the loser is never charged, but you lose something else (see below)

OneLinkWins supports the last two, and the live site runs the third one.

Manual Capture vs. Charging and Refunding

Stripe lets you split a card payment into two steps: authorize (reserve the amount on the card) and capture (actually move the money). If you never capture, you cancel the authorization and the buyer is not charged. That is manual capture, and it was the original plan.

Check https://docs.stripe.com/payments/place-a-hold-on-a-payment-method for the details.

The catch: Stripe Checkout can email the buyer a proper invoice after payment (invoice_creation), but not for a payment that is only authorized and captured later. OneLinkWins wants every buyer to get an invoice, so the capture method became a config switch:

automatic (default, live) manual
At checkout Charged Only authorized
Lost the race Refunded in full automatically Authorization cancelled, never charged
Invoice Stripe invoice by email None, only Stripe's receipt

The good news is that the hard part is the same in both modes. The payment completes first, and our server decides afterwards, in one place, who gets the spot. Only the last step differs: capture or cancel in manual mode, keep or refund in automatic mode.

Initial Setup

The project uses PHP 8.2+, MySQL 8 and a single Composer dependency:

composer require stripe/stripe-php
Enter fullscreen mode Exit fullscreen mode

We will call Stripe while holding a database lock, so the Stripe client gets short timeouts:

$http = new \Stripe\HttpClient\CurlClient();
$http->setTimeout(10);
$http->setConnectTimeout(5);
\Stripe\ApiRequestor::setHttpClient($http);

$stripe = new \Stripe\StripeClient(['api_key' => $secretKey, 'max_network_retries' => 1]);
Enter fullscreen mode Exit fullscreen mode

Now that the basic setup is done, let us start with the database.

Step 1: The Database Tables

We need three tables for the purchase flow, plus a takeovers table that stores every reign.

Step 1.1: The spot state

CREATE TABLE spot_state (
  id TINYINT UNSIGNED PRIMARY KEY,
  current_takeover_id BIGINT UNSIGNED NULL,
  last_price_cents INT UNSIGNED NOT NULL DEFAULT 0,
  takeover_count INT UNSIGNED NOT NULL DEFAULT 0,
  updated_at DATETIME(3) NOT NULL
) ENGINE=InnoDB;

INSERT INTO spot_state (id, updated_at) VALUES (1, UTC_TIMESTAMP(3));
Enter fullscreen mode Exit fullscreen mode

Why a single row? Everything about the current state of the game lives here: who holds the spot and what the last price was. Every purchase locks this row first, which turns "two buyers at the same time" into "two buyers one after the other".

The next price is always derived from this row:

function nextPrice(array $state): int
{
    $last = (int) $state['last_price_cents'];
    return $last === 0 ? START_PRICE_CENTS : $last + PRICE_STEP_CENTS;   // both 100
}
Enter fullscreen mode Exit fullscreen mode

Step 1.2: Checkout attempts

CREATE TABLE checkout_attempts (
  id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  user_id BIGINT UNSIGNED NOT NULL,
  startup_id BIGINT UNSIGNED NOT NULL,
  expected_price_cents INT UNSIGNED NOT NULL,
  credits_cents INT UNSIGNED NOT NULL DEFAULT 0,
  message VARCHAR(80) NOT NULL,
  stripe_checkout_session_id VARCHAR(255) NULL UNIQUE,
  stripe_payment_intent_id VARCHAR(255) NULL,
  status ENUM('pending','captured','cancelled_outbid','cancelled_holder','expired','failed')
    NOT NULL DEFAULT 'pending',
  takeover_id BIGINT UNSIGNED NULL,
  created_at DATETIME(3) NOT NULL,
  resolved_at DATETIME(3) NULL,
  KEY idx_status_created (status, created_at)
) ENGINE=InnoDB;
Enter fullscreen mode Exit fullscreen mode

What this stores:

  • expected_price_cents - the price the buyer saw when they started checkout. This is the value we compare against later.
  • credits_cents - the part of the price paid with credits. Sites that show the widget earn credits, and a buyer can use them for up to half of a takeover. The card is charged the rest.
  • status - where the attempt ended up. Only pending attempts can still be finalized.
  • takeover_id - the reign this attempt created, if it won

Step 1.3: Processed Stripe events

CREATE TABLE stripe_events (
  event_id VARCHAR(255) PRIMARY KEY,
  type VARCHAR(100) NOT NULL,
  received_at DATETIME(3) NOT NULL,
  processed_at DATETIME(3) NULL
) ENGINE=InnoDB;
Enter fullscreen mode Exit fullscreen mode

Why this table? Stripe may deliver the same webhook more than once. Recording every event lets us skip duplicates, and processed_at tells us whether we actually finished handling it.

Step 2: Creating the Checkout Session

When a buyer submits the take form, we check the price, create an attempt and send them to Stripe Checkout. The form sends the price it showed as a hidden field.

$state = $db->query('SELECT * FROM spot_state WHERE id = 1')->fetch();
$expected = nextPrice($state);

if ($shownPrice !== $expected) {
    throw new UserError('The price just changed. Please check and confirm again.');
}
if (currentHolderStartupId($db, $state) === $startupId) {
    throw new UserError('You already hold the spot.');
}

$attemptId = createAttempt($db, $userId, $startupId, $expected, $message);

$params = [
    'mode' => 'payment',
    'customer_email' => $user['email'],
    'client_reference_id' => (string) $attemptId,
    'line_items' => [[
        'quantity' => 1,
        'price_data' => [
            'currency' => 'usd',
            'unit_amount' => $expected,
            'product_data' => ['name' => sprintf('Takeover #%d — %s', $state['takeover_count'] + 1, $startup['name'])],
        ],
    ]],
    'payment_intent_data' => [
        'capture_method' => $captureMethod,   // 'automatic' or 'manual'
        'metadata' => ['checkout_attempt_id' => (string) $attemptId],
    ],
    'metadata' => ['checkout_attempt_id' => (string) $attemptId],
    // Stripe wants at least 30 minutes, measured from its own clock: add a minute of buffer
    'expires_at' => time() + 31 * 60,
    'success_url' => $baseUrl . '/take/status/' . $attemptId,
    'cancel_url' => $baseUrl . '/take?cancelled=' . $attemptId,
];

if ($captureMethod === 'automatic') {
    $params['invoice_creation'] = ['enabled' => true];
}

$session = $stripe->checkout->sessions->create($params, ['idempotency_key' => 'checkout-attempt-' . $attemptId]);

saveSessionId($db, $attemptId, $session->id);
header('Location: ' . $session->url, true, 303);
Enter fullscreen mode Exit fullscreen mode

What we just did:

  • Read the current price without a lock. It is only the price the buyer expects to pay; the real decision happens later.
  • Rejected the request early if the price changed since the form was shown, or if the buyer's startup already holds the spot
  • Created a pending attempt that stores this expected price
  • Created a Checkout Session with the configured capture_method, and turned on the invoice only in automatic mode
  • Put the attempt ID into the metadata, so the webhook can find it again
  • Used the attempt ID as the idempotency key, so a retried request cannot create a second session

If a buyer has credits, the attempt also reserves them and unit_amount is the price minus the credits. The code above leaves that out.

Step 2.1: When the buyer cancels

The cancel_url carries the attempt ID. When the buyer comes back that way, we expire the session at Stripe right away instead of waiting 30 minutes:

function cancelByBuyer(PDO $db, StripeClient $stripe, int $attemptId, int $userId): void
{
    $attempt = findPendingAttempt($db, $attemptId, $userId);
    if ($attempt === null || !$attempt['stripe_checkout_session_id']) {
        return;
    }

    try {
        $stripe->checkout->sessions->expire($attempt['stripe_checkout_session_id']);
    } catch (\Throwable $e) {
        return;   // maybe completed in another tab: the webhook decides
    }

    resolveAttempt($db, $stripe, $attemptId, 'expired');
}
Enter fullscreen mode Exit fullscreen mode

resolveAttempt() sets a final status on a pending attempt in its own small transaction, gives back any reserved credits, and can cancel an open authorization. We will use it a few more times.

Step 3: Receiving the Webhook

$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';

try {
    $event = \Stripe\Webhook::constructEvent($payload, $signature, $webhookSecret);
} catch (\Throwable $e) {
    http_response_code(400);
    exit;
}

$db->prepare('INSERT IGNORE INTO stripe_events (event_id, type, received_at) VALUES (?, ?, UTC_TIMESTAMP(3))')
    ->execute([$event->id, $event->type]);

$processed = $db->prepare('SELECT processed_at FROM stripe_events WHERE event_id = ?');
$processed->execute([$event->id]);

if ($processed->fetchColumn() !== null) {
    http_response_code(200);   // already handled this event
    exit;
}

try {
    handleEvent($db, $stripe, $event);
} catch (\Throwable $e) {
    if ($db->inTransaction()) {
        $db->rollBack();
    }
    http_response_code(500);   // Stripe retries the event later
    exit;
}

$db->prepare('UPDATE stripe_events SET processed_at = UTC_TIMESTAMP(3) WHERE event_id = ?')
    ->execute([$event->id]);
http_response_code(200);
Enter fullscreen mode Exit fullscreen mode

And the handler:

function handleEvent(PDO $db, StripeClient $stripe, $event): void
{
    $session = $event->data->object;
    $attemptId = attemptIdForSession($db, $session);   // by session ID, metadata as fallback
    if ($attemptId === null) {
        return;
    }

    if ($event->type === 'checkout.session.completed') {
        finalizeAttempt($db, $stripe, $attemptId, $session->payment_intent);
    } elseif ($event->type === 'checkout.session.expired') {
        resolveAttempt($db, $stripe, $attemptId, 'expired');
    } elseif ($event->type === 'checkout.session.async_payment_failed') {
        resolveAttempt($db, $stripe, $attemptId, 'failed');
    }
}
Enter fullscreen mode Exit fullscreen mode

What's happening here:

  • constructEvent verifies the signature, so nobody can fake a payment by posting to our webhook URL
  • INSERT IGNORE records the event, and processed_at tells us whether it was already handled
  • A completed session goes to finalizeAttempt, which is where the actual decision happens
  • If handling throws, we answer 500 and Stripe sends the event again

Why not skip every event we have seen before? Then an event whose handling failed halfway would be skipped on Stripe's retry as well. An event only counts as done once processed_at is set.

Step 4: Finalizing in One Locked Transaction

This is the core of the whole flow.

function finalizeAttempt(PDO $db, StripeClient $stripe, int $attemptId, string $paymentIntentId): string
{
    $db->beginTransaction();

    $state = $db->query('SELECT * FROM spot_state WHERE id = 1 FOR UPDATE')->fetch();

    $stmt = $db->prepare('SELECT * FROM checkout_attempts WHERE id = ? FOR UPDATE');
    $stmt->execute([$attemptId]);
    $attempt = $stmt->fetch();

    if ($attempt['status'] !== 'pending') {
        $db->commit();
        return $attempt['status'];
    }

    $priceNow = nextPrice($state);
    $charge = (int) $attempt['expected_price_cents'] - (int) $attempt['credits_cents'];

    $intent = $stripe->paymentIntents->retrieve($paymentIntentId);
    $alreadyCharged = $intent->status === 'succeeded';   // automatic capture
    $available = $alreadyCharged ? $intent->amount_received : $intent->amount_capturable;
    $intentOk = in_array($intent->status, ['requires_capture', 'succeeded'], true)
        && $available >= $charge
        && $intent->metadata['checkout_attempt_id'] === (string) $attemptId;

    $reject = match (true) {
        !$intentOk => 'failed',
        (int) $attempt['expected_price_cents'] !== $priceNow => 'cancelled_outbid',
        currentHolderStartupId($db, $state) === (int) $attempt['startup_id'] => 'cancelled_holder',
        default => null,
    };

    if ($reject !== null) {
        if ($alreadyCharged) {
            refundPayment($stripe, $paymentIntentId, $intent->amount_received);
        } elseif ($intent->status === 'requires_capture') {
            cancelAuthorization($stripe, $paymentIntentId);
        }
        setAttemptStatus($db, $attemptId, $reject);   // also gives back reserved credits
        $db->commit();
        notifyLoser($attempt, $reject, $priceNow);
        return $reject;
    }

    // Write the takeover first, capture last
    endCurrentReign($db, $state);
    $takeoverId = startNewReign($db, $attempt, $priceNow, $paymentIntentId);
    updateSpotState($db, $takeoverId, $priceNow);
    spendCredits($db, $attemptId);
    markCaptured($db, $attemptId, $takeoverId);

    if (!$alreadyCharged && !capture($stripe, $paymentIntentId)) {
        $db->rollBack();
        resolveAttempt($db, $stripe, $attemptId, 'failed', cancel: $paymentIntentId);
        return 'failed';
    }

    try {
        $db->commit();
    } catch (\Throwable $e) {
        // The money moved, but the takeover was not saved: give it back
        $db->rollBack();
        refundPayment($stripe, $paymentIntentId, $charge);
        resolveAttempt($db, $stripe, $attemptId, 'failed');
        return 'failed';
    }

    return 'captured';
}
Enter fullscreen mode Exit fullscreen mode

What this function does, in order:

  • Locks the spot_state row with FOR UPDATE. A second webhook arriving at the same moment waits here until the first one commits.
  • Locks the attempt and returns early if it is no longer pending. This covers duplicate webhooks that made it past Step 3.
  • Asks Stripe for the PaymentIntent and checks it: authorized (manual) or already paid (automatic), enough money, and really belongs to this attempt
  • Compares the expected price with the current price. If they differ, someone else completed a takeover first. In manual mode we cancel the authorization and the buyer is not charged; in automatic mode we refund the payment in full.
  • Rejects a startup that already holds the spot, since buying it again would only raise the price for everyone else
  • Ends the old reign, starts the new one, updates the state and marks the attempt, all still inside the transaction
  • Captures the payment last (manual mode only), then commits

The refund and cancel helpers catch and log their own errors, so a failed Stripe call never leaves the attempt stuck in pending. The email to the losing buyer goes out after the response is sent, so Stripe gets its 200 quickly.

Why write first and capture last? If the capture fails, one rollback undoes the whole takeover. If it succeeds, the only thing left is the commit. That commit can still fail in theory, so the code catches it and refunds the payment. In automatic mode there is nothing to capture: the money already moved at checkout, and the same function decides whether to keep or refund it.

The capture itself needs care, because a timeout does not tell you whether it went through:

function capture(StripeClient $stripe, string $paymentIntentId): bool
{
    try {
        $intent = $stripe->paymentIntents->capture($paymentIntentId, [], ['idempotency_key' => 'capture-' . $paymentIntentId]);
        return $intent->status === 'succeeded';
    } catch (\Throwable $e) {
        // Ask Stripe what actually happened
        return $stripe->paymentIntents->retrieve($paymentIntentId)->status === 'succeeded';
    }
}
Enter fullscreen mode Exit fullscreen mode

If even that second call fails, the exception bubbles up, the webhook rolls back and answers 500, and Stripe retries. On the retry, finalizeAttempt reads the PaymentIntent again. A capture that did go through now simply looks like an automatic-capture payment, and the same logic handles it.

Holding a row lock during one or two Stripe calls is fine at this scale. That is what the short timeouts from the setup are for.

Step 5: The Status Page

Stripe redirects the buyer to success_url right after payment, often before the webhook has arrived. So the status page does not change anything. It only asks the server for the attempt status, first after one second and then every two seconds:

const card = document.querySelector('[data-attempt-status]');

async function poll() {
  try {
    const res = await fetch(`/api/attempt/${card.dataset.attemptId}`, { cache: 'no-store' });
    if (res.ok) {
      const data = await res.json();
      showAttempt(data.status, data); // captured, cancelled_outbid, cancelled_holder, expired, failed
      if (data.status !== 'pending') return;
    }
  } catch (e) { /* network hiccup, try again */ }
  setTimeout(poll, 2000);
}

if (card.dataset.status === 'pending') setTimeout(poll, 1000);
Enter fullscreen mode Exit fullscreen mode

Why not finalize on the redirect? The redirect can be skipped, repeated or opened in a second tab. The webhook is the only reliable signal, so all decisions happen there. If the buyer never sees the status page, they get an email, and the result also shows up on their next page view within the hour.

Step 6: A Safety Net for Stuck Attempts

If a webhook never arrives, an attempt would stay pending and any authorization would stay open until it expires on its own. A cron job checks for old pending attempts every minute:

$stuck = $db->query(
    "SELECT * FROM checkout_attempts
     WHERE status = 'pending' AND created_at < UTC_TIMESTAMP(3) - INTERVAL 35 MINUTE
     ORDER BY id LIMIT 50"
)->fetchAll();

foreach ($stuck as $attempt) {
    $id = (int) $attempt['id'];

    if (!$attempt['stripe_checkout_session_id']) {
        resolveAttempt($db, $stripe, $id, 'failed');   // the session was never created
        continue;
    }

    $session = $stripe->checkout->sessions->retrieve($attempt['stripe_checkout_session_id']);

    if ($session->status === 'complete' && $session->payment_intent) {
        finalizeAttempt($db, $stripe, $id, $session->payment_intent);
    } elseif ($session->status === 'open') {
        $stripe->checkout->sessions->expire($session->id);
        resolveAttempt($db, $stripe, $id, 'expired');
    } else {
        resolveAttempt($db, $stripe, $id, 'expired', cancel: $session->payment_intent);
    }
}
Enter fullscreen mode Exit fullscreen mode

What this covers: a missed or failed webhook delivery, and sessions that are still open after their time is up. Every attempt ends up captured, refunded, cancelled or expired, and no buyer is left with an open authorization on their card.

Step 7: Final Project Structure

The parts of the project we covered look like this:

onelinkwins/
├── public/
│   ├── index.php                # front controller
│   └── assets/app.js            # status page polling
├── src/
│   ├── SpotService.php          # checkout, finalize, cancel, cron safety net
│   ├── StripeService.php        # thin wrapper around stripe-php
│   ├── PaymentGateway.php       # interface StripeService implements
│   └── Controllers/
│       ├── TakeController.php   # take form, checkout start, status page
│       ├── StripeController.php # webhook
│       └── ApiController.php    # /api/attempt/{id}
├── templates/
│   └── take_status.php
├── cli/
│   └── cron.php                 # runs every minute, includes the safety net
├── migrations/
│   └── 001_init.sql
└── composer.json
Enter fullscreen mode Exit fullscreen mode

Try It Live

The flow is running on onelinkwins.com in automatic mode. The take page shows the current price, and if someone takes the spot while you are paying, your payment is refunded in full automatically.

Use Stripe's test mode and the Stripe CLI to try the race condition yourself:

stripe listen --forward-to localhost:8000/stripe/webhook
Enter fullscreen mode Exit fullscreen mode

Then open two browsers, start checkout in both at the same price and complete both payments. One attempt ends up captured, the other cancelled_outbid. In automatic mode, the Stripe dashboard shows the second payment as refunded. Set stripe_capture_method to manual and the second PaymentIntent is cancelled instead, without ever being charged.

Top comments (0)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.