Stripe Connect Authorize-Then-Capture: The Implementation Nobody Documents
If you've read the Stripe Connect docs and thought "right, but how do I actually build this?" — this article is for you. I built a full authorize-then-capture flow for a marketplace platform and the g
Stripe Connect Authorize-Then-Capture: The Implementation Nobody Documents
If you've read the Stripe Connect docs and thought "right, but how do I actually build this?" — this article is for you. I built a full authorize-then-capture flow for a marketplace platform and the gap between Stripe's documentation and a working implementation is... significant.
The platform was a service marketplace. Customers book a service provider, pay upfront (authorized, not captured), the service happens, then the payment is captured and the provider gets paid. Classic two-sided marketplace flow.
Here's how it actually works.
Why Authorize-Then-Capture
The pattern is simple: charge the customer's card when they book (authorization), but don't actually take the money until the service is delivered (capture). If the booking is cancelled, you release the authorization instead of processing a refund.
Why this matters for marketplaces:
- Customer protection. They see a pending charge, not an actual charge. If things go wrong, you void the auth — no refund process needed.
- Provider confidence. The money is "locked in" — the customer can't claim they don't have funds when it's time to pay.
- Platform control. You decide when to capture, which means you control the flow of money.
The alternative — charge immediately and refund if cancelled — is messier. Refunds take days to appear. Customers complain. Banks get suspicious if you have a high refund rate.
Connected Accounts
Every service provider on the platform needs a Stripe Connected Account. This is how Stripe knows where to send their money.
// app/Actions/Stripe/CreateConnectedAccount.php
final class CreateConnectedAccount
{
public function execute(Provider $provider): string
{
$account = Stripe::accounts()->create([
'type' => 'express',
'country' => 'GB',
'email' => $provider->email,
'capabilities' => [
'card_payments' => ['requested' => true],
'transfers' => ['requested' => true],
],
'business_type' => 'individual',
'metadata' => [
'provider_id' => $provider->id,
],
]);
$provider->update([
'stripe_account_id' => $account->id,
]);
return $account->id;
}
}
Express accounts are right for most marketplaces. Stripe handles the KYC, the dashboard, the tax forms. You just need to get the provider through the onboarding flow:
// app/Actions/Stripe/CreateOnboardingLink.php
final class CreateOnboardingLink
{
public function execute(Provider $provider): string
{
$link = Stripe::accountLinks()->create([
'account' => $provider->stripe_account_id,
'refresh_url' => route('provider.onboarding.refresh'),
'return_url' => route('provider.onboarding.complete'),
'type' => 'account_onboarding',
]);
return $link->url;
}
}
The provider clicks the link, fills in their details on Stripe's hosted pages, and comes back to your platform. You'll get a webhook when their account is fully verified.
The Authorization
When a customer books a service, you create a PaymentIntent with capture_method: 'manual'. This authorizes the amount but doesn't charge it.
// app/Actions/Payment/AuthorizeBookingPayment.php
final class AuthorizeBookingPayment
{
public function execute(Booking $booking): PaymentIntent
{
$intent = Stripe::paymentIntents()->create([
'amount' => $booking->total_pence,
'currency' => 'gbp',
'capture_method' => 'manual',
'payment_method' => $booking->customer->default_payment_method_id,
'customer' => $booking->customer->stripe_customer_id,
'confirm' => true,
'off_session' => true,
'metadata' => [
'booking_id' => $booking->id,
'provider_id' => $booking->provider_id,
],
'transfer_group' => "booking_{$booking->id}",
]);
$booking->update([
'stripe_payment_intent_id' => $intent->id,
'payment_status' => PaymentStatus::Authorized,
]);
return $intent;
}
}
Key things here:
capture_method: 'manual'— This is what makes it authorize-only. Without this, Stripe captures immediately.transfer_group— Links this payment to the eventual transfer to the provider. Essential for reconciliation.off_session: true— The customer has already provided their payment method. We're charging without them being present.metadata— Always store your internal IDs. You'll need them in webhooks.
Important caveat: authorizations expire after 7 days (for most card types). If your service delivery window is longer than that, you need a different approach. More on that later.
Capturing the Payment
After the service is delivered and confirmed, you capture the authorized amount:
// app/Actions/Payment/CaptureBookingPayment.php
final class CaptureBookingPayment
{
public function execute(Booking $booking): PaymentIntent
{
$intent = Stripe::paymentIntents()->capture(
$booking->stripe_payment_intent_id,
[
'amount_to_capture' => $booking->total_pence,
],
);
$booking->update([
'payment_status' => PaymentStatus::Captured,
'captured_at' => now(),
]);
// Now transfer to the provider
$this->transferToProvider($booking, $intent);
return $intent;
}
private function transferToProvider(
Booking $booking,
PaymentIntent $intent,
): void {
$platformFee = (int) round($booking->total_pence * 0.15);
$providerAmount = $booking->total_pence - $platformFee;
Stripe::transfers()->create([
'amount' => $providerAmount,
'currency' => 'gbp',
'destination' => $booking->provider->stripe_account_id,
'transfer_group' => "booking_{$booking->id}",
'source_transaction' => $intent->latest_charge,
'metadata' => [
'booking_id' => $booking->id,
'platform_fee' => $platformFee,
],
]);
$booking->update([
'provider_payout_pence' => $providerAmount,
'platform_fee_pence' => $platformFee,
]);
}
}
The source_transaction Trick
This is the bit nobody documents properly. When you create a transfer, you can pass source_transaction — the charge ID from the captured payment. This does something critical: it links the transfer to the specific charge, so the transfer only succeeds if that charge's funds are available.
Without source_transaction, Stripe transfers from your platform's available balance. If you have multiple payments in flight, this can get messy. With source_transaction, each transfer is explicitly linked to its source of funds. Clean reconciliation, no surprises.
To get the charge ID, you need latest_charge from the PaymentIntent after capture. Not before capture — the charge doesn't exist until the payment is actually captured.
// The charge ID is only available after capture
$intent = Stripe::paymentIntents()->capture($intentId);
$chargeId = $intent->latest_charge; // "ch_xxx"
Handling Cancellations
If the booking is cancelled before capture, void the authorization:
// app/Actions/Payment/CancelBookingPayment.php
final class CancelBookingPayment
{
public function execute(Booking $booking): void
{
if ($booking->payment_status !== PaymentStatus::Authorized) {
throw new InvalidPaymentStateException(
"Cannot cancel payment in state: {$booking->payment_status->value}"
);
}
Stripe::paymentIntents()->cancel(
$booking->stripe_payment_intent_id,
);
$booking->update([
'payment_status' => PaymentStatus::Cancelled,
'cancelled_at' => now(),
]);
}
}
The customer's card authorization disappears within a few days. No refund. No processing fees. Clean.
Webhook Handling
Stripe webhooks are where the real complexity lives. You need to handle several events:
// app/Http/Controllers/Webhook/StripeWebhookController.php
final class StripeWebhookController extends Controller
{
public function __invoke(Request $request): JsonResponse
{
$event = Stripe::webhooks()->constructEvent(
$request->getContent(),
$request->header('Stripe-Signature'),
config('services.stripe.webhook_secret'),
);
match ($event->type) {
'payment_intent.amount_capturable_updated' => $this->handleAuthorized($event),
'payment_intent.succeeded' => $this->handleCaptured($event),
'payment_intent.canceled' => $this->handleCancelled($event),
'payment_intent.payment_failed' => $this->handleFailed($event),
'account.updated' => $this->handleAccountUpdated($event),
'transfer.created' => $this->handleTransferCreated($event),
default => null,
};
return response()->json(['received' => true]);
}
private function handleFailed(Event $event): void
{
$intent = $event->data->object;
$bookingId = $intent->metadata->booking_id ?? null;
if (! $bookingId) {
return;
}
$booking = Booking::find($bookingId);
$booking?->update([
'payment_status' => PaymentStatus::Failed,
'payment_failure_reason' => $intent->last_payment_error?->message,
]);
// Notify the customer and internal team
if ($booking) {
$booking->customer->notify(
new PaymentFailedNotification($booking),
);
}
}
}
Critical webhook rule: always return 200, even if you can't process the event. If you return an error, Stripe retries, and retries, and retries. Log the error internally but acknowledge receipt.
The 7-Day Authorization Window
Authorizations expire. For most card networks, it's 7 days. Some are shorter. If your service delivery takes longer than that, you have two options:
Option 1: Extended authorizations. Stripe supports these for certain merchant categories, extending to 31 days. You need to apply for this and it's not guaranteed.
Option 2: Authorize closer to capture. Instead of authorizing at booking time, authorize the day before the service. This requires a different UX — the customer needs to know they'll be charged later — but it avoids the expiry problem.
I went with option 2 for bookings more than 5 days out. A scheduled job runs daily, finds bookings happening tomorrow that haven't been authorized yet, and processes the authorization:
// app/Jobs/AuthorizePendingBookings.php
final class AuthorizePendingBookings implements ShouldQueue
{
public function handle(AuthorizeBookingPayment $authorize): void
{
Booking::query()
->where('payment_status', PaymentStatus::Pending)
->whereDate('service_date', now()->addDay())
->each(function (Booking $booking) use ($authorize) {
try {
$authorize->execute($booking);
} catch (CardDeclinedException $e) {
$booking->update([
'payment_status' => PaymentStatus::Failed,
]);
$booking->customer->notify(
new PaymentFailedNotification($booking),
);
}
});
}
}
Edge Cases That Will Bite You
Partial captures. You can capture less than the authorized amount (customer got a discount, service was shorter than expected). You cannot capture more. If the final amount might be higher, authorize for the maximum possible amount.
Currency precision. Always work in the smallest currency unit (pence for GBP, cents for USD). Never work with floats for money. Store amounts as integers. The total_pence column is an integer, not a decimal.
Idempotency. Webhooks can be delivered more than once. Your handlers must be idempotent. Check the payment status before updating it. If it's already Captured, don't capture again.
Account not yet verified. You can authorize a payment before the provider's connected account is fully verified. But you can't transfer funds to an unverified account. Always check charges_enabled on the connected account before attempting a transfer.
This is real code from a real platform, handling real money. It took weeks to get right, not because the individual pieces are hard, but because the edge cases are endless and Stripe's docs don't cover them all. Hopefully this saves you some of that time.
Building a SaaS product? I can help with architecture, payments, and the hard bits.
Get the Friday email
What I shipped this week, what I learned, one useful thing.
No spam. Unsubscribe anytime. Privacy policy.