Tested and maintained, so you don't have to. $99/year.
Checkout
Hosted checkout for Flick forms. Create a payment session, redirect the visitor to Polar or Stripe, then confirm the payment with a signed webhook.
Why Use This Service?
What you'd build yourself: Polar and Stripe checkout session APIs, amount conversion across currencies, webhook signature verification, and a way to join the payment back to the form row you saved.
Maintained for you: when a provider changes a checkout or webhook contract, the gateway is updated; your form code does not.
What Pro provides:
- One
$form->checkoutAPI for Polar and Stripe Checkout - Hosted checkout only — the visitor pays on Polar or Stripe, not on your form
- Webhook signature verification so a redirect is never treated as proof of payment
- A
GatewayInterfaceso you can plug in another provider
This is not a billing platform. There are no subscriptions, refunds, customer portals, or embedded card fields.
Installation
Flick Pro requires a license. See Pro installation for setup instructions.
Configuration
Checkout requires a driver, secretKey, webhookSecret, successUrl, and cancelUrl. Polar also requires a product from your Polar dashboard — Polar hangs ad-hoc amounts on a product, Stripe does not.
Stripe
$config = [ 'services' => [ 'checkout' => [ 'driver' => 'stripe', 'secretKey' => 'sk_test_...', 'webhookSecret' => 'whsec_...', 'successUrl' => 'https://example.com/thanks', 'cancelUrl' => 'https://example.com/checkout', 'currency' => 'usd', ], ], ]; $form = new Flick($config);
Polar
Create a product in the Polar dashboard and copy its ID. Checkout uses that product with an ad-hoc price for the amount you pass to create().
$config = [ 'services' => [ 'checkout' => [ 'driver' => 'polar', 'secretKey' => 'polar_oat_...', 'webhookSecret' => 'your-polar-webhook-secret', 'product' => '00000000-0000-4000-8000-000000000000', 'successUrl' => 'https://example.com/thanks', 'cancelUrl' => 'https://example.com/checkout', 'currency' => 'usd', 'sandbox' => false, ], ], ]; $form = new Flick($config);
Set sandbox to true to use https://sandbox-api.polar.sh instead of production. Stripe uses test vs live keys (sk_test_... / sk_live_...) and does not need a sandbox flag.
Full Configuration
| Option | Type | Default | Description |
|---|---|---|---|
driver |
string | — | stripe, polar, or a class that implements Flick\Checkout\Gateway\GatewayInterface |
secretKey |
string | — | Stripe secret key or Polar organization access token |
webhookSecret |
string | — | Signing secret from the provider's webhook settings |
successUrl |
string | — | Where the visitor lands after a successful payment |
cancelUrl |
string | — | Where the visitor lands if they cancel. Polar shows this as a back button (return_url) |
currency |
string | usd |
ISO 4217 currency code. Overridable per create() call |
product |
string | — | Required for Polar: product ID. Optional for Stripe: a Price ID; when set, create() uses that catalog price and ignores amount |
timeout |
int | 10 |
API request timeout in seconds |
curl |
bool | false |
true uses cURL; false uses file_get_contents |
sandbox |
bool | false |
Polar only. Use the sandbox API |
apiBase |
string | provider default | Override the API base URL. For pointing tests at a local stub — not needed in normal use |
Missing required keys throw InvalidArgumentException when the service is resolved, not when the form is submitted.
Custom gateway
'checkout' => [ 'driver' => App\Payments\PaddleGateway::class, 'successUrl' => 'https://example.com/thanks', 'cancelUrl' => 'https://example.com/checkout', ],
The class must implement Flick\Checkout\Gateway\GatewayInterface. Its constructor receives the checkout config array. Every built-in config check is skipped for a custom driver — including successUrl and cancelUrl, since not every provider redirects. Set them and they reach your gateway; leave them out and it receives null for both. Your gateway validates whatever it actually needs.
GatewayInterface has four methods:
| Method | Returns | Description |
|---|---|---|
create(array $request) |
?CheckoutSession |
Build the session and return its id and url, or null on failure |
webhook(string $payload, array $headers) |
?PaymentEvent |
Verify the signature and normalize the event, or null if it does not verify |
signatureHeaders() |
array |
The request header names your provider signs with, e.g. ['Paddle-Signature'] |
getLastError() |
?string |
Why the last call failed |
signatureHeaders() is how webhook() knows what to read off the incoming request when you call it with no arguments. Return every header your verification needs — Stripe returns one, Polar returns three.
Create a checkout
Save the form row first, put its id in metadata, then redirect. The webhook uses that metadata to mark the row paid.
if ($form->submitted()) { $data = $form->request('Email[required,email], Amount[required,numeric]'); if ($form->ok()) { $id = $form->sql->save('orders', [ 'email' => $data['email'], 'amount' => $data['amount'], 'status' => 'pending', ]); $session = $form->checkout->create([ 'amount' => $data['amount'], 'email' => $data['email'], 'name' => 'Registration', 'metadata' => ['order_id' => (string) $id], ]); if ($session) { $form->redirect($session->url); } } }
create() returns a CheckoutSession with public id and url properties, or null on failure. Check $form->errors() after a null.
$form->redirect() already waits for submitted() and ok(), so this is the same redirect you use after a normal form save.
create() options
| Option | Type | Description |
|---|---|---|
amount |
int, float, or string | Required unless Stripe is using a catalog product. Major units: 25.00 is twenty-five dollars, not 25 cents. See Currency decimals below. A string has to be a plain number — '25.00' is fine, '$25.00' and '1,234.00' are rejected rather than guessed at |
currency |
string | Overrides config currency |
email |
string | Prefills the hosted checkout |
name |
string | Line-item name on Stripe (default Payment). Polar uses the dashboard product name |
metadata |
array | Copied onto the session and returned on the webhook event. Values should be strings |
successUrl |
string | Overrides config |
cancelUrl |
string | Overrides config |
product |
string | Overrides config product |
Currency decimals
You always pass major units. Flick converts to whatever the provider expects, which is not the same for every currency.
| Kind | Currencies | 25 becomes |
|---|---|---|
| Two decimal | Everything not listed below, including USD, EUR and GBP | 2500 |
| Zero decimal | BIF, CLP, DJF, GNF, JPY, KMF, KRW, MGA, PYG, RWF, VND, VUV, XAF, XOF, XPF | 25 |
| Three decimal | BHD, IQD, JOD, KWD, LYD, OMR, TND | 25000 |
Two currencies are worth calling out. Stripe lists UGX and ISK as zero decimal, but its own special-case rules say to send them as two-decimal values ending in 00. Flick follows the special-case rules, so both are treated as two decimal here and 25 becomes 2500. You do not need to compensate for that yourself.
Confirm with a webhook
The success URL is not proof of payment. The visitor can close the tab. Point Polar or Stripe at a dedicated PHP file and confirm there.
<?php require 'vendor/autoload.php'; $form = new Flick([ 'services' => [ 'checkout' => [ 'driver' => 'stripe', 'secretKey' => 'sk_live_...', 'webhookSecret' => 'whsec_...', 'successUrl' => 'https://example.com/thanks', 'cancelUrl' => 'https://example.com/checkout', ], 'sql' => [ 'driver' => 'sqlite', 'path' => __DIR__.'/app.db', ], ], ]); $event = $form->checkout->webhook(); if (! $event) { http_response_code(400); exit; } if ($event->paid()) { $orderId = $event->metadata()['order_id'] ?? null; if ($orderId) { $form->sql->save('orders', [ 'id' => $orderId, 'status' => 'paid', 'provider_id' => $event->id(), ]); } } http_response_code(200);
webhook() reads the raw request body and signature headers. You can pass them explicitly in tests:
$event = $form->checkout->webhook($payload, [ 'Stripe-Signature' => $header, ]);
A bad signature, missing body, or unverifiable header returns null and adds an error to the checkout bag.
Providers retry failed deliveries. Make fulfillment idempotent: unique on provider_id, or skip rows that are already paid.
Some payment methods do not settle right away. Bank debits like ACH, SEPA and Bacs, and voucher methods like Boleto and OXXO, finish the checkout first and move the money days later. Stripe sends the completed event immediately for those, then a second event once the funds arrive. paid() is false on the first one and true on the second, so the if ($event->paid()) check above is all you need — you will not ship anything before you are paid. Point your webhook endpoint at checkout.session.completed, checkout.session.async_payment_succeeded and checkout.session.async_payment_failed so you hear about all three.
PaymentEvent methods
| Method | Returns | Description |
|---|---|---|
paid() |
bool |
true only when the money has actually settled. On Stripe that means a checkout.session.completed or checkout.session.async_payment_succeeded event whose payment_status is paid or no_payment_required; on Polar, checkout.succeeded or order.created |
id() |
string |
Provider session or order id |
amount() |
int |
Amount in minor units (cents for USD) |
currency() |
string |
Lowercase ISO currency code |
email() |
?string |
Customer email when the provider sent one |
metadata() |
array |
Metadata you passed to create() |
type() |
string |
Raw provider event type |
raw() |
array |
The decoded webhook payload |
Methods
create(array $options = []): ?CheckoutSession
Create a hosted checkout session. Returns null on failure.
webhook(?string $payload = null, ?array $headers = null): ?PaymentEvent
Verify a webhook and return a normalized event. Returns null on failure.
Error handling
Runtime failures use the same pattern as every other Pro service: addError('checkout', ...) and null. Configuration mistakes throw InvalidArgumentException when the service is resolved.
$session = $form->checkout->create(['amount' => 25.00]); if (! $session) { // $form->errors() includes the checkout failure }
Requirements
- PHP 8.3+
- Flick Pro
- A Polar or Stripe account (or your own
GatewayInterfaceimplementation)