Contoh integrasi
Satu berkas lengkap per bahasa untuk membuat pembayaran, memberi alamat deposit, dan menangani webhook. Salin, atur kunci Anda, lalu ganti dua fungsi yang memperbarui basis data Anda.
Contoh yang sama, ditambah bot Telegram dan checkout Next.js, ada di GitHub: github.com/maxwellzen1/kokiwa-examples
Sebelum mulai
- Buat kunci API di dasbor merchant. Mulailah dengan kunci uji (sk_test_) di testnet.
- Tambahkan URL webhook yang mengarah ke server Anda, misalnya https://your-shop.com/kokiwa/webhook, dan salin rahasia tanda tangannya (whsec_).
- Simpan sebagai KOKIWA_KEY dan KOKIWA_WEBHOOK_SECRET di environment server. Jangan pernah menaruhnya di kode front-end.
Isi setiap contoh
createPayment: Membuat pembayaran USDT untuk pesanan dan mengembalikan checkout_url. Arahkan pelanggan ke sana.depositAddress: Mengembalikan alamat deposit BNB Chain tetap milik pelanggan untuk isi saldo.verifySignature: Memeriksa Kokiwa-Signature terhadap body permintaan asli dan menolak permintaan lebih dari 5 menit.handleEvent: Menandai pesanan lunas pada payment_intent.paid dan mengkreditkan isi saldo pada deposit.confirmed.
Hal yang perlu diperhatikan
- Sebuah event bisa datang lebih dari sekali. markOrderPaid dan creditCustomer harus aman diulang, dengan kunci pesanan atau deposit.id.
- deposit.confirmed juga dikirim untuk pembayaran pesanan. Contoh hanya mengkreditkan saat payment_intent bernilai null, sehingga pesanan tidak terhitung dua kali.
- Dengan Idempotency-Key order-{id}, percobaan ulang mengembalikan pembayaran yang sama. Jika pembayaran kedaluwarsa dan pelanggan membayar lagi, gunakan kunci baru seperti order-{id}-2.
- Balas 2xx dengan cepat. Balasan selain 2xx atau waktu habis akan dicoba lagi selama sekitar 3 hari.
Node.js
kokiwa.mjs
// kokiwa integration for Node.js 18 or later, with no dependencies.
// Run: KOKIWA_KEY=sk_test_... KOKIWA_WEBHOOK_SECRET=whsec_... node kokiwa.mjs
import crypto from "node:crypto";
import http from "node:http";
const API = process.env.KOKIWA_API ?? "https://api.kokiwa.com";
const KEY = process.env.KOKIWA_KEY;
const SECRET = process.env.KOKIWA_WEBHOOK_SECRET;
async function kokiwa(method, path, body, idempotencyKey) {
const headers = { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" };
if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;
const res = await fetch(API + path, { method, headers, body: body && JSON.stringify(body) });
const data = await res.json();
if (!res.ok) throw new Error(`${data.error.code}: ${data.error.message}`);
return data;
}
// 1. Create a payment for an order, then send the customer to the returned URL.
export async function createPayment(orderId, amount, customerId) {
const intent = await kokiwa("POST", "/v1/payment_intents", {
currency: "USDT",
amount, // a string, such as "25.00"
customer: customerId,
metadata: { order_id: orderId },
success_url: `https://your-shop.com/orders/${orderId}`,
}, `order-${orderId}`); // a retry returns the same payment instead of a second one
return intent.checkout_url;
}
// 2. A customer's permanent deposit address on BNB Chain, for top-ups.
export async function depositAddress(customerId) {
const result = await kokiwa("POST", "/v1/deposit_addresses", { customer: customerId });
return result.address;
}
// 3. Check the Kokiwa-Signature header against the raw request body.
export function verifySignature(rawBody, header, secret = SECRET) {
const parts = header.split(",").map((part) => part.trim().split("="));
const t = parts.find(([key]) => key === "t")?.[1] ?? "";
const signatures = parts.filter(([key]) => key === "v1").map(([, value]) => value ?? "");
if (!/^\d+$/.test(t) || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest("hex");
return signatures.some((s) => s.length === expected.length && crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)));
}
// 4. Act on events. They can arrive twice, so each step must be safe to repeat.
async function handleEvent(event) {
const { payment_intent: intent, deposit } = event.data;
if (event.type === "payment_intent.paid") {
await markOrderPaid(intent.metadata.order_id, intent.id);
} else if (event.type === "deposit.confirmed" && deposit.payment_intent === null) {
// a top-up to a deposit address; payments for orders are handled above
await creditCustomer(deposit.customer, deposit.amount, deposit.id);
}
}
// Replace these with your own database updates.
async function markOrderPaid(orderId, paymentIntentId) {
console.log("order paid", orderId, paymentIntentId);
}
async function creditCustomer(customerId, amount, depositId) {
console.log("credit", customerId, amount, depositId);
}
http.createServer((req, res) => {
if (req.method !== "POST" || req.url !== "/kokiwa/webhook") return res.writeHead(404).end();
const chunks = [];
req.on("data", (chunk) => chunks.push(chunk));
req.on("end", async () => {
const raw = Buffer.concat(chunks);
if (!verifySignature(raw, req.headers["kokiwa-signature"] ?? "")) return res.writeHead(400).end();
try {
await handleEvent(JSON.parse(raw));
res.writeHead(200).end();
} catch (err) {
console.error(err);
res.writeHead(500).end(); // kokiwa retries later
}
});
}).listen(3000);
Python
kokiwa.py
# kokiwa integration for Python 3.9 or later, with Flask and requests.
# pip install flask requests
# Run: KOKIWA_KEY=sk_test_... KOKIWA_WEBHOOK_SECRET=whsec_... flask --app kokiwa run --port 3000
import hashlib
import hmac
import os
import time
import requests
from flask import Flask, request
API = os.environ.get("KOKIWA_API", "https://api.kokiwa.com")
KEY = os.environ["KOKIWA_KEY"]
SECRET = os.environ["KOKIWA_WEBHOOK_SECRET"].encode()
app = Flask(__name__)
def kokiwa(method, path, body=None, idempotency_key=None):
headers = {"Authorization": f"Bearer {KEY}"}
if idempotency_key:
headers["Idempotency-Key"] = idempotency_key
res = requests.request(method, API + path, json=body, headers=headers, timeout=15)
data = res.json()
if not res.ok:
raise RuntimeError(f"{data['error']['code']}: {data['error']['message']}")
return data
# 1. Create a payment for an order, then send the customer to the returned URL.
def create_payment(order_id, amount, customer_id):
intent = kokiwa("POST", "/v1/payment_intents", {
"currency": "USDT",
"amount": amount, # a string, such as "25.00"
"customer": customer_id,
"metadata": {"order_id": order_id},
"success_url": f"https://your-shop.com/orders/{order_id}",
}, idempotency_key=f"order-{order_id}") # a retry returns the same payment instead of a second one
return intent["checkout_url"]
# 2. A customer's permanent deposit address on BNB Chain, for top-ups.
def deposit_address(customer_id):
return kokiwa("POST", "/v1/deposit_addresses", {"customer": customer_id})["address"]
# 3. Check the Kokiwa-Signature header against the raw request body.
def verify_signature(raw_body: bytes, header: str, secret: bytes = SECRET) -> bool:
t, signatures = "", []
for part in header.split(","):
key, _, value = part.strip().partition("=")
if key == "t":
t = value
elif key == "v1":
signatures.append(value)
if not t.isdigit() or abs(time.time() - int(t)) > 300:
return False
expected = hmac.new(secret, t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, s) for s in signatures)
# 4. Act on events. They can arrive twice, so each step must be safe to repeat.
def handle_event(event):
data = event["data"]
if event["type"] == "payment_intent.paid":
intent = data["payment_intent"]
mark_order_paid(intent["metadata"]["order_id"], intent["id"])
elif event["type"] == "deposit.confirmed" and data["deposit"]["payment_intent"] is None:
# a top-up to a deposit address; payments for orders are handled above
deposit = data["deposit"]
credit_customer(deposit["customer"], deposit["amount"], deposit["id"])
# Replace these with your own database updates.
def mark_order_paid(order_id, payment_intent_id):
print("order paid", order_id, payment_intent_id)
def credit_customer(customer_id, amount, deposit_id):
print("credit", customer_id, amount, deposit_id)
@app.post("/kokiwa/webhook")
def webhook():
raw = request.get_data()
if not verify_signature(raw, request.headers.get("Kokiwa-Signature", "")):
return "", 400
handle_event(request.get_json(force=True))
return "", 200
PHP
PHP memakai dua berkas: kokiwa.php berisi fungsi, dan webhook.php adalah URL yang Anda daftarkan.
kokiwa.php
<?php
// kokiwa integration for PHP 8.0 or later, with the curl extension.
// Set KOKIWA_KEY and KOKIWA_WEBHOOK_SECRET in the environment, then require this file.
const KOKIWA_TOLERANCE = 300; // seconds
function kokiwa(string $method, string $path, ?array $body = null, ?string $idempotencyKey = null): array
{
$api = getenv('KOKIWA_API') ?: 'https://api.kokiwa.com';
$headers = ['Authorization: Bearer ' . getenv('KOKIWA_KEY'), 'Content-Type: application/json'];
if ($idempotencyKey !== null) {
$headers[] = 'Idempotency-Key: ' . $idempotencyKey;
}
$ch = curl_init($api . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
$response = curl_exec($ch);
if ($response === false) {
throw new RuntimeException(curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$data = json_decode($response, true);
if ($status >= 400) {
throw new RuntimeException($data['error']['code'] . ': ' . $data['error']['message']);
}
return $data;
}
// 1. Create a payment for an order, then send the customer to the returned URL.
function kokiwa_create_payment(string $orderId, string $amount, string $customerId): string
{
$intent = kokiwa('POST', '/v1/payment_intents', [
'currency' => 'USDT',
'amount' => $amount, // a string, such as "25.00"
'customer' => $customerId,
'metadata' => ['order_id' => $orderId],
'success_url' => 'https://your-shop.com/orders/' . $orderId,
], 'order-' . $orderId); // a retry returns the same payment instead of a second one
return $intent['checkout_url'];
}
// 2. A customer's permanent deposit address on BNB Chain, for top-ups.
function kokiwa_deposit_address(string $customerId): string
{
return kokiwa('POST', '/v1/deposit_addresses', ['customer' => $customerId])['address'];
}
// 3. Check the Kokiwa-Signature header against the raw request body.
function kokiwa_verify_signature(string $rawBody, string $header, string $secret): bool
{
$t = '';
$signatures = [];
foreach (explode(',', $header) as $part) {
[$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');
if ($key === 't') {
$t = $value;
} elseif ($key === 'v1') {
$signatures[] = $value;
}
}
if (!ctype_digit($t) || abs(time() - (int) $t) > KOKIWA_TOLERANCE) {
return false;
}
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) {
return true;
}
}
return false;
}
webhook.php
<?php
// The webhook URL you register in the kokiwa dashboard points here.
require __DIR__ . '/kokiwa.php';
$raw = file_get_contents('php://input');
$header = $_SERVER['HTTP_KOKIWA_SIGNATURE'] ?? '';
if (!kokiwa_verify_signature($raw, $header, getenv('KOKIWA_WEBHOOK_SECRET'))) {
http_response_code(400);
exit;
}
// 4. Act on events. They can arrive twice, so each step must be safe to repeat.
$event = json_decode($raw, true);
$data = $event['data'];
if ($event['type'] === 'payment_intent.paid') {
mark_order_paid($data['payment_intent']['metadata']['order_id'], $data['payment_intent']['id']);
} elseif ($event['type'] === 'deposit.confirmed' && $data['deposit']['payment_intent'] === null) {
// a top-up to a deposit address; payments for orders are handled above
credit_customer($data['deposit']['customer'], $data['deposit']['amount'], $data['deposit']['id']);
}
http_response_code(200);
// Replace these with your own database updates.
function mark_order_paid(string $orderId, string $paymentIntentId): void
{
error_log("order paid $orderId $paymentIntentId");
}
function credit_customer(string $customerId, string $amount, string $depositId): void
{
error_log("credit $customerId $amount $depositId");
}
Go
main.go
// kokiwa integration for Go 1.22 or later, standard library only.
// Run: KOKIWA_KEY=sk_test_... KOKIWA_WEBHOOK_SECRET=whsec_... go run .
package main
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
)
var (
api = envOr("KOKIWA_API", "https://api.kokiwa.com")
key = os.Getenv("KOKIWA_KEY")
secret = []byte(os.Getenv("KOKIWA_WEBHOOK_SECRET"))
client = &http.Client{Timeout: 15 * time.Second}
)
func kokiwa(method, path string, body any, idempotencyKey string, out any) error {
payload, err := json.Marshal(body)
if err != nil {
return err
}
req, err := http.NewRequest(method, api+path, bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
if idempotencyKey != "" {
req.Header.Set("Idempotency-Key", idempotencyKey)
}
res, err := client.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode >= 400 {
var e struct {
Error struct{ Code, Message string } `json:"error"`
}
json.NewDecoder(res.Body).Decode(&e)
return fmt.Errorf("%s: %s", e.Error.Code, e.Error.Message)
}
return json.NewDecoder(res.Body).Decode(out)
}
// 1. Create a payment for an order, then send the customer to the returned URL.
func createPayment(orderID, amount, customerID string) (string, error) {
var intent struct {
CheckoutURL string `json:"checkout_url"`
}
err := kokiwa("POST", "/v1/payment_intents", map[string]any{
"currency": "USDT",
"amount": amount, // a string, such as "25.00"
"customer": customerID,
"metadata": map[string]string{"order_id": orderID},
"success_url": "https://your-shop.com/orders/" + orderID,
}, "order-"+orderID, &intent) // a retry returns the same payment instead of a second one
return intent.CheckoutURL, err
}
// 2. A customer's permanent deposit address on BNB Chain, for top-ups.
func depositAddress(customerID string) (string, error) {
var result struct {
Address string `json:"address"`
}
err := kokiwa("POST", "/v1/deposit_addresses", map[string]string{"customer": customerID}, "", &result)
return result.Address, err
}
// 3. Check the Kokiwa-Signature header against the raw request body.
func verifySignature(rawBody []byte, header string, secret []byte) bool {
var t string
var signatures []string
for _, part := range strings.Split(header, ",") {
k, v, _ := strings.Cut(strings.TrimSpace(part), "=")
switch k {
case "t":
t = v
case "v1":
signatures = append(signatures, v)
}
}
ts, err := strconv.ParseInt(t, 10, 64)
if err != nil || time.Since(time.Unix(ts, 0)).Abs() > 5*time.Minute {
return false
}
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(t + "."))
mac.Write(rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
for _, s := range signatures {
if hmac.Equal([]byte(s), []byte(expected)) {
return true
}
}
return false
}
type event struct {
Type string `json:"type"`
Data struct {
PaymentIntent *struct {
ID string `json:"id"`
Metadata map[string]string `json:"metadata"`
} `json:"payment_intent"`
Deposit *struct {
ID string `json:"id"`
Customer string `json:"customer"`
Amount string `json:"amount"`
PaymentIntent *string `json:"payment_intent"`
} `json:"deposit"`
} `json:"data"`
}
// 4. Act on events. They can arrive twice, so each step must be safe to repeat.
func handleEvent(e event) error {
switch {
case e.Type == "payment_intent.paid":
return markOrderPaid(e.Data.PaymentIntent.Metadata["order_id"], e.Data.PaymentIntent.ID)
case e.Type == "deposit.confirmed" && e.Data.Deposit.PaymentIntent == nil:
// a top-up to a deposit address; payments for orders are handled above
d := e.Data.Deposit
return creditCustomer(d.Customer, d.Amount, d.ID)
}
return nil
}
// Replace these with your own database updates.
func markOrderPaid(orderID, paymentIntentID string) error {
log.Println("order paid", orderID, paymentIntentID)
return nil
}
func creditCustomer(customerID, amount, depositID string) error {
log.Println("credit", customerID, amount, depositID)
return nil
}
func webhook(w http.ResponseWriter, r *http.Request) {
raw, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil || !verifySignature(raw, r.Header.Get("Kokiwa-Signature"), secret) {
w.WriteHeader(http.StatusBadRequest)
return
}
var e event
if err := json.Unmarshal(raw, &e); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
if err := handleEvent(e); err != nil {
log.Println(err)
w.WriteHeader(http.StatusInternalServerError) // kokiwa retries later
return
}
w.WriteHeader(http.StatusOK)
}
func envOr(name, fallback string) string {
if v := os.Getenv(name); v != "" {
return v
}
return fallback
}
func main() {
http.HandleFunc("POST /kokiwa/webhook", webhook)
log.Fatal(http.ListenAndServe(":3000", nil))
}
Java
Kokiwa.java
// kokiwa integration for Java 17 or later, with Jackson (com.fasterxml.jackson.core:jackson-databind).
// Run: KOKIWA_KEY=sk_test_... KOKIWA_WEBHOOK_SECRET=whsec_... java Kokiwa.java
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
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.security.MessageDigest;
import java.time.Duration;
import java.util.HexFormat;
import java.util.Map;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public class Kokiwa {
static final String API = System.getenv().getOrDefault("KOKIWA_API", "https://api.kokiwa.com");
static final String KEY = System.getenv("KOKIWA_KEY");
static final String SECRET = System.getenv("KOKIWA_WEBHOOK_SECRET");
static final HttpClient CLIENT = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(15)).build();
static final ObjectMapper JSON = new ObjectMapper();
static JsonNode kokiwa(String method, String path, Object body, String idempotencyKey) throws Exception {
HttpRequest.Builder req = HttpRequest.newBuilder(URI.create(API + path))
.timeout(Duration.ofSeconds(15))
.header("Authorization", "Bearer " + KEY)
.header("Content-Type", "application/json")
.method(method, HttpRequest.BodyPublishers.ofString(JSON.writeValueAsString(body)));
if (idempotencyKey != null) req.header("Idempotency-Key", idempotencyKey);
HttpResponse<String> res = CLIENT.send(req.build(), HttpResponse.BodyHandlers.ofString());
JsonNode data = JSON.readTree(res.body());
if (res.statusCode() >= 400) {
throw new IllegalStateException(data.at("/error/code").asText() + ": " + data.at("/error/message").asText());
}
return data;
}
// 1. Create a payment for an order, then send the customer to the returned URL.
static String createPayment(String orderId, String amount, String customerId) throws Exception {
JsonNode intent = kokiwa("POST", "/v1/payment_intents", Map.of(
"currency", "USDT",
"amount", amount, // a string, such as "25.00"
"customer", customerId,
"metadata", Map.of("order_id", orderId),
"success_url", "https://your-shop.com/orders/" + orderId
), "order-" + orderId); // a retry returns the same payment instead of a second one
return intent.get("checkout_url").asText();
}
// 2. A customer's permanent deposit address on BNB Chain, for top-ups.
static String depositAddress(String customerId) throws Exception {
return kokiwa("POST", "/v1/deposit_addresses", Map.of("customer", customerId), null).get("address").asText();
}
// 3. Check the Kokiwa-Signature header against the raw request body.
static boolean verifySignature(byte[] rawBody, String header, String secret) throws Exception {
String t = "";
java.util.List<String> signatures = new java.util.ArrayList<>();
for (String part : header.split(",")) {
String[] kv = part.trim().split("=", 2);
if (kv.length < 2) continue;
if (kv[0].equals("t")) t = kv[1];
else if (kv[0].equals("v1")) signatures.add(kv[1]);
}
if (!t.matches("\\d+") || Math.abs(System.currentTimeMillis() / 1000 - Long.parseLong(t)) > 300) return false;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
mac.update((t + ".").getBytes(StandardCharsets.UTF_8));
byte[] expected = HexFormat.of().formatHex(mac.doFinal(rawBody)).getBytes(StandardCharsets.UTF_8);
for (String s : signatures) {
if (MessageDigest.isEqual(expected, s.getBytes(StandardCharsets.UTF_8))) return true;
}
return false;
}
// 4. Act on events. They can arrive twice, so each step must be safe to repeat.
static void handleEvent(JsonNode event) {
String type = event.get("type").asText();
JsonNode data = event.get("data");
if (type.equals("payment_intent.paid")) {
JsonNode intent = data.get("payment_intent");
markOrderPaid(intent.at("/metadata/order_id").asText(), intent.get("id").asText());
} else if (type.equals("deposit.confirmed") && data.at("/deposit/payment_intent").isNull()) {
// a top-up to a deposit address; payments for orders are handled above
JsonNode deposit = data.get("deposit");
creditCustomer(deposit.get("customer").asText(), deposit.get("amount").asText(), deposit.get("id").asText());
}
}
// Replace these with your own database updates.
static void markOrderPaid(String orderId, String paymentIntentId) {
System.out.println("order paid " + orderId + " " + paymentIntentId);
}
static void creditCustomer(String customerId, String amount, String depositId) {
System.out.println("credit " + customerId + " " + amount + " " + depositId);
}
static void webhook(HttpExchange ex) throws java.io.IOException {
int status;
try {
byte[] raw = ex.getRequestBody().readAllBytes();
String header = ex.getRequestHeaders().getFirst("Kokiwa-Signature");
if (!"POST".equals(ex.getRequestMethod()) || header == null || !verifySignature(raw, header, SECRET)) {
status = 400;
} else {
handleEvent(JSON.readTree(raw));
status = 200;
}
} catch (Exception e) {
e.printStackTrace();
status = 500; // kokiwa retries later
}
ex.sendResponseHeaders(status, -1);
ex.close();
}
public static void main(String[] args) throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress(3000), 0);
server.createContext("/kokiwa/webhook", Kokiwa::webhook);
server.start();
}
}
Semua parameter ada di referensi API, dan semua event ada di halaman webhook. API · Webhooks