# Request signing

> Learn how Onerway authenticates API requests with request signatures, how to generate the sign field, and how to verify webhook notifications.

Onerway authenticates API requests using the `sign` field. Generate the `sign` dynamically on your server using the [environment-specific `SECRET`](/payments/get-started/setup#retrieve-your-credentials). **Never** expose the `SECRET` in frontend code, mobile applications, or public repositories.

Before sending server-side API requests, you must also [configure the IP allowlist](/payments/get-started/setup#configure-your-ip-allowlist) for the target environment. Configure sandbox and production allowlists separately.

## Signing rules

Generate the `sign` dynamically using the following rules:

1. Exclude the `sign` field.
2. Exclude fields with `null`, `undefined`, or empty string values.
3. Serialize object (Object) and array (Array) values into compact JSON strings (without extra spaces or line breaks); see [Object and array fields](#object-and-array-fields) for nested structures.
4. Sort field names in ascending ASCII order.
5. Concatenate only the sorted field values directly. Do not include field names, equals signs (`=`), ampersands (`&`), or any other separators. The resulting string is called the **Canonical String**.
6. Append the `SECRET` to the end of the Canonical String.
7. Compute the `SHA-256` hash of the final string and output the result as a lowercase hexadecimal string.

Non-string scalars are concatenated as strings: booleans become `true` / `false`; numbers use their decimal string form without trailing zeros or scientific notation (for example `1.10` is concatenated as `1.1`).

## Object and array fields

Object and array fields must be serialized into compact JSON strings:

```json
{
  "billingInformation": {
    "country": "US",
    "email": "customer@test.com"
  }
}
```

After conversion, the value used for signing and sending is:

```json
{
  "billingInformation": "{\"country\":\"US\",\"email\":\"customer@test.com\"}"
}
```

Nested values are serialized from the inside out. For example, `txnOrderMsg.products` is an array, so it becomes a JSON string inside the `txnOrderMsg` string:

```json
{
  "txnOrderMsg": "{\"appId\":\"replace_with_app_id\",\"products\":\"[{\\\"currency\\\":\\\"USD\\\",\\\"name\\\":\\\"test product\\\",\\\"num\\\":\\\"1\\\",\\\"price\\\":\\\"1\\\"}]\",\"returnUrl\":\"https://developers.onerway.com/example-return\"}"
}
```

## Request example

The original request can retain object structures with an empty `sign` field:

```json
{
  "merchantNo": "replace_with_merchant_no",
  "merchantTxnId": "replace_with_unique_transaction_id",
  "merchantTxnTime": "2026-04-24 15:37:39",
  "orderAmount": "1",
  "orderCurrency": "USD",
  "billingInformation": {
    "country": "US",
    "email": "customer@test.com",
    "province": "CA"
  },
  "txnOrderMsg": {
    "appId": "replace_with_app_id",
    "products": [
      {
        "currency": "USD",
        "name": "test product",
        "num": "1",
        "price": "1"
      }
    ],
    "returnUrl": "https://developers.onerway.com/example-return"
  },
  "productType": "CARD",
  "subProductType": "DIRECT",
  "txnType": "SALE",
  "sign": null
}
```

Before sending, convert object values to strings and fill in the calculated `sign`:

```json
{
  "billingInformation": "{\"country\":\"US\",\"email\":\"customer@test.com\",\"province\":\"CA\"}",
  "merchantNo": "replace_with_merchant_no",
  "merchantTxnId": "replace_with_unique_transaction_id",
  "merchantTxnTime": "2026-04-24 15:37:39",
  "orderAmount": "1",
  "orderCurrency": "USD",
  "productType": "CARD",
  "sign": "replace_with_calculated_signature",
  "subProductType": "DIRECT",
  "txnOrderMsg": "{\"appId\":\"replace_with_app_id\",\"products\":\"[{\\\"currency\\\":\\\"USD\\\",\\\"name\\\":\\\"test product\\\",\\\"num\\\":\\\"1\\\",\\\"price\\\":\\\"1\\\"}]\",\"returnUrl\":\"https://developers.onerway.com/example-return\"}",
  "txnType": "SALE"
}
```

You may sort the final request body by field name for easier troubleshooting. Signature validity strictly depends on the sorting and concatenation rules, not on the displayed body order.

## Worked example

Use this minimal request with the example secret `example_secret` to check your implementation:

```json
{
  "merchantNo": "demo_merchant_no",
  "merchantTxnId": "demo_txn_id_001",
  "orderAmount": "1",
  "orderCurrency": "USD",
  "sign": null
}
```

```text
Canonical String: demo_merchant_nodemo_txn_id_0011USD
String to hash:   demo_merchant_nodemo_txn_id_0011USDexample_secret
sign:             8af506acd9322fcab9d676dd9b861512f194d9ab724eacdb358d90d087d9caac
```

## Code examples

The examples below assume the input is a server-side map (Map) or object (Object); if a field is already a JSON string, ensure it has been serialized with the same inside-out rule. Use the `normalizeValue` result for both signing and the final request body; do not rebuild object or array fields with a different serializer after calculating the signature — this causes verification failures on the gateway.

<code-collapse name="code examples">
<code-group sync="signing-lang">

```php [PHP]
<?php

function is_assoc_array(array $value): bool {
    if ($value === []) {
        return false;
    }
    return array_keys($value) !== range(0, count($value) - 1);
}

function normalize_nested($value) {
    if (!is_array($value)) {
        return $value;
    }

    if (!is_assoc_array($value)) {
        return array_map('normalize_nested', $value);
    }

    $result = [];
    foreach ($value as $key => $child) {
        $result[$key] = is_array($child)
            ? json_encode(normalize_nested($child), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
            : $child;
    }
    return $result;
}

function normalize_value($value) {
    if ($value === null || $value === '') {
        return $value;
    }
    if (is_bool($value)) {
        return $value ? 'true' : 'false';
    }
    return is_array($value)
        ? json_encode(normalize_nested($value), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
        : (string) $value;
}

function generate_signature(array $payload, string $secret): string {
    $normalized = [];
    foreach ($payload as $key => $value) {
        if ($key !== 'sign') {
            $normalized[$key] = normalize_value($value);
        }
    }

    ksort($normalized, SORT_STRING);

    $canonical = '';
    foreach ($normalized as $value) {
        if ($value !== null && $value !== '') {
            $canonical .= $value;
        }
    }

    return hash('sha256', $canonical . $secret);
}
```

```java [Java]
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;

public final class OnerwaySign {
    private static final ObjectMapper JSON = new ObjectMapper();

    private static Object normalizeNested(Object value) throws JsonProcessingException {
        if (value instanceof List<?> list) {
            List<Object> result = new ArrayList<>();
            for (Object item : list) {
                result.add(normalizeNested(item));
            }
            return result;
        }

        if (value instanceof Map<?, ?> map) {
            TreeMap<String, Object> result = new TreeMap<>();
            for (Map.Entry<?, ?> entry : map.entrySet()) {
                Object child = entry.getValue();
                result.put(
                    String.valueOf(entry.getKey()),
                    child instanceof Map<?, ?> || child instanceof List<?>
                        ? JSON.writeValueAsString(normalizeNested(child))
                        : child
                );
            }
            return result;
        }

        return value;
    }

    private static String normalizeValue(Object value) throws JsonProcessingException {
        if (value == null) {
            return null;
        }
        if (value instanceof String text) {
            return text.isEmpty() ? null : text;
        }
        if (value instanceof Map<?, ?> || value instanceof List<?>) {
            return JSON.writeValueAsString(normalizeNested(value));
        }
        return String.valueOf(value);
    }

    public static String generateSignature(Map<String, Object> payload, String secret) throws Exception {
        TreeMap<String, String> normalized = new TreeMap<>();
        for (Map.Entry<String, Object> entry : payload.entrySet()) {
            if (!"sign".equals(entry.getKey())) {
                normalized.put(entry.getKey(), normalizeValue(entry.getValue()));
            }
        }

        StringBuilder canonical = new StringBuilder();
        for (String value : normalized.values()) {
            if (value != null && !value.isEmpty()) {
                canonical.append(value);
            }
        }

        MessageDigest digest = MessageDigest.getInstance("SHA-256");
        byte[] hash = digest.digest((canonical + secret).getBytes(StandardCharsets.UTF_8));

        StringBuilder hex = new StringBuilder();
        for (byte b : hash) {
            hex.append(String.format("%02x", b));
        }
        return hex.toString();
    }
}
```

```go [Go]
package signing

import (
    "crypto/sha256"
    "encoding/hex"
    "encoding/json"
    "fmt"
    "sort"
)

func normalizeNested(value any) any {
    switch typed := value.(type) {
    case []any:
        result := make([]any, len(typed))
        for i, item := range typed {
            result[i] = normalizeNested(item)
        }
        return result
    case map[string]any:
        result := map[string]any{}
        for key, child := range typed {
            switch child.(type) {
            case map[string]any, []any:
                bytes, _ := json.Marshal(normalizeNested(child))
                result[key] = string(bytes)
            default:
                result[key] = child
            }
        }
        return result
    default:
        return value
    }
}

func normalizeValue(value any) *string {
    if value == nil {
        return nil
    }

    switch typed := value.(type) {
    case string:
        if typed == "" {
            return nil
        }
        return &typed
    case map[string]any, []any:
        bytes, _ := json.Marshal(normalizeNested(value))
        text := string(bytes)
        return &text
    default:
        text := fmt.Sprint(typed)
        return &text
    }
}

func GenerateSignature(payload map[string]any, secret string) string {
    keys := make([]string, 0, len(payload))
    normalized := map[string]*string{}

    for key, value := range payload {
        if key == "sign" {
            continue
        }
        normalized[key] = normalizeValue(value)
        keys = append(keys, key)
    }

    sort.Strings(keys)

    canonical := ""
    for _, key := range keys {
        if value := normalized[key]; value != nil {
            canonical += *value
        }
    }

    sum := sha256.Sum256([]byte(canonical + secret))
    return hex.EncodeToString(sum[:])
}
```

```js [JavaScript]
import { createHash } from 'node:crypto'

function isPlainObject(value) {
  return Object.prototype.toString.call(value) === '[object Object]'
}

function normalizeNested(value) {
  if (Array.isArray(value)) {
    return value.map((item) => normalizeNested(item))
  }

  if (isPlainObject(value)) {
    const result = {}
    Object.keys(value).forEach((key) => {
      const child = value[key]
      result[key] = child !== null && typeof child === 'object'
        ? JSON.stringify(normalizeNested(child))
        : child
    })
    return result
  }

  return value
}

function normalizeValue(value) {
  if (value === null || value === undefined || value === '') {
    return value
  }
  if (typeof value === 'object') {
    return JSON.stringify(normalizeNested(value))
  }
  return String(value)
}

export function generateSignature(payload, secret) {
  const canonical = Object.keys(payload)
    .filter((key) => key !== 'sign')
    .sort()
    .map((key) => normalizeValue(payload[key]))
    .filter((value) => value !== null && value !== undefined && value !== '')
    .join('')

  return createHash('sha256')
    .update(canonical + secret, 'utf8')
    .digest('hex')
}
```

</code-group>
</code-collapse>

## Webhook signature verification

Profit share, reversal, and Ethoca / RDR enrollment status notifications use the body `sign` field for verification. Other notifications use the `X-Rh-Signature` header described below. See each webhook’s API Reference for the fields that participate in its signature.

### Profit share and reversal notifications

Use the `SECRET` for the current environment to compute the received notification’s signature following the [signing rules](#signing-rules), then compare it with the body `sign`. Exclude `sign` itself. Include `relatedTxnId` and `relatedMerchantTxnId`, excluding them when their value is `null` as specified by the signing rules. Use the raw `receivers` JSON string exactly as received; do not parse and re-serialize it. See the [Profit share result webhook](/payments/api-reference/webhooks/profit-share-result) for the complete payload.

The `/profit/query` response also returns `data.sign`, but merchants do not need to verify query response signatures.

### Ethoca / RDR enrollment status notifications

Use the `SECRET` for the current environment to compute the received notification’s signature following the [signing rules](#signing-rules), then compare it with the body `sign`. Exclude `sign` itself; all other fields follow the general signing rules, including the exclusion of `null` and empty-string values.

The notification sends `id` as a JSON number, which can exceed JavaScript’s safe integer range. Parse the payload with a parser that preserves large integers without losing precision, and retain the original decimal value for signature verification and acknowledgement. Do not first convert it to a JavaScript `number` that may lose precision.

See the [Ethoca enrollment status webhook](/payments/api-reference/webhooks/ethoca-enrollment-changed) and [RDR enrollment status webhook](/payments/api-reference/webhooks/rdr-enrollment-changed) for the complete payloads and acknowledgement requirements. Ethoca alert notifications continue to use the header verification described below.

### Other notifications

Onerway carries webhook signatures in the `X-Rh-Signature` notification header: one or more comma-separated `v1=<signature>` entries, where `v1` identifies the signature scheme version. When multiple keys are active at the same time (for example during key rotation), each active key produces one entry:

```text
X-Rh-Signature: v1=4bde57c3350c402ca8c3728697cd5d7dbd78c8f0313761607e7f538be3349c39, v1=6b543c2e887e20a98dd4629242dd60050a4cfdda3372cdc17b4422c8fe98d5fa
```

To verify, compute the signature of the received notification with your currently configured `SECRET` following the [signing rules](#signing-rules); verification passes if the result matches any entry in `X-Rh-Signature`. Field selection and concatenation follow the same rules, with two differences:

- Also exclude the fields marked as excluded from the signature in the webhook's API Reference ["Signature coverage"](/payments/api-reference/webhooks/payment-result) section, **even when the notification returns values for them**. Notification fields added in the future participate by default.
- Object and array fields arrive as compact JSON strings; use the raw string values exactly as received — do not re-parse and re-serialize them.

The notification body still returns a `sign` field, but it is computed with the first active key only and may differ from the signature you calculate using your configured `SECRET` during key rotation. For these notifications, verify against `X-Rh-Signature` rather than the body `sign`. For notifications that use the body `sign`, follow the corresponding section above.

## Common mistakes

- Failing to serialize object (Object) or array (Array) fields into compact JSON strings.
- Leaving `txnOrderMsg.products` as an array object instead of a JSON string inside the `txnOrderMsg` string.
- Sending fields that are not defined for the endpoint: the gateway computes the signature over the fields defined by the API, so extra fields enter only your calculation and cause a mismatch; field structures that deviate from the documentation fail the same way.
- Environment mismatch, such as using a sandbox `merchantNo` / `SECRET` with the production API, or production credentials with the sandbox API.

## When you need support

When request signing fails, print and provide the following server-side information:

- The final concatenated string before signature calculation (Canonical String).
- The generated `sign`.
- The request environment: sandbox or production.
- The API endpoint and full request body. You may mask sensitive information, but do not modify the values of the fields participating in the signature.

When webhook verification fails, provide the full notification payload as received, the body `sign` or `X-Rh-Signature` specified by the webhook’s API Reference, and the Canonical String and signature you computed.
