> For the complete documentation index, see [llms.txt](https://docs.omni.integratedcommerce.io/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.omni.integratedcommerce.io/hosted-payment-form/redirect-verification.md).

# 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.

{% hint style="danger" %}
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.
{% endhint %}

## 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.

```mermaid
flowchart LR
    R["referenceId"] --> C["referenceId + resultCode<br/>no separator"]
    RC["resultCode"] --> C
    C --> H["HMAC-SHA256"]
    K["Key: the session's paymentMethodId (pmt_vrt_...)"] --> H
    H --> O["hash query parameter<br/>lowercase hex"]
```

{% hint style="warning" %}
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.
{% endhint %}

## Verification steps

{% stepper %}
{% step %}

## Extract the values

Take the `hash`, `referenceId`, and `resultCode` parameter values from the returned URL.
{% endstep %}

{% step %}

## Concatenate the input

Join `referenceId` and `resultCode` with no spaces: `referenceId + resultCode`.
{% endstep %}

{% step %}

## 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.
{% endstep %}

{% step %}

## Compare

Compare your generated hash with the received `hash` parameter. Only accept the redirect if the hashes match exactly.
{% endstep %}
{% endstepper %}

## 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

{% tabs %}
{% tab title="Node.js" %}

```javascript
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);
}
```

{% endtab %}

{% tab title="C#" %}

```csharp
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));
}
```

{% endtab %}

{% tab title="Python" %}

```python
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 "")
```

{% endtab %}
{% endtabs %}

{% hint style="info" %}
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.
{% endhint %}

## This is not the same as webhook verification

The redirect hash and the two webhook signature schemes - the [legacy checksum](/webhooks/legacy/verifying-the-checksum.md) and the [Svix signature](/webhooks/svix/verifying-signatures.md) - 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](/webhooks/webhooks.md#two-delivery-methods): legacy unless Fiska has enabled Svix for your account.

{% hint style="warning" %}
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](/webhooks/webhooks.md) payload or [`GET /transactions`](/payments/transaction-retrieval.md) before releasing goods.
{% endhint %}
