Verifikasi tanda tangan webhook
Cara memverifikasi header OngkirHub-Signature dengan HMAC SHA-256, lengkap dengan contoh kode Node.js, PHP, dan Python, vektor uji, dan penyebab umum gagal.
URL webhook bisa diakses siapa saja dari internet. Verifikasi tanda tangan memastikan kiriman benar berasal dari OngkirHub dan isinya tidak diubah di jalan. Lakukan verifikasi sebelum isi event menyentuh logika bisnis apa pun.
Cara kerja
Setiap kiriman membawa header seperti ini:
OngkirHub-Signature: t=1791698400,v1=a678e60f2138825190c79dbe83e76e02fcc4f0ac41913d77c25d35ea5f26877f
tadalah waktu pengiriman dalam detik unix.v1adalah HMAC SHA-256 dalam heksadesimal huruf kecil (64 karakter).- Kunci HMAC adalah secret endpoint, seluruh teksnya termasuk awalan
whsec_, dipakai apa adanya. - Teks yang ditandatangani adalah nilai
t, sebuah titik, lalu body mentah:t + "." + body.
Langkah verifikasi:
- Ambil body mentah, yaitu byte persis seperti yang diterima, sebelum diubah menjadi objek JSON.
- Pisahkan
tdanv1dari headerOngkirHub-Signature. - Tolak kiriman bila selisih
tdengan jam servermu lebih dari 5 menit. Ini mencegah kiriman lama direkam lalu dikirim ulang oleh pihak lain. - Hitung HMAC SHA-256 dengan secret endpoint atas
t + "." + body mentah. - Bandingkan hasilnya dengan
v1memakai perbandingan waktu-konstan, lalu tolak bila tidak sama.
Nilai t dibuat ulang di setiap percobaan, sedangkan body tetap sama. Karena itu tanda tangan setiap percobaan berbeda, tetapi id event tetap sama.
Node.js
Contoh dengan Express. express.raw menjaga body tetap berupa byte asli untuk rute webhook.
import crypto from "node:crypto";
import express from "express";
const TOLERANCE_SEC = 300;
export function verifySignature(header, rawBody, secret) {
if (!header) return false;
const parts = {};
for (const item of header.split(",")) {
const index = item.indexOf("=");
if (index > 0) parts[item.slice(0, index).trim()] = item.slice(index + 1).trim();
}
const t = parts.t ?? "";
const v1 = parts.v1 ?? "";
if (!/^\d+$/.test(t) || !/^[0-9a-f]{64}$/.test(v1)) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > TOLERANCE_SEC) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
return crypto.timingSafeEqual(expected, Buffer.from(v1, "hex"));
}
const app = express();
app.post("/webhooks/ongkirhub", express.raw({ type: "application/json" }), (req, res) => {
const rawBody = req.body.toString("utf8");
if (!verifySignature(req.get("OngkirHub-Signature"), rawBody, process.env.ONGKIRHUB_WEBHOOK_SECRET)) {
return res.status(400).send("Tanda tangan tidak valid");
}
const event = JSON.parse(rawBody);
// Simpan event.id, masukkan event ke antreanmu, lalu jawab secepatnya.
res.sendStatus(200);
});
Pola 64 karakter pada v1 dicek lebih dulu karena timingSafeEqual mensyaratkan panjang kedua nilai sama.
PHP
Contoh PHP murni. Body mentah dibaca dari php://input.
<?php
function verify_ongkirhub_signature(string $header, string $rawBody, string $secret, int $toleranceSec = 300): bool
{
$parts = [];
foreach (explode(',', $header) as $item) {
$pair = explode('=', trim($item), 2);
if (count($pair) === 2) {
$parts[$pair[0]] = $pair[1];
}
}
$t = $parts['t'] ?? '';
$v1 = $parts['v1'] ?? '';
if (!ctype_digit($t) || !preg_match('/^[0-9a-f]{64}$/', $v1)) {
return false;
}
if (abs(time() - (int) $t) > $toleranceSec) {
return false;
}
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
return hash_equals($expected, $v1);
}
$rawBody = file_get_contents('php://input');
$header = $_SERVER['HTTP_ONGKIRHUB_SIGNATURE'] ?? '';
$secret = getenv('ONGKIRHUB_WEBHOOK_SECRET');
if (!verify_ongkirhub_signature($header, $rawBody, $secret)) {
http_response_code(400);
exit('Tanda tangan tidak valid');
}
$event = json_decode($rawBody, true);
// Simpan $event['id'], masukkan event ke antreanmu, lalu jawab secepatnya.
http_response_code(200);
Di framework PHP, ambil body mentah dari objek request framework tersebut, bukan dari data yang sudah di-decode.
Python
Contoh dengan Flask. request.get_data() mengembalikan byte asli sebelum di-parse.
import hashlib
import hmac
import os
import time
from flask import Flask, abort, request
TOLERANCE_SEC = 300
def verify_signature(header, raw_body: bytes, secret: str) -> bool:
if not header:
return False
parts = dict(item.strip().split("=", 1) for item in header.split(",") if "=" in item)
t, v1 = parts.get("t", ""), parts.get("v1", "")
if not t.isdigit() or abs(time.time() - int(t)) > TOLERANCE_SEC:
return False
expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
app = Flask(__name__)
@app.post("/webhooks/ongkirhub")
def ongkirhub_webhook():
raw_body = request.get_data()
if not verify_signature(request.headers.get("OngkirHub-Signature"), raw_body, os.environ["ONGKIRHUB_WEBHOOK_SECRET"]):
abort(400)
event = request.get_json()
# Simpan event["id"], masukkan event ke antreanmu, lalu jawab secepatnya.
return "", 200
Vektor uji
Pakai data ini untuk menguji fungsi verifikasimu. Matikan pemeriksaan selisih waktu saat menguji, karena nilai t di contoh sudah lama.
| Nilai | |
|---|---|
| Secret | whsec_xxx |
t |
1791698400 |
Hasil v1 yang benar |
a678e60f2138825190c79dbe83e76e02fcc4f0ac41913d77c25d35ea5f26877f |
Body mentah (satu baris, tanpa spasi tambahan):
{"id":"evt_0a1b2c3d4e5f6a7b8c9d0e1f","type":"webhook.test","createdAt":"2026-10-11T06:00:00.000Z","livemode":false,"data":{"message":"Tes webhook dari OngkirHub"}}
Untuk uji menyeluruh, tekan Kirim tes di tab Webhook menu Developer. Kiriman itu ditandatangani dengan secret endpoint yang sebenarnya.
Kenapa verifikasi gagal
- Body sudah di-parse. Middleware JSON bawaan framework mengubah body menjadi objek. Saat diubah lagi menjadi teks, spasi atau urutannya bisa berbeda. Pakai pembaca body mentah khusus untuk rute webhook.
- Encoding berubah. Perlakukan body sebagai UTF-8 tanpa memangkas spasi atau baris baru.
- Secret salah. Setiap endpoint punya secret sendiri. Pakai secret endpoint yang benar, lengkap dengan awalan
whsec_, tanpa spasi tambahan saat disalin. - Secret sudah diganti. Setelah Ganti secret, secret lama langsung tidak berlaku.
- Format hasil berbeda. Bandingkan dengan heksadesimal huruf kecil, bukan base64.
- Jam server melenceng. Sinkronkan jam server secara otomatis agar pemeriksaan selisih waktu tidak menolak kiriman yang sah.
- Proxy mengubah body. Pastikan reverse proxy atau layanan perantara meneruskan body apa adanya.
Mengganti secret
Kalau secret bocor, buka menu titik tiga di endpoint lalu pilih Ganti secret. Secret lama langsung tidak berlaku dan secret baru tampil sekali di dialog. Kiriman yang datang sebelum secret baru terpasang di servermu akan gagal verifikasi. Jawab kiriman itu dengan status selain 2xx agar dicoba ulang otomatis, atau kirim ulang dari tab Log webhook setelah secret baru terpasang.
Kamu juga bisa melihat secret yang sedang aktif kapan saja lewat Tampilkan secret.