개발자/연동
Webhook
Webhook은 주문 처리가 끝났을 때 CardV가 귀사 서버로 보내는 알림입니다.
Webhook을 쓰면 주문 상태를 계속 조회하지 않아도 됩니다. Webhook에는 코드가 들어 있지 않으므로, 알림을 받은 뒤
GET/api/v1/orders/{order_id}로 주문을 조회하세요.
Webhook URL은 포털(Owner, Integrations → Webhooks)에서 설정하며, Live와 Sandbox에서 각각 따로 설정합니다. 이 설정을 위한 API는 없습니다.
#이벤트
| 이벤트 | 발송 시점(주문 상태가 다음으로 바뀔 때) |
|---|---|
order.succeeded | succeeded |
order.partially_succeeded | partially_succeeded |
order.failed | failed(아직 환불된 것은 아님) |
order.refunded | refunded(대금이 지갑으로 돌아옴) |
- 각 이벤트는 주문 하나와 Webhook URL 하나당 최대 한 번 발송됩니다(재시도는 별도).
accepted,processing상태나 지갑 충전에 대한 이벤트는 없습니다.
#요청 내용
CardV는 귀사의 HTTPS URL로 POST 요청을 보냅니다.
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본문은 다음과 같습니다. 보기 쉽게 줄을 나눴지만, 실제로는 한 줄로 전송됩니다.
{
"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을 만들거나 시크릿을 재설정할 때 포털에 한 번만 표시됩니다.
서명 방식:
X-CardV-Signature: t=<Unix 시간(초)>,v1=<소문자 16진수>
v1 = HMAC-SHA256(key = 서명 시크릿, message = "<t>" + "." + 원본 본문 바이트)검증 순서:
- JSON을 파싱하기 전에 원본 본문 바이트를 읽습니다.
- 헤더에서
t와v1을 꺼냅니다. t가 서버 시각과 300초 넘게 차이 나면 거부합니다.- 올바른
v1을 직접 계산하고, 고정 시간 비교(constant-time compare)로 비교합니다. - 검증을 통과한 뒤에 JSON을 파싱합니다.
서명 대상은 t와 본문뿐입니다. 이벤트 종류는 X-CardV-Event 헤더가 아니라본문의 event 필드에서 읽으세요.
테스트 값(가짜 시크릿):
secret whsec_TEST_ONLY_not_a_real_secret_000000000000
t 1790000100
v1 2baac51da272ee8d4b7b1382d48037976c7272f1749c9399a50e758604de8a27이 테스트 값에 쓰인 본문은 정확히 아래 한 줄입니다(266바이트, 끝에 줄바꿈 없음).
{"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)
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 laterNode.js (Express)
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 | 이벤트 발생 직후 |
| 2 | 1회차 실패 후 약 1분 뒤 |
| 3 | 2회차 실패 후 약 5분 뒤 |
| 4 | 3회차 실패 후 약 15분 뒤 |
| 5 | 4회차 실패 후 약 30분 뒤 |
| 6 | 5회차 실패 후 약 60분 뒤 |
발송 시점은 최대 1분까지 늦어질 수 있습니다. 6회차까지 실패하면 더 이상 보내지 않습니다. 포털의 발송 내역에서 모든 알림을 확인하고 다시 보낼 수 있습니다.
#중복 수신
같은 이벤트가 여러 번 올 수 있고, 이벤트 순서가 뒤바뀔 수도 있습니다.
- 이미 처리한 이벤트는 무시하세요.
검증을 통과한 본문의
order.order_id와event조합으로 판단합니다. 포털에서 다시 보낸 알림은X-CardV-DeliveryID가 새로 바뀌므로, 이 ID만으로는 부족합니다. - 본문은 이벤트가 발생한 시점의 내용입니다. 항상 주문을 다시 조회해서 현재 상태를 기준으로 처리하세요.
- Webhook이 누락될 수도 있습니다. 몇 분 넘게 완료되지 않은 주문을확인하는 배치 작업도 함께 운영하세요.
#수신 URL 규칙
- URL은
https://로 시작하고 공개 인터넷 주소여야 합니다. 사설 주소나 로컬 주소는 등록할 수 없습니다. - 4개 이벤트를 모두 구독하거나 일부만 골라 구독할 수 있습니다.
- 서명 시크릿을 재설정하면 즉시 적용되며, 이전 시크릿은 바로 쓸 수 없게 됩니다. 중단 없이 교체하려면 새 시크릿으로 두 번째 Webhook을 추가해 배포한 뒤이전 Webhook을 비활성화하세요.
- CardV는 문제 해결을 위해 귀사 응답의 앞 1,000자를 보관합니다. 응답에 비밀 정보나 개인정보를 넣지 마세요.
연동 관련 문의는 Merchant ID와 주문 ID 또는 요청 ID를 포함해 [email protected]로 보내 주세요.