AKD Pay Docs
Bahasa IndonesiaID Dashboard
LIVE This documentation applies to both sandbox and live.

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_at bernilai null untuk payment.failed dan payment.expired.
  • event_id bersifat opaque (jangan diurai). Event dari penyapu kedaluwarsa berbentuk sweep:<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_status adalah PARTIAL bila masih ada sisa yang bisa direfund, FULL bila seluruh nominal sudah direfund.
  • event_id unik per refund; gunakan untuk deduplikasi seperti event lain.
  • amount adalah nominal refund ini; refunded_amount adalah total refund pembayaran tersebut sejauh ini (refund sebagian dan berulang diperbolehkan).
  • Status pembayaran tetap PAID. Abaikan event yang tidak Anda kenal (jawab 2xx), 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 2xx yang 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 (string whsec_… utuh sebagai byte UTF-8), atas byte "<t>." + raw body.
  • Saat rotasi secret ada dua v1 selama 24 jam (secret baru dan lama). Terima request bila salah satu v1 cocok.

Langkah verifikasi:

  1. Baca raw body sebelum parsing JSON, dan verifikasi byte tersebut apa adanya.
  2. Pisahkan header menjadi t dan semua nilai v1.
  3. Tolak bila |sekarang − t| > 300 detik.
  4. Hitung hex(HMAC_SHA256(secret, t + "." + body)) dan bandingkan dengan setiap v1 secara constant-time. Tolak bila tidak ada yang cocok.
  5. Deduplikasi dengan X-AKD-Event-ID.
  6. Jawab 2xx cepat (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 DEAD dan 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, dan FAILED (delivery lama sebelum sistem outbox; tidak bisa dikirim ulang).
  • Kirim ulang: Dashboard Merchant → Pengembang → Riwayat webhook → "Kirim ulang" untuk delivery DEAD (atau FAILED). Delivery diantrikan lagi dengan jatah 10 percobaan baru, body dan X-AKD-Event-ID yang sama, dan signature baru. Batas 30 kirim ulang per merchant per jam. Tim AKD juga dapat mengirim ulang dari console.