A paid customer can finish checkout and still arrive at your app before your webhook has been processed. A cancelled customer can revisit an old success URL. A person can also copy a URL with a convincing query string and send it to someone else.
That makes the checkout return URL useful for navigation, but unsafe as proof of access.
For a small Next.js product, the clean model is simple:
- The checkout sends the browser back to your app.
- Your app shows a friendly processing or success screen.
- Polar sends the payment event to your webhook.
- Your webhook verifies the event, stores the entitlement, and can be retried safely.
- Your protected page reads access from your own database.
This separation prevents a surprising number of edge cases.
Treat the return URL as a receipt screen
Your return page can read a checkout identifier and use it to explain what happens next. It should not grant a download, unlock a dashboard, or create a subscription record just because the browser reached that page.
A minimal return route might look like this:
export async function GET(request: Request) {
const url = new URL(request.url)
const checkoutId = url.searchParams.get("checkoutId")
if (!checkoutId) {
return new Response("Missing checkout identifier", { status: 400 })
}
return Response.json({
status: "processing",
checkoutId,
message: "Payment received. Your access will appear after confirmation."
})
}
The important part is not the exact route. The important part is that this route does not write an entitlement.
If your product is instant and your webhook usually arrives quickly, the page can poll your own status endpoint for a short time. It can also offer a refresh button or tell the customer to check their email. None of those options require trusting a query parameter.
Store intent before the customer leaves
Before redirecting to checkout, create a pending purchase record in your database. Give it an internal identifier and associate it with the signed in customer when one exists.
Useful fields include:
- An internal purchase identifier
- The customer identifier or email
- The product identifier you expect
- The checkout identifier when it becomes available
- A status such as pending, paid, cancelled, or disputed
- The time the record was created
- The time the entitlement was granted
For an anonymous buyer, use the email supplied by the payment flow only after your server has verified the payment event. Do not let a return page choose the account that receives access.
This pending record gives your webhook a place to land and gives support a useful trail when a customer says, “I paid but I cannot see the product.”
Make the webhook the authority
Your webhook handler should verify the signature using the secret configured for the correct Polar environment. Then validate the event shape and the product or price that your application expects.
After verification, process the event as a state transition. A simplified handler could follow this shape:
export async function POST(request: Request) {
const rawBody = await request.text()
const signature = request.headers.get("polarSignature")
const event = verifyPolarEvent(rawBody, signature)
if (!event) {
return new Response("Invalid event", { status: 401 })
}
const eventKey = event.id
const alreadySeen = await events.exists(eventKey)
if (alreadySeen) {
return Response.json({ received: true })
}
await database.transaction(async (tx) => {
await tx.events.insert({ eventKey })
await applyPurchaseChange(tx, event)
})
return Response.json({ received: true })
}
The names will differ in your application. The properties should not be copied without checking the current Polar documentation. The design is the useful part: verify first, record the event key, apply a controlled change, and return a successful response only after the database work completes.
Decide what each event means
Write the event map before you write the handler. This turns vague payment logic into explicit business rules.
- A completed payment can grant an entitlement.
- A refund can revoke or suspend an entitlement, depending on your product.
- A failed payment should not grant access.
- A cancelled subscription can change future access without deleting historical orders.
- A repeated delivery should produce the same final state as the first delivery.
- An event for an unknown product should be recorded for review and should not unlock anything.
The last rule is especially valuable when you have a test product, a preview product, and a live product that look similar in the dashboard.
Preview and production are different worlds
A preview deployment may point at one database while production points at another. Your Polar products and webhook endpoints can also differ by environment. If a preview checkout writes into the production entitlement table, a test can accidentally unlock a real account. If production listens with a preview secret, every event will fail verification.
Keep an environment table for yourself:
- Local uses a local site, local data, test products, and a test secret.
- Preview uses a preview site, preview data, test products, and a test secret.
- Production uses the live site, live data, live products, and the live secret.
The table is intentionally boring. Boring is good here. The most expensive payment bugs often come from one value copied into the wrong environment.
For Vercel, check the values available to the deployment that is actually running. A value saved for production does not automatically mean a preview deployment can use it. After changing a secret or product identifier, redeploy and test the full path again.
A small entitlement checklist
Before calling the flow finished, test these cases with a safe product and a disposable account:
- The customer completes checkout and lands on the return page.
- The return page is refreshed several times.
- The webhook arrives before the return page loads.
- The return page loads before the webhook arrives.
- The same webhook is delivered twice.
- The customer opens the return URL in another browser.
- The event contains a product your app does not recognize.
- The webhook has an invalid signature.
- The database write fails and the provider retries the event.
- A refund changes the expected access state.
For each case, define the expected database state. Do not rely on what the browser appears to show.
A useful manual test is to open your browser developer tools and watch the sequence. The customer facing page should be able to say “we are confirming your purchase” without pretending that confirmation has already happened.
Keep the user experience calm
Separating navigation from entitlement does not require a confusing experience. Show the checkout identifier in a support friendly way, explain that confirmation may take a moment, and give the customer a clear next action.
For example:
Your payment is being confirmed. Keep this page open for a moment. If access does not appear, refresh once or contact support with your order email.
When the webhook finishes, your status endpoint can return the real state from your database. If access is still pending after a reasonable interval, show support instructions rather than asking the customer to pay again.
The same pattern works for a small digital download, a private dashboard, or a subscription service. The browser tells you where the customer went. The verified event tells you what happened.
A practical shortcut for indie teams
If you are building alone, make three tiny functions and keep their responsibilities separate:
-
createPendingPurchaserecords checkout intent. -
handleVerifiedEventchanges purchase and entitlement state. -
getEntitlementForUserreads access for the current account.
This makes it easier to test the difficult parts without opening a payment page every time. It also gives you a natural place to add logs for event identifiers, product identifiers, and state changes without logging payment secrets.
I keep a small launch checklist for this kind of release because payment bugs are much less stressful when the checks are written down. The Next.js and Vercel Production Launch Kit covers the wider release pass, and Deploy Guard focuses on deployment checks and environment confidence.
Build the return page for humans. Build the webhook for truth. Your future support inbox will be quieter.
Top comments (0)