Charge once with Stripe Checkout
Take a single payment for something that is neither a plan nor an add-on, and confirm it before you deliver.
A one-off charge is a Stripe Checkout session in payment mode: one line, one amount, paid once, no subscription created. Use it when you sell something that is not recurring and not a capability of the account: a report, a ticket, a course, a lifetime licence you then grant as an add-on. This guide is honest about the starting point: the kit ships no one-off charge flow, no class, no route and no table for it. What it ships is everything the flow needs to stand on, and this guide shows how to build it in a few lines.
When to use it
| You sell | Use | Why |
|---|---|---|
| Access renewed every month or year | A plan | Stripe manages the subscription, invoices and retries. See Create and price a plan. |
| A capability of the account, recurring or free | An add-on | The catalogue, the button and the access check already exist. See Sell add-ons. |
| Something paid once, delivered once | A one-off charge | Nothing to renew, nothing to cancel. |
| A capability paid once and kept forever | A one-off charge that grants a lifetime add-on |
The payment is this guide; the access is the add-on resolver. |
What the kit gives you
| Piece | Where | What it does for the charge |
|---|---|---|
| Stripe keys | STRIPE_KEY, STRIPE_SECRET, STRIPE_WEBHOOK_SECRET in .env, read by config/services.php |
The same secret plans use. No extra variable. |
| The Cashier customer | WeblaborMx\BillingCore\Models\BillingAccount, one per user, with stripe_id |
Every Cashier charge method is on it, including checkoutCharge(). |
| The Stripe client | Stripe\StripeClient, a singleton bound with your secret |
To retrieve a session or a payment intent from your own code. |
| The webhook endpoint | POST /api/stripe/webhook in routes/api.php |
Verifies the Stripe signature with STRIPE_WEBHOOK_SECRET, logs every event, and hands it to the package controller. |
| The webhook log | Webhook Events in the admin panel, /admin/webhook-events |
Every event received, its payload, its response status and any error. |
Cashier's WebhookReceived event |
Laravel\Cashier\Events\WebhookReceived |
Dispatched for every event before the controller looks for a handler. Your listener goes here. |
The package controller handles subscription and invoice events. It has no handler for checkout.session.completed: the event is answered with 200, logged as success, and nothing else happens. Confirming the payment is your listener's job.
Launch the charge from code
Get the billing account of the user, then open the session. In a Livewire component:
use WeblaborMx\BillingCore\BillingManager;
public function buyReport()
{
$account = app(BillingManager::class)->context(auth()->user(), 'user')->account;
$checkout = $account->checkoutCharge(
49900, // amount in minor units: $499.00
__('Annual report'), // product name shown on the Stripe page
1, // quantity
[
'success_url' => route('app.dashboard') . '?checkout=success',
'cancel_url' => route('app.dashboard') . '?checkout=cancel',
'metadata' => [
'purpose' => 'annual_report',
'user_id' => auth()->id(),
],
],
);
return redirect()->away($checkout->url);
}
| Parameter | What it is |
|---|---|
$amount |
Integer, in the smallest unit of the currency. 49900 is 499.00 for a two-decimal currency. |
$name |
The line description on the Checkout page. |
$quantity |
Default 1. |
$sessionOptions |
Passed to Stripe's checkout.sessions.create. Set success_url, cancel_url and metadata. mode is already payment. |
$customerOptions |
Passed when the Stripe customer has to be created. Usually empty. |
$productData |
Extra product_data fields, such as description or images. |
checkoutCharge() creates the Stripe customer when the account has none, so the payment is attached to the same customer a later subscription would use. The returned object exposes the session fields: $checkout->url to redirect, $checkout->id to store before redirecting, or return $checkout; from a controller to redirect with a 303.
Currency
checkoutCharge() charges in config('cashier.currency'), which reads CASHIER_CURRENCY and defaults to usd. The kit does not publish config/cashier.php and .env.example does not list the variable, so a project that bills in pesos writes:
CASHIER_CURRENCY=mxn
Cashier applies that currency to every one-off charge. Plans and add-ons are not affected: their currency is the one of each price you create in the admin panel.
The amount is an integer in the smallest unit, so for currencies with two decimals you multiply by 100. Zero-decimal currencies take the whole amount as is: JPY, KRW, VND, CLP, BIF, DJF, GNF, KMF, MGA, PYG, RWF, UGX, VUV, XAF, XOF, XPF. Passing ¥500 as 50000 charges five hundred times too much, so convert per currency before calling.
Where the customer returns
Stripe sends the browser to success_url after the payment form is completed and to cancel_url when the customer backs out. The kit's own checkouts append ?checkout=success and ?checkout=cancel to the plans page or the dashboard, and nothing in the kit reads those parameters: they are there for your page to show a message. Follow the same convention so one component can react to every return.
Arriving at success_url is not a confirmation. It means the Stripe page finished; it does not prove the payment was captured, and some payment methods complete asynchronously. Deliver on the webhook, never on the redirect.
Confirm it by webhook
Stripe sends checkout.session.completed when the session finishes. Its data.object is the session: id, customer, payment_status, amount_total, currency, payment_intent and the metadata you set. Register a listener in App\Providers\AppServiceProvider::boot():
use Laravel\Cashier\Events\WebhookReceived;
Event::listen(WebhookReceived::class, FulfilOneOffCharge::class);
namespace App\Listeners;
use Laravel\Cashier\Events\WebhookReceived;
use WeblaborMx\BillingCore\Models\BillingAccount;
class FulfilOneOffCharge
{
public function handle(WebhookReceived $event): void
{
if ($event->payload['type'] !== 'checkout.session.completed') {
return;
}
$session = $event->payload['data']['object'];
if ($session['mode'] !== 'payment' || $session['payment_status'] !== 'paid') {
return;
}
$account = BillingAccount::where('stripe_id', $session['customer'])->first();
if (! $account) {
return;
}
// Deliver. This example grants a capability for life, keyed by the session
// id so a replayed event grants nothing twice.
$account->grantAddOn('white_label', 'lifetime', ['reference' => $session['id']]);
}
}
What to keep in mind:
- The endpoint is
/api/stripe/webhook. Add it in the Stripe dashboard with the eventcheckout.session.completedselected: Stripe only sends the events you tick. - The signature is verified before your listener runs, with
STRIPE_WEBHOOK_SECRET. - Stripe retries an event that did not get a 2xx and may deliver one twice. Make the fulfilment idempotent: store the session id and refuse a second delivery.
payment_statusispaidfor card payments, andunpaidfor delayed methods; for those Stripe later sendscheckout.session.async_payment_succeeded, which your listener can treat the same way.- The kit creates no record of the charge. If you need a history of purchases, create your own table and write it from this listener; the Webhook Events log keeps the raw payload but is not a ledger.
Read the result in your code
When the customer comes back with a session id you stored, ask Stripe rather than the redirect:
use Stripe\StripeClient;
$session = app(StripeClient::class)->checkout->sessions->retrieve($sessionId);
$session->payment_status; // 'paid', 'unpaid' or 'no_payment_required'
$session->amount_total; // minor units
$session->currency; // 'mxn'
$session->metadata->purpose;
Use it to show "payment received" on the return page. Deliver only from the webhook.
Test it locally
Use test keys in .env and forward Stripe's events to your machine with the Stripe CLI:
stripe listen --forward-to your-project.test/api/stripe/webhook
The command prints a signing secret. Put it in .env as STRIPE_WEBHOOK_SECRET and clear the configuration cache. Then run the flow with a test card, 4242 4242 4242 4242, any future date and any CVC. Every event appears in Webhook Events with its status; a listener that throws shows there as failed, with the exception.
To replay the event without paying again:
stripe trigger checkout.session.completed
The triggered session belongs to a fixture customer, so your listener will not find a billing account for it; use it to see the endpoint answer 200 and the log fill, and use a real test payment to see the fulfilment run.