Skip to main content

Webhook Signature Verification

Every outbound webhook delivery from Kanall includes a cryptographic signature in the X-Kanall-Signature header. Verifying this signature before processing the payload protects your system from forged requests and replay attacks.

This guide covers the full verification flow with production-ready code in JavaScript, Python, Go, and Java.


How the signature works

When Kanall sends a webhook to your endpoint, it:

  1. Takes the raw JSON payload as a byte string
  2. Combines it with a Unix timestamp: {timestamp}.{payload}
  3. Computes HMAC-SHA256 of that string using your per-tenant secret
  4. Sends the result as: X-Kanall-Signature: t={timestamp},v1={hex_encoded_hmac}

Your verification steps:

  1. Extract t and v1 from the header
  2. Reject if the timestamp is more than 5 minutes old (replay protection)
  3. Build the same signed string: {t}.{raw_request_body}
  4. Compute HMAC-SHA256 with your secret
  5. Compare your computed hex against v1 using a constant-time comparison

Get your webhook secret

Retrieve your secret once from the API and store it in your environment. Calling this endpoint again returns the same secret — it does not rotate it.

curl -X POST https://kanall.onrender.com/auth/webhook-secret
# Requires a valid session cookie (dashboard login)
{
"webhookSecret": "a3f8b2c9d1e4f7a0b3c6d9e2f5a8b1c4d7e0f3a6b9c2d5e8f1a4b7c0d3e6f9a2"
}

Store it as KANALL_WEBHOOK_SECRET in your environment. Never commit it to version control.


Verification function

// kanall-verify.js
const crypto = require('crypto')

/**
* Verifies an X-Kanall-Signature header against the raw request body.
*
* @param {Buffer|string} rawBody - The raw, unparsed request body
* @param {string} sigHeader - Value of the X-Kanall-Signature header
* @param {string} secret - Your KANALL_WEBHOOK_SECRET
* @returns {boolean}
*/
function verifyKanallSignature(rawBody, sigHeader, secret) {
if (!sigHeader) return false

const parts = Object.fromEntries(
sigHeader.split(',').map(p => {
const idx = p.indexOf('=')
return [p.slice(0, idx), p.slice(idx + 1)]
})
)

const timestamp = parts['t']
const receivedSig = parts['v1']

if (!timestamp || !receivedSig) return false

// Reject if more than 5 minutes old — protects against replay attacks
const ageSeconds = Math.abs(Date.now() / 1000 - parseInt(timestamp, 10))
if (ageSeconds > 300) return false

const signedString = `${timestamp}.${rawBody}`
const expected = crypto
.createHmac('sha256', secret)
.update(signedString)
.digest('hex')

// Constant-time comparison — prevents timing attacks
try {
return crypto.timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(receivedSig.padEnd(expected.length, '0'), 'hex').slice(0, expected.length)
)
} catch {
return false
}
}

module.exports = { verifyKanallSignature }

Wiring it into your handler

Once you have the verification function, call it at the top of your webhook endpoint before doing anything else. Full handler examples in all languages are in Tutorial Step 3 — Receive Payment Webhooks.

The one rule that catches most developers: read the raw byte body before your framework parses it. Parsing changes whitespace and breaks the HMAC.

FrameworkRaw body access
Express (Node.js)express.raw({ type: 'application/json' }) — not express.json()
Django (Python)request.body
Go net/httpio.ReadAll(r.Body)
Spring Boot (Java)@RequestBody byte[] parameter

Common mistakes

Verifying parsed JSON instead of the raw body

The most common reason signature verification fails is parsing the JSON body before verifying. Many frameworks re-format the JSON when they deserialise and re-serialise it — changing whitespace, field order, or encoding. This changes the byte content and breaks the HMAC.

Always read the raw byte stream first, then parse after verification passes.

FrameworkRaw body access
Express (Node.js)express.raw({ type: 'application/json' }) — not express.json()
Django (Python)request.body
Go net/httpio.ReadAll(r.Body) before json.NewDecoder
Spring Boot (Java)@RequestBody byte[] parameter

Sending the wrong body in tests

When testing with curl or Postman, the body must be byte-for-byte identical to what your server receives. If your test client compresses, re-formats, or re-encodes the body, the signature will not match.

Clock skew

Kanall rejects timestamps more than 5 minutes old. If your server's clock is out of sync, valid webhooks will fail verification. Ensure your server uses NTP.

Wrong secret

The secret from POST /auth/webhook-secret is your per-tenant outbound webhook signing secret. It is separate from any Nomba signing secrets and separate from your Kanall API key.


Security checklist

Before going live:

  • KANALL_WEBHOOK_SECRET is stored in an environment variable, not in source code
  • You read the raw body before passing it to any JSON parser
  • You use constant-time comparison (not === or ==) when comparing HMACs
  • You reject webhooks with timestamps older than 5 minutes
  • Your webhook endpoint returns 200 OK before doing any database writes
  • You have an idempotency check on transactionGroupId to handle retries safely