One2Pays

Signature Verification

One2Pays signs webhook deliveries with HMAC-SHA256. Verify the configured signature version, timestamp, and signature before changing business state. A v2 receiver may parse the body and then discard the original bytes, but its JSON parser must enforce the I-JSON rules below.

Security

Treat every webhook as untrusted until signature verification, replay-window checks, schema validation, and atomic idempotency have passed.

Headers

Both versions include X-Webhook-Event, X-Webhook-Id, and Content-Type: application/json. Authentication headers are version-specific:

VersionSignature headerTimestamp headerVersion header
v1X-BroPay-SignatureX-BroPay-TimestampAbsent
v2X-Webhook-SignatureX-Webhook-TimestampX-Webhook-Signature-Version: v2

The v1 names are frozen for backward compatibility. New v2 authentication headers are vendor-neutral. v1 retains its legacy User-Agent: BroPay/1.0; v2 sends User-Agent: Webhook-Delivery/2.0. Do not mix names from different versions or use User-Agent for authentication.

Integration Secret Key

Use the Integration Secret Key (integration API secret) belonging to the integration that owns the endpoint. Endpoint creation does not generate a separate signing secret. Keep this key in a server-side secret manager; never put it in browser code, logs, or webhook responses.

Pin the endpoint's expected version

Configure each receiver with the signature version selected for that endpoint:

  • A v1 receiver must require the version header to be absent and read X-BroPay-Signature plus X-BroPay-Timestamp.
  • A v2 receiver must require X-Webhook-Signature-Version: v2 and read X-Webhook-Signature plus X-Webhook-Timestamp.
  • Reject every mismatch or unsupported value.

Do not select the algorithm solely from an unauthenticated incoming version header. Existing endpoints remain v1 unless they are explicitly configured for v2.

For either version, reject timestamps outside your replay window (for example, five minutes) using a trusted clock. Webhook delivery is at least once: accept the event and record its idempotency key atomically before returning 2xx.

Each v2 HTTP retry has a newly generated timestamp and signature while retaining the same webhook ID and event type. Deduplicate by the authenticated webhook ID, not by the signature.

v1: exact raw-body verification

v1 signs the timestamp, one ASCII dot, and the exact raw HTTP body bytes:

<timestamp>.<rawBody>

Whitespace, escaping, and object-key order are part of the v1 message. Capture the raw body before JSON parsing; re-serializing a parsed object can invalidate the signature. X-Webhook-Id and X-Webhook-Event are not authenticated in v1, so do not use the ID header as the sole key for a money-bearing transition. Derive v1 idempotency from signed payload business identity and enforce a unique, monotonic state transition in your database.

Node.js v1 verification example

import crypto from 'node:crypto';

const REPLAY_WINDOW_MS = 5 * 60 * 1000;

function constantTimeHexEqual(leftHex: string, rightHex: string): boolean {
  if (!/^[0-9a-f]+$/.test(leftHex) || !/^[0-9a-f]+$/.test(rightHex)) return false;
  const left = Buffer.from(leftHex, 'hex');
  const right = Buffer.from(rightHex, 'hex');
  return left.length === right.length && crypto.timingSafeEqual(left, right);
}

function timestampIsFresh(timestamp: string): boolean {
  if (!/^\d+$/.test(timestamp)) return false;
  const timestampMs = Number(timestamp);
  return (
    Number.isSafeInteger(timestampMs) && Math.abs(Date.now() - timestampMs) <= REPLAY_WINDOW_MS
  );
}

function verifyV1(rawBody: Uint8Array, signatureHeader: string, timestamp: string, secret: string) {
  if (!/^sha256=[0-9a-f]{64}$/.test(signatureHeader) || !timestampIsFresh(timestamp)) return false;
  const expected = crypto
    .createHmac('sha256', secret)
    .update(timestamp, 'ascii')
    .update('.', 'ascii')
    .update(rawBody)
    .digest('hex');
  return constantTimeHexEqual(signatureHeader.slice('sha256='.length), expected);
}

export async function POST(request: Request) {
  // Read the exact HTTP entity bytes once. Do not call request.text() before v1 verification.
  const rawBody = new Uint8Array(await request.arrayBuffer());
  const signature = request.headers.get('x-bropay-signature');
  const timestamp = request.headers.get('x-bropay-timestamp');
  const version = request.headers.get('x-webhook-signature-version');

  if (
    version !== null ||
    !signature ||
    !timestamp ||
    !verifyV1(rawBody, signature, timestamp, process.env.INTEGRATION_SECRET_KEY!)
  ) {
    return new Response('Invalid signature or timestamp', { status: 401 });
  }

  let event: unknown;
  try {
    // Decode only after the byte-level signature passes; fatal mode rejects invalid UTF-8.
    const rawText = new TextDecoder('utf-8', { fatal: true }).decode(rawBody);
    event = JSON.parse(rawText);
  } catch {
    return new Response('Invalid UTF-8 JSON', { status: 400 });
  }
  validateEventSchema(event);
  // Insert a unique signed business key and apply the state change in one transaction.
  await acceptV1EventAtomically(event);
  return new Response('OK');
}

v2: full-payload RFC 8785/JCS verification

v2 canonicalizes the entire parsed payload with RFC 8785 JSON Canonicalization Scheme (JCS). It then signs this exact message encoded as UTF-8:

webhook.signature.v2\n<timestamp>\n<X-Webhook-Id>\n<X-Webhook-Event>\n<canonicalPayload>

Each \n above is one ASCII LF byte (0x0a); there is no final LF after the canonical payload. The timestamp contains decimal digits. The ID and event type must be non-empty and must not contain CR or LF. Because JCS escapes control characters inside JSON strings, these separators are unambiguous. The domain prefix authenticates the v2 protocol, and the signature covers the timestamp, delivery ID, event type, and every payload value. Never sign a fixed subset of fields.

Canonicalization contract

  • Input is I-JSON. Reject duplicate member names before constructing an object.
  • Recursively sort raw object keys by unsigned UTF-16 code units; preserve array order.
  • Emit no whitespace and use the JSON literals null, true, and false.
  • Use ECMAScript JSON string escaping and IEEE-754 double number serialization.
  • Encode the canonical result as UTF-8. Preserve code points; do not normalize Unicode.
  • Reject lone surrogates, Unicode noncharacters, negative zero, NaN, Infinity, duplicate names, and any unsupported/non-JSON value. Represent integers requiring precision beyond an IEEE-754 double as strings.

Object key order is semantically unordered in v2. Reordering keys produces the same canonical payload, while changing array order or any value changes the signature.

Node.js v2 verification example

This example uses an RFC 8785 implementation plus a token visitor that rejects every duplicate name, including empty names, equal values, and escaped spellings of the same name:

npm install canonicalize@2 jsonc-parser@3.3.1
import crypto from 'node:crypto';
import canonicalize from 'canonicalize';
import { printParseErrorCode, visit } from 'jsonc-parser';

const REPLAY_WINDOW_MS = 5 * 60 * 1000;
const EXPECTED_SIGNATURE_VERSION = 'v2';

function constantTimeHexEqual(leftHex: string, rightHex: string): boolean {
  if (!/^[0-9a-f]+$/.test(leftHex) || !/^[0-9a-f]+$/.test(rightHex)) return false;
  const left = Buffer.from(leftHex, 'hex');
  const right = Buffer.from(rightHex, 'hex');
  return left.length === right.length && crypto.timingSafeEqual(left, right);
}

function timestampIsFresh(timestamp: string): boolean {
  if (!/^\d+$/.test(timestamp)) return false;
  const timestampMs = Number(timestamp);
  return (
    Number.isSafeInteger(timestampMs) && Math.abs(Date.now() - timestampMs) <= REPLAY_WINDOW_MS
  );
}

function validSignedHeader(value: string): boolean {
  return value.length > 0 && !/[\r\n]/.test(value);
}

function assertValidIJsonString(value: string): void {
  for (const symbol of value) {
    const codePoint = symbol.codePointAt(0)!;
    const isSurrogate = codePoint >= 0xd800 && codePoint <= 0xdfff;
    const isNoncharacterRange = codePoint >= 0xfdd0 && codePoint <= 0xfdef;
    const low16 = codePoint & 0xffff;
    const isPlaneNoncharacter = low16 === 0xfffe || low16 === 0xffff;
    if (isSurrogate || isNoncharacterRange || isPlaneNoncharacter) {
      throw new SyntaxError('JSON contains an invalid Unicode code point');
    }
  }
}

function assertValidIJson(value: unknown): void {
  if (value === null || typeof value === 'boolean') return;
  if (typeof value === 'string') return assertValidIJsonString(value);
  if (typeof value === 'number') {
    if (!Number.isFinite(value) || Object.is(value, -0)) {
      throw new SyntaxError('JSON number is not a supported finite IEEE-754 value');
    }
    return;
  }
  if (Array.isArray(value)) {
    for (const item of value) assertValidIJson(item);
    return;
  }
  if (typeof value !== 'object') throw new SyntaxError('Unsupported JSON value');
  const prototype = Object.getPrototypeOf(value);
  if (prototype !== Object.prototype && prototype !== null) {
    throw new SyntaxError('Unsupported JSON object');
  }
  for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
    assertValidIJsonString(key);
    assertValidIJson(item);
  }
}

function assertNoDuplicateJsonNames(rawBody: string): void {
  const objectNames: Array<Set<string>> = [];
  let parseError: SyntaxError | undefined;

  visit(
    rawBody,
    {
      onObjectBegin() {
        objectNames.push(new Set());
      },
      onObjectProperty(name) {
        const names = objectNames.at(-1)!;
        if (names.has(name))
          throw new SyntaxError(`Duplicate JSON member: ${JSON.stringify(name)}`);
        names.add(name);
      },
      onObjectEnd() {
        objectNames.pop();
      },
      onError(error, offset) {
        parseError ??= new SyntaxError(`${printParseErrorCode(error)} at offset ${offset}`);
      },
    },
    { allowTrailingComma: false, disallowComments: true }
  );

  if (parseError) throw parseError;
}

function parseIJson(rawBody: string): unknown {
  // The visitor reports decoded property names without constructing an object, so equal values,
  // escaped-equivalent names, empty names, and "__proto__" are all handled correctly.
  assertNoDuplicateJsonNames(rawBody);

  // Native JSON.parse preserves valid member names such as "__proto__" as own data properties.
  const payload: unknown = JSON.parse(rawBody);
  assertValidIJson(payload);
  return payload;
}

function verifyV2(
  payload: unknown,
  signatureHeader: string,
  timestamp: string,
  webhookId: string,
  eventType: string,
  secret: string
): boolean {
  if (
    !/^sha256=[0-9a-f]{64}$/.test(signatureHeader) ||
    !timestampIsFresh(timestamp) ||
    !validSignedHeader(webhookId) ||
    !validSignedHeader(eventType)
  ) {
    return false;
  }

  const canonicalPayload = canonicalize(payload);
  if (canonicalPayload === undefined) return false;
  const signatureInput = [
    'webhook.signature.v2',
    timestamp,
    webhookId,
    eventType,
    canonicalPayload,
  ].join('\n');
  const expected = crypto.createHmac('sha256', secret).update(signatureInput, 'utf8').digest('hex');
  return constantTimeHexEqual(signatureHeader.slice('sha256='.length), expected);
}

export async function POST(request: Request) {
  if (request.headers.get('x-webhook-signature-version') !== EXPECTED_SIGNATURE_VERSION) {
    return new Response('Unsupported signature version', { status: 400 });
  }

  const signature = request.headers.get('x-webhook-signature');
  const timestamp = request.headers.get('x-webhook-timestamp');
  const webhookId = request.headers.get('x-webhook-id');
  const eventType = request.headers.get('x-webhook-event');
  if (!signature || !timestamp || !webhookId || !eventType) {
    return new Response('Missing webhook headers', { status: 400 });
  }

  let payload: unknown;
  try {
    // Fatal decoding rejects malformed UTF-8 instead of replacing it with U+FFFD. The text is
    // transient: parse it strictly, then discard it.
    const rawBytes = new Uint8Array(await request.arrayBuffer());
    const rawText = new TextDecoder('utf-8', { fatal: true }).decode(rawBytes);
    payload = parseIJson(rawText);
  } catch {
    return new Response('Invalid I-JSON', { status: 400 });
  }

  if (
    !verifyV2(
      payload,
      signature,
      timestamp,
      webhookId,
      eventType,
      process.env.INTEGRATION_SECRET_KEY!
    )
  ) {
    return new Response('Invalid signature or timestamp', { status: 401 });
  }

  if (
    typeof payload !== 'object' ||
    payload === null ||
    Array.isArray(payload) ||
    (payload as Record<string, unknown>).event !== eventType
  ) {
    return new Response('Event header does not match payload', { status: 400 });
  }

  validateEventSchema(payload);
  // Insert the authenticated webhookId and apply the state change in one transaction.
  // A unique-key conflict must acknowledge the already accepted event without applying it twice.
  await acceptV2EventAtomically(webhookId, payload);
  return new Response('OK');
}

Python v2 verification example

python -m pip install rfc8785==0.1.4

rfc8785.dumps(payload) returns canonical UTF-8 bytes. json.dumps(sort_keys=True) is not JCS.

import hashlib
import hmac
import json
import math
import os
import re
import time
from flask import Flask, request, abort
import rfc8785

app = Flask(__name__)
SECRET = os.environ["INTEGRATION_SECRET_KEY"].encode("utf-8")
EXPECTED_SIGNATURE_VERSION = "v2"
REPLAY_WINDOW_MS = 5 * 60 * 1000

def reject_duplicates(pairs):
    result = {}
    for key, value in pairs:
        if key in result:
            raise ValueError(f"duplicate JSON member: {key!r}")
        result[key] = value
    return result

def reject_constant(token):
    raise ValueError(f"invalid JSON number: {token}")

def parse_ieee754(number_text):
    value = float(number_text)
    if (not math.isfinite(value) or
            (value == 0.0 and math.copysign(1.0, value) < 0)):
        raise ValueError("JSON number is not a supported finite IEEE-754 value")
    return value

def assert_valid_ijson_string(value):
    for symbol in value:
        code_point = ord(symbol)
        low16 = code_point & 0xffff
        if (0xd800 <= code_point <= 0xdfff or
                0xfdd0 <= code_point <= 0xfdef or
                low16 in (0xfffe, 0xffff)):
            raise ValueError("JSON contains an invalid Unicode code point")

def assert_valid_ijson(value):
    if value is None or isinstance(value, bool):
        return
    if isinstance(value, str):
        assert_valid_ijson_string(value)
        return
    if isinstance(value, float):
        if (not math.isfinite(value) or
                (value == 0.0 and math.copysign(1.0, value) < 0)):
            raise ValueError("JSON number is not a supported finite IEEE-754 value")
        return
    if isinstance(value, list):
        for item in value:
            assert_valid_ijson(item)
        return
    if isinstance(value, dict):
        for key, item in value.items():
            assert_valid_ijson_string(key)
            assert_valid_ijson(item)
        return
    raise ValueError("unsupported JSON value")

def parse_ijson(raw_body):
    payload = json.loads(
        raw_body.decode("utf-8", errors="strict"),
        object_pairs_hook=reject_duplicates,
        parse_int=parse_ieee754,
        parse_float=parse_ieee754,
        parse_constant=reject_constant,
    )
    assert_valid_ijson(payload)
    return payload

def valid_signed_header(value):
    return bool(value) and "\r" not in value and "\n" not in value

def verify_v2(payload, signature_header, timestamp, webhook_id, event_type):
    if not re.fullmatch(r"sha256=[0-9a-f]{64}", signature_header or ""):
        return False
    if re.fullmatch(r"[0-9]+", timestamp) is None or not valid_signed_header(webhook_id) or not valid_signed_header(event_type):
        return False
    timestamp_ms = int(timestamp)
    if timestamp_ms > 2**53 - 1 or abs(int(time.time() * 1000) - timestamp_ms) > REPLAY_WINDOW_MS:
        return False

    canonical_payload = rfc8785.dumps(payload)
    signature_input = (
        b"webhook.signature.v2\n"
        + timestamp.encode("ascii") + b"\n"
        + webhook_id.encode("utf-8") + b"\n"
        + event_type.encode("utf-8") + b"\n"
        + canonical_payload
    )
    expected_hex = hmac.new(SECRET, signature_input, hashlib.sha256).hexdigest()
    return hmac.compare_digest(signature_header[len("sha256="):], expected_hex)

@app.post("/webhook")
def webhook():
    if request.headers.get("X-Webhook-Signature-Version") != EXPECTED_SIGNATURE_VERSION:
        abort(400, "unsupported signature version")

    signature = request.headers.get("X-Webhook-Signature")
    timestamp = request.headers.get("X-Webhook-Timestamp")
    webhook_id = request.headers.get("X-Webhook-Id")
    event_type = request.headers.get("X-Webhook-Event")
    if not all((signature, timestamp, webhook_id, event_type)):
        abort(400, "missing webhook headers")

    try:
        payload = parse_ijson(request.get_data(cache=False))
        signature_valid = verify_v2(payload, signature, timestamp, webhook_id, event_type)
    except (UnicodeDecodeError, ValueError, rfc8785.CanonicalizationError):
        abort(400, "invalid I-JSON")

    if not signature_valid:
        abort(401, "invalid signature or timestamp")
    if not isinstance(payload, dict) or payload.get("event") != event_type:
        abort(400, "event header does not match payload")

    validate_event_schema(payload)
    # Atomically insert the authenticated ID and apply the state transition.
    accept_v2_event_atomically(webhook_id, payload)
    return "OK", 200

Executable v2 test vector

Use the public, non-production vector at /webhook-signature-v2-test-vector.json. It contains the sample payload, canonicalPayload, timestamp, webhook ID, event type, exact signatureInput, a non-production test Integration Secret Key, and expected signature. The test key is public test data; never use it in staging or production. The vector proves protocol canonicalization only; its payload values are not a production event-state contract.

Troubleshooting

  • Version mismatch: configure the receiver for the endpoint's selected version and reject any other incoming value.
  • Invalid v1 signature: ensure middleware did not parse or re-serialize the raw body.
  • Invalid v2 signature: strictly parse I-JSON, canonicalize the entire payload, and bind the exact timestamp, webhook ID, and event type headers in the documented order.
  • Replay: reject stale timestamps. Atomically deduplicate the authenticated ID in v2; use signed business identity for v1.
  • Wrong key: use the Integration Secret Key for the endpoint's integration, not a generated per-endpoint value.

On this page