API reference
Webhooks & signatures
The events AKD sends, how to verify X-AKD-Signature-V2, and the retry schedule.
In short: AKD sends payment.paid, payment.failed, payment.expired, and payment.refunded as a JSON POST to your HTTPS endpoint, at least once and without ordering guarantees. Verify X-AKD-Signature-V2 (t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>." + raw body> keyed with your whsec_… secret), reject timestamps more than 300 seconds off, deduplicate on X-AKD-Event-ID, and answer 2xx within 10 seconds. Failed deliveries are retried up to 10 times (about 1.5 days) before they become DEAD. The verification examples below (Node.js, PHP, Go) need no translation.
§7 Webhook ke server merchant#
AKD mengirim POST JSON ke endpoint HTTPS yang terdaftar setiap kali pembayaran mencapai status final.
| Event | Dikirim saat |
|---|---|
payment.paid |
pembayaran menjadi PAID |
payment.failed |
pembayaran menjadi FAILED |
payment.expired |
pembayaran menjadi EXPIRED (oleh provider, atau oleh AKD sekitar 15 menit setelah expires_at) |
payment.refunded |
refund selesai (sejak v1.0.3) |
Body payment.paid, payment.failed, payment.expired:
{
"event": "payment.paid",
"event_id": "xendit:5f0c2a…",
"data": {
"payment_id": "2b8f6c1e-0d5a-4c1b-9f3e-7a6d5c4b3a21",
"external_order_id": "ORDER-10023",
"amount": 150000,
"currency": "IDR",
"status": "PAID",
"paid_at": "2026-11-01T03:12:09Z"
}
}paid_atbernilainulluntukpayment.faileddanpayment.expired.event_idbersifat opaque (jangan diurai). Event dari penyapu kedaluwarsa berbentuksweep:<payment_id>:EXPIRED.
Body payment.refunded:
{
"event": "payment.refunded",
"event_id": "akd-refund:6a1d0c9e-…",
"data": {
"payment_id": "2b8f6c1e-0d5a-4c1b-9f3e-7a6d5c4b3a21",
"external_order_id": "ORDER-10023",
"refund_id": "6a1d0c9e-…",
"amount": 50000,
"refunded_amount": 50000,
"status": "SUCCEEDED",
"currency": "IDR",
"payment_amount": 150000,
"payment_status": "PAID",
"refund_status": "PARTIAL",
"refunded_at": "2026-11-02T04:10:00Z"
}
}refund_statusadalahPARTIALbila masih ada sisa yang bisa direfund,FULLbila seluruh nominal sudah direfund.event_idunik per refund; gunakan untuk deduplikasi seperti event lain.amountadalah nominal refund ini;refunded_amountadalah total refund pembayaran tersebut sejauh ini (refund sebagian dan berulang diperbolehkan).- Status pembayaran tetap
PAID. Abaikan event yang tidak Anda kenal (jawab2xx), supaya event baru tidak memutus integrasi.
Header setiap pengiriman:
| Header | Nilai |
|---|---|
Content-Type |
application/json |
User-Agent |
AKD-Webhooks/1 |
X-AKD-Event-ID |
event_id; sama untuk setiap retry dan kirim ulang |
X-AKD-Timestamp |
waktu kirim, RFC 3339 UTC (informasi saja, tidak ditandatangani) |
X-AKD-Signature-V2 |
signature per endpoint (skema both dan v2) |
X-AKD-Signature |
signature lama (skema legacy dan both) |
Jaminan pengiriman:
- At least once: event yang sama bisa datang lebih dari sekali. Deduplikasi dengan
X-AKD-Event-ID. - Tidak ada jaminan urutan antar pembayaran.
- Hanya
2xxyang dianggap sukses. Status lain, timeout 10 detik, error koneksi, atau redirect (tidak diikuti) dianggap gagal.
§8 Verifikasi signature webhook#
Setiap endpoint punya signature_scheme yang diatur tim AKD dan terlihat di Dashboard Merchant → Pengembang:
| Skema | Header yang dikirim |
|---|---|
legacy |
hanya X-AKD-Signature |
both |
X-AKD-Signature dan X-AKD-Signature-V2 (masa transisi) |
v2 |
hanya X-AKD-Signature-V2 (disarankan, wajib untuk integrasi baru) |
V2 (disarankan)#
X-AKD-Signature-V2: t=1761966729,v1=5257a869e7…[,v1=9c1f…]t: waktu kirim dalam detik Unix.v1: hex huruf kecil dari HMAC-SHA256 dengan kunci = signing secret endpoint (stringwhsec_…utuh sebagai byte UTF-8), atas byte"<t>." + raw body.- Saat rotasi secret ada dua
v1selama 24 jam (secret baru dan lama). Terima request bila salah satuv1cocok.
Langkah verifikasi:
- Baca raw body sebelum parsing JSON, dan verifikasi byte tersebut apa adanya.
- Pisahkan header menjadi
tdan semua nilaiv1. - Tolak bila
|sekarang − t| > 300detik. - Hitung
hex(HMAC_SHA256(secret, t + "." + body))dan bandingkan dengan setiapv1secara constant-time. Tolak bila tidak ada yang cocok. - Deduplikasi dengan
X-AKD-Event-ID. - Jawab
2xxcepat (di bawah 10 detik); kerjakan proses berat setelahnya.
Node.js (Express)
const crypto = require('crypto');
const express = require('express');
function verifyAkdV2(secret, header, rawBody, toleranceSec = 300) {
if (!header) return false;
let t = null;
const sigs = [];
for (const part of header.split(',')) {
const i = part.indexOf('=');
if (i < 0) continue;
const k = part.slice(0, i).trim();
const v = part.slice(i + 1).trim();
if (k === 't') t = v;
else if (k === 'v1') sigs.push(v);
}
if (!t || !/^\d+$/.test(t) || Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) return false;
const want = Buffer.from(crypto.createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest('hex'));
return sigs.some((s) => {
const got = Buffer.from(s);
return got.length === want.length && crypto.timingSafeEqual(got, want);
});
}
const app = express();
app.post('/webhooks/akd', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifyAkdV2(process.env.AKD_WEBHOOK_SECRET, req.get('X-AKD-Signature-V2'), req.body)) {
return res.status(401).end();
}
const event = JSON.parse(req.body.toString('utf8'));
// Deduplikasi dengan req.get('X-AKD-Event-ID'), lalu proses event.event / event.data.
res.status(204).end();
});PHP
<?php
function akd_verify_v2(string $secret, string $header, string $rawBody, int $tolerance = 300): bool
{
$t = null;
$sigs = [];
foreach (explode(',', $header) as $part) {
$kv = explode('=', trim($part), 2);
if (count($kv) !== 2) {
continue;
}
if ($kv[0] === 't') {
$t = $kv[1];
} elseif ($kv[0] === 'v1') {
$sigs[] = $kv[1];
}
}
if ($t === null || !ctype_digit($t) || abs(time() - (int) $t) > $tolerance) {
return false;
}
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
foreach ($sigs as $sig) {
if (hash_equals($expected, $sig)) {
return true;
}
}
return false;
}
$rawBody = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_AKD_SIGNATURE_V2'] ?? '';
if (!akd_verify_v2(getenv('AKD_WEBHOOK_SECRET'), $header, $rawBody)) {
http_response_code(401);
exit;
}
$event = json_decode($rawBody, true);
$eventId = $_SERVER['HTTP_X_AKD_EVENT_ID'] ?? '';
// Deduplikasi dengan $eventId, lalu proses $event['event'] / $event['data'].
http_response_code(204);Go
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strconv"
"strings"
"time"
)
func VerifyAKDV2(secret, header string, body []byte, now time.Time) bool {
var t string
var sigs []string
for _, part := range strings.Split(header, ",") {
k, v, ok := strings.Cut(strings.TrimSpace(part), "=")
if !ok {
continue
}
switch k {
case "t":
t = v
case "v1":
sigs = append(sigs, v)
}
}
ts, err := strconv.ParseInt(t, 10, 64)
if err != nil {
return false
}
if d := now.Unix() - ts; d > 300 || d < -300 {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(t + "."))
mac.Write(body)
want := []byte(hex.EncodeToString(mac.Sum(nil)))
for _, s := range sigs {
if hmac.Equal([]byte(s), want) {
return true
}
}
return false
}
// Di handler: body, _ := io.ReadAll(r.Body); VerifyAKDV2(secret, r.Header.Get("X-AKD-Signature-V2"), body, time.Now())Legacy (X-AKD-Signature, usang)#
X-AKD-Signature = hex huruf kecil HMAC-SHA256 atas raw body, dengan kunci bersama AKD (bukan secret per endpoint). Signature ini tidak memuat timestamp sehingga tidak melindungi dari replay. Gunakan hanya selama migrasi ke v2.
Node.js
const want = crypto.createHmac('sha256', legacyKey).update(rawBody).digest('hex');
const ok = sig.length === want.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(want));PHP
$ok = hash_equals(hash_hmac('sha256', $rawBody, $legacyKey), $_SERVER['HTTP_X_AKD_SIGNATURE'] ?? '');Go
mac := hmac.New(sha256.New, []byte(legacyKey))
mac.Write(body)
ok := hmac.Equal([]byte(r.Header.Get("X-AKD-Signature")), []byte(hex.EncodeToString(mac.Sum(nil))))Migrasi: minta skema both, terapkan verifikasi V2, pastikan semua webhook lolos V2, lalu minta skema v2.
Rotasi secret#
Minta rotasi ke tim AKD. Secret baru ditampilkan sekali; secret lama tetap ikut menandatangani selama 24 jam (dua nilai v1). Pasang secret baru di server Anda dalam 24 jam tersebut.
§9 Retry, DEAD, dan kirim ulang#
| Percobaan gagal ke- | Jeda sebelum percobaan berikutnya |
|---|---|
| 1 | 30 detik |
| 2 | 2 menit |
| 3 | 10 menit |
| 4 | 30 menit |
| 5 | 1 jam |
| 6 | 3 jam |
| 7 | 6 jam |
| 8 | 12 jam |
| 9 | 12 jam |
- Setiap jeda diacak ±20%.
- Maksimal 10 percobaan per delivery (sekitar 1,5 hari). Setelah percobaan ke-10 gagal, delivery menjadi
DEADdan tidak dicoba lagi otomatis. - Saat delivery menjadi
DEAD, owner dan developer merchant menerima email (paling banyak satu email per endpoint per jam). - Status delivery:
PENDING(menunggu atau sedang dicoba ulang),DELIVERED,DEAD, danFAILED(delivery lama sebelum sistem outbox; tidak bisa dikirim ulang). - Kirim ulang: Dashboard Merchant → Pengembang → Riwayat webhook → "Kirim ulang" untuk delivery
DEAD(atauFAILED). Delivery diantrikan lagi dengan jatah 10 percobaan baru, body danX-AKD-Event-IDyang sama, dan signature baru. Batas 30 kirim ulang per merchant per jam. Tim AKD juga dapat mengirim ulang dari console.