Skip to Content

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 referenceId and resultCode values, concatenated with no spaces (referenceId + resultCode).
  • HMAC key - The paymentMethodId (for example pmt_vrt_...) used to create the hosted payment form session.
  • Output format - Lowercase hex-encoded HMAC-SHA256 hash.

referenceId

referenceId + resultCode
no separator

resultCode

HMAC-SHA256

Key: the session's paymentMethodId (pmt_vrt_...)

hash query parameter
lowercase hex

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.

Questions about your integration? Contact support@doura.ca.