Autentikasi
Setiap permintaan ke api.nawapay.co.id harus membuktikan dua hal: siapa pengirimnya dan bahwa isi permintaan tidak berubah di jalan. Pembuktian itu dilakukan dengan tanda tangan HMAC-SHA256 atas stempel waktu dan badan permintaan. Tanda tangan diverifikasi pada setiap endpoint tanpa pengecualian.
Header yang wajib dikirim
| Header | Wajib | Isi |
|---|---|---|
| Authorization | Ya | Bearer <client_id>. Bagian publik dari pasangan kredensial. |
| X-Timestamp | Ya | Waktu Unix dalam detik (bukan milidetik), sebagai teks desimal. |
| X-Signature | Ya | Heksadesimal huruf kecil, 64 karakter. Hasil HMAC-SHA256 atas string kanonik. |
| Idempotency-Key | Untuk POST | Lihat Idempotensi. |
| Content-Type | Untuk POST | application/json |
String kanonik
Nilai yang ditandatangani dibentuk dari dua bagian yang dipisahkan satu titik:
canonical = X-Timestamp + "." + raw_request_body
signature = hex( hmac_sha256( secret, canonical ) )
Contoh nyata:
X-Timestamp : 1785200130
body : {"amount_minor":150000,"currency":"IDR","payment_method":"qris","reference_id":"ORD-10427"}
canonical : 1785200130.{"amount_minor":150000,"currency":"IDR","payment_method":"qris","reference_id":"ORD-10427"}
Permintaan tanpa badan
Untuk GET dan permintaan lain tanpa badan, gunakan string kosong sebagai badan. String kanoniknya berakhir dengan titik, misalnya 1785200130. — bukan hanya stempel waktu. Parameter kueri tidak ikut ditandatangani.
Jendela waktu & pengulangan
- Selisih antara X-Timestamp dan jam server maksimum 300 detik ke arah mana pun. Di luar itu permintaan ditolak dengan 401 TIMESTAMP_OUT_OF_WINDOW.
- Kombinasi client_id + X-Timestamp + X-Signature hanya boleh dipakai satu kali. Pengulangan persis ditolak dengan 401 SIGNATURE_REPLAYED.
- Pastikan jam server kamu tersinkronisasi (NTP). Jam yang melenceng adalah penyebab utama galat autentikasi yang muncul tiba-tiba tanpa perubahan kode.
Contoh kode
Enam implementasi yang menghasilkan tanda tangan identik untuk masukan yang sama. Ganti nilai kredensial dengan milikmu — dan baca dari variabel lingkungan, bukan dari konstanta di dalam kode.
#!/usr/bin/env bash
set -euo pipefail
BASE="https://api.nawapay.co.id/v1"
CLIENT_ID="${NPY_CLIENT_ID:?variabel NPY_CLIENT_ID belum diisi}"
SECRET="${NPY_SECRET:?variabel NPY_SECRET belum diisi}"
TS=$(date +%s)
BODY='{"amount_minor":150000,"currency":"IDR","payment_method":"qris","reference_id":"ORD-10427"}'
SIG=$(printf '%s' "$TS.$BODY" | openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1)
curl -sS -X POST "$BASE/payments" \
-H "Authorization: Bearer $CLIENT_ID" \
-H "X-Timestamp: $TS" \
-H "X-Signature: $SIG" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d "$BODY"
import hashlib
import hmac
import json
import os
import time
import uuid
import requests
BASE = "https://api.nawapay.co.id/v1"
CLIENT_ID = os.environ["NPY_CLIENT_ID"]
SECRET = os.environ["NPY_SECRET"].encode("utf-8")
def sign(secret: bytes, timestamp: str, body: str) -> str:
canonical = f"{timestamp}.{body}".encode("utf-8")
return hmac.new(secret, canonical, hashlib.sha256).hexdigest()
def post(path: str, payload: dict, idempotency_key: str) -> requests.Response:
# Serialisasi SATU KALI: nilai inilah yang ditandatangani sekaligus dikirim.
body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
ts = str(int(time.time()))
headers = {
"Authorization": f"Bearer {CLIENT_ID}",
"X-Timestamp": ts,
"X-Signature": sign(SECRET, ts, body),
"Idempotency-Key": idempotency_key,
"Content-Type": "application/json",
}
return requests.post(
BASE + path, data=body.encode("utf-8"), headers=headers, timeout=20
)
res = post(
"/payments",
{
"amount_minor": 150_000, # Rp150.000 — IDR memakai eksponen 0
"currency": "IDR",
"payment_method": "qris",
"reference_id": "ORD-10427",
},
idempotency_key=str(uuid.uuid4()),
)
print(res.status_code, res.json())
<?php
declare(strict_types=1);
const BASE = 'https://api.nawapay.co.id/v1';
function npy_sign(string $secret, string $timestamp, string $body): string
{
return hash_hmac('sha256', $timestamp . '.' . $body, $secret);
}
function npy_post(string $path, array $payload, string $idempotencyKey): array
{
$clientId = getenv('NPY_CLIENT_ID');
$secret = getenv('NPY_SECRET');
// Serialisasi sekali; string $body ini yang ditandatangani sekaligus dikirim.
$body = json_encode(
$payload,
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR
);
$ts = (string) time();
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 20,
CURLOPT_POSTFIELDS => $body,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $clientId,
'X-Timestamp: ' . $ts,
'X-Signature: ' . npy_sign($secret, $ts, $body),
'Idempotency-Key: ' . $idempotencyKey,
'Content-Type: application/json',
],
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
return ['status' => $status, 'data' => json_decode((string) $raw, true)];
}
$result = npy_post('/payments', [
'amount_minor' => 150000, // Rp150.000
'currency' => 'IDR',
'payment_method' => 'qris',
'reference_id' => 'ORD-10427',
], bin2hex(random_bytes(16)));
var_dump($result);
// Node.js 18+ (fetch bawaan). Simpan sebagai npy.mjs
import crypto from 'node:crypto';
const BASE = 'https://api.nawapay.co.id/v1';
const CLIENT_ID = process.env.NPY_CLIENT_ID;
const SECRET = process.env.NPY_SECRET;
function sign(secret, timestamp, body) {
return crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${body}`, 'utf8')
.digest('hex');
}
async function post(path, payload, idempotencyKey) {
// Serialisasi sekali; string body ini yang ditandatangani sekaligus dikirim.
const body = JSON.stringify(payload);
const ts = Math.floor(Date.now() / 1000).toString();
const res = await fetch(BASE + path, {
method: 'POST',
headers: {
Authorization: `Bearer ${CLIENT_ID}`,
'X-Timestamp': ts,
'X-Signature': sign(SECRET, ts, body),
'Idempotency-Key': idempotencyKey,
'Content-Type': 'application/json'
},
body,
signal: AbortSignal.timeout(20000)
});
return { status: res.status, data: await res.json() };
}
const out = await post(
'/payments',
{
amount_minor: 150000, // Rp150.000
currency: 'IDR',
payment_method: 'qris',
reference_id: 'ORD-10427'
},
crypto.randomUUID()
);
console.log(out.status, out.data);
// Java 17+
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.HexFormat;
import java.util.UUID;
public final class NpyClient {
private static final String BASE = "https://api.nawapay.co.id/v1";
static String sign(String secret, String timestamp, String body) throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] digest = mac.doFinal((timestamp + "." + body).getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(digest); // heksadesimal huruf kecil
}
public static void main(String[] args) throws Exception {
String clientId = System.getenv("NPY_CLIENT_ID");
String secret = System.getenv("NPY_SECRET");
// Nominal selalu long dalam satuan terkecil — tidak pernah double.
long amountMinor = 150_000L;
String body = "{\"amount_minor\":" + amountMinor
+ ",\"currency\":\"IDR\",\"payment_method\":\"qris\""
+ ",\"reference_id\":\"ORD-10427\"}";
String ts = Long.toString(System.currentTimeMillis() / 1000L);
HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + "/payments"))
.header("Authorization", "Bearer " + clientId)
.header("X-Timestamp", ts)
.header("X-Signature", sign(secret, ts, body))
.header("Idempotency-Key", UUID.randomUUID().toString())
.header("Content-Type", "application/json")
.timeout(Duration.ofSeconds(20))
.POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8))
.build();
HttpResponse<String> res = HttpClient.newHttpClient()
.send(req, HttpResponse.BodyHandlers.ofString());
System.out.println(res.statusCode() + " " + res.body());
}
}
package main
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
"github.com/google/uuid"
)
const base = "https://api.nawapay.co.id/v1"
func sign(secret, timestamp, body string) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(timestamp + "." + body))
return hex.EncodeToString(mac.Sum(nil))
}
func main() {
clientID := os.Getenv("NPY_CLIENT_ID")
secret := os.Getenv("NPY_SECRET")
// Nominal selalu int64 satuan terkecil.
const amountMinor int64 = 150000
body := fmt.Sprintf(
`{"amount_minor":%d,"currency":"IDR","payment_method":"qris","reference_id":"ORD-10427"}`,
amountMinor,
)
ts := strconv.FormatInt(time.Now().Unix(), 10)
req, err := http.NewRequest(http.MethodPost, base+"/payments", bytes.NewBufferString(body))
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+clientID)
req.Header.Set("X-Timestamp", ts)
req.Header.Set("X-Signature", sign(secret, ts, body))
req.Header.Set("Idempotency-Key", uuid.NewString())
req.Header.Set("Content-Type", "application/json")
res, err := (&http.Client{Timeout: 20 * time.Second}).Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
out, _ := io.ReadAll(res.Body)
fmt.Println(res.StatusCode, string(out))
}
Memverifikasi tanda tangan di sisi kamu
Algoritma yang sama dipakai untuk memeriksa webhook yang kami kirim ke kamu. Dua hal yang wajib diperhatikan saat menulis pemeriksa:
- Pakai badan mentah. Ambil byte permintaan sebelum kerangka kerja web memparsingnya menjadi objek.
- Bandingkan dengan waktu tetap. Gunakan hmac.compare_digest, hash_equals, crypto.timingSafeEqual, atau hmac.Equal — jangan memakai == biasa pada string tanda tangan.
import hashlib
import hmac
import time
def verify(secret: bytes, timestamp: str, raw_body: bytes, signature: str,
tolerance_seconds: int = 300) -> bool:
try:
skew = abs(int(time.time()) - int(timestamp))
except (TypeError, ValueError):
return False
if skew > tolerance_seconds:
return False
expected = hmac.new(
secret, timestamp.encode("utf-8") + b"." + raw_body, hashlib.sha256
).hexdigest()
# Perbandingan waktu tetap — mencegah kebocoran lewat pengukuran waktu.
return hmac.compare_digest(expected, signature)
Galat autentikasi
| HTTP | Kode | Penyebab umum |
|---|---|---|
| 401 | UNAUTHORIZED | Header Authorization hilang atau formatnya salah. |
| 401 | CLIENT_NOT_FOUND | client_id tidak dikenal atau sudah dicabut. |
| 401 | SIGNATURE_MISSING | X-Signature atau X-Timestamp tidak dikirim. |
| 401 | SIGNATURE_INVALID | Badan yang ditandatangani berbeda dengan yang dikirim, atau secret salah. |
| 401 | TIMESTAMP_OUT_OF_WINDOW | Selisih jam lebih dari 300 detik. |
| 401 | SIGNATURE_REPLAYED | Tanda tangan yang sama dikirim dua kali. |
| 403 | ENVIRONMENT_MISMATCH | Kunci sandbox dipakai pada objek produksi, atau sebaliknya. |
Menjaga dan merotasi kredensial
- Simpan secret di brankas rahasia atau variabel lingkungan dengan izin berkas 600. Jangan pernah menaruhnya di repositori, berkas konfigurasi publik, atau kode sisi peramban.
- Satu pasang kredensial untuk satu sistem. Dengan begitu pencabutan tidak mematikan seluruh integrasi.
- Saat merotasi: buat pasangan baru, terapkan, pastikan lalu lintas sudah berpindah, baru cabut yang lama. Dua pasang kunci boleh aktif bersamaan selama masa peralihan.
- Cabut segera bila secret pernah tampil di log, tiket, atau tangkapan layar.

