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.
| Version | Format | Binds |
|---|---|---|
| v2 | sig=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
¤cy=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
| Option | Required for v2? | Default | Purpose |
|---|---|---|---|
expectedSuccessUrl | Yes | — | The successUrl you passed to sessions.create. Compared after normalisation (trailing slash stripped, query params sorted, fragment dropped). |
expectedKeyMode | Yes | — | "test" or "live". Prevents a test-mode signature from being accepted as live. |
maxAgeSeconds | No | 600 | Maximum age of the signature in seconds. A captured redirect older than this is rejected. |
rejectV1 | No | false | When 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 greedyrstrip("/").
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:
successUrlbinding stops cross-merchant replaykeyModebinding stops test-mode sigs from being accepted as liveiatfreshness caps the useful life of a captured redirect to 10 minutes
After verification
- Mark the order as paid in your system
- Show a confirmation page to the buyer
- 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:
- Confirm the payment completed —
await client.sessions.get(session)and checkstatus === "succeeded". - Guard your own idempotency —
sessions.getreports payment status, not whether you have already fulfilled this order, and it keeps returningsucceededon a replay. Record which session IDs you have fulfilled (for example, aUNIQUEcolumn 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.