# Webhook Signature Verification: Panduan Keamanan untuk API Mutasi Bank

> Protect your webhook endpoints dari spoofing attacks dengan HMAC-SHA256 signature verification

---

## **📌 TL;DR (Quick Summary)**

Webhook tanpa signature verification = **SANGAT BERBAHAYA!** Siapa saja bisa kirim fake webhook dan manipulasi sistem Anda.

Learn how to:

* ✅ **Verify HMAC-SHA256 signatures** dengan benar
    
* ✅ **Prevent replay attacks** menggunakan timestamp validation
    
* ✅ **Implement di 3 bahasa**: PHP, Node.js, Python
    
* ✅ **Production-ready code** dengan error handling
    
* ✅ **Test locally** sebelum deploy
    

**Reading time:** 10 menit • **Level:** Intermediate • **Security:** Critical 🔒

---

## **Daftar Isi**

1. [Kenapa Webhook Signature Penting?](https://file+.vscode-resource.vscode-cdn.net/Users/daffigusti/development/laravel/mutasibank/docs/BLOG_ARTICLE_02_DRAFT.md#kenapa-webhook-signature-penting)
    
2. [Cara Kerja HMAC-SHA256](https://file+.vscode-resource.vscode-cdn.net/Users/daffigusti/development/laravel/mutasibank/docs/BLOG_ARTICLE_02_DRAFT.md#cara-kerja-hmac-sha256)
    
3. [Implementation Guide](https://file+.vscode-resource.vscode-cdn.net/Users/daffigusti/development/laravel/mutasibank/docs/BLOG_ARTICLE_02_DRAFT.md#implementation-guide)
    
4. [Testing & Debugging](https://file+.vscode-resource.vscode-cdn.net/Users/daffigusti/development/laravel/mutasibank/docs/BLOG_ARTICLE_02_DRAFT.md#testing-debugging)
    
5. [Common Mistakes](https://file+.vscode-resource.vscode-cdn.net/Users/daffigusti/development/laravel/mutasibank/docs/BLOG_ARTICLE_02_DRAFT.md#common-mistakes)
    
6. [FAQ](https://file+.vscode-resource.vscode-cdn.net/Users/daffigusti/development/laravel/mutasibank/docs/BLOG_ARTICLE_02_DRAFT.md#faq)
    

---

## **Kenapa Webhook Signature Penting?**

### **🚨 Scenario Tanpa Signature Verification:**

Bayangkan Anda punya endpoint untuk auto-confirm order:

```php
// ❌ DANGEROUS CODE - DON'T DO THIS!
<?php
// webhook.php
$data = json_decode(file_get_contents('php://input'), true);

foreach ($data['data_mutasi'] as $tx) {
    if ($tx['amount'] == 100000) {
        // Auto-confirm order
        confirmOrder($orderId);  // 😱 SIAPA SAJA BISA TRIGGER INI!
    }
}
```

**Attacker bisa:**

1. Kirim fake POST request ke endpoint Anda
    
2. Dengan payload yang dibuat-buat (fake transaction)
    
3. Trigger auto-confirm untuk order yang belum dibayar
    
4. **FRAUD!** 💸
    

### **✅ Dengan Signature Verification:**

```php
// ✅ SECURE CODE
<?php
// 1. Verify signature FIRST
if (!verifySignature($payload, $signature, $secret)) {
    die('Invalid signature'); // Reject fake webhooks
}

// 2. THEN process (only if signature valid)
$data = json_decode($payload, true);
confirmOrder($orderId); // ✅ Aman!
```

**Benefit:**

* 🔒 Only webhooks dari Mutasibank yang diterima
    
* 🛡️ Protect dari spoofing/fraud
    
* ✅ Compliance dengan security best practices
    

---

## **Cara Kerja HMAC-SHA256**

### **What is HMAC?**

**HMAC** = Hash-based Message Authentication Code

Ini adalah cara untuk **"menandatangani" message** dengan secret key:

```plaintext
Signature = HMAC-SHA256(Message, Secret Key)
```

### **Flow Diagram:**

```plaintext
Mutasibank Server:
1. Ada transaksi baru
2. Buat payload JSON
3. Calculate: signature = hmac_sha256(payload, webhook_secret)
4. Send POST dengan headers:
   - X-Mutasibank-Signature: [signature]
   - X-Mutasibank-Timestamp: [unix_time]
   - Body: [payload]

Your Server:
5. Receive POST request
6. Extract signature dari header
7. Get raw payload dari request body
8. Calculate: expected = hmac_sha256(payload, webhook_secret)
9. Compare: expected == signature?
   - ✅ YES: Process webhook
   - ❌ NO: Reject (fake webhook!)
```

### **Headers yang Dikirim:**

| **Header** | **Format** | **Purpose** |
| --- | --- | --- |
| `X-Mutasibank-Signature` | 64 hex chars | HMAC-SHA256 signature |
| `X-Mutasibank-Timestamp` | Unix timestamp | Prevent replay attacks |
| `X-Mutasibank-Webhook-Id` | UUID | Webhook identifier |
| `Content-Type` | application/json | Payload format |

---

## **Implementation Guide**

### **PHP Implementation (RECOMMENDED)**

```php
<?php
/**
 * Secure Webhook Handler with Signature Verification
 */

// ============================================================================
// CONFIGURATION
// ============================================================================

// Get from Mutasibank Dashboard → Webhook Settings
define('WEBHOOK_SECRET', getenv('WEBHOOK_SECRET'));
define('TIMESTAMP_TOLERANCE', 300); // 5 minutes

// ============================================================================
// STEP 1: EXTRACT HEADERS
// ============================================================================

$signature = $_SERVER['HTTP_X_MUTASIBANK_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_X_MUTASIBANK_TIMESTAMP'] ?? 0;
$webhookId = $_SERVER['HTTP_X_MUTASIBANK_WEBHOOK_ID'] ?? '';

// ============================================================================
// STEP 2: VALIDATE HEADERS
// ============================================================================

if (empty($signature) || empty($timestamp)) {
    http_response_code(401);
    die(json_encode([
        'error' => 'Missing required headers',
        'message' => 'X-Mutasibank-Signature and X-Mutasibank-Timestamp are required'
    ]));
}

// ============================================================================
// STEP 3: CHECK TIMESTAMP (Prevent Replay Attacks)
// ============================================================================

$currentTime = time();
$timeDiff = abs($currentTime - $timestamp);

if ($timeDiff > TIMESTAMP_TOLERANCE) {
    http_response_code(401);
    die(json_encode([
        'error' => 'Request expired',
        'message' => "Timestamp outside tolerance window ({$timeDiff}s difference)"
    ]));
}

// ============================================================================
// STEP 4: GET RAW PAYLOAD
// ============================================================================

// ⚠️ IMPORTANT: Use raw input, NOT $_POST!
// Signature is calculated on exact JSON bytes
$payload = file_get_contents('php://input');

if (empty($payload)) {
    http_response_code(400);
    die(json_encode(['error' => 'Empty payload']));
}

// ============================================================================
// STEP 5: VERIFY SIGNATURE
// ============================================================================

// Calculate expected signature
$expectedSignature = hash_hmac('sha256', $payload, WEBHOOK_SECRET);

// ⚠️ Use hash_equals() for constant-time comparison
// DON'T use == or === (vulnerable to timing attacks!)
if (!hash_equals($expectedSignature, $signature)) {
    error_log("Invalid webhook signature from webhook_id: {$webhookId}");

    http_response_code(401);
    die(json_encode([
        'error' => 'Invalid signature',
        'message' => 'Signature verification failed'
    ]));
}

// ============================================================================
// ✅ SIGNATURE VALID - PROCESS WEBHOOK
// ============================================================================

$data = json_decode($payload, true);

// Log successful webhook
error_log("Webhook verified successfully: {$webhookId}");

// Process transactions
foreach ($data['data_mutasi'] as $transaction) {
    $type = $transaction['type'] == 'CR' ? 'MASUK' : 'KELUAR';

    echo "Processing [{$type}] Rp " . number_format($transaction['amount']) . "\n";

    if ($transaction['type'] == 'CR') {
        // Handle incoming transaction
        processIncomingPayment($transaction);
    } else {
        // Handle outgoing transaction
        processOutgoingPayment($transaction);
    }
}

// ============================================================================
// SEND SUCCESS RESPONSE
// ============================================================================

http_response_code(200);
echo json_encode([
    'success' => true,
    'message' => 'Webhook processed',
    'webhook_id' => $webhookId,
    'transactions_processed' => count($data['data_mutasi'])
]);

// ============================================================================
// HELPER FUNCTIONS
// ============================================================================

function processIncomingPayment($transaction) {
    // Your business logic here
    // Example: Auto-confirm order, send email, update database

    // Extract order ID from description
    // Example: "TRANSFER FROM CUSTOMER ORDER-12345"
    if (preg_match('/ORDER-(\d+)/', $transaction['description'], $matches)) {
        $orderId = $matches[1];

        // Confirm order in your database
        // DB::table('orders')->where('id', $orderId)->update(['status' => 'paid']);

        // Send notification
        // sendWhatsApp($customer, "✅ Pembayaran order #{$orderId} confirmed!");

        echo "✅ Order #{$orderId} confirmed\n";
    }
}

function processOutgoingPayment($transaction) {
    // Handle debit transactions if needed
    echo "Debit transaction logged\n";
}
```

[**📥 Download complete example**](https://github.com/daffigusti/mutasi_api_sample/blob/main/webhook_with_signature.php)

---

### **Node.js Implementation (Express)**

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

const app = express();
app.use(express.json());
app.use(express.raw({ type: 'application/json' }));

// Configuration
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;
const TIMESTAMP_TOLERANCE = 300; // 5 minutes

// Webhook endpoint
app.post('/webhook/mutasibank', (req, res) => {
    // Extract headers
    const signature = req.headers['x-mutasibank-signature'] || '';
    const timestamp = parseInt(req.headers['x-mutasibank-timestamp']) || 0;
    const webhookId = req.headers['x-mutasibank-webhook-id'] || '';

    // Validate headers
    if (!signature || !timestamp) {
        return res.status(401).json({
            error: 'Missing signature headers'
        });
    }

    // Check timestamp (prevent replay attacks)
    const currentTime = Math.floor(Date.now() / 1000);
    const timeDiff = Math.abs(currentTime - timestamp);

    if (timeDiff > TIMESTAMP_TOLERANCE) {
        return res.status(401).json({
            error: 'Request expired',
            message: `Timestamp outside tolerance (${timeDiff}s difference)`
        });
    }

    // Get raw payload
    const payload = JSON.stringify(req.body);

    // Calculate expected signature
    const expectedSignature = crypto
        .createHmac('sha256', WEBHOOK_SECRET)
        .update(payload)
        .digest('hex');

    // Verify signature (constant-time comparison)
    if (!crypto.timingSafeEqual(
        Buffer.from(expectedSignature),
        Buffer.from(signature)
    )) {
        console.error('Invalid signature:', webhookId);
        return res.status(401).json({
            error: 'Invalid signature'
        });
    }

    // ✅ Signature valid - process webhook
    const data = req.body;

    console.log('✅ Webhook verified:', webhookId);

    // Process transactions
    data.data_mutasi.forEach(transaction => {
        if (transaction.type === 'CR') {
            // Handle incoming payment
            processIncomingPayment(transaction);
        }
    });

    res.json({
        success: true,
        webhook_id: webhookId,
        transactions_processed: data.data_mutasi.length
    });
});

function processIncomingPayment(transaction) {
    // Extract order ID
    const match = transaction.description.match(/ORDER-(\d+)/);

    if (match) {
        const orderId = match[1];
        console.log(`✅ Order #${orderId} confirmed`);

        // Your business logic:
        // - Update database
        // - Send email
        // - Send WhatsApp notification
    }
}

app.listen(3000, () => {
    console.log('Webhook server running on port 3000');
});
```

[**📥 Download complete example**](https://github.com/daffigusti/mutasi_api_sample/blob/main/webhook_nodejs.js)

---

### **Python Implementation (Flask)**

```python
from flask import Flask, request, jsonify
import hmac
import hashlib
import time
import json
import os

app = Flask(__name__)

# Configuration
WEBHOOK_SECRET = os.getenv('WEBHOOK_SECRET').encode()
TIMESTAMP_TOLERANCE = 300  # 5 minutes

@app.route('/webhook/mutasibank', methods=['POST'])
def webhook_handler():
    # Extract headers
    signature = request.headers.get('X-Mutasibank-Signature', '')
    timestamp = int(request.headers.get('X-Mutasibank-Timestamp', 0))
    webhook_id = request.headers.get('X-Mutasibank-Webhook-Id', '')

    # Validate headers
    if not signature or not timestamp:
        return jsonify({'error': 'Missing signature headers'}), 401

    # Check timestamp (prevent replay attacks)
    current_time = int(time.time())
    time_diff = abs(current_time - timestamp)

    if time_diff > TIMESTAMP_TOLERANCE:
        return jsonify({
            'error': 'Request expired',
            'time_diff': time_diff
        }), 401

    # Get raw payload
    payload = request.get_data()

    # Calculate expected signature
    expected_signature = hmac.new(
        WEBHOOK_SECRET,
        payload,
        hashlib.sha256
    ).hexdigest()

    # Verify signature (constant-time comparison)
    if not hmac.compare_digest(expected_signature, signature):
        print(f'Invalid signature: {webhook_id}')
        return jsonify({'error': 'Invalid signature'}), 401

    # ✅ Signature valid - process webhook
    data = request.json

    print(f'✅ Webhook verified: {webhook_id}')

    # Process transactions
    for transaction in data['data_mutasi']:
        if transaction['type'] == 'CR':
            process_incoming_payment(transaction)

    return jsonify({
        'success': True,
        'webhook_id': webhook_id,
        'transactions_processed': len(data['data_mutasi'])
    })

def process_incoming_payment(transaction):
    # Extract order ID from description
    import re
    match = re.search(r'ORDER-(\d+)', transaction['description'])

    if match:
        order_id = match.group(1)
        print(f'✅ Order #{order_id} confirmed')

        # Your business logic:
        # - Update database
        # - Send notifications

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

[**📥 Download complete example**](https://github.com/daffigusti/mutasi_api_sample/blob/main/webhook_python.py)

---

## **Security Best Practices**

### **✅ DO (Must Implement!)**

**1\. Always Verify Signature**

```php
// ✅ Good
if (!hash_equals($expected, $signature)) {
    die('Invalid signature');
}

// ❌ Bad - DON'T USE ==
if ($expected == $signature) { // Vulnerable to timing attacks!
    // ...
}
```

**2\. Check Timestamp (Prevent Replay Attacks)**

```php
$tolerance = 300; // 5 minutes
if (abs(time() - $timestamp) > $tolerance) {
    die('Request expired');
}
```

**3\. Use Raw Payload**

```php
// ✅ Good
$payload = file_get_contents('php://input');

// ❌ Bad
$payload = json_encode($_POST); // Wrong! Signature won't match
```

**4\. Constant-Time Comparison**

```php
// ✅ Good (PHP)
hash_equals($expected, $signature)

// ✅ Good (Node.js)
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))

// ✅ Good (Python)
hmac.compare_digest(expected, signature)

// ❌ Bad - ALL languages
$expected == $signature  // Timing attack vulnerable!
```

**5\. HTTPS Only**

```plaintext
✅ https://yoursite.com/webhook
❌ http://yoursite.com/webhook (INSECURE!)
```

**6\. Store Secret Securely**

```bash
# .env file
WEBHOOK_SECRET=your_64_char_secret_here

# ❌ DON'T hardcode in code
define('SECRET', 'abc123...'); // BAD!
```

---

### **❌ DON'T (Common Mistakes!)**

**1\. Skip Signature Verification**

```php
// ❌ NEVER DO THIS!
$data = json_decode(file_get_contents('php://input'), true);
processWebhook($data); // 😱 No verification = DANGEROUS!
```

**2\. Use Wrong Comparison**

```php
// ❌ Vulnerable to timing attacks
if ($expected == $signature) { ... }

// ✅ Use constant-time
if (hash_equals($expected, $signature)) { ... }
```

**3\. Log Sensitive Data**

```php
// ❌ DON'T log secret!
error_log("Secret: " . WEBHOOK_SECRET);

// ❌ DON'T log signature
error_log("Signature: " . $signature);

// ✅ Log only webhook ID
error_log("Webhook received: " . $webhookId);
```

**4\. Wrong Payload**

```php
// ❌ Wrong - signature calculated on raw bytes
$payload = json_encode($_POST);

// ✅ Correct - use raw input
$payload = file_get_contents('php://input');
```

---

## **Testing & Debugging**

### **Local Testing dengan cURL**

Generate signature secara manual untuk testing:

```bash
#!/bin/bash

# Configuration
SECRET="your_webhook_secret_64_chars"
TIMESTAMP=$(date +%s)
PAYLOAD='{"api_key":"test","account_id":1,"data_mutasi":[{"amount":100000}]}'

# Calculate signature
SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" | cut -d' ' -f2)

# Send test webhook
curl -X POST http://localhost:8000/webhook.php \
  -H "Content-Type: application/json" \
  -H "X-Mutasibank-Signature: $SIGNATURE" \
  -H "X-Mutasibank-Timestamp: $TIMESTAMP" \
  -H "X-Mutasibank-Webhook-Id: test-webhook-123" \
  -d "$PAYLOAD"
```

**Expected response:**

```json
{
  "success": true,
  "webhook_id": "test-webhook-123",
  "transactions_processed": 1
}
```

---

### **Debug Checklist**

**Webhook tidak terkirim?**

1. ✅ URL webhook publicly accessible (bukan localhost)?
    
2. ✅ SSL certificate valid?
    
3. ✅ Port 80/443 open (firewall)?
    
4. ✅ Endpoint return 200 OK?
    

**"Invalid signature" error?**

Common causes:

```php
// 1. ❌ Pakai $_POST instead of raw input
$payload = json_encode($_POST); // WRONG!
$payload = file_get_contents('php://input'); // CORRECT!

// 2. ❌ Secret salah
// Check di Mutasibank dashboard, copy exactly!

// 3. ❌ Pakai == instead of hash_equals()
if ($a == $b) // WRONG!
if (hash_equals($a, $b)) // CORRECT!
```

**"Request expired" error?**

```php
// Server time tidak sync?
// Install NTP dan sync time
sudo ntpdate -s time.nist.gov

// Atau increase tolerance (production use 300 = 5 min)
define('TIMESTAMP_TOLERANCE', 600); // 10 minutes (for testing)
```

---

## **Common Mistakes & Solutions**

### **Mistake #1: Using == for Comparison**

**Problem:**

```php
if ($expected == $signature) { // ❌ Timing attack vulnerable!
```

**Why bad?**

* `==` operator return different time tergantung berapa karakter yang match
    
* Attacker bisa brute-force signature character by character
    
* Measuring response time → gradually discover signature
    

**Solution:**

```php
if (hash_equals($expected, $signature)) { // ✅ Constant-time
```

### **Mistake #2: Wrong Payload**

**Problem:**

```php
$payload = json_encode($_POST); // ❌ Signature won't match!
```

**Why bad?**

* Signature calculated on **exact raw bytes** dari request body
    
* `$_POST` is parsed & might change formatting
    

**Solution:**

```php
$payload = file_get_contents('php://input'); // ✅ Raw input
```

### **Mistake #3: Not Checking Timestamp**

**Problem:**

```php
// ❌ No timestamp check
verifySignature($payload, $signature);
processWebhook($payload);
```

**Why bad?**

* Attacker bisa capture valid webhook
    
* Replay it multiple times
    
* Trigger duplicate processing
    

**Solution:**

```php
// ✅ Check timestamp first
if (abs(time() - $timestamp) > 300) {
    die('Expired');
}
```

### **Mistake #4: Logging Sensitive Data**

**Problem:**

```php
// ❌ DON'T DO THIS!
error_log("Webhook secret: " . WEBHOOK_SECRET);
error_log("Full payload: " . $payload); // Might contain sensitive data
```

**Solution:**

```php
// ✅ Log only non-sensitive info
error_log("Webhook ID: {$webhookId}");
error_log("Transactions count: " . count($data['data_mutasi']));
```

---

## **Production Deployment**

### **Environment Variables**

```bash
# .env
WEBHOOK_SECRET=your_64_character_secret_from_dashboard
API_TOKEN=your_api_token
TIMESTAMP_TOLERANCE=300
ENABLE_LOGGING=true
```

### **Server Requirements**

* ✅ PHP 7.4+ / Node.js 14+ / Python 3.8+
    
* ✅ SSL certificate (HTTPS required)
    
* ✅ Port 443 open
    
* ✅ Firewall configured
    
* ✅ NTP time sync enabled
    

### **Monitoring**

**Log these events:**

* ✅ Webhook received (dengan webhook\_id)
    
* ✅ Signature verification success/failure
    
* ✅ Transactions processed count
    
* ❌ DON'T log: secrets, signatures, sensitive data
    

**Alert on:**

* ⚠️ Multiple signature failures (potential attack)
    
* ⚠️ Webhook endpoint down
    
* ⚠️ Processing errors
    

---

## **FAQ**

### **Q: Apakah wajib implement signature verification?**

**A:** **YA, WAJIB!** Tanpa ini, sistem Anda vulnerable terhadap fraud. Attacker bisa kirim fake webhooks dan manipulasi sistem Anda.

### **Q: Berapa tolerance timestamp yang aman?**

**A:** **300 seconds (5 menit)** adalah balance yang baik antara security dan tolerance untuk network delay. Jangan set terlalu besar (risiko replay attack).

### **Q: Dimana dapat webhook secret?**

**A:** Di Mutasibank Dashboard:

1. Login → Webhook Settings
    
2. Create webhook atau view existing
    
3. Secret akan ditampilkan (64 characters)
    
4. **Copy & simpan di .env** - Tidak bisa lihat lagi setelah close!
    

### **Q: Webhook secret sama untuk semua webhook?**

**A:** **Tidak.** Setiap webhook punya secret sendiri (auto-generated saat create). Jika punya multiple webhooks, simpan secret masing-masing.

### **Q: Bisa regenerate secret?**

**A:** Ya, tapi:

1. Old secret langsung invalid
    
2. Webhook existing akan gagal verification
    
3. Harus update secret di server Anda
    
4. **Coordination needed** untuk zero-downtime
    

### **Q: Apa itu timing attack?**

**A:** Timing attack adalah teknik hacking yang measure response time untuk discover secret:

```php
// Vulnerable code:
if ($a == $b) {
    // Comparison stops at first different character
    // Different timing for different positions!
}

// Safe code:
if (hash_equals($a, $b)) {
    // Always takes same time regardless of input
    // ✅ Secure!
}
```

---

## **Kesimpulan**

Webhook signature verification adalah **critical security measure** untuk protect sistem Anda dari fraud dan spoofing attacks.

**Key Takeaways:**

✅ **Always verify** HMAC-SHA256 signatures

✅ **Check timestamp** untuk prevent replay attacks

✅ **Use constant-time comparison** (hash\_equals, timingSafeEqual)

✅ **HTTPS only** untuk webhook URLs ✅ **Store secrets securely** di environment variables

✅ **Test thoroughly** sebelum production

**Implementation time:** 30-60 menit untuk basic setup

**Security impact:** Protect dari potential fraud senilai jutaan rupiah! 🔒

---

## **🚀 Start Building Secure Webhooks**

Sudah punya account Mutasibank?

* ✓ Login → Create webhook → Get secret
    
* ✓ Download sample code (PHP/Node.js/Python)
    
* ✓ Implement verification
    
* ✓ Test locally
    
* ✓ Deploy to production
    

[**👉 Daftar Gratis 7 Hari**](https://mutasibank.co.id/login?utm_source=blog&utm_medium=article&utm_campaign=webhook_security)

---

## **📚 Resources**

**Code Examples:**

* 💻 [GitHub Repository](https://github.com/daffigusti/mutasi_api_sample)
    
* 📖 [API Documentation](https://mutasibank.co.id/api/docs)
    
* 📘 [Security Guide](https://mutasibank.co.id/api/docs#security)
    

**Support:**

* 💬 [WhatsApp](https://wa.me/628561205976)
    
* 📧 [support@mutasibank.co.id](mailto:support@mutasibank.co.id)
    

**Related Articles:**

* [REST API Mutasi Bank - Dokumentasi Lengkap](https://blog.mutasibank.co.id/rest-api-mutasi-bank-dokumentasi-lengkap-dengan-contoh-code)
    
* Coming soon: Best Practices Security untuk Banking API
    
* Coming soon: Error Handling & Retry Logic
    

---

**Questions tentang webhook security?** Drop a comment below! 👇

**Found this helpful?** Share dengan developer friends! 🙏

---

**Tags:** #webhook #security #hmac #php #nodejs #python #api #banking #tutorial
