Skip to content

Verify Webhook Signature

To ensure that the webhooks received by your server truly originate from Gerbang Pay (and not from a malicious actor forging transaction data), we secure every webhook delivery using the HMAC-SHA256 algorithm.

All HTTP Request Body payloads are signed using your unique webhook_secret, and the result is attached to the X-Gerbang-Signature header.

Basic Verification Concepts

  1. Retrieve the raw string/bytes from the request body (before it is parsed into a JSON object).
  2. Retrieve the value of the X-Gerbang-Signature header.
  3. Remove the sha256= prefix from the header; the remainder is the original hash (in hexadecimal format).
  4. On your server side, calculate the HMAC-SHA256 of the raw body using your webhook_secret key.
  5. Compare your server's calculated result with the hash from the header. If they match, the webhook is valid. If they differ, reject the webhook (HTTP 401/403).

⚠️ Very Important: You must use the request body in its raw form (raw buffer/string bytes). If your framework (e.g., Express.js) converts the body into a JSON object first, and then stringifies it again, the spacing can change so the HMAC result will never match (always invalid).


Implementation Examples in Various Languages

1. Node.js (Express)

For Express.js users, you need to bypass express.json() specifically for the webhook route so you can access its raw body.

javascript
const express = require('express');
const crypto = require('crypto');

const app = express();
const WEBHOOK_SECRET = 'your_dashboard_secret'; // Store in .env

// Save rawBody specifically for verification
app.use(express.json({
  verify: (req, res, buf) => {
    req.rawBody = buf;
  }
}));

app.post('/webhook/gerbangpay', (req, res) => {
  const signatureHeader = req.headers['x-gerbang-signature'];
  if (!signatureHeader || !signatureHeader.startsWith('sha256=')) {
    return res.status(401).send('Missing or invalid signature header');
  }

  // Remove the 'sha256=' text
  const providedSignature = signatureHeader.replace('sha256=', '');

  // Regenerate HMAC
  const hmac = crypto.createHmac('sha256', WEBHOOK_SECRET);
  hmac.update(req.rawBody);
  const calculatedSignature = hmac.digest('hex');

  // Compare using constant-time compare to prevent timing attacks
  if (crypto.timingSafeEqual(Buffer.from(providedSignature), Buffer.from(calculatedSignature))) {
    // Signature is valid!
    const event = req.body;
    console.log(`Received event: ${event.event_type} for transaction ${event.data.provider_order_id}`);
    
    // TODO: Update status in your database
    
    res.status(200).send('OK');
  } else {
    // Signature is invalid, reject!
    console.error("Webhook signature mismatch!");
    res.status(401).send('Invalid signature');
  }
});

app.listen(3000, () => console.log('Server running'));

2. Python (Flask)

python
from flask import Flask, request, jsonify
import hmac
import hashlib

app = Flask(__name__)
WEBHOOK_SECRET = 'your_dashboard_secret'.encode('utf-8')

@app.route('/webhook/gerbangpay', methods=['POST'])
def webhook_handler():
    signature_header = request.headers.get('X-Gerbang-Signature', '')
    if not signature_header.startswith('sha256='):
        return jsonify({"error": "Invalid signature format"}), 401
    
    provided_signature = signature_header.replace('sha256=', '')
    
    # Get raw body
    raw_body = request.get_data()
    
    # Generate HMAC
    calculated_signature = hmac.new(
        WEBHOOK_SECRET, 
        msg=raw_body, 
        digestmod=hashlib.sha256
    ).hexdigest()
    
    # Compare securely
    if hmac.compare_digest(provided_signature, calculated_signature):
        event = request.json
        print(f"Valid event: {event.get('event_type')}")
        return "OK", 200
    else:
        return "Invalid signature", 401

if __name__ == '__main__':
    app.run(port=5000)

3. PHP

php
<?php

$webhookSecret = 'your_dashboard_secret';

$signatureHeader = $_SERVER['HTTP_X_GERBANG_SIGNATURE'] ?? '';

if (strpos($signatureHeader, 'sha256=') !== 0) {
    http_response_code(401);
    die('Invalid signature header');
}

$providedSignature = substr($signatureHeader, 7); // Cut 'sha256='

// Get raw HTTP body
$rawBody = file_get_contents('php://input');

// Calculate HMAC
$calculatedSignature = hash_hmac('sha256', $rawBody, $webhookSecret);

// Use hash_equals to prevent timing attacks
if (hash_equals($calculatedSignature, $providedSignature)) {
    // Valid
    $event = json_decode($rawBody, true);
    
    // Perform database update...
    
    http_response_code(200);
    echo "OK";
} else {
    // Invalid
    http_response_code(401);
    die('Signature verification failed');
}

4. Go (Golang)

go
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"io"
	"net/http"
	"strings"
)

const WebhookSecret = "your_dashboard_secret"

func webhookHandler(w http.ResponseWriter, r *http.Request) {
	signatureHeader := r.Header.Get("X-Gerbang-Signature")
	if !strings.HasPrefix(signatureHeader, "sha256=") {
		http.Error(w, "Invalid signature header", http.StatusUnauthorized)
		return
	}

	providedSignature := strings.TrimPrefix(signatureHeader, "sha256=")

	// Read raw body
	rawBody, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "Cannot read body", http.StatusInternalServerError)
		return
	}
	defer r.Body.Close()

	// Calculate HMAC
	mac := hmac.New(sha256.New, []byte(WebhookSecret))
	mac.Write(rawBody)
	calculatedSignature := hex.EncodeToString(mac.Sum(nil))

	// Compare securely
	if hmac.Equal([]byte(providedSignature), []byte(calculatedSignature)) {
		// Valid
		w.WriteHeader(http.StatusOK)
		w.Write([]byte("OK"))
	} else {
		// Invalid
		http.Error(w, "Invalid signature", http.StatusUnauthorized)
	}
}

func main() {
	http.HandleFunc("/webhook/gerbangpay", webhookHandler)
	http.ListenAndServe(":8080", nil)
}

Gerbang Pay API Documentation