Bagian 11

Signature dan retry webhook

Verifikasi HMAC SHA256, langkah pemeriksaan, contoh kode, dan kebijakan retry.

Header webhook

HeaderIsi
X-AntaraPay-Event-IdSama dengan event_id pada body.
X-AntaraPay-TimestampWaktu delivery berupa Unix timestamp dalam detik.
X-AntaraPay-Signaturev1=<hex HMAC SHA256>; secret per merchant/environment.

Rumus signature

text
signed_payload = event_id + "." + timestamp + "." + raw_body
signature      = hex(HMAC_SHA256(webhook_secret, signed_payload))
header         = "v1=" + signature

Langkah pemeriksaan di server merchant

  1. Ambil raw request body sebagai bytes sebelum JSON parser mengubahnya. Baca tiga header wajib dan batasi ukuran payload.
  2. Pastikan timestamp integer dan selisih dengan waktu server maksimum 300 detik. Sinkronkan jam server. occurred_at dapat lebih lama karena retry; pengecekan freshness menggunakan header timestamp.
  3. Hitung HMAC atas event_id, timestamp, pemisah titik, dan raw body persis diterima. Bandingkan digest secara constant-time. Tolak signature yang tidak valid.
  4. Parse JSON hanya setelah signature lolos; pastikan event_id body sama dengan header dan schema_version didukung.
  5. Simpan event_id dengan unique constraint dan event ke inbox durable secara atomik. Jika sudah tersimpan, balas 2xx tanpa memproses ulang.
  6. Proses event dengan versi resource terbaru. Terapkan perubahan status pesanan secara idempotent. Jika data bertentangan atau urutan hilang, konfirmasi melalui GET API.

Contoh kode

Pseudocode

text
raw = request.raw_body
event_id = header("X-AntaraPay-Event-Id")
ts = header("X-AntaraPay-Timestamp")
assert abs(unix_now() - int(ts)) <= 300
message = utf8(event_id + "." + ts + ".") + raw
expected = hmac_sha256(secret, message)
assert constant_time_equal(expected, decode_signature(header_sig))
event = json_parse(raw)
assert event.event_id == event_id
persist_once(event_id, event)
return 204

Node.js (Express)

webhook.js
import crypto from "node:crypto";
import express from "express";

const app = express();
const SECRET = process.env.ANTARAPAY_WEBHOOK_SECRET;

// Raw body is required: do NOT use express.json() on this route.
app.post(
  "/webhooks/antarapay",
  express.raw({ type: "application/json", limit: "256kb" }),
  async (req, res) => {
    const eventId = req.get("X-AntaraPay-Event-Id");
    const ts = req.get("X-AntaraPay-Timestamp");
    const header = req.get("X-AntaraPay-Signature") || "";

    if (!eventId || !/^[0-9]+$/.test(ts || "")) return res.sendStatus(400);
    if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(400);

    const expected = crypto
      .createHmac("sha256", SECRET)
      .update(Buffer.concat([Buffer.from(eventId + "." + ts + "."), req.body]))
      .digest();
    const given = Buffer.from(header.startsWith("v1=") ? header.slice(3) : "", "hex");
    if (given.length !== expected.length || !crypto.timingSafeEqual(given, expected)) {
      return res.sendStatus(401);
    }

    const event = JSON.parse(req.body.toString("utf8"));
    if (event.event_id !== eventId) return res.sendStatus(400);

    // Insert into a durable inbox with a UNIQUE(event_id) constraint.
    // If it already exists, still answer 2xx without reprocessing.
    await saveOnce(eventId, event);
    res.sendStatus(204);
  },
);

Python (Flask)

webhook.py
import hashlib
import hmac
import json
import os
import time

from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["ANTARAPAY_WEBHOOK_SECRET"].encode()


@app.post("/webhooks/antarapay")
def antarapay_webhook():
    raw = request.get_data()  # bytes, before any JSON parsing
    event_id = request.headers.get("X-AntaraPay-Event-Id", "")
    ts = request.headers.get("X-AntaraPay-Timestamp", "")
    header = request.headers.get("X-AntaraPay-Signature", "")

    if not event_id or not ts.isdigit():
        abort(400)
    if abs(time.time() - int(ts)) > 300:
        abort(400)

    message = (event_id + "." + ts + ".").encode() + raw
    expected = "v1=" + hmac.new(SECRET, message, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, header):
        abort(401)

    event = json.loads(raw)
    if event["event_id"] != event_id:
        abort(400)

    save_once(event_id, event)  # UNIQUE(event_id); 2xx if already stored
    return "", 204
Contoh Node.js dan Python di atas adalah ilustrasi implementasi dari aturan di panduan; fungsi saveOnce/save_once adalah penyimpanan inbox milik Anda.

Kebijakan pengiriman ulang

AspekRancangan v1
Jadwal retrySegera, 1 menit, 5 menit, 15 menit, 1 jam, 6 jam, dan 24 jam, dengan jitter.
Timeout delivery10 detik.
Dianggap gagalRespons non-2xx, timeout, dan kegagalan jaringan; redirect tidak diikuti.
Retry/replayMemakai event_id tetap dengan timestamp dan signature delivery baru.
Retry habisStatus pengiriman webhook menjadi FAILED; ini status delivery, bukan data.status transaksi.

Transaksi tidak berubah karena notifikasi gagal. Event dapat datang berulang dan tidak berurutan; proses versi terbaru dan deduplikasi event_id. Replay memakai ID event yang sama. Saat rotasi secret, gunakan periode transisi yang disepakati dengan AntaraPay.