Langsung ke konten

Cari

    Menu dokumentasi

    Webhooks

    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.

    Diperbarui

    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
    • t adalah waktu pengiriman dalam detik unix.
    • v1 adalah 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:

    1. Ambil body mentah, yaitu byte persis seperti yang diterima, sebelum diubah menjadi objek JSON.
    2. Pisahkan t dan v1 dari header OngkirHub-Signature.
    3. Tolak kiriman bila selisih t dengan jam servermu lebih dari 5 menit. Ini mencegah kiriman lama direkam lalu dikirim ulang oleh pihak lain.
    4. Hitung HMAC SHA-256 dengan secret endpoint atas t + "." + body mentah.
    5. Bandingkan hasilnya dengan v1 memakai 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.