Skip to main content

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.

The return page is never the source of truth

Three things are true of every payment redirect, and each one costs money if you assume otherwise.

  1. Arriving at your success page is not proof of payment. The buyer reaches it after the attempt, whatever the outcome.
  2. The return may never happen at all. Buyers close the tab, lose signal, or hit back. The payment still went through.
  3. 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 — Hand the return parameters to the SDK​

The redirect carries a session ID, and today some display values beside it:

https://mystore.com/order/123/confirm?session=vp_cs_live_k7x9m2n4p3&status=…&amount=…&sig=…

Pass the whole query object through. You do not need to pick fields out of it, and you should not branch on any of them — see Do not trust the URL. The display values (status, amount, currency, transaction_id, sig) are deprecated and stop being sent on or after 2026-10-13; session is the only parameter to rely on, and it is the only one the confirmation below reads.

Step 2 — Confirm with one call​

confirmReturn verifies the return and reads the session status from the server, reporting paid only when the server agrees. It needs your secret key, so this is server-side only.

import { VonPayCheckout } from "@vonpay/checkout-node";

const client = new VonPayCheckout(process.env.VON_PAY_SECRET_KEY);

const params = Object.fromEntries(
new URL(req.url, `https://${req.headers.host}`).searchParams,
);

const outcome = await client.sessions.confirmReturn(params);

if (outcome.paid) {
// Show the confirmation page.
}
from vonpay.checkout import VonPayCheckout

client = VonPayCheckout(os.environ["VON_PAY_SECRET_KEY"])

outcome = client.sessions.confirm_return(dict(request.args))

if outcome.paid:
...
Do not pass a signing secret here

confirmReturn takes an optional second argument for the secret the redirect was signed with. Leave it out.

Returns are signed with a platform-wide secret, not one issued to you — a per-merchant ss_* (older accounts only; no longer issued) can never match it. Passing one buys a check that fails every time.

Your session lookup is already authenticated by your API key, and that is what answers "did this buyer pay".

Reading the outcome​

FieldWhat it tells you
paidThe field to branch on. The server reports this session succeeded.
statusThe authoritative status read from the server — not the one in the URL.
reasonPresent only when paid is false. Which of three different situations you are in.
amountThe amount as the server reports it. Render this, never the one from the URL.
signatureValidWhether the redirect's signature was checked, and if so whether it passed.

signatureValid: null is the normal, healthy value. It means no check was performed because you supplied no secret — which is the recommended integration. It is three states on purpose: false means checked and failed, null means not checked. Do not treat null as a problem, and do not gate your confirmation page on this field at all.

When paid is false, reason says what to do​

reasonWhat happenedWhat to show
still_pendingThe charge is still resolving — this is not a failure.A neutral "confirming your payment" page. HTTP 200, no retry button.
not_succeededThe payment reached a final state and did not succeed.A failure page, with the option to try again.
missing_sessionThe URL carried no session ID, so there was nothing to look up.Treat as a broken link, not a payment outcome.

On the 3-D Secure path the buyer is routinely returned to your success URL before the payment settles, so still_pending is the ordinary case there, not a failure: show the neutral confirming page from the table above, never a retry button, or the buyer pays again on a card that is already settling. Only failed and expired count as final. Anything else, including a status this SDK version has never heard of, reports as still_pending.

On a captureMethod: "manual" session, do not offer a retry on not_succeeded without reading the payment intent first. A session and a hold have different lifetimes: the session expires 30 minutes after you created it, while the hold lasts until you capture or void it. So a session that reads expired does not mean nothing was held. Read GET /v1/payment_intents/{id} for the paymentIntentId you stored at submit: authorized means the money is held and yours to capture, and showing that buyer a retry leaves a second hold on their card.

A lookup that fails is not a buyer who did not pay​

If the session lookup itself fails, confirmReturn throws — it does not return paid: false. Retry or show a neutral page, never a failure. Catch broadly. API errors arrive as VonPayError, but a network failure surfaces as the underlying transport error, so narrowing your catch to VonPayError lets a connection blip escape and return a 500 to a buyer who just paid.

try {
const outcome = await client.sessions.confirmReturn(params);
return outcome.paid ? renderSuccess(outcome) : renderPending(outcome);
} catch {
// We could not reach the answer — that is not the same as "they did not pay".
return renderPending();
}

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 that confirmation to feel instant, 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.

paid: true tells you the session completed: the payment was taken, or on a captureMethod: "manual" session, authorised and held. 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" });
}

Claim first, do the work, then mark it finished — a single "handled" row inserted before the work means a failure after the insert is never retried, and the buyer never gets what they paid for. 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 paid from the outcome, as in step 2.
  • Never render the amount from the URL. Anyone can complete a real one-unit payment, edit amount in their own return URL, and screenshot a receipt for any figure they like. Render outcome.amount, which comes from the server.
  • A signature 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.

If your integration calls verifyReturnSignature (or verify_return_signature) and shows a confirmation when it returns true, replace that call with confirmReturn as in step 2, and drop the secret argument. That function stops working on or after 2026-10-13: the redirect stops carrying the parameters it checks, so from then on it refuses every return and a fulfilment path that depends on it breaks. The SDKs log a warning the first time it is called. Signature verification belongs on webhooks — Verifying webhooks.

What's next​