Payments
Every WPTom template can take payments with Stripe Checkout: deposits, tickets, gift cards, shop items, donations or subscriptions, depending on the template. The visitor clicks a button on your site, pays on Stripe’s hosted page (cards, wallets and whatever else you turn on in Stripe), and comes back to your site, which thanks them. Your site never sees card details.
Instead of Stripe you can use Lemon Squeezy, a merchant of record: see Lemon Squeezy instead of Stripe.
Templates: all of them, with Stripe or Lemon Squeezy (a site made earlier gets it with npx wptom update).
Service: Stripe or Lemon Squeezy.
| Stripe | Lemon Squeezy | |
|---|---|---|
| Who sells | You (Stripe processes the payment) | Lemon Squeezy, as merchant of record: it sells to the buyer, handles VAT and sales tax, and pays you out |
| Settings | STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET |
LEMONSQUEEZY_API_KEY, LEMONSQUEEZY_STORE_ID, LEMONSQUEEZY_WEBHOOK_SECRET |
| What goes in the price field | A Price id (price_…) |
A variant id (a number) |
| Webhook | /api/stripe/webhook, event checkout.session.completed |
/api/lemonsqueezy/webhook, event order_created |
Use one of them. If both keys are set, Stripe is used.
How it works
Section titled “How it works”- A buy button on your page posts the name of the item (never a price) to your site’s
/api/checkoutroute. - The route looks the item up in your content to find its Stripe Price id. Only items with a Price id in your content can be bought, so nobody can change the price from the browser.
- It creates a Stripe Checkout session and sends the visitor to Stripe.
- After paying, Stripe sends the visitor back to your page with a success message. If they cancel, they go back to the page they came from.
- Stripe then calls your site’s webhook,
/api/stripe/webhook, with the eventcheckout.session.completed. The site checks Stripe’s signature and sends the buyer your thank-you email (see Emails).
Each checkout is for one item. Stripe Checkout also shows a field for promotion codes, which you create in Stripe.
Without keys
Section titled “Without keys”The buttons are there. Without STRIPE_SECRET_KEY, a click brings the visitor back to the same
page with your “payments aren’t set up yet” message. An item without a Price id says how to
set it up instead of showing a working button (the exact behavior varies a little by
template). The webhook route answers that it isn’t configured.
What can be sold, and where the Price ids go
Section titled “What can be sold, and where the Price ids go”In Stripe, everything you sell is a Product with one or more Prices. The site needs
the Price id (it starts with price_), pasted into the field below in the content editor.
Use a one-time Price unless the table says recurring.
| Template | What visitors pay for | Where the Price id goes (content editor) |
|---|---|---|
| Aurora | Plans (subscription, 14-day free trial) | Pricing page → Plans → a plan → Stripe checkout: Stripe Price id, monthly and yearly (recurring Prices) |
| Swell | Plans (subscription) | Pricing page → Plans → a plan → Stripe checkout: turn on Sell with Stripe Checkout, then monthly and yearly Price ids (recurring). Free trial: Pricing page → Free trial (days) |
| Deckle | Subscriptions, gift subscriptions, single issues | Subscribe page → Plans → Stripe Price id (a yearly recurring Price when Payment is “Yearly subscription”, one-time for “One payment”); Issues → an issue → Stripe Price id (one-time) |
| Blaze | Trail passport and deposits | Trips page → Passport and deposits (Stripe) → Options |
| Burin | Lesson packs and tickets | Learn page → Lesson packs and tickets (Stripe) → Options |
| Handy | Deposits and gift vouchers | Prices page → Deposits and gift vouchers (online payment) → Options → an option → Price id |
| Kelvin | Deposits and gift vouchers | Prices page → Deposits and gift vouchers (Stripe) → Options |
| Iris | Deposits and gift vouchers | Pricing page → Deposits and gift vouchers (Stripe) → Options |
| Jab | Passes and gift cards | Membership page → Passes and gift cards (Stripe) → Options |
| Mint | Deposits and gift cards | Prices page → Deposits and gift cards (Stripe) → Options |
| Docket | A fixed-fee first consultation | Contact page → Pay for a fixed-fee consultation (Stripe) |
| Kerf | Deposits | Services → a service → Stripe Price id (one-time deposit), and a general deposit in Contact page → Pay a deposit (Stripe) |
| Lava | Paid sessions | Contact page → Paid sessions (Stripe), on each offer |
| Slab | Fixed-price offers | Services page → Fixed-price offers (Stripe), on each offer |
| Ember | Deposits for stays | Stays → a stay → Stripe Price id (one-time deposit). A Booking link on the stay replaces Stripe; a fully booked stay can’t be paid |
| Chalk | Gift cards and merch | Shop items → an item → Stripe Price id (one-time). Sold-out items can’t be bought |
| Toon | Shop products | Products → a product → Stripe Price id (one-time). Sold-out items can’t be bought |
| Encore | Festival tickets | Tickets → a ticket → Stripe Price id (one-time). Sold-out tickets can’t be bought |
| Bloom | Bouquets | Bouquets → a bouquet → Order online (Stripe): turn on Sell with Stripe Checkout, add the Price id |
| Fold | Books | Books → a book → Order online (Stripe), as in Bloom |
| Putty | Pre-orders | Bakes → a bake → Order online (Stripe), as in Bloom |
| Quest | Supporter editions | Games → a game → Supporter edition (Stripe): turn on, add the Price id |
| Hush | Tickets | Walk-throughs → one → Tickets (Stripe): turn on Sell tickets with Stripe Checkout, add the Price id |
| Lantern | Tickets | Shows → a show → Tickets (Stripe), as in Hush |
| Junction | Tickets | Journeys → a journey → Tickets (Stripe), as in Hush |
| Stroll | Tickets | Walks → a walk → Tickets (Stripe): the Price of one ticket |
| Stride | Race entries | Races → a race → Entries (Stripe): the Price of one entry |
| Razor | Gift cards | Services page → Gift cards → a card → Sell online (Stripe): turn on, add the Price id |
| Tuck | Hour packs and vouchers | Prices page → Packs and vouchers → a pack → Sell online (Stripe), as in Razor |
| Tiny Town | Workspace passes | Workspace page → Passes and plans → a pass → Checkout (Stripe): turn on, add the Price id |
| Wag, Whisker | Donations and monthly sponsorships | Donations: Help page → Donate (Stripe) → Amounts (one-time). Sponsorships: Dogs (Wag) or Cats (Whisker) → an animal → Sponsorship (Stripe): turn on Can be sponsored, add a monthly recurring Price id |
Templates updated for Lemon Squeezy rename these groups from “(Stripe)” to “(online payment)” and the field to Price id: it takes a Stripe Price id or a Lemon Squeezy variant id. The place in the editor stays the same.
1. Get your test keys
Section titled “1. Get your test keys”Build and test everything in Stripe’s test mode (or a sandbox): no real money moves, and switching to live later is only a matter of other keys and ids.
- Sign up at stripe.com. You can test before activating your account.
- Make sure the dashboard is in test mode (the toggle or sandbox switcher at the top).
- Open Developers → API keys and copy the Secret key (
sk_test_…).
2. Create your Products and Prices
Section titled “2. Create your Products and Prices”- In Stripe, Product catalog → Add product.
- Name it as on your site (e.g. “Gift voucher £50”), set the amount and currency, and choose One-off (or Recurring, monthly or yearly, where the table above says so).
- Save, open the product, and copy the Price id from its price (
price_…). Use the Price id, not the product id (prod_…). - Repeat for every item you sell.
3. Put the Price ids in your content
Section titled “3. Put the Price ids in your content”Open the content editor (/keystatic), go to the field from the table above, paste the Price
id, turn on the switch if your template has one, and Save.
4. Add the secret key to .env
Section titled “4. Add the secret key to .env”STRIPE_SECRET_KEY=sk_test_...STRIPE_WEBHOOK_SECRET=Restart npm run dev. The buy buttons now open Stripe Checkout. The webhook secret comes in
the next step.
5. Test the whole flow with the Stripe CLI
Section titled “5. Test the whole flow with the Stripe CLI”On your computer, Stripe can’t reach localhost, so the Stripe CLI forwards its events to you.
-
Install the Stripe CLI (on macOS:
brew install stripe/stripe-cli/stripe; for Windows and Linux see Stripe’s page). -
Log in once:
Terminal window stripe login -
In a second terminal (keep
npm run devrunning in the first), start forwarding. ReplacePORTwith the port number in the address your dev server shows (usually 4321):Terminal window stripe listen --forward-to localhost:PORT/api/stripe/webhookIt prints
Your webhook signing secret is whsec_…. -
Put that secret in
.envand restartnpm run dev(leavestripe listenrunning):Terminal window STRIPE_WEBHOOK_SECRET=whsec_... -
On your site, click a buy button. Stripe Checkout opens in test mode.
-
Pay with the test card 4242 4242 4242 4242, any future expiry date, any 3-digit CVC and any postal code. Use an email address you can check.
-
What you should see:
- Stripe sends you back to your site, which shows the success message.
- The
stripe listenterminal showscheckout.session.completedfollowed by status 200. - Your thank-you email arrives (with Resend set up), or the dev terminal prints
[email] to you@example.com: …. - In the Stripe dashboard (test mode), Payments lists the payment, and Transactions or Customers show the buyer.
-
Try cancelling on the Stripe page too: you should land back where you started.
6. Put it online
Section titled “6. Put it online”- In Vercel, Settings → Environment Variables: add
STRIPE_SECRET_KEY(still the test key for now). - In Stripe (test mode), Developers → Webhooks → Add endpoint (in newer dashboards: Add
destination → Webhook endpoint):
- Endpoint URL:
https://your-domain.com/api/stripe/webhook - Event:
checkout.session.completed
- Endpoint URL:
- Open the new endpoint and reveal its Signing secret (
whsec_…). It is not the one fromstripe listen. Add it in Vercel asSTRIPE_WEBHOOK_SECRET. - Redeploy and make one test purchase on the live site with the test card. In Stripe, the webhook endpoint shows the delivery with status 200.
Point the webhook at your production domain: preview deployments are often protected by Vercel’s login, which Stripe can’t pass.
7. Go live
Section titled “7. Go live”- Activate your Stripe account (business details, bank account).
- Switch the dashboard to live mode. Test Products and Prices don’t exist there: create them again (or copy them to live mode) and replace every Price id in your content with the live ones.
- Copy the live Secret key (
sk_live_…) and replaceSTRIPE_SECRET_KEYin Vercel. - Create the webhook endpoint again in live mode (same URL and event) and replace
STRIPE_WEBHOOK_SECRETin Vercel with its signing secret. - Commit the content change, redeploy, and make one small real purchase. Refund it from Payments in Stripe.
Keep your .env on test keys, so trying things on your computer never charges anyone.
Lemon Squeezy instead of Stripe
Section titled “Lemon Squeezy instead of Stripe”Lemon Squeezy is a merchant of record: legally it sells to your buyer, charges the right VAT or sales tax, sends the receipt and pays you out, so you don’t register for tax in your buyers’ countries. Its fees differ from Stripe’s; check its pricing page. On your site nothing changes: the same buttons, the same content field, the same success message and thank-you email.
The site uses Lemon Squeezy when LEMONSQUEEZY_API_KEY and LEMONSQUEEZY_STORE_ID are set
and STRIPE_SECRET_KEY is empty.
1. Create a store and switch to test mode
Section titled “1. Create a store and switch to test mode”- Sign up at lemonsqueezy.com and create your store.
- Turn on Test mode (the switch in the dashboard). Test mode has its own products, API keys and webhooks, and no real money moves.
2. Create your products and copy the variant ids
Section titled “2. Create your products and copy the variant ids”-
Store → Products → New product. Name it as on your site (e.g. “Gift voucher £50”), set the price as a single payment (or a subscription where the payments table says recurring), and publish it.
-
Copy the product’s variant id, a number. Every product has at least one variant, and the variant id is not the same number as the product id. The product’s menu in the products list offers to copy the variant id; if you can’t find it, list your variants with the API key from the next step:
Terminal window curl -H "Authorization: Bearer YOUR_API_KEY" -H "Accept: application/vnd.api+json" https://api.lemonsqueezy.com/v1/variantsEach entry’s
idis a variant id;attributes.nameandattributes.pricetell them apart. -
Paste the variant id into the item’s Price id field in the content editor (the place from the table above), turn on its switch if your template has one, and Save.
3. Copy the API key and store id
Section titled “3. Copy the API key and store id”- Settings → API → + (create a key), name it and copy it. You see it only once. A key made in test mode works only with test-mode products.
- Settings → Stores: the number next to your store’s name is the store id (without
the
#).
4. Add them to .env
Section titled “4. Add them to .env”STRIPE_SECRET_KEY=LEMONSQUEEZY_API_KEY=eyJ0eXAiOiJKV1Qi...LEMONSQUEEZY_STORE_ID=12345LEMONSQUEEZY_WEBHOOK_SECRET=Restart npm run dev. A buy button now opens Lemon Squeezy’s checkout. In test mode, pay with
the test card 4242 4242 4242 4242, any future expiry date and any CVC. After paying,
Lemon Squeezy’s confirmation leads back to your page, which shows the success message. To
cancel, the buyer simply goes back in the browser.
5. The webhook
Section titled “5. The webhook”Lemon Squeezy calls your site when an order is paid; the site checks the signature and sends your thank-you email (Lemon Squeezy sends its own receipt too). It needs a public address, so set it up on the deployed site:
- In Vercel, Settings → Environment Variables: add
LEMONSQUEEZY_API_KEYandLEMONSQUEEZY_STORE_ID(and leaveSTRIPE_SECRET_KEYout). - In Lemon Squeezy (still in test mode), Settings → Webhooks → +:
- Callback URL:
https://your-domain.com/api/lemonsqueezy/webhook - Signing secret: type a long random string (a password manager can make one) and keep a copy
- Events:
order_created
- Callback URL:
- Add the same string in Vercel as
LEMONSQUEEZY_WEBHOOK_SECRET(and in your.env), then redeploy. - Buy something on the live site with the test card. You should see the success message, the thank-you email at the address you gave in the checkout, and the order under Orders in Lemon Squeezy (test mode).
To test the webhook on your computer instead, expose npm run dev through a tunnel (e.g.
Cloudflare Tunnel or ngrok) and use the tunnel’s address as the callback URL.
To do more after a payment, add your steps to src/pages/api/lemonsqueezy/webhook.ts.
6. Go live
Section titled “6. Go live”- Activate your store in Lemon Squeezy (it asks for your details and reviews the store before live payments).
- Turn test mode off. Test products don’t exist in live mode: copy them to live mode (or create them again) and replace every variant id in your content with the live ones.
- Create a live API key and replace
LEMONSQUEEZY_API_KEYin Vercel. - Create the webhook again in live mode (same URL, event
order_created) and put its signing secret inLEMONSQUEEZY_WEBHOOK_SECRET. - Commit the content change, redeploy and make one small real purchase. Refund it from Orders.
The thank-you email
Section titled “The thank-you email”After checkout.session.completed (Stripe) or order_created (Lemon Squeezy), the site emails the address the buyer gave in Checkout.
Its texts are in Interface texts → Emails (the payment, purchase, ticket or entry email,
depending on the template); see Emails. Lemon Squeezy always sends
its own receipt. Stripe can also send its own receipt: turn it on in Stripe under Settings → Customer emails.
To do more after a payment (note an order, add someone to a guest list), add your steps to
src/pages/api/stripe/webhook.ts, where the comment shows the place.
Changing the messages
Section titled “Changing the messages”The success, not-configured and error messages shown after Checkout are in the content editor, in the group named “Checkout messages (Stripe)”, “Ticket messages (Stripe)” or similar on the selling page, or next to the payment options (in Handy: Prices page → Deposits and gift vouchers (online payment): Success, Not configured and Error).
Troubleshooting
Section titled “Troubleshooting”| What you see | Why, and what to do |
|---|---|
| “Payments aren’t set up” after clicking | STRIPE_SECRET_KEY isn’t set: restart npm run dev, or add it in Vercel and redeploy |
| The “unknown plan” or error message | The item has no Price id in your content (or its switch is off), or Stripe refused the session. The log shows [checkout] failed with the reason from Stripe or Lemon Squeezy |
No such price in the log |
A test Price id with a live key (or the other way round), or a product id instead of a Price id |
An error about payment mode and a recurring price |
A recurring Price where a one-time one is expected, or the other way round (Wag/Whisker sponsorships and the subscription plans need recurring Prices; everything else one-time) |
stripe listen shows status 400 |
STRIPE_WEBHOOK_SECRET doesn’t match: copy the secret stripe listen printed into .env and restart npm run dev |
| Status 503 from the webhook | STRIPE_WEBHOOK_SECRET is empty |
| Status 500 from the webhook | Your webhook code failed, often the email (see [stripe] handler for checkout.session.completed failed in the log). Stripe retries later |
| Paid, but no email | The webhook isn’t set up for the live site, or the signing secret belongs to another endpoint or mode. Check the endpoint’s deliveries in Stripe |
| Lemon Squeezy is set, but Stripe opens (or “payments aren’t set up”) | STRIPE_SECRET_KEY is still set and wins: empty it. Or one of LEMONSQUEEZY_API_KEY and LEMONSQUEEZY_STORE_ID is missing |
Lemon Squeezy variant ids are numbers in the log |
The price field still holds a Stripe price_… id. Put the variant id there |
Lemon Squeezy error 404 or 422 in the log |
The variant id is a product id, belongs to another store, or is a test-mode id used with a live key (or the other way round) |
Lemon Squeezy error 401 in the log |
The API key is wrong, deleted, or from the other mode |
| The Lemon Squeezy webhook answers 400 “Invalid signature” | LEMONSQUEEZY_WEBHOOK_SECRET isn’t the signing secret you typed in that webhook. Set the same string in both places and redeploy |
| The Lemon Squeezy webhook answers 503 | LEMONSQUEEZY_WEBHOOK_SECRET is empty |
| The Lemon Squeezy webhook answers 500 | Your webhook code failed, often the email ([lemonsqueezy] handler for order_created failed in the log). Lemon Squeezy retries later |