Request signing
Onerway authenticates API requests using the sign field. Generate the sign dynamically on your server using the environment-specific SECRET. 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 for the target environment. Configure sandbox and production allowlists separately.
Signing rules
Generate the sign dynamically using the following rules:
- Exclude the
signfield. - Exclude fields with
null,undefined, or empty string values. - Serialize object (Object) and array (Array) values into compact JSON strings (without extra spaces or line breaks); see Object and array fields for nested structures.
- Sort field names in ascending ASCII order.
- 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. - Append the
SECRETto the end of the Canonical String. - Compute the
SHA-256hash 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:
{
"billingInformation": {
"country": "US",
"email": "customer@test.com"
}
}
After conversion, the value used for signing and sending is:
{
"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:
{
"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:
{
"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:
{
"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:
{
"merchantNo": "demo_merchant_no",
"merchantTxnId": "demo_txn_id_001",
"orderAmount": "1",
"orderCurrency": "USD",
"sign": null
}
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.
<?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);
}
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();
}
}
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[:])
}
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')
}
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, 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 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, 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 and RDR enrollment status webhook 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:
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; 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" 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.productsas an array object instead of a JSON string inside thetxnOrderMsgstring. - 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/SECRETwith 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.
Setup
Activate a sandbox account, obtain API credentials, configure your IP allowlist, and send your first test transaction before going live.
Webhooks
Receive and verify Onerway payment notifications, acknowledge and deduplicate them correctly, and fall back to query APIs when no notification arrives.