# Verifikasi tanda tangan webhook

Notifikasi pembayaran tiba di alamat yang hanya diketahui toko Anda dan layanan kami. Namun alamatnya bisa bocor—dari log, dari konfigurasi, dari riwayat pengembangan. Untuk membedakan notifikasi asli dari palsu, setiap permintaan ditandatangani dengan kunci rahasia webhook.

Verifikasi tanda tangan sebelum mengirim pesanan atau mengubah statusnya.

## Header tanda tangan

| Header        | Nilai                                            |
| ------------- | ------------------------------------------------ |
| `X-Timestamp` | Waktu pengiriman, unix time dalam detik          |
| `X-Signature` | Prefiks `sha256=` dan HMAC-SHA256 dalam heksadesimal |

String yang ditandatangani adalah waktu pengiriman, titik, dan isi permintaan:

```
1756901234.{"wallet":{...},"project":{...},"invoice":{...},"payment":{...}}
```

Kunci penandatanganannya adalah kolom Kunci rahasia webhook (Webhook secret) di pengaturan proyek. Anda dapat menerbitkannya ulang di sana jika nilainya bocor.

## Langkah verifikasi

1. Ambil isi permintaan mentah, sebelum mengurai JSON.
2. Gabungkan nilai `X-Timestamp`, titik, dan isi permintaan.
3. Hitung HMAC-SHA256 dengan kunci rahasia webhook.
4. Bandingkan hasilnya dengan `X-Signature` memakai perbandingan waktu konstan.
5. Tolak permintaan jika `X-Timestamp` menyimpang terlalu jauh dari waktu saat ini.

## Contoh implementasi

**PHP**

```php showLineNumbers
<?php
$secret = 'webhook secret from your project settings';

$body      = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_SIGNATURE'] ?? '';

// Guards against a replayed request
if (abs(time() - (int)$timestamp) > 300) {
    http_response_code(400);
    exit;
}

$expected = 'sha256='.hash_hmac('sha256', $timestamp.'.'.$body, $secret);

// hash_equals compares in constant time
if (!hash_equals($expected, $signature)) {
    http_response_code(403);
    exit;
}

$data = json_decode($body, true);

// Handle the order

http_response_code(200);
```

**TypeScript**

```typescript showLineNumbers
import { createHmac, timingSafeEqual } from 'node:crypto';
import express, { type Request, type Response } from 'express';

const app = express();
const secret = 'webhook secret from your project settings';

// The body must stay raw, so express.raw instead of express.json
app.post('/webhook', express.raw({ type: 'application/json' }), (req: Request, res: Response) => {
    const timestamp = req.get('X-Timestamp') ?? '';
    const signature = req.get('X-Signature') ?? '';
    const body = (req.body as Buffer).toString('utf8');

    if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
        return res.sendStatus(400);
    }

    const expected = 'sha256=' + createHmac('sha256', secret)
        .update(`${timestamp}.${body}`, 'utf8')
        .digest('hex');

    const a = Buffer.from(expected);
    const b = Buffer.from(signature);

    // Check the length first: timingSafeEqual needs buffers of equal size
    if (a.length !== b.length || !timingSafeEqual(a, b)) {
        return res.sendStatus(403);
    }

    const data = JSON.parse(body);

    // Handle the order

    res.sendStatus(200);
});
```

**JavaScript**

Penangan yang sama tanpa tipe—untuk proyek JavaScript biasa.

```js showLineNumbers
import { createHmac, timingSafeEqual } from 'node:crypto';
import express from 'express';

const app = express();
const secret = 'webhook secret from your project settings';

// The body must stay raw, so express.raw instead of express.json
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
    const timestamp = req.get('X-Timestamp') ?? '';
    const signature = req.get('X-Signature') ?? '';
    const body = req.body.toString('utf8');

    if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
        return res.sendStatus(400);
    }

    const expected = 'sha256=' + createHmac('sha256', secret)
        .update(`${timestamp}.${body}`, 'utf8')
        .digest('hex');

    const a = Buffer.from(expected);
    const b = Buffer.from(signature);

    // Check the length first: timingSafeEqual needs buffers of equal size
    if (a.length !== b.length || !timingSafeEqual(a, b)) {
        return res.sendStatus(403);
    }

    const data = JSON.parse(body);

    // Handle the order

    res.sendStatus(200);
});
```

**Python**

```python showLineNumbers
import hmac
import hashlib
import time

from flask import Flask, request

app = Flask(__name__)
secret = 'webhook secret from your project settings'

@app.post('/webhook')
def webhook():
    timestamp = request.headers.get('X-Timestamp', '')
    signature = request.headers.get('X-Signature', '')
    body = request.get_data(as_text=True)

    if abs(time.time() - int(timestamp or 0)) > 300:
        return '', 400

    expected = 'sha256=' + hmac.new(
        secret.encode(),
        f'{timestamp}.{body}'.encode(),
        hashlib.sha256,
    ).hexdigest()

    if not hmac.compare_digest(expected, signature):
        return '', 403

    data = request.get_json()

    # Handle the order

    return '', 200
```

## Hal yang perlu diingat

**Yang ditandatangani isi mentah.** Jika Anda mengurai JSON lalu menyusunnya kembali, urutan kunci dan spasinya berubah—tanda tangan tidak akan cocok. Hitung HMAC sebelum mengurai.

**Setiap percobaan ulang punya tanda tangannya sendiri.** Waktu pengiriman baru pada setiap percobaan ulang, jadi tanda tangannya pun berbeda. Jangan menyimpan tanda tangan untuk dibandingkan dengan permintaan berikutnya.

**Kunci rahasia dapat diterbitkan ulang.** Kunci lama berhenti bekerja segera setelah penerbitan ulang, jadi perbarui nilainya di toko Anda pada saat yang sama.

**Verifikasi tanda tangan tidak menggantikan idempotensi.** Tanda tangan membuktikan permintaan itu autentik, bukan bahwa Anda melihatnya untuk pertama kali. Percobaan ulang tiba dengan tanda tangan yang sah—periksa terhadap `invoice.id`.
