NawaPayNawaPay Docs

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.

HeaderWajibIsi
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"}
Tanda tangani byte yang benar-benar dikirim. Bila kamu menyusun JSON dengan pustaka, ambil hasil serialisasinya satu kali lalu pakai nilai itu untuk menandatangani sekaligus mengirim. Menandatangani hasil serialisasi ulang adalah penyebab kegagalan tanda tangan yang paling sering: perbedaan spasi, urutan kunci, atau pelolosan karakter Unicode menghasilkan byte yang berbeda.

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.
Penolakan pengulangan berbeda dengan idempotensi. Percobaan ulang yang sah harus memakai stempel waktu baru dan tanda tangan baru, tetapi Idempotency-Key yang sama.

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

HTTPKodePenyebab umum
401UNAUTHORIZEDHeader Authorization hilang atau formatnya salah.
401CLIENT_NOT_FOUNDclient_id tidak dikenal atau sudah dicabut.
401SIGNATURE_MISSINGX-Signature atau X-Timestamp tidak dikirim.
401SIGNATURE_INVALIDBadan yang ditandatangani berbeda dengan yang dikirim, atau secret salah.
401TIMESTAMP_OUT_OF_WINDOWSelisih jam lebih dari 300 detik.
401SIGNATURE_REPLAYEDTanda tangan yang sama dikirim dua kali.
403ENVIRONMENT_MISMATCHKunci 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.

NawaPay berada dalam tahap pengembangan. Seluruh contoh pada dokumentasi ini merujuk pada lingkungan sandbox; sistem belum berizin sebagai penyelenggara jasa pembayaran dan belum memproses transaksi uang sungguhan.

© 2026 NawaPay · docs.nawapay.co.id