Skip to content

Webhook Signature Verification

When you register a webhook endpoint in the Kora merchant dashboard, you can optionally set a signing secret for that endpoint. When a signing secret is configured, the platform signs each webhook delivery with HMAC-SHA256 and sends the signature in the X-Webhook-Signature header. Your server should verify this signature before processing the webhook to ensure the request came from Kora and was not tampered with.

When Signatures Are Sent

  • Signatures are sent only if you set a Signing secret when creating or editing the webhook endpoint (Developer → Webhook Endpoints → Add/Edit).
  • If no signing secret is set, the X-Webhook-Signature header is not sent.
  • The same secret is used for all events (e.g. payment.succeeded, payment.failed) delivered to that endpoint.

Header Format

X-Webhook-Signature: sha256=<hex_digest>
  • Algorithm: HMAC-SHA256
  • Signed payload: The raw HTTP request body (UTF-8), i.e. the exact JSON string sent in the POST body. Do not modify, re-serialize, or add/remove whitespace.
  • Output: Hexadecimal string (lowercase), prefixed with sha256=.

How to Verify (Server-Side)

  1. Read the raw body of the request exactly as received (before parsing JSON). If your framework parses the body automatically, use the raw buffer or string that was used for parsing; do not re-stringify the parsed object.
  2. Get the signature from the X-Webhook-Signature header. If the header is missing and you have a signing secret configured, reject the request.
  3. Parse the header value: it must be of the form sha256=<hex>. Extract the <hex> part (64 characters for SHA-256).
  4. Compute the expected signature: HMAC-SHA256 of the raw body (UTF-8) using your webhook endpoint’s signing secret. Encode the result as lowercase hex.
  5. Compare the expected signature with the extracted value using a constant-time comparison to avoid timing attacks.

Example (Node.js)

javascript
const crypto = require('crypto');

/**
 * Verify Kora webhook signature.
 * @param {string|Buffer} rawBody - Raw request body (UTF-8 string or Buffer)
 * @param {string} signatureHeader - Value of X-Webhook-Signature header
 * @param {string} signingSecret - Your webhook endpoint's signing secret
 * @returns {boolean}
 */
function verifyWebhookSignature(rawBody, signatureHeader, signingSecret) {
  if (!signatureHeader || !signingSecret) {
    return false;
  }
  const body = typeof rawBody === 'string' ? rawBody : rawBody.toString('utf8');
  const match = /^sha256=([a-f0-9]{64})$/i.exec(signatureHeader.trim());
  if (!match) {
    return false;
  }
  const receivedSignature = match[1].toLowerCase();
  const hmac = crypto.createHmac('sha256', signingSecret);
  hmac.update(body, 'utf8');
  const expectedSignature = hmac.digest('hex');
  return crypto.timingSafeEqual(
    Buffer.from(expectedSignature, 'utf8'),
    Buffer.from(receivedSignature, 'utf8')
  );
}

// Express example: use raw body for webhook route
app.post('/webhooks/kora', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-webhook-signature'];
  const signingSecret = process.env.KORA_WEBHOOK_SECRET; // Your endpoint signing secret

  if (!verifyWebhookSignature(req.body, signature, signingSecret)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  const payload = JSON.parse(req.body.toString('utf8'));
  // Handle payload: payload.event, payload.payment_id, etc.
  res.status(200).send();
});

Important: For Express, you must use the raw body for the webhook route (e.g. express.raw({ type: 'application/json' })) so that the body is not parsed before you verify the signature. If you parse JSON first, you must use the exact same string that was parsed (e.g. store the raw body in a middleware and pass it to the verifier).

Example (Other Languages)

  • Python: Use hmac.new(secret.encode(), body.encode(), 'sha256').hexdigest() and compare with the hex part of the header using hmac.compare_digest.
  • PHP: hash_hmac('sha256', $rawBody, $signingSecret) and compare with the extracted hex using hash_equals.

Payload Contents

The webhook body is a JSON object with at least:

  • event – e.g. payment.succeeded, payment.failed
  • payment_id, order_id, amount, currency, status
  • gateway{ id, code, name }
  • gateway_payment_id, authorized_at, captured_at, failed_at, failure_reason
  • created_at, updated_at, channel_id

All of this is signed as the raw JSON string; do not sign a modified or re-serialized version.

Security Notes

  • Keep the signing secret secret. Use the same value only on your server when verifying; do not expose it to the client or in logs.
  • Use constant-time comparison (e.g. crypto.timingSafeEqual, hmac.compare_digest, hash_equals) when comparing the computed signature with the header.
  • Always verify server-side. Do not rely on client-side checks for authenticity.

References

Kore Payment SDK Documentation. Support: contact@koremena.com