Skip to content

Verifikasi Signature Webhook

Untuk memastikan bahwa webhook yang diterima oleh server Anda benar-benar berasal dari Gerbang Pay (dan bukan dari pihak jahat yang memalsukan data transaksi), kami mengamankan setiap pengiriman webhook menggunakan algoritma HMAC-SHA256.

Semua payload HTTP Request Body ditandatangani menggunakan webhook_secret unik milik Anda, dan hasilnya dilampirkan pada header X-Gerbang-Signature.

Konsep Dasar Verifikasi

  1. Ambil raw string/bytes dari request body (sebelum di-parse menjadi JSON object).
  2. Ambil nilai header X-Gerbang-Signature.
  3. Buang prefix sha256= dari header tersebut, sisanya adalah hash aslinya (berbentuk hexadecimal).
  4. Di sisi server Anda, hitung HMAC-SHA256 dari raw body menggunakan kunci webhook_secret milik Anda.
  5. Bandingkan hasil perhitungan server Anda dengan hash dari header. Jika sama, webhook valid. Jika berbeda, tolak webhook tersebut (HTTP 401/403).

⚠️ Sangat Penting: Anda harus menggunakan body request dalam bentuk mentah (raw buffer/string bytes). Jika framework Anda (misalnya Express.js) mengubah body menjadi JSON object terlebih dahulu, lalu di-stringify kembali, spasinya bisa berubah sehingga hasil HMAC tidak akan pernah cocok (selalu invalid).


Contoh Implementasi di Berbagai Bahasa

1. Node.js (Express)

Bagi pengguna Express.js, Anda perlu mem-bypass express.json() khusus untuk route webhook agar Anda bisa mengakses raw body-nya.

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

const app = express();
const WEBHOOK_SECRET = 'secret_dari_dashboard_anda'; // Simpan di .env

// Simpan rawBody khusus untuk verifikasi
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');
  }

  // Buang tulisan 'sha256='
  const providedSignature = signatureHeader.replace('sha256=', '');

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

  // Bandingkan menggunakan constant-time compare untuk mencegah timing attack
  if (crypto.timingSafeEqual(Buffer.from(providedSignature), Buffer.from(calculatedSignature))) {
    // Signature valid!
    const event = req.body;
    console.log(`Menerima event: ${event.event_type} untuk transaksi ${event.data.provider_order_id}`);
    
    // TODO: Update status di database Anda
    
    res.status(200).send('OK');
  } else {
    // Signature invalid, tolak!
    console.error("Webhook signature mismatch!");
    res.status(401).send('Invalid signature');
  }
});

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

2. Python (Flask)

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

app = Flask(__name__)
WEBHOOK_SECRET = 'secret_dari_dashboard_anda'.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=', '')
    
    # Ambil body raw
    raw_body = request.get_data()
    
    # Generate HMAC
    calculated_signature = hmac.new(
        WEBHOOK_SECRET, 
        msg=raw_body, 
        digestmod=hashlib.sha256
    ).hexdigest()
    
    # Compare secara aman
    if hmac.compare_digest(provided_signature, calculated_signature):
        event = request.json
        print(f"Event valid: {event.get('event_type')}")
        return "OK", 200
    else:
        return "Invalid signature", 401

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

3. PHP

php
<?php

$webhookSecret = 'secret_dari_dashboard_anda';

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

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

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

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

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

// Gunakan hash_equals untuk mencegah timing attack
if (hash_equals($calculatedSignature, $providedSignature)) {
    // Valid
    $event = json_decode($rawBody, true);
    
    // Lakukan update database...
    
    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 = "secret_dari_dashboard_anda"

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=")

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

	// Hitung 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