Bagian 11
Signature dan retry webhook
Verifikasi HMAC SHA256, langkah pemeriksaan, contoh kode, dan kebijakan retry.
Header webhook
| Header | Isi |
|---|---|
X-AntaraPay-Event-Id | Sama dengan event_id pada body. |
X-AntaraPay-Timestamp | Waktu delivery berupa Unix timestamp dalam detik. |
X-AntaraPay-Signature | v1=<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=" + signatureLangkah pemeriksaan di server merchant
- Ambil raw request body sebagai bytes sebelum JSON parser mengubahnya. Baca tiga header wajib dan batasi ukuran payload.
- Pastikan timestamp integer dan selisih dengan waktu server maksimum 300 detik. Sinkronkan jam server.
occurred_atdapat lebih lama karena retry; pengecekan freshness menggunakan header timestamp. - Hitung HMAC atas
event_id, timestamp, pemisah titik, dan raw body persis diterima. Bandingkan digest secara constant-time. Tolak signature yang tidak valid. - Parse JSON hanya setelah signature lolos; pastikan
event_idbody sama dengan header danschema_versiondidukung. - Simpan
event_iddengan unique constraint dan event ke inbox durable secara atomik. Jika sudah tersimpan, balas 2xx tanpa memproses ulang. - 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 204Node.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 "", 204Contoh 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
| Aspek | Rancangan v1 |
|---|---|
| Jadwal retry | Segera, 1 menit, 5 menit, 15 menit, 1 jam, 6 jam, dan 24 jam, dengan jitter. |
| Timeout delivery | 10 detik. |
| Dianggap gagal | Respons non-2xx, timeout, dan kegagalan jaringan; redirect tidak diikuti. |
| Retry/replay | Memakai event_id tetap dengan timestamp dan signature delivery baru. |
| Retry habis | Status 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.