kokiwa

웹훅

결제가 입금되거나 결제 상태가 바뀌면 kokiwa가 내 https URL로 POST 요청을 보냅니다. 수신 URL은 가맹점 대시보드의 웹훅 페이지에서 관리합니다.

바로 복사해 쓸 코드가 필요하신가요? Node.js, Python, PHP, Go, Java 연동 예제

요청 형식

{
  "id": "evt_3Fk9Qw2Lm7Rt5Xb1Nc8Vz4",
  "type": "payment_intent.paid",
  "created_at": "2026-11-02T08:41:07Z",
  "livemode": true,
  "data": {
    "payment_intent": { "id": "pi_7Gm4Rk2Wp9Ls1Qv8Bd3Zt6", "object": "payment_intent", "status": "paid", "...": "..." },
    "deposit": { "id": "dep_9Kx2Pa6Wn3Ty8Lq5Rb1Ms4", "object": "deposit", "status": "settled", "...": "..." }
  }
}
헤더설명
Kokiwa-Signature서명. 형식은 t=타임스탬프,v1=서명.
Kokiwa-Event-Id이벤트 ID. 본문의 id와 같습니다.
Kokiwa-Delivery-Attempt전송 횟수. 1부터 시작합니다.

data.deposit은 입금 객체입니다. data.payment_intent는 결제 조회가 반환하는 객체와 같고 checkout_url과 livemode만 없습니다. livemode는 이벤트 최상위에 있습니다.

이벤트 유형

type전송 시점data
payment_intent.paid결제 전액 수령payment_intent、deposit
payment_intent.partially_paid결제 일부 수령payment_intent、deposit
payment_intent.expired결제가 미결제로 만료payment_intent
payment_intent.paid_late결제 만료 후 입금payment_intent、deposit
deposit.confirmed입금 기록이 온체인에서 확정됨(결제 또는 입금 주소)deposit
deposit.settled입금 기록이 수령 주소에 도착deposit
webhook.test대시보드에서 보낸 테스트 이벤트message

지갑 결제는 결제 트랜잭션 안에서 정산되므로 deposit.confirmed와 deposit.settled가 함께 옵니다. 송금은 deposit.confirmed가 먼저 오고 회수 후에 deposit.settled가 옵니다. confirmed를 받으면 사용자에게 입금 처리하세요. settled를 기다릴 필요는 없습니다.

서명 검증

수신 URL마다 서명 시크릿(whsec_…)이 있으며 만들 때 한 번만 표시됩니다.

  1. Kokiwa-Signature에서 t와 v1을 꺼냅니다.
  2. 시크릿으로 문자열 {t}.{원본 요청 본문}의 HMAC-SHA256을 계산해 소문자 16진수로 바꾸고 v1과 상수 시간으로 비교합니다.
  3. t가 현재 시각과 5분 넘게 차이 나는 요청은 거부합니다.

반드시 원본 요청 본문으로 계산하세요. JSON을 파싱해 다시 직렬화하면 바이트가 바뀌어 서명이 맞지 않습니다.

Node.js

import crypto from "node:crypto";
import express from "express";

const app = express();
const secret = process.env.KOKIWA_WEBHOOK_SECRET;

app.post("/kokiwa/webhook", express.raw({ type: "application/json" }), (req, res) => {
  const header = req.get("Kokiwa-Signature") ?? "";
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const expected = crypto.createHmac("sha256", secret)
    .update(`${parts.t}.${req.body}`).digest("hex");
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
  const match = parts.v1?.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
  if (!fresh || !match) {
    return res.sendStatus(400);
  }
  const event = JSON.parse(req.body);
  // deduplicate by event.id, process, then return 2xx quickly
  res.sendStatus(200);
});

Go

func verify(secret, body []byte, header string) bool {
	var t, v1 string
	for _, p := range strings.Split(header, ",") {
		k, v, _ := strings.Cut(strings.TrimSpace(p), "=")
		switch k {
		case "t":
			t = v
		case "v1":
			v1 = v
		}
	}
	ts, err := strconv.ParseInt(t, 10, 64)
	if err != nil || math.Abs(float64(time.Now().Unix()-ts)) > 300 {
		return false
	}
	mac := hmac.New(sha256.New, secret)
	mac.Write([]byte(t + "."))
	mac.Write(body)
	return hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(v1))
}

Python

import hashlib, hmac, time

def verify(secret: bytes, body: bytes, header: str) -> bool:
    parts = dict(p.strip().split("=", 1) for p in header.split(","))
    t, v1 = parts.get("t", ""), parts.get("v1", "")
    if not t.isdigit() or abs(time.time() - int(t)) > 300:
        return False
    expected = hmac.new(secret, f"{t}.".encode() + body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1)

응답과 재시도

  • 10초 안에 2xx를 반환하고, 오래 걸리는 작업은 백그라운드 큐로 넘기세요.
  • 2xx가 아니거나 시간 초과, 연결 실패 시 11회 재시도합니다. 간격은 30초, 2분, 10분, 30분, 1, 2, 4, 8, 12, 24, 20시간이며 약 3일 후 중단합니다.
  • 같은 이벤트가 여러 번 올 수 있고 순서도 보장되지 않습니다. 이벤트 id로 중복을 제거하고, 최신 상태는 GET /v1/payment_intents/{id}로 확인하세요.
  • 웹훅 페이지에서 전송 결과를 보고 직접 재전송할 수 있습니다.
  • 재시도가 모두 실패하면 GET /v1/deposits로 놓친 입금을 보충하세요.

Telegram 알림

설정 페이지에서 @KoKiWaPayBot을 연결하면 입금마다 메시지를 받습니다. 알림 전용이며 웹훅을 대신할 수 없습니다.

Connect a wallet with WalletConnect