Integration examples
One complete file per language that creates payments, gives customers deposit addresses and handles webhooks. Copy it, set your keys, and replace the two functions that update your database.
The same examples, plus a Telegram bot and a Next.js checkout, are on GitHub: github.com/maxwellzen1/kokiwa-examples
Before you start
- Create an API key in the merchant dashboard. Start with a test key (sk_test_) on testnet.
- Add a webhook URL that points to your server, such as https://your-shop.com/kokiwa/webhook, and copy its signing secret (whsec_).
- Set them as KOKIWA_KEY and KOKIWA_WEBHOOK_SECRET in your server's environment. Never put them in front-end code.
What every example does
createPayment: Creates a USDT payment for an order and returns checkout_url. Send the customer there.depositAddress: Returns a customer's permanent BNB Chain deposit address for top-ups.verifySignature: Checks Kokiwa-Signature against the raw request body and rejects requests more than 5 minutes old.handleEvent: Marks the order paid on payment_intent.paid and credits top-ups on deposit.confirmed.
Things to get right
- Events can arrive more than once. Make markOrderPaid and creditCustomer safe to repeat, keyed by the order or by deposit.id.
- deposit.confirmed is also sent for order payments. The examples credit it only when payment_intent is null, so an order is never counted twice.
- The Idempotency-Key order-{id} makes a retry return the same payment. If a payment expires and the customer tries again, use a new key such as order-{id}-2.
- Return 2xx quickly. A non-2xx response or a timeout is retried for about 3 days.
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 uses two files: kokiwa.php has the functions, and webhook.php is the URL you register.
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();
}
}
Every parameter is in the API reference, and every event is on the Webhooks page. API · Webhooks