Webhooks ke project

Saat charge menjadi paid, expired, atau failed, Payroute mengirim POST ke callback_url charge itu.

Request

Header:

Header Isi
X-Payroute-Event charge.paid / charge.expired / charge.failed
X-Payroute-Delivery id pengiriman, mis. dlv_1842 — sama untuk setiap retry event itu
X-Payroute-Timestamp Unix detik saat dikirim
X-Payroute-Signature HMAC-SHA256 hex dengan secret client Anda
User-Agent VOG-Payroute/1

Body:

{
  "event": "charge.paid",
  "charge": { "id": "PR01JC5Q8ZK3M2N4P6R8T0", "invoice_id": "INV-20261105-0001", "status": "paid", "gateway": "tripay", "rerouted": false, "...": "…" }
}

charge sama persis dengan objek di GET /v1/charges/{id}.

Verifikasi (wajib)

Hitung signature seperti request signature dengan method POST dan path + query callback_url Anda, lalu bandingkan dengan constant-time compare. Tolak (401) kalau tidak cocok atau timestamp terlalu jauh (mis. > 5 menit — retry selalu ditandatangani ulang dengan waktu kirimnya).

// pathAndQuery: path + query of the callback_url you registered (as Payroute
// sent it), e.g. "/payroute/callback" — use r.URL.RequestURI() only when no
// proxy rewrites the path.
func Verify(secret, pathAndQuery string, r *http.Request, body []byte, nowUnix int64) bool {
	ts := r.Header.Get("X-Payroute-Timestamp")
	sec, err := strconv.ParseInt(ts, 10, 64)
	if err != nil || nowUnix-sec > 300 || sec-nowUnix > 300 { // tolak di luar ±5 menit (anti replay)
		return false
	}
	want := Sign(secret, ts, "POST", pathAndQuery, body)
	return hmac.Equal([]byte(want), []byte(r.Header.Get("X-Payroute-Signature")))
}

Versi lengkap (Go dan TypeScript) dengan test: docs/examples/sign.go, docs/examples/sign.ts.

Aturan penting

  1. Jawab 2xx secepatnya (< 10 detik). Proses berat lakukan di belakang.
  2. At-least-once: event yang sama bisa datang lebih dari sekali. Dedup dengan X-Payroute-Delivery atau dengan membuat update Anda idempotent.
  3. Percaya charge.status, bukan nama event. Body selalu berisi status terkini saat dikirim; retry charge.expired yang telat bisa membawa status: "paid" kalau pelanggan sudah bayar.
  4. Redirect (3xx) tidak diikuti dan dihitung gagal. Gunakan URL final.
  5. Charge yang gagal saat create (502) tidak mengirim webhook — Anda sudah tahu dari responsnya.

Retry

Jawaban selain 2xx, timeout > 10 detik, atau koneksi gagal → dicoba lagi setelah 1 menit, 5 menit, 15 menit, 1 jam, 6 jam, 24 jam, 24 jam. Kegagalan ke-8 (± 2 hari 7 jam) menghentikan pengiriman (dead); tim Payroute bisa mengirim ulang. Selama itu, GET /v1/charges/{id} tetap memberi status terbaru.