paymark

웹훅 받기

서명 검증 · 중복 거르기 · 순서 · 재시도 · 터널로 로컬 개발

주문 · 입금에 변화가 생기면 등록한 주소로 JSON 을 POST 합니다. 서명을 검증하고, 중복을 거르고, 2xx 로 빨리 답하면 됩니다.

등록

설정 > API > 웹훅에서 주소를 추가합니다. 모드(라이브 · 테스트)마다 엔드포인트 5개까지, 엔드포인트마다 받을 이벤트를 고릅니다(기본 전부). 입금과 출금을 다른 서버로 받고 싶으면 엔드포인트 둘에 이벤트를 나눠 고르면 됩니다.

  • https 만 받습니다. 테스트 모드도 같습니다. 자체 서명 인증서는 안 됩니다
  • 리다이렉트는 따라가지 않습니다 — 3xx 는 실패입니다. 최종 주소를 등록하세요
  • localhost · 사설 IP 는 등록할 수 없습니다. 로컬 개발은 아래 "로컬에서 받기"
  • 시크릿(whsec_…)은 등록할 때 만들어지고 화면에서 다시 볼 수 있습니다

봉투와 헤더

Standard Webhooks 규격 그대로입니다.

POST /your/endpoint HTTP/1.1
content-type: application/json
webhook-id: evt_8f3k2m9d1a2b3c4d5e6f7a8b9c0d1e2f
webhook-timestamp: 1791000000
webhook-signature: v1,K3h0uQ9…=
{
  "id": "evt_8f3k2m9d1a2b3c4d5e6f7a8b9c0d1e2f",
  "object": "event",
  "type": "order.paid",
  "livemode": true,
  "created_at": "2026-10-06T14:11:52+09:00",
  "data": { "object": { "id": "ord_…", "object": "order", "status": "paid", "…": "…" } }
}
  • webhook-id 는 이벤트 id 와 같고 재시도해도 바뀌지 않습니다. 중복은 이것으로 거릅니다
  • data.object 는 이벤트가 난 순간의 스냅샷입니다. 지금 상태가 필요하면 GET /v1/orders/{id} 로 다시 읽습니다
  • 모르는 type 과 모르는 필드는 무시하세요 — 더하는 변경은 버전을 올리지 않습니다

이벤트

type언제data.object
order.created주문이 생김 — API · 결제 링크 · 쇼핑몰order
order.updatedPATCH /v1/orders/{id}order
order.paid결제 확정 — 자동 매칭 · 화면에서 확정 · match · mark-paid · 쇼핑몰 밖 결제order (payment 포함)
order.canceled취소order
order.expired매칭 마감이 지남order
order.payment_reversed되돌리기 — 주문은 다시 openorder + data.previous_payment
transaction.deposited입금 통지를 받고 첫 매칭 판정이 끝난 뒤 — 미매칭도 여기transaction (status 가 결과)
transaction.withdrawn출금 통지transaction
transaction.held입금이 보류가 됨 — 후보 여럿 · 늦게 온 주문 · 규칙 밖 이름transaction + data.candidate_order_ids

한 입금에서 여러 이벤트가 나면 transaction.deposited → order.paid(또는 transaction.held) 순으로 기록됩니다. 그러나 도착 순서는 보장하지 않습니다 — 전송은 이벤트마다 독립입니다. 순서가 필요하면 GET /v1/events 의 목록 순서(기록 순)와 객체의 현재 상태를 보세요.

서명 검증

시크릿은 whsec_ + base64 입니다. 서명은 HMAC-SHA256(secret, "{webhook-id}.{webhook-timestamp}.{본문}") 의 base64 이고, 헤더에 v1, 을 붙여 옵니다. 라이브러리가 서명과 시각(5분)을 함께 확인합니다. 본문은 파싱하기 전의 원문 그대로 넘깁니다 — 다시 직렬화하면 바이트가 달라집니다.

Node

import { Webhook } from "standardwebhooks"   // npm i standardwebhooks

const wh = new Webhook(secret)               // "whsec_…" 그대로
const event = wh.verify(rawBody, {
  "webhook-id": req.headers["webhook-id"],
  "webhook-timestamp": req.headers["webhook-timestamp"],
  "webhook-signature": req.headers["webhook-signature"],
})

Python

from standardwebhooks.webhooks import Webhook   # pip install standardwebhooks

wh = Webhook(secret)
event = wh.verify(raw_body, headers)   # headers: dict — webhook-id · webhook-timestamp · webhook-signature

Java

import com.standardwebhooks.Webhook;

Webhook webhook = new Webhook(secret);
webhook.verify(rawBody, headers);   // 틀리면 WebhookVerificationException

PHP

use StandardWebhooks\Webhook;

$wh = new Webhook($secret);
$event = $wh->verify($rawBody, $headers);   // 틀리면 예외

라이브러리 설치법과 다른 언어는 standard-webhooks 저장소에 있습니다.

시크릿을 재발급하면 24시간 동안 옛 · 새 시크릿 서명이 공백으로 이어져 둘 다 옵니다(v1,aaa v1,bbb). 위 라이브러리는 여러 서명 중 하나만 맞으면 통과시키므로 받는 서버의 시크릿을 그 사이에 바꾸면 끊기지 않습니다.

받는 쪽에서 지킬 것

  • 2xx 로 10초 안에 답합니다. 그 밖(3xx 포함) · 타임아웃 · 연결 실패는 실패로 보고 재시도합니다. 무거운 처리는 답한 뒤에 하세요
  • 중복을 거릅니다. 최소 한 번(at-least-once) 전달이라 같은 webhook-id 가 두 번 올 수 있습니다. 처리한 id 를 기억해 두면 됩니다
  • 순서를 믿지 않습니다. order.paid 뒤에 transaction.deposited 가 올 수 있습니다. 이벤트마다 따로 처리하도록 만드세요
  • 응답 본문은 저장하지 않습니다. 짧게 {} 로 충분합니다

재시도 · 실패 · 자동 끄기

시도12345678
간격바로1분5분30분2시간6시간12시간24시간
  • 8번째도 실패하면 그 전송은 failed 가 되고 조직 소유자에게 메일이 갑니다(엔드포인트마다 24시간에 한 통)
  • 실패만 72시간 이어지면 엔드포인트가 자동으로 꺼지고 메일이 갑니다. 고친 뒤 설정 > API > 웹훅에서 다시 켭니다 — 꺼진 동안 것은 자동으로 다시 가지 않고, 전송 로그에서 건별로 재전송합니다
  • 전송 로그(엔드포인트마다, 30일)에 시도마다 상태 코드 · 걸린 시간이 남습니다. evt_ 로 찾을 수 있습니다
  • 빠진 이벤트는 GET /v1/events(30일)로 언제든 끌어올 수 있습니다

테스트 이벤트

설정 > API > 웹훅 > 엔드포인트의 "테스트 이벤트 보내기"는 type: "webhook.test"(id 는 evt_test_…)를 실제와 같은 서명으로 보냅니다. 연결과 서명 검증만 확인하는 용도입니다 — 실제 주문 처리로 흘리지 마세요. 전송 로그에는 남지 않습니다.

로컬에서 받기

  1. 받는 서버를 띄웁니다(위 Node 예시는 3000 포트)
  2. 터널을 엽니다 — ngrok http 3000 또는 cloudflared tunnel --url http://localhost:3000
  3. 터널이 준 https://… 주소를 테스트 탭의 웹훅에 등록합니다
  4. 시작하기 4단계의 가짜 입금을 넣고 받는지 봅니다

터널 주소가 바뀌면 엔드포인트의 주소를 고칩니다(시크릿은 그대로입니다). 받는 서버 없이 개발하려면 GET /v1/events 를 주기적으로 읽어도 됩니다 — 같은 봉투가 옵니다.