Handle the Return
After payment the buyer is redirected to your successUrl. That redirect tells you the buyer came back. It does not tell you they paid.
Confirm the payment on your server, and fulfil the order from the webhook.
Three things are true of every payment redirect, and each one costs money if you assume otherwise.
- Arriving at your success page is not proof of payment. The buyer reaches it after the attempt, whatever the outcome.
- The return may never happen at all. Buyers close the tab, lose signal, or hit back. The payment still went through.
- The return can arrive more than once. It is a URL — it can be reloaded, bookmarked, and shared.
Read the payment status from the API, and treat the webhook as the event that authorises fulfilment.
Step 1 — Read the session ID from the URL
The redirect carries your session ID:
https://mystore.com/order/123/confirm?session=vp_cs_live_k7x9m2n4p3
That ID is all you need. Ignore the other query parameters (see Do not trust the URL below).
Step 2 — Confirm the status on your server
Look the session up with your secret key. This is a server-to-server call — never make it from the browser.
import { VonPayCheckout } from "@vonpay/checkout-node";
const client = new VonPayCheckout(process.env.VON_PAY_SECRET_KEY);
const sessionId = new URL(req.url, `https://${req.headers.host}`)
.searchParams.get("session");
const { status } = await client.sessions.get(sessionId);
from vonpay.checkout import VonPayCheckout
client = VonPayCheckout(os.environ["VON_PAY_SECRET_KEY"])
session_id = request.args["session"]
session = client.sessions.get(session_id)
status = session.status
What each status means for your page
| Status | What happened | What to show |
|---|---|---|
succeeded | The payment completed | Your confirmation page |
pending / processing | Still in flight — not a failure | A neutral "confirming your payment" page, HTTP 200, no retry button |
failed | The payment did not complete | A failure page, with the option to try again |
expired | The session timed out before payment | Send them back to checkout |
pending and processing mean the charge is still resolving. On the 3-D Secure path the buyer is routinely returned to your success URL before the payment settles, so this is the ordinary case there, not an edge case.
Show a failure page and the buyer will pay again. Show a neutral "we're confirming your payment" page instead, return HTTP 200, and give them nothing to retry. The webhook will tell you the outcome moments later.
Step 3 — Fulfil from the webhook, not from this page
Subscribe to charge.succeeded and do your fulfilment there — grant access, ship goods, send the receipt.
This is required, not a nicety: a buyer who pays and closes their laptop never loads your return page. The webhook is the only delivery path that does not depend on the browser coming back.
Use the return page for what it is good at — showing the buyer something immediately. If you want the confirmation to feel instant, you can run the same fulfilment routine here too, as long as it is safe to run twice.
Fulfil exactly once
Both the webhook and the return page can fire for the same order, more than once, and possibly at the same moment. Your fulfilment routine must be safe to run repeatedly.
Reading succeeded tells you the payment completed. It does not tell you whether you have already acted on it — the lookup keeps returning succeeded forever.
Record which session IDs you have fulfilled and refuse to fulfil one twice. A UNIQUE constraint on the session ID in your orders table is the simplest version and is enough.
// Safe to call from the webhook AND the return page.
async function fulfil(sessionId: string) {
const { status } = await client.sessions.get(sessionId);
if (status !== "succeeded") return;
// 1. CLAIM the order. UNIQUE(session_id) makes this the concurrency lock:
// whichever caller gets here first wins, the other gets a violation.
try {
await db.orders.insert({ session_id: sessionId, state: "claimed" });
} catch (err) {
if (!isUniqueViolation(err)) throw err;
// Someone already claimed it — but did they FINISH? Only skip if they did.
const existing = await db.orders.findOne({ session_id: sessionId });
if (existing?.state === "fulfilled") return;
// Claimed but not finished (a previous attempt died mid-way) — fall through
// and complete it. This is the case a claim-only guard silently loses.
}
await grantAccessAndSendReceipt(sessionId);
// 2. Only now is it done. A crash before this line leaves state 'claimed',
// so the next delivery retries instead of skipping.
await db.orders.update({ session_id: sessionId }, { state: "fulfilled" });
}
The tempting shortcut is a single insert that means "handled". It isn't safe: if the work after the insert fails — an email provider blip, an access-grant timeout — the row already exists, so every retry sees the unique violation, returns early, and the buyer never gets what they paid for. Nothing errors, so nothing alerts.
Claim first, do the work, then mark it finished. Retries then resume anything left claimed, and grantAccessAndSendReceipt still has to be safe to run twice.
Do not trust the URL
Everything on the return URL arrives via the buyer's browser. Treat all of it as a display hint, never as a fact about money:
- Never read the payment outcome from a query parameter. Read it from the API, as in step 2.
- Never read the amount from the URL. Get it from the session lookup.
- Any signature on the URL is not your confirmation. A signature can only tell you a message was not altered in transit — it cannot tell you the payment succeeded, because a declined payment produces an equally valid one.
verifyReturnSignatureIf your integration calls VonPayCheckout.verifyReturnSignature (or verify_return_signature) and shows a confirmation when it returns true, replace that with the server-side status check in step 2. A signature check answers "was this message altered?", which is a different question from "did the buyer pay?" — and only the second one should gate a confirmation page or an order.
Signature verification does have a place in your integration: it belongs on webhooks, where each of your endpoints has its own signing secret and the message comes to you directly from Von Payments rather than through a browser. See Verifying webhooks.
What's next
- Webhook events — subscribe to
charge.succeeded - Verifying webhooks — where signature checking actually belongs
- Session object — the full shape returned by the lookup
- Reconciliation — catching anything both paths missed