NodukanNodukan/ Developers
Guides

Webhook Verification

Deep dive into HMAC-SHA256 webhook signature verification in Node.js, Python, PHP, and Go — with production hardening tips.

Webhook Verification

Every Nodukan webhook delivery is signed using HMAC-SHA256. Verifying the signature before processing ensures you only act on events that genuinely came from Nodukan — protecting against spoofed or replayed requests.

How signing works#

When Nodukan dispatches a webhook, it:

  1. Records the current Unix timestamp t
  2. Constructs the signed payload: ${t}.${rawBody}
  3. Computes HMAC-SHA256(signingSecret, signedPayload)
  4. Sends the result in the X-Nodukan-Signature header:
X-Nodukan-Signature: t=1715000130,v1=3c5e9a2f7d4b1a8e9c...

Your server must reconstruct steps 2–3 using the same signingSecret (from webhook creation) and compare against the v1 value using a constant-time comparison to prevent timing attacks.

Warning:

Always use the raw request body bytes — before any JSON parsing. Re-serializing parsed JSON can change whitespace, alter key ordering, or escape characters differently, which breaks the HMAC.


Node.js#

// verify.js
import crypto from 'crypto';

/**
 * Verify a Nodukan webhook signature.
 *
 * @param {string | Buffer} rawBody - Raw request body (before JSON.parse)
 * @param {string} signatureHeader - Value of X-Nodukan-Signature header
 * @param {string} signingSecret - Your webhook's signing secret (whsec_...)
 * @param {number} [toleranceSeconds=300] - Max age of event to accept (replay protection)
 * @returns {boolean}
 */
export function verifyNodukansignature(rawBody, signatureHeader, signingSecret, toleranceSeconds = 300) {
  // Parse the header
  const parts = Object.fromEntries(
    signatureHeader.split(',').flatMap((part) => {
      const idx = part.indexOf('=');
      return idx === -1 ? [] : [[part.slice(0, idx), part.slice(idx + 1)]];
    }),
  );

  const { t, v1 } = parts;
  if (!t || !v1) return false;

  // Replay protection: reject stale events
  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(t));
  if (age > toleranceSeconds) return false;

  // Reconstruct the signed payload
  const body = Buffer.isBuffer(rawBody) ? rawBody.toString('utf8') : rawBody;
  const signedPayload = `${t}.${body}`;

  // Compute expected HMAC
  const expected = crypto
    .createHmac('sha256', signingSecret)
    .update(signedPayload, 'utf8')
    .digest('hex');

  // Constant-time comparison — CRITICAL: prevents timing oracle attacks
  try {
    const vBuf = Buffer.from(v1, 'hex');
    const eBuf = Buffer.from(expected, 'hex');
    if (vBuf.length !== eBuf.length) return false;
    return crypto.timingSafeEqual(vBuf, eBuf);
  } catch {
    return false;
  }
}

Express integration#

// routes/webhook.js
import express from 'express';
import { verifyNodukansignature } from '../verify.js';

const router = express.Router();

router.post(
  '/nodukan',
  express.raw({ type: 'application/json' }), // ← raw body, not express.json()
  (req, res) => {
    const rawBody = req.body;
    const sig = req.headers['x-nodukan-signature'];

    if (!sig || !verifyNodukansignature(rawBody, sig, process.env.NODUKAN_WEBHOOK_SECRET)) {
      return res.status(401).send('Unauthorized');
    }

    res.status(200).send('OK'); // respond fast

    const event = JSON.parse(rawBody);
    processEvent(event).catch(console.error); // async queue
  },
);

export default router;

Next.js App Router (Edge or Node)#

// app/api/webhooks/nodukan/route.ts
import { verifyNodukansignature } from '@/lib/verify';
import { NextRequest, NextResponse } from 'next/server';

export const runtime = 'nodejs'; // Required — Edge runtime lacks crypto.timingSafeEqual

export async function POST(req: NextRequest) {
  const rawBody = await req.text();
  const sig = req.headers.get('x-nodukan-signature') ?? '';

  if (!verifyNodukansignature(rawBody, sig, process.env.NODUKAN_WEBHOOK_SECRET!)) {
    return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
  }

  const event = JSON.parse(rawBody);
  // Process event...

  return NextResponse.json({ received: true });
}

Python#

# verify.py
import hashlib
import hmac
import time


def verify_nodukan_signature(
    raw_body: bytes | str,
    signature_header: str,
    signing_secret: str,
    tolerance_seconds: int = 300,
) -> bool:
    """
    Verify a Nodukan webhook signature.

    Args:
        raw_body: Raw request body bytes (before json.loads)
        signature_header: Value of X-Nodukan-Signature header
        signing_secret: Your webhook's signing secret (whsec_...)
        tolerance_seconds: Maximum age of event to accept

    Returns:
        True if signature is valid, False otherwise
    """
    parts = {}
    for part in signature_header.split(","):
        if "=" in part:
            k, _, v = part.partition("=")
            parts[k] = v

    t = parts.get("t")
    v1 = parts.get("v1")
    if not t or not v1:
        return False

    # Replay protection
    if abs(time.time() - int(t)) > tolerance_seconds:
        return False

    body_str = raw_body.decode("utf-8") if isinstance(raw_body, bytes) else raw_body
    signed_payload = f"{t}.{body_str}"

    expected = hmac.new(
        signing_secret.encode("utf-8"),
        signed_payload.encode("utf-8"),
        hashlib.sha256,
    ).hexdigest()

    # Constant-time comparison
    return hmac.compare_digest(v1, expected)


# Flask integration
from flask import Flask, request, abort
import json

app = Flask(__name__)

@app.post("/webhooks/nodukan")
def nodukan_webhook():
    raw = request.get_data()  # raw bytes — before Flask parses JSON
    sig = request.headers.get("X-Nodukan-Signature", "")

    if not verify_nodukan_signature(raw, sig, app.config["NODUKAN_WEBHOOK_SECRET"]):
        abort(401)

    event = json.loads(raw)
    # Respond immediately, process async
    process_event.delay(event)  # e.g., Celery
    return {"received": True}, 200


# Django integration
from django.http import HttpResponse, HttpResponseForbidden
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST

@csrf_exempt
@require_POST
def nodukan_webhook(request):
    sig = request.headers.get("X-Nodukan-Signature", "")
    if not verify_nodukan_signature(request.body, sig, settings.NODUKAN_WEBHOOK_SECRET):
        return HttpResponseForbidden("Invalid signature")

    event = json.loads(request.body)
    process_event.delay(event)
    return HttpResponse("OK")

PHP#

<?php
// verify.php

/**
 * Verify a Nodukan webhook signature.
 *
 * @param string $rawBody       Raw request body string
 * @param string $sigHeader     X-Nodukan-Signature header value
 * @param string $signingSecret Webhook signing secret (whsec_...)
 * @param int    $tolerance     Maximum event age in seconds (default 300)
 * @return bool
 */
function verifyNodukansignature(
    string $rawBody,
    string $sigHeader,
    string $signingSecret,
    int $tolerance = 300
): bool {
    $parts = [];
    foreach (explode(',', $sigHeader) as $part) {
        [$k, $v] = explode('=', $part, 2) + [null, null];
        if ($k && $v) $parts[$k] = $v;
    }

    $t  = $parts['t']  ?? null;
    $v1 = $parts['v1'] ?? null;

    if (!$t || !$v1) return false;

    // Replay protection
    if (abs(time() - (int)$t) > $tolerance) return false;

    $signedPayload = "{$t}.{$rawBody}";
    $expected = hash_hmac('sha256', $signedPayload, $signingSecret);

    // Constant-time comparison
    return hash_equals($expected, $v1);
}

// Laravel usage
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

Route::post('/webhooks/nodukan', function (Request $request) {
    $rawBody = $request->getContent();
    $sig     = $request->header('X-Nodukan-Signature', '');

    if (!verifyNodukansignature($rawBody, $sig, config('services.nodukan.webhook_secret'))) {
        abort(401, 'Invalid signature');
    }

    $event = json_decode($rawBody, true);
    // Dispatch to queue
    ProcessNodukansignatureEvent::dispatch($event);

    return response()->json(['received' => true]);
});

Go#

// verify.go
package webhook

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "fmt"
    "math"
    "strconv"
    "strings"
    "time"
)

// VerifyNodukansignature verifies a Nodukan webhook signature.
// rawBody must be the unmodified request body bytes.
func VerifyNodukansignature(rawBody []byte, sigHeader, signingSecret string, toleranceSecs int) (bool, error) {
    parts := make(map[string]string)
    for _, part := range strings.Split(sigHeader, ",") {
        idx := strings.Index(part, "=")
        if idx == -1 {
            continue
        }
        parts[part[:idx]] = part[idx+1:]
    }

    t, ok1 := parts["t"]
    v1, ok2 := parts["v1"]
    if !ok1 || !ok2 {
        return false, fmt.Errorf("missing t or v1 in signature header")
    }

    ts, err := strconv.ParseInt(t, 10, 64)
    if err != nil {
        return false, fmt.Errorf("invalid timestamp: %w", err)
    }

    // Replay protection
    age := math.Abs(float64(time.Now().Unix() - ts))
    if age > float64(toleranceSecs) {
        return false, fmt.Errorf("event timestamp too old (%0.f seconds)", age)
    }

    // Reconstruct signed payload
    signedPayload := fmt.Sprintf("%s.%s", t, string(rawBody))

    // Compute expected HMAC
    mac := hmac.New(sha256.New, []byte(signingSecret))
    mac.Write([]byte(signedPayload))
    expected := hex.EncodeToString(mac.Sum(nil))

    // Constant-time comparison
    expectedBytes, err := hex.DecodeString(expected)
    if err != nil {
        return false, err
    }
    v1Bytes, err := hex.DecodeString(v1)
    if err != nil {
        return false, fmt.Errorf("invalid v1 hex: %w", err)
    }

    return hmac.Equal(v1Bytes, expectedBytes), nil
}


// net/http handler example
func NodukansignatureWebhookHandler(secret string) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        body, err := io.ReadAll(r.Body)
        if err != nil {
            http.Error(w, "cannot read body", http.StatusBadRequest)
            return
        }

        sig := r.Header.Get("X-Nodukan-Signature")
        valid, err := VerifyNodukansignature(body, sig, secret, 300)
        if err != nil || !valid {
            http.Error(w, "Unauthorized", http.StatusUnauthorized)
            return
        }

        w.WriteHeader(http.StatusOK)
        w.Write([]byte(`{"received":true}`))

        // Process asynchronously
        go processEvent(body)
    }
}

Common mistakes#

MistakeFix
Using req.body (parsed JSON) instead of the raw body bytesUse express.raw() / request.get_data() / $request->getContent()
Using === instead of constant-time comparisonAlways use crypto.timingSafeEqual / hmac.compare_digest / hash_equals / hmac.Equal
Not checking the timestamp tAdd replay protection — reject events older than 5 minutes
Comparing truncated hex stringsEnsure both sides decode from hex to bytes before comparing
Using the API key instead of the signing secretThe signing secret (whsec_...) is separate from your API key (NDK_live_...)
Was this helpful?
Edit on GitHub

Last updated