웹훅 개요
페이싱크 v2 웹훅으로 주문과 입금 이벤트를 받는 방법, 서명 검증, 응답 규칙, 재시도와 재전송을 설명해요.
주문이 만들어지거나, 바뀌거나, 결제 완료되거나, 만료되거나, 취소되면 페이싱크가 등록한 URL로 POST 요청을 보내요. 새 입금이 들어와도 알려 줘요.
서명 방식은 Standard Webhooks 명세를 따라요. standardwebhooks 라이브러리(npm, PyPI, Maven 등)로 서명을 검증해도 돼요.
웹훅 등록
대시보드 웹훅에서 웹훅 추가를 눌러요.
| 항목 | 설명 |
|---|---|
| 이름 | 웹훅을 구분할 이름이에요. 1~32자예요. |
| 받을 URL | 이벤트를 받을 URL이에요. POST 요청을 받을 수 있는 공개 주소를 경로까지 입력해요. 2048자까지예요. |
| API 버전 | 페이로드 형식이에요. 새로 만든다면 v2를 고르세요. v1은 예전 형식이에요. |
| 받을 이벤트 | 받을 이벤트를 하나 이상 골라요. 고른 이벤트만 보내요. |
| 웹훅 켜기 | 끄면 이 URL로 이벤트를 보내지 않아요. |
- URL은 외부에서 접근할 수 있는 공개 주소여야 해요.
localhost, 사설 IP, 링크 로컬 주소처럼 내부망으로 이어지는 주소는 등록할 수 없고, 보낼 때도 막아요. - 운영 환경에서는
https://를 쓰세요. - 웹훅을 만들면 서명 키(
whsec_...)가 생겨요. 웹훅 목록의 보기에서 확인하고, 요청이 페이싱크에서 왔는지 이 키로 검증해요.
웹훅을 여러 개 만들면 이벤트가 각 웹훅으로 따로 가요. 켜 둘 수 있는 웹훅 수는 요금제마다 달라요. 요금제를 내리면 한도를 넘는 웹훅은 최근에 만든 것부터 꺼져요.
지원 이벤트
| 이벤트 | 전송 시점 |
|---|---|
invoice.created |
주문이 만들어졌어요. |
invoice.updated |
결제 대기 주문의 내용이 바뀌었어요. |
invoice.paid |
주문이 결제 완료됐어요. |
invoice.expired |
입금 기한이 지나 주문이 만료됐어요. |
invoice.canceled |
주문이 취소됐어요. |
invoice.reopened |
매칭이 해제돼 결제 완료 주문이 다시 열렸어요. |
transaction.created |
새 입금이 들어왔어요. |
페이로드
모든 v2 이벤트는 같은 봉투에 담겨요.
{
"id": "evt_f1g2h3i4j5k6l7m8n9o0p1q2",
"type": "invoice.paid",
"timestamp": "2026-04-28T03:20:12Z",
"data": { "id": "ivc_...", "...": "..." }
}
| 필드 | 타입 | 설명 |
|---|---|---|
id |
string | 이벤트 ID예요. evt_로 시작해요. 같은 이벤트는 모든 웹훅과 재시도, 재전송에서 같은 값이에요. |
type |
string | 이벤트 종류예요. |
timestamp |
string | 이벤트가 일어난 시각이에요. ISO 8601, UTC예요. |
data |
object | invoice.* 이벤트는 주문 페이로드, transaction.created는 입금 페이로드예요. |
모르는 type을 받으면 무시하고 2xx를 돌려주세요. 새 이벤트가 추가돼도 기존 코드가 깨지지 않아요. data에 새 필드가 추가될 수도 있어요.
주문 페이로드
invoice.* 이벤트의 data는 이벤트가 일어난 직후의 주문이에요. API 주문 객체와 비슷하지만 시각이 ISO 8601 문자열이고, createdBy, paidBy, canceledBy, shopOrder, guideUrl, guideEmailSentAt, history는 없어요.
{
"id": "ivc_a1b2c3d4e5f6g7h8i9j0k1l2",
"number": 1043,
"status": "PAID",
"customer": {
"name": "홍길동",
"email": "hong@example.com",
"phoneNumber": "01012345678"
},
"amount": 50000,
"paidAmount": 50000,
"amountDiff": null,
"bankAccountIds": [],
"cashReceipt": null,
"metadata": { "orderId": "ORDER-2026-0001" },
"source": "API",
"createdAt": "2026-04-28T03:14:15Z",
"updatedAt": "2026-04-28T03:20:12Z",
"expiresAt": "2026-04-29T03:14:15Z",
"paidAt": "2026-04-28T03:20:12Z",
"paidTrigger": "AUTOMATIC_MATCHING",
"matchedTransactionId": "trx_m1n2o3p4q5r6s7t8u9v0w1x2",
"canceledAt": null,
"cancelReason": null,
"expiredAt": null,
"reopenedAt": null
}
HTTP 헤더
| 헤더 | 설명 |
|---|---|
webhook-id |
전송 ID예요. whm_로 시작해요. 재시도에서는 같은 값이고, 대시보드에서 재전송하면 새 값이에요. |
webhook-timestamp |
이번 시도의 전송 시각이에요. Unix 초 단위예요. |
webhook-signature |
v1,<Base64 서명> 형식의 HMAC-SHA256 서명이에요. 시도마다 새로 서명해요. |
content-type |
application/json |
서명 검증
요청마다 서명을 검증해서 위조된 요청을 걸러야 해요.
서명 키를 디코딩해요
서명 키에서 whsec_를 떼고 남은 문자열을 Base64로 디코딩하면 HMAC 키가 나와요.
서명할 문자열을 만들어요
{webhook-id}.{webhook-timestamp}.{본문}처럼 점(.)으로 이어요. 본문은 파싱하기 전의 원본 그대로 써야 해요. JSON을 파싱했다가 다시 문자열로 만들면 키 순서나 공백이 달라져서 서명이 맞지 않아요.
HMAC-SHA256을 계산하고 비교해요
HMAC-SHA256 결과를 Base64로 인코딩하고, webhook-signature에서 v1, 뒤의 값과 비교해요. 비교는 타이밍 공격에 안전한 함수로 하세요.
전송 시각을 확인해요
webhook-timestamp가 현재 시각과 5분 넘게 차이 나면 재전송 공격으로 보고 거부하세요.
검증 코드
v1과 v2 웹훅 모두 같은 방법으로 검증해요. 검증을 통과하면 type으로 나눠 처리해요.
import crypto from "node:crypto";
import express from "express";
const app = express();
// 원본 바이트가 필요해요. Content-Type 헤더가 없어도 받도록 모든 타입을 허용해요.
app.post("/paysync/webhook", express.raw({ type: () => true }), (req, res) => {
const id = req.header("webhook-id");
const timestamp = req.header("webhook-timestamp");
const signatureHeader = req.header("webhook-signature");
if (!id || !timestamp || !signatureHeader) return res.sendStatus(401);
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return res.status(401).send("expired");
}
const body = req.body.toString("utf8");
const key = Buffer.from(process.env.PAYSYNC_WEBHOOK_SECRET.replace(/^whsec_/, ""), "base64");
const expected = crypto.createHmac("sha256", key).update(`${id}.${timestamp}.${body}`).digest();
const provided = Buffer.from(signatureHeader.split(",")[1] ?? "", "base64");
if (provided.length !== expected.length || !crypto.timingSafeEqual(provided, expected)) {
return res.status(401).send("invalid signature");
}
const event = JSON.parse(body);
switch (event.type) {
case "invoice.paid":
break;
case "invoice.expired":
break;
case "transaction.created":
break;
default:
break; // 모르는 이벤트는 무시해요.
}
res.sendStatus(200);
});import base64
import hashlib
import hmac
import json
import os
import time
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["PAYSYNC_WEBHOOK_SECRET"] # whsec_...
@app.post("/paysync/webhook")
def paysync_webhook():
webhook_id = request.headers.get("webhook-id")
timestamp = request.headers.get("webhook-timestamp")
signature_header = request.headers.get("webhook-signature")
if not (webhook_id and timestamp and signature_header):
abort(401)
if abs(time.time() - int(timestamp)) > 300:
abort(401, "expired")
body = request.get_data(as_text=True)
key = base64.b64decode(SECRET.removeprefix("whsec_"))
expected = base64.b64encode(
hmac.new(key, f"{webhook_id}.{timestamp}.{body}".encode(), hashlib.sha256).digest()
).decode()
provided = signature_header.split(",", 1)[1] if "," in signature_header else ""
if not hmac.compare_digest(expected, provided):
abort(401, "invalid signature")
event = json.loads(body)
# event["type"]으로 나눠 처리해요.
return "", 200import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import java.security.MessageDigest
import java.util.Base64
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import kotlin.math.abs
fun Application.paysyncWebhook() {
val secret = System.getenv("PAYSYNC_WEBHOOK_SECRET") // whsec_...
routing {
post("/paysync/webhook") {
val id = call.request.header("webhook-id")
val timestamp = call.request.header("webhook-timestamp")
val signatureHeader = call.request.header("webhook-signature")
if (id == null || timestamp == null || signatureHeader == null) {
return@post call.respond(HttpStatusCode.Unauthorized)
}
if (abs(System.currentTimeMillis() / 1000 - timestamp.toLong()) > 300) {
return@post call.respond(HttpStatusCode.Unauthorized, "expired")
}
val body = call.receiveText()
val key = Base64.getDecoder().decode(secret.removePrefix("whsec_"))
val mac = Mac.getInstance("HmacSHA256").apply { init(SecretKeySpec(key, "HmacSHA256")) }
val expected = Base64.getEncoder().encodeToString(mac.doFinal("$id.$timestamp.$body".toByteArray()))
val provided = signatureHeader.substringAfter(",", "")
if (!MessageDigest.isEqual(expected.toByteArray(), provided.toByteArray())) {
return@post call.respond(HttpStatusCode.Unauthorized, "invalid signature")
}
// body의 type으로 나눠 처리해요.
call.respond(HttpStatusCode.OK)
}
}
}import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Base64;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class PaySyncWebhookController {
private static final String SECRET = System.getenv("PAYSYNC_WEBHOOK_SECRET"); // whsec_...
@PostMapping("/paysync/webhook")
public ResponseEntity<String> receive(
@RequestHeader("webhook-id") String id,
@RequestHeader("webhook-timestamp") String timestamp,
@RequestHeader("webhook-signature") String signatureHeader,
@RequestBody String body
) throws Exception {
long now = System.currentTimeMillis() / 1000;
if (Math.abs(now - Long.parseLong(timestamp)) > 300) {
return ResponseEntity.status(401).body("expired");
}
byte[] key = Base64.getDecoder().decode(SECRET.replaceFirst("^whsec_", ""));
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(key, "HmacSHA256"));
String expected = Base64.getEncoder().encodeToString(
mac.doFinal((id + "." + timestamp + "." + body).getBytes(StandardCharsets.UTF_8))
);
String provided = signatureHeader.contains(",") ? signatureHeader.split(",", 2)[1] : "";
if (!MessageDigest.isEqual(expected.getBytes(), provided.getBytes())) {
return ResponseEntity.status(401).body("invalid signature");
}
// body의 type으로 나눠 처리해요.
return ResponseEntity.ok().build();
}
}use axum::http::{HeaderMap, StatusCode};
use base64::{engine::general_purpose::STANDARD as B64, Engine};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use std::time::{SystemTime, UNIX_EPOCH};
use subtle::ConstantTimeEq;
type HmacSha256 = Hmac<Sha256>;
async fn paysync_webhook(headers: HeaderMap, body: String) -> StatusCode {
let header = |name: &str| headers.get(name).and_then(|v| v.to_str().ok());
let (Some(id), Some(ts), Some(sig_header)) =
(header("webhook-id"), header("webhook-timestamp"), header("webhook-signature"))
else {
return StatusCode::UNAUTHORIZED;
};
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64;
if (now - ts.parse::<i64>().unwrap_or(0)).abs() > 300 {
return StatusCode::UNAUTHORIZED;
}
let secret = std::env::var("PAYSYNC_WEBHOOK_SECRET").unwrap(); // whsec_...
let Ok(key) = B64.decode(secret.trim_start_matches("whsec_")) else {
return StatusCode::INTERNAL_SERVER_ERROR;
};
let mut mac = HmacSha256::new_from_slice(&key).unwrap();
mac.update(format!("{id}.{ts}.{body}").as_bytes());
let expected = B64.encode(mac.finalize().into_bytes());
let provided = sig_header.split_once(',').map(|(_, s)| s).unwrap_or("");
if expected.as_bytes().ct_eq(provided.as_bytes()).unwrap_u8() != 1 {
return StatusCode::UNAUTHORIZED;
}
// serde_json::from_str(&body)로 파싱하고 type으로 나눠 처리해요.
StatusCode::OK
}서명 키 교체
서명 키가 유출됐다면 대시보드 웹훅의 행 메뉴에서 서명 키 교체를 누르세요. 새 키는 바로 적용되고, 그 뒤로는 이전 키로 서명한 요청이 오지 않아요. 키를 바꾸기 전에 내 서버에 새 키를 넣을 준비를 해 두세요.
응답 규칙
| 내 서버의 응답 | 페이싱크의 처리 |
|---|---|
HTTP 200~299 |
성공으로 기록해요. |
| 그 밖의 상태 코드, 리디렉션, 연결 실패, 10초 안에 응답 없음 | 실패로 기록하고 다시 보내요. |
페이싱크는 리디렉션을 따라가지 않아요. 응답은 10초까지 기다려요. 처리가 오래 걸리는 작업은 200을 먼저 돌려준 뒤 큐나 백그라운드 작업으로 넘기세요.
실패와 재시도
전송이 실패하면 페이싱크가 같은 페이로드를 다시 보내요. 처음 시도를 포함해 최대 7번 보내요.
| 시도 | 이전 시도 뒤 대기 |
|---|---|
| 2번째 | 30초 |
| 3번째 | 1분 |
| 4번째 | 2분 |
| 5번째 | 4분 |
| 6번째 | 8분 |
| 7번째 | 16분 |
7번 모두 실패하면 전송을 실패로 끝내고, 이메일 알림에서 웹훅 전송 실패를 켠 멤버에게 메일을 보내요. 웹훅을 끄거나 지우면 기다리던 재시도도 멈춰요.
대시보드 웹훅의 전송 기록에서 이런 일을 할 수 있어요.
- 전송 내역 보기: 시도마다 HTTP 상태, 응답 시간, 응답 본문 앞부분, 실패 이유를 보여 줘요. 전송 기록은 30일 동안 남아요.
- 재전송: 같은 페이로드를 새
webhook-id로 바로 다시 보내요. - 지금 다시 보내기: 재시도를 기다리는 전송을 바로 보내요.
- 재시도 멈추기: 기다리는 재시도를 멈춰요.
- 테스트 이벤트 보내기: 웹훅 목록의 행 메뉴에서 예시 페이로드를 한 번 보내요. 테스트 이벤트는 재시도하지 않아요.
전송이 빠진 이벤트는 주문 목록 조회나 입금 내역 목록 조회로 상태를 맞출 수 있어요.
중복 처리
재시도와 재전송 때문에 같은 이벤트를 여러 번 받을 수 있어요. v2 웹훅은 페이로드의 id(evt_...)가 이벤트마다 하나라서 이 값으로 중복을 걸러요.
if (await db.isEventProcessed(event.id)) {
return res.sendStatus(200);
}
await processEvent(event);
await db.markEventProcessed(event.id);
이벤트가 오는 순서는 보장하지 않아요. 순서가 중요하면 data.updatedAt이나 timestamp를 비교하거나, 주문 단건 조회로 최신 상태를 확인하세요.
로컬에서 테스트하기
로컬 서버는 ngrok 같은 터널로 외부에 연 뒤 그 URL을 웹훅으로 등록하세요.
ngrok http 3000
https://abcd-1234.ngrok-free.app 같은 주소가 나오면 뒤에 경로를 붙여 https://abcd-1234.ngrok-free.app/paysync/webhook처럼 등록해요. 등록한 뒤 테스트 이벤트 보내기로 서명 검증까지 확인해 보세요.