개발자/연동

Webhook

Webhook은 주문 처리가 끝났을 때 CardV가 귀사 서버로 보내는 알림입니다. Webhook을 쓰면 주문 상태를 계속 조회하지 않아도 됩니다. Webhook에는 코드가 들어 있지 않으므로, 알림을 받은 뒤 GET/api/v1/orders/{order_id}로 주문을 조회하세요.

Webhook URL은 포털(Owner, Integrations → Webhooks)에서 설정하며, Live와 Sandbox에서 각각 따로 설정합니다. 이 설정을 위한 API는 없습니다.

관련 문서: 상품과 주문 · 보안

#이벤트

이벤트발송 시점(주문 상태가 다음으로 바뀔 때)
order.succeededsucceeded
order.partially_succeededpartially_succeeded
order.failedfailed(아직 환불된 것은 아님)
order.refundedrefunded(대금이 지갑으로 돌아옴)
  • 각 이벤트는 주문 하나와 Webhook URL 하나당 최대 한 번 발송됩니다(재시도는 별도).
  • accepted, processing 상태나 지갑 충전에 대한 이벤트는 없습니다.

#요청 내용

CardV는 귀사의 HTTPS URL로 POST 요청을 보냅니다.

HTTP
POST /cardv/webhook HTTP/1.1
Content-Type: application/json
User-Agent: CardV-B2B-Webhook/1.0
X-CardV-Event: order.succeeded
X-CardV-Delivery: 5521
X-CardV-Timestamp: 1790000100
X-CardV-Signature: t=1790000100,v1=2baac51da272ee8d4b7b1382d48037976c7272f1749c9399a50e758604de8a27

본문은 다음과 같습니다. 보기 쉽게 줄을 나눴지만, 실제로는 한 줄로 전송됩니다.

JSON
{
  "event": "order.succeeded",
  "order": {
    "id": "O-00000001",
    "order_id": "O-00000001",
    "external_order_id": "TEST-0001",
    "status": "succeeded",
    "currency": "USD",
    "total_amount": "9.2500",
    "items": [
      {
        "sku_id": "S000001",
        "product_name": "Example Card",
        "quantity": 1,
        "delivery_count": 1
      }
    ]
  }
}
  • order.order_id는 CardV 주문 ID입니다. order.id에도 같은 값이 들어 있습니다.
  • order.external_order_id는 자체 주문번호입니다.
  • order.status는 이벤트가 발생한 시점의 상태입니다. 지금 상태와 다를 수 있습니다.
  • X-CardV-Delivery는 이 알림의 ID입니다. 포털의 발송 내역에서 확인할 수 있습니다.

#서명 검증

모든 Webhook은 해당 Webhook의 서명 시크릿(whsec_...)으로 서명됩니다. 시크릿은 Webhook을 만들거나 시크릿을 재설정할 때 포털에 한 번만 표시됩니다.

서명 방식:

Text
X-CardV-Signature: t=<Unix 시간(초)>,v1=<소문자 16진수>
v1 = HMAC-SHA256(key = 서명 시크릿, message = "<t>" + "." + 원본 본문 바이트)

검증 순서:

  1. JSON을 파싱하기 전에 원본 본문 바이트를 읽습니다.
  2. 헤더에서 t와 v1을 꺼냅니다.
  3. t가 서버 시각과 300초 넘게 차이 나면 거부합니다.
  4. 올바른 v1을 직접 계산하고, 고정 시간 비교(constant-time compare)로 비교합니다.
  5. 검증을 통과한 뒤에 JSON을 파싱합니다.

서명 대상은 t와 본문뿐입니다. 이벤트 종류는 X-CardV-Event 헤더가 아니라본문의 event 필드에서 읽으세요.

테스트 값(가짜 시크릿):

Text
secret   whsec_TEST_ONLY_not_a_real_secret_000000000000
t        1790000100
v1       2baac51da272ee8d4b7b1382d48037976c7272f1749c9399a50e758604de8a27

이 테스트 값에 쓰인 본문은 정확히 아래 한 줄입니다(266바이트, 끝에 줄바꿈 없음).

JSON
{"event":"order.succeeded","order":{"id":"O-00000001","order_id":"O-00000001","external_order_id":"TEST-0001","status":"succeeded","currency":"USD","total_amount":"9.2500","items":[{"sku_id":"S000001","product_name":"Example Card","quantity":1,"delivery_count":1}]}}

Python (Flask)

Python
import hashlib, hmac, os, time
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["CARDV_WEBHOOK_SECRET"].encode()


def verify(raw_body: bytes, header: str, tolerance: int = 300) -> bool:
    try:
        parts = dict(item.split("=", 1) for item in header.split(","))
        t, v1 = parts["t"].strip(), parts["v1"].strip()
    except (KeyError, ValueError):
        return False
    if not t.isdigit() or abs(time.time() - int(t)) > tolerance:
        return False
    signed = t.encode() + b"." + raw_body
    expected = hmac.new(SECRET, signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected.encode(), v1.encode())  # constant time


@app.post("/cardv/webhook")
def cardv_webhook():
    raw = request.get_data()  # raw bytes, before JSON parsing
    if not verify(raw, request.headers.get("X-CardV-Signature", "")):
        abort(400)
    event = request.get_json()
    store_event(request.headers["X-CardV-Delivery"], event)  # your code
    return "", 204  # answer fast; do the work later

Node.js (Express)

Node.js
import crypto from "node:crypto";
import express from "express";

const app = express();
const SECRET = process.env.CARDV_WEBHOOK_SECRET;

function verify(rawBody, header, toleranceSeconds = 300) {
  const parts = {};
  for (const p of String(header || "").split(",")) {
    const i = p.indexOf("=");
    parts[p.slice(0, i).trim()] = p.slice(i + 1).trim();
  }
  const { t, v1 } = parts;
  if (!t || !v1 || !/^\d+$/.test(t)) return false;
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - Number(t)) > toleranceSeconds) return false;
  const expected = crypto.createHmac("sha256", SECRET)
    .update(Buffer.concat([Buffer.from(`${t}.`, "utf8"), rawBody]))
    .digest("hex");
  const a = Buffer.from(expected, "utf8");
  const b = Buffer.from(v1, "utf8");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// express.raw keeps the exact bytes. Do not use express.json() on this route.
const rawJson = express.raw({ type: "application/json" });

app.post("/cardv/webhook", rawJson, async (req, res) => {
  if (!verify(req.body, req.get("X-CardV-Signature"))) return res.sendStatus(400);
  const event = JSON.parse(req.body.toString("utf8"));
  await storeEvent(req.get("X-CardV-Delivery"), event); // your code
  res.sendStatus(204);
});

두 예제 모두 위 테스트 값으로 검증을 통과합니다.

#재시도

  • 이벤트를 저장한 뒤 15초 안에 2xx 상태 코드로 응답하세요. 주문 조회나 코드 전달처럼 오래 걸리는 작업은 응답한 다음에 처리하세요.
  • 다른 상태 코드, 타임아웃, 연결 오류는 모두 실패로 봅니다.
  • CardV는 리디렉션을 따라가지 않습니다. 3xx 응답도 실패입니다. 최종 URL을 정확히 등록하세요.
  • 실패한 알림은 최대 6번(첫 발송 포함)까지 다시 보냅니다.
회차발송 시점
1이벤트 발생 직후
21회차 실패 후 약 1분 뒤
32회차 실패 후 약 5분 뒤
43회차 실패 후 약 15분 뒤
54회차 실패 후 약 30분 뒤
65회차 실패 후 약 60분 뒤

발송 시점은 최대 1분까지 늦어질 수 있습니다. 6회차까지 실패하면 더 이상 보내지 않습니다. 포털의 발송 내역에서 모든 알림을 확인하고 다시 보낼 수 있습니다.

#중복 수신

같은 이벤트가 여러 번 올 수 있고, 이벤트 순서가 뒤바뀔 수도 있습니다.

  • 이미 처리한 이벤트는 무시하세요. 검증을 통과한 본문의 order.order_id와 event 조합으로 판단합니다. 포털에서 다시 보낸 알림은 X-CardV-Delivery ID가 새로 바뀌므로, 이 ID만으로는 부족합니다.
  • 본문은 이벤트가 발생한 시점의 내용입니다. 항상 주문을 다시 조회해서 현재 상태를 기준으로 처리하세요.
  • Webhook이 누락될 수도 있습니다. 몇 분 넘게 완료되지 않은 주문을확인하는 배치 작업도 함께 운영하세요.

#수신 URL 규칙

  • URL은 https://로 시작하고 공개 인터넷 주소여야 합니다. 사설 주소나 로컬 주소는 등록할 수 없습니다.
  • 4개 이벤트를 모두 구독하거나 일부만 골라 구독할 수 있습니다.
  • 서명 시크릿을 재설정하면 즉시 적용되며, 이전 시크릿은 바로 쓸 수 없게 됩니다. 중단 없이 교체하려면 새 시크릿으로 두 번째 Webhook을 추가해 배포한 뒤이전 Webhook을 비활성화하세요.
  • CardV는 문제 해결을 위해 귀사 응답의 앞 1,000자를 보관합니다. 응답에 비밀 정보나 개인정보를 넣지 마세요.

연동 관련 문의는 Merchant ID와 주문 ID 또는 요청 ID를 포함해 [email protected]로 보내 주세요.