There are three honest answers, and which one is right depends on a question nobody asks first: how much of billing do you want to own? Stripe is not the hard part. The hard part is everything that has to be true after the card clears, and that work is the same whichever SDK you install.
Worth laying out before choosing, because every route below is really a decision about who owns the right-hand column.
Correct when you want no third party in the path.
Install the Stripe SDK, create a Checkout Session in a route handler, and handle the webhook. For a single one-time price this is genuinely an afternoon. The cost arrives later, with the second price, the first failed renewal and the first missed webhook, and it is the work listed above rather than anything Stripe did wrong.
Correct when you do not want to own sales tax.
Paddle, Lemon Squeezy and Dodo Payments sell to your customer on your behalf. They register for VAT in the places that require it, charge the right rate, file the returns and carry the liability. You get a cleaner tax life and a higher effective rate on every transaction.
What Spark Stack ships, and what the rest of this page documents.
Keep your own Stripe account and let something else hold the plans, the webhook and the subscription state. Your app asks one question, "what may this email do", and gets a typed answer. This is the narrowest of the three: it removes the work in the right-hand column above without taking over your payments, and it is not a Stripe alternative. Stripe still processes every card and still charges its ordinary fees.
Spark Stack ships lib/spark-stripe/ as a client kit against a SparkPay backend. Two environment variables, and only one of them is a secret:
# A public identifier. Safe to commit.
NEXT_PUBLIC_SPARK_PAY_APP_ID=your-app-id
# The only secret the app needs. No Stripe secret key, ever.
SPARK_STRIPE_WEBHOOK_SECRET=whsec_from_the_dashboardThen the gate is the whole integration:
import { SparkGate, useSparkStatus } from './SparkStripe';
import { getLimit } from './sparkstripe-server'; // no 'use client'
function Exporter({ email }: { email: string }) {
const { status } = useSparkStatus(email);
if (!status) return null;
// SparkGate takes the status it should judge. With no minTier it falls
// back to the paywall flag, which already accounts for the trial.
return (
<SparkGate status={status}>
{/* getLimit returns the plan's CAP, not what is left. */}
<ExportButton monthlyCap={getLimit(status, 'exports')} />
</SparkGate>
);
}| Export | What it does |
|---|---|
useSparkStatus(email) | React hook, from SparkStripe.tsx. Returns status, loading, tier, isPaid and showPaywall. |
useCheckout() | From SparkStripe.tsx. Sends the user to hosted checkout for a tier. |
SparkGate / SparkBadge | From SparkStripe.tsx. The wall below minTier, and the current-plan badge. |
getStatusCached(email) | From sparkstripe-server.ts. Server read, cached 60s against a revalidate tag. |
getLimit / hasFeature | From sparkstripe-server.ts. One plan limit or feature flag off a status; -1 means unlimited. |
guard / guards.<tier> | From sparkstripe-server.ts. Route-handler wrappers, generated from plans.json. |
createWebhookHandler() | From sparkstripe-server.ts. Verifies the signature and hands you a typed payload. Re-export its POST from your route. |
The kit is two files on purpose. sparkstripe-server.ts has no 'use client' and holds the status reads, limits, gate and webhook, so a route handler, a Server Component or a webhook can call it. SparkStripe.tsx is the React half. Do not re-export the server half through the client one to tidy the import paths: that turns those functions back into client references, and a webhook route built that way compiles clean, passes CI, deploys, and then throws Attempted to call POST() from the server but POST is on the client on every delivery, with nothing reporting it. Subscription state simply stops propagating: a customer who paid stays un-activated, one who cancelled stays paid.
For routes one and three, yes, and it is yours. For route two the merchant of record sells on your behalf, so the account that faces the card network is theirs and your customer sees their name on the receipt.
Only if something else holds them. Calling Stripe from your own route handler requires a secret key in your environment, which is fine but is one more secret you are responsible for rotating. On route three the only secret the app needs is a webhook signing secret, and the app id it talks to is a public identifier that is safe to commit.
Webhook signature verification, because a framework that has already parsed the request body gives you a body Stripe cannot sign-check. After that it is duplicate delivery: Stripe retries, so an event handler that is not idempotent grants the same entitlement twice or emails the same receipt twice.
No, and any page that implies otherwise is wrong. Stripe still processes the payment and still takes its percentage. A layer on top replaces the plans, the webhook and the entitlement read, which is engineering time rather than card fees.
On its SparkPay pricing page, which is the page checkout charges from and the only place the figure is maintained. Quoting it here would mean a second number that goes stale the day a plan changes.