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:
| Version | Signature header | Timestamp header | Version header |
|---|---|---|---|
| v1 | X-BroPay-Signature | X-BroPay-Timestamp | Absent |
| v2 | X-Webhook-Signature | X-Webhook-Timestamp | X-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-SignatureplusX-BroPay-Timestamp. - A v2 receiver must require
X-Webhook-Signature-Version: v2and readX-Webhook-SignatureplusX-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, andfalse. - 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.1import 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.4rfc8785.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", 200Executable 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.