Webhook Verification
Webhook verification is the most security-critical part of payment integration. A forged webhook could credit a user’s balance without real payment.
How each gateway verifies
Midtrans — SHA512 signature
Midtrans sends a signature_key in the webhook body, computed as:
SHA512(order_id + status_code + gross_amount + server_key)Murai recomputes this hash from the payload fields and your server key, then compares using crypto.timingSafeEqual:
const hash = createHash('sha512') .update(order_id + status_code + gross_amount + serverKey) .digest('hex');
// Timing-safe comparison — prevents timing attacksconst hashBuf = Buffer.from(hash, 'hex');const sigBuf = Buffer.from(signature, 'hex');if (hashBuf.length !== sigBuf.length) return false;return timingSafeEqual(hashBuf, sigBuf);Webhook endpoint setup:
// The signature is INSIDE the JSON bodyconst body = await request.json();const result = await checkout.handleWebhook({ payload: body, signature: body.signature_key,});Xendit — Callback token
Xendit uses a simpler approach: a shared secret sent in the x-callback-token header. Murai compares it using crypto.timingSafeEqual:
const sigBuf = Buffer.from(signature);const tokenBuf = Buffer.from(callbackToken);if (sigBuf.length !== tokenBuf.length) return false;return timingSafeEqual(sigBuf, tokenBuf);Webhook endpoint setup:
// The signature is in a HEADER, not the bodyconst body = await request.json();const result = await checkout.handleWebhook({ payload: body, signature: request.headers.get('x-callback-token') ?? '',});Why timing-safe comparison?
A naive === comparison returns false at the first mismatched byte. An attacker can measure response times to guess the correct signature byte-by-byte.
'abc' === 'axc' → fails at byte 2 (fast)'abc' === 'abx' → fails at byte 3 (slower)'abc' === 'abc' → matches all 3 (slowest)timingSafeEqual always compares all bytes, making response time constant regardless of how many bytes match.
Handling non-success webhooks
Not all webhooks mean “payment succeeded.” Gateways send webhooks for expired, failed, and pending statuses too.
Murai handles these automatically:
success→ credit the ledger, mark checkout aspaidexpired/failed→ mark checkout asfailed(no credit)pending→ skip (no action needed)
Responding to webhooks
Always return 200 OK to acknowledge receipt, even if you skip the webhook:
const result = await checkout.handleWebhook({ payload, signature });// result.action: 'credited' | 'skipped' | 'duplicate'
// Always 200 — the gateway will retry if it gets anything elsereturn new Response(JSON.stringify(result), { status: 200 });Testing webhooks locally
Both gateways provide sandbox environments. Use a tunnel to expose your local server:
# ngrokngrok http 3000
# Then configure your webhook URL in the gateway dashboard:# https://abc123.ngrok.io/api/webhooks/midtrans