본문으로 건너뛰기
페이싱크 개발자센터
Esc
↑↓이동↵열기⌘J미리보기
이 페이지에서

웹훅 개요

페이싱크 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 "", 200
import 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처럼 등록해요. 등록한 뒤 테스트 이벤트 보내기로 서명 검증까지 확인해 보세요.

이 페이지가 도움이 되었나요?