Flick
Guide Validation Services API Pro Examples GitHub
Docs / Services
Flick Pro Service

Tested and maintained, so you don't have to. $99/year.

Get Flick Pro

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->checkout API 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 GatewayInterface so 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 GatewayInterface implementation)