Verifying the redirect
Validate the HMAC-SHA256 hash on a redirect before you trust its parameters.
Server-generated returnUrl redirects include an HMAC-SHA256 hash. The hash covers only referenceId and resultCode. Other query parameters are not authenticated by it.
Verify this hash when it is present, but confirm payment through an authenticated API response or a verified webhook before fulfillment. The HMAC key is a payment method ID, which can appear in browser-visible data; it is not a private signing secret. Client-side error redirects can omit the hash.
How the hash is built
Server-generated redirects to your return URL include transaction parameters and a lowercase hex-encoded HMAC-SHA256 value in the hash query parameter.
- Hash input - The
referenceIdandresultCodevalues, concatenated with no spaces (referenceId + resultCode). - HMAC key - The
paymentMethodId(for examplepmt_vrt_...) used to create the hosted payment form session. - Output format - Lowercase hex-encoded HMAC-SHA256 hash.
The key here is the virtual terminal paymentMethodId you used to create the session (pmt_vrt_...) - not the pmt_tkn_... token that comes back in the redirect parameters. Mixing these up is the most common cause of a verification that never matches.
Verification steps
Extract the values
Take the hash, referenceId, and resultCode parameter values from the returned URL.
Concatenate the input
Join referenceId and resultCode with no spaces: referenceId + resultCode.
Compute the HMAC
Use the original paymentMethodId used when generating the hosted payment form session (the pmt_vrt_... value) as the HMAC key. Generate the HMAC-SHA256 hash and encode the result as a lowercase hex string.
Compare
Compare your generated hash with the received hash parameter. Only accept the redirect if the hashes match exactly.
Worked example
A payment method with ID pmt_vrt_01JTNZ2Z2XBNMGMG9ENY41AKRG was used to create a hosted payment page session, and the customer was redirected to:
https://merchant.example.com/payment-complete
?success=true
&paymentMethodId=pmt_tkn_01JVGD11ZW2N8859Z9RSJKBPDB
&resultCode=0
&cardType=VISA
&transactionId=trx_01JVGD0BD3D6SRW6A941CPWCJ5
&referenceId=ref_238832ae
&amount=1899
&invoiceNumber=inv_7d7ab39d
&orderNumber=ord_cead58aa
&hash=7795ad43353004b2d8434af7ff5253d597ca290492eec43c714460a0d7ecdbce
Extract the referenceId and resultCode values:
referenceId = ref_238832ae
resultCode = 0
Concatenate the values into a single string:
ref_238832ae0
Use the payment method ID that created the session as the HMAC key:
pmt_vrt_01JTNZ2Z2XBNMGMG9ENY41AKRG
Generate the HMAC-SHA256 hash and encode the result as a lowercase hex string:
7795ad43353004b2d8434af7ff5253d597ca290492eec43c714460a0d7ecdbce
The hash value from the query string must match this value.
Implementation
const crypto = require("crypto");
function verifyRedirect({ referenceId, resultCode, hash }, sessionPaymentMethodId) {
const expected = crypto
.createHmac("sha256", sessionPaymentMethodId)
.update(`${referenceId}${resultCode}`)
.digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(hash ?? "", "utf8");
// Length check first: timingSafeEqual throws on a length mismatch.
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
using System.Security.Cryptography;
using System.Text;
static bool VerifyRedirect(string referenceId, string resultCode, string hash,
string sessionPaymentMethodId)
{
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(sessionPaymentMethodId));
var computed = Convert.ToHexString(
hmac.ComputeHash(Encoding.UTF8.GetBytes(referenceId + resultCode)))
.ToLowerInvariant();
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(computed),
Encoding.UTF8.GetBytes(hash ?? string.Empty));
}
import hmac
import hashlib
def verify_redirect(reference_id, result_code, received_hash, session_payment_method_id):
expected = hmac.new(
session_payment_method_id.encode("utf-8"),
f"{reference_id}{result_code}".encode("utf-8"),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, received_hash or "")
Compare with a constant-time function - crypto.timingSafeEqual, CryptographicOperations.FixedTimeEquals, or hmac.compare_digest - rather than ==. A plain string comparison returns early on the first differing byte, which leaks how much of a guessed hash was correct.
This is not the same as webhook verification
The redirect hash and the two webhook signature schemes - the legacy checksum and the Svix signature - are three separate mechanisms with different inputs, different secrets, and different transports. Verifying one does not verify another, and the code is not interchangeable.
| Redirect hash | Legacy webhook | Svix webhook (invite only) | |
|---|---|---|---|
| Input | referenceId + resultCode |
Raw request body | {svix-id}.{svix-timestamp}.{raw request body} |
| Secret | The session's paymentMethodId |
Shared secret provided out-of-band | The endpoint's signing secret (whsec_...) |
| Encoding | Lowercase hex | Lowercase hex | Base64 |
| Location | hash query string parameter |
x-fsk-wh-chksm header |
svix-signature header |
| Replay protection | None - see below | None | Timestamp tolerance, enforced by the Svix libraries |
Which webhook scheme applies to you is explained in Two delivery methods: legacy unless Svix is enabled for your account.
The redirect hash covers only referenceId and resultCode. It does not cover amount, transactionId, or any other parameter, and it carries no timestamp, so the same signed redirect stays valid indefinitely. The key is a payment method identifier that can also appear in browser-visible payloads, so the hash alone is not proof of payment. Confirm the amount and the transaction against the webhook payload or GET /transactions before releasing goods.