Skip to main content

Handle the Return

After payment, the buyer is redirected to your successUrl with signed query parameters. Your server verifies the signature and shows a confirmation page.

Signature versions

Von Payments ships two signature formats. The SDK auto-detects which is in use and routes to the right verifier.

VersionFormatBinds
v2sig=v2.<base64url-payload>.<hex-hmac>session, status, amount, currency, transactionId, successUrl, keyMode, iat
v1 (legacy, default)sig=<64-char-hex>session, status, amount, currency, transactionId

v1 is the current default and remains fully supported. v2 is opt-in: when Von Payments emits v2 returns, it prevents redirect lifting (signature replay against a different merchant) and caps signature lifetime with an iat freshness check. Either way, the SDK detects the format from the sig value and verifies it correctly — your verification code does not need to know which version produced a given return.

v2 — Return URL format

https://mystore.com/order/123/confirm
?session=vp_cs_live_k7x9m2n4p3
&status=succeeded
&amount=1499
&currency=USD
&transaction_id=vp_tx_live_abc123
&sig=v2.eyJzaWQiOiJ2cF9jc19saXZlX2s3eDltMm40cDMiLCJzdGF0dXMiOiJzdWNjZWVkZWQiLCJhbW91bnQiOjE0OTksImN1cnJlbmN5IjoiVVNEIiwidHJhbnNhY3Rpb25JZCI6InZwX3R4X2xpdmVfYWJjMTIzIiwic3VjY2Vzc1VybCI6Imh0dHBzOi8vbXlzdG9yZS5jb20vb3JkZXIvMTIzL2NvbmZpcm0iLCJrZXlNb2RlIjoibGl2ZSIsImlhdCI6MTcxMzcxNTIwMH0.4e1f...b2c3d4

The v2 return URL exposes the same query params as v1 — session, status, amount, currency, transaction_id, sig. The extra v2 fields (successUrl, keyMode, iat) are bound inside the signed payload, not added as separate query params.

The sig is three dot-separated parts: literal v2, a base64url-encoded JSON payload, and the hex HMAC over v2.<payload>.

Signed payload:

{
"sid": "vp_cs_live_k7x9m2n4p3",
"status": "succeeded",
"amount": 1499,
"currency": "USD",
"transactionId": "vp_tx_live_abc123",
"successUrl": "https://mystore.com/order/123/confirm",
"keyMode": "live",
"iat": 1713715200
}

Verify with the Node SDK

VonPayCheckout.verifyReturnSignature auto-detects v1 vs v2. For v2, you must pass expectedSuccessUrl and expectedKeyMode — without them, v2 signatures are rejected.

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

const url = new URL(req.url, `https://${req.headers.host}`);
const params = {
session: url.searchParams.get("session"),
status: url.searchParams.get("status"),
amount: url.searchParams.get("amount"),
currency: url.searchParams.get("currency"),
transaction_id: url.searchParams.get("transaction_id"),
sig: url.searchParams.get("sig"),
};

const isValid = VonPayCheckout.verifyReturnSignature(
params,
process.env.VON_PAY_SESSION_SECRET, // ss_test_* or ss_live_*, NOT your API key
{
expectedSuccessUrl: "https://mystore.com/order/123/confirm",
expectedKeyMode: "live", // "live" or "test"
maxAgeSeconds: 600, // optional, default 600 (10 minutes)
},
);

if (!isValid) {
return res.status(400).send("Invalid signature");
}

// Safe to show order confirmation

The secret argument is your session signing secret (ss_test_* or ss_live_*), not your API key. The method returns a boolean: true if the signature is valid, false otherwise. The 400 above is a response code your app chooses — the SDK does not impose one.

Options bag

OptionRequired for v2?DefaultPurpose
expectedSuccessUrlYesThe successUrl you passed to sessions.create. Compared after normalisation (trailing slash stripped, query params sorted, fragment dropped).
expectedKeyModeYes"test" or "live". Prevents a test-mode signature from being accepted as live.
maxAgeSecondsNo600Maximum age of the signature in seconds. A captured redirect older than this is rejected.
rejectV1NofalseWhen true, refuse legacy v1 signatures outright (return false) and accept only replay-safe v2.

For v1 signatures, the v2-specific options (expectedSuccessUrl, expectedKeyMode, maxAgeSeconds) are ignored. The rejectV1 flag, however, is honoured on the v1 path: passing { rejectV1: true } causes an otherwise-valid v1 signature to be refused, so once Von Payments emits v2 returns you can set it to lock out the replayable legacy format.

Verify with the Python SDK

from vonpay.checkout import VonPayCheckout

params = {
"session": request.args["session"],
"status": request.args["status"],
"amount": request.args["amount"],
"currency": request.args["currency"],
"transaction_id": request.args["transaction_id"],
"sig": request.args["sig"],
}

is_valid = VonPayCheckout.verify_return_signature(
params,
os.environ["VON_PAY_SESSION_SECRET"], # session signing secret, NOT your API key
expected_success_url="https://mystore.com/order/123/confirm",
expected_key_mode="live",
max_age_seconds=600,
)

if not is_valid:
abort(400, "Invalid signature")

verify_return_signature is a static method. params (a dict) and secret are positional; expected_success_url, expected_key_mode, and max_age_seconds (default 600) are keyword-only. It returns a boolean.

Manual verification (any language)

If you're not using an SDK, here's the algorithm for both versions.

v2

parts = sig.split(".")
# parts = ["v2", <base64url-payload>, <hex-hmac>]

signed_input = "v2." + parts[1]
expected_hmac = HMAC-SHA256(key=VON_PAY_SESSION_SECRET, data=signed_input).hex()
constant_time_compare(parts[2], expected_hmac) # reject on mismatch

payload = JSON.parse(base64url_decode(parts[1]))

# Cross-check every bound field:
assert payload.sid == session
assert payload.status == status
assert str(payload.amount) == amount
assert payload.currency == currency
assert str(payload.transactionId) == (transaction_id or "")
assert payload.successUrl == normalise(expected_success_url)
assert payload.keyMode == expected_key_mode
assert now_sec - payload.iat <= 600

normalise(url) = origin + path (trailing slash stripped unless root) + query params sorted alphabetically, fragment dropped.

Python example:

import base64, hmac, hashlib, json, time
from urllib.parse import urlparse, urlencode, parse_qsl

def normalise_success_url(raw):
u = urlparse(raw)
qs = urlencode(sorted(parse_qsl(u.query)))
path = u.path[:-1] if (u.path.endswith("/") and u.path != "/") else u.path
return f"{u.scheme}://{u.netloc}{path}" + (f"?{qs}" if qs else "")

def verify_v2(sig, session, status, amount, currency, transaction_id,
secret, expected_success_url, expected_key_mode, max_age=600):
parts = sig.split(".")
if len(parts) != 3 or parts[0] != "v2":
return False
signed = f"v2.{parts[1]}"
expected = hmac.new(secret.encode(), signed.encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(parts[2], expected):
return False
pad = parts[1] + "=" * ((4 - len(parts[1]) % 4) % 4)
payload = json.loads(base64.urlsafe_b64decode(pad))
if payload["sid"] != session: return False
if payload["status"] != status: return False
if str(payload["amount"]) != amount: return False
if payload["currency"] != currency: return False
if str(payload.get("transactionId", "")) != (transaction_id or ""): return False
if payload["successUrl"] != normalise_success_url(expected_success_url): return False
if payload["keyMode"] != expected_key_mode: return False
now = int(time.time())
if now - payload["iat"] > max_age: return False
if payload["iat"] > now + 60: return False # future-skew tolerance (matches SDK)
return True

The normalise step strips exactly one trailing slash (not multiple) unless the path is the root /. Use a single-character slice, not a greedy rstrip("/").

v1 (legacy)

data = session + "." + status + "." + amount + "." + currency + "." + (transaction_id or "")
expected = HMAC-SHA256(key=VON_PAY_SESSION_SECRET, data=data).hex()
constant_time_compare(sig, expected)

The SDK routes to v1 automatically when the sig query param is 64 hex characters (no v2. prefix).

Why v2

v1 signatures do not bind the successUrl, the key mode, or a timestamp. A signed redirect for merchant A's success page could in principle be lifted (from browser history, referrer logs, or a compromised redirect proxy) and replayed against merchant B's success endpoint. v2 closes those gaps:

  • successUrl binding stops cross-merchant replay
  • keyMode binding stops test-mode sigs from being accepted as live
  • iat freshness caps the useful life of a captured redirect to 10 minutes

After verification

  1. Mark the order as paid in your system
  2. Show a confirmation page to the buyer
  3. Send an order confirmation email

Because a captured v1 return URL verifies forever and can be replayed, v1 verification is not sufficient on its own to authorize fulfilment. Before acting on a return, do two server-side checks:

  1. Confirm the payment completedawait client.sessions.get(session) and check status === "succeeded".
  2. Guard your own idempotencysessions.get reports payment status, not whether you have already fulfilled this order, and it keeps returning succeeded on a replay. Record which session IDs you have fulfilled (for example, a UNIQUE column on the order row) and refuse to fulfil one twice.

Prefer v2 (pass expectedSuccessUrl + expectedKeyMode), which is freshness- and URL-bound, or pass { rejectV1: true } to refuse v1 outright once Von Payments emits v2 returns.