CrypaxDocs

웹훅

서명된 POST 요청으로 실시간 결제 이벤트를 수신하세요. Crypax는 HMAC-SHA256 서명과 함께 웹훅을 전달하므로 처리 전에 신뢰성을 검증할 수 있습니다.


개요

결제 상태가 변경되면 Crypax가 설정된 웹훅 URL로 POST 요청을 보냅니다. 10초 이내에 2xx 상태 코드로 응답해야 합니다. 전송에 실패하면 최대 4회까지 재시도합니다. 웹훅 URL은 대시보드의 웹훅 메뉴에서 설정하세요.

이벤트 종류

Crypax는 아래 이벤트를 발생시킵니다. 개별 이벤트를 구독하거나 비워두면 모든 이벤트를 수신합니다.

이벤트이름발생 시점
payment.created결제 생성새 결제가 생성됨.
payment.processing결제 처리 중트랜잭션 해시가 제출되어 온체인 확인 대기 중.
payment.confirmed결제 확인온체인에서 트랜잭션이 검증됨. 주문을 처리해도 안전합니다.
payment.failed결제 실패트랜잭션이 제출되었지만 온체인 검증에 실패함.
payment.expired결제 만료expiresAt 이전에 결제가 확인되지 않음.
payment.refunded결제 환불이 결제에 대한 환불이 완료됨.
refund.created환불 생성새 환불이 시작됨.
refund.completed환불 완료환불 트랜잭션이 온체인에서 확인됨.
refund.failed환불 실패환불 트랜잭션이 실패함.
settlement.requested정산 요청정산이 요청됨.
settlement.approved정산 승인정산이 승인됨.
settlement.completed정산 완료정산 트랜잭션이 확인됨.
settlement.failed정산 실패정산 트랜잭션이 실패함.
customer.created고객 생성새 고객 레코드가 생성됨.
customer.updated고객 업데이트고객 레코드가 업데이트됨.

페이로드 구조

Crypax는 JSON 바디와 함께 두 개의 헤더를 전송합니다: 인증용 X-Crypax-Signature와 이벤트 유형용 X-Crypax-Event.

POST /your-webhook-endpoint
POST /your-webhook-endpoint HTTP/1.1
Content-Type: application/json
X-Crypax-Signature: v1=a4b3c2d1e0f9...
X-Crypax-Event: payment.confirmed
X-Crypax-Timestamp: 1704067200

{
  "id": "pay_01HZ...",
  "status": "confirmed",
  "txHash": "0xabcdef1234...",
  "blockNumber": 12345,
  "amount": "10.00",
  "currency": "native",
  "chainId": 41956
}

페이로드 필드

필드타입설명
idstring고유 결제 ID
statusstring이벤트 발생 시점의 결제 상태
txHashstring | null온체인 트랜잭션 해시 (미제출 시 null)
blockNumbernumber | null트랜잭션이 포함된 블록 번호 (미확인 시 null)
amountstring최소 단위의 금액 (PLM은 wei)
currencystringPLM은 'native', ERC20은 컨트랙트 주소
orderIdstring | null결제 생성 시 전달한 내부 주문 ID

서명 검증

모든 웹훅 요청에는 X-Crypax-Signature 헤더가 포함됩니다. 이벤트를 처리하기 전에 반드시 서명을 검증하여 요청이 Crypax에서 온 것인지 확인하세요.

헤더설명
X-Crypax-Signaturev1={hex}{timestamp}.{rawBody}를 웹훅 시크릿으로 서명한 HMAC-SHA256 값
X-Crypax-Event이벤트 유형, 예: payment.confirmed
X-Crypax-Timestamp이벤트가 전송된 Unix 타임스탬프(초). 오래된 요청을 거부하는 데 사용하세요.

검증 알고리즘

서명 대상 페이로드는 timestamp + '.' + rawBody입니다. 웹훅 시크릿을 키로 이 문자열의 HMAC-SHA256을 계산하고 hex로 인코딩한 뒤, 시그니처 헤더의 v1= 이후 값과 비교합니다. 타이밍 공격 방지를 위해 crypto.timingSafeEqual을 사용하세요. 5분 이상 오래된 타임스탬프는 거부하세요.

verify-signature.js
const crypto = require('crypto');

function verifyWebhookSignature(rawBody, signatureHeader, timestampHeader, secret) {
  // signatureHeader format: "v1=<hex>"
  const signedPayload = timestampHeader + '.' + rawBody;

  const expected = 'v1=' + crypto
    .createHmac('sha256', secret)
    .update(signedPayload)
    .digest('hex');

  // Reject stale requests (older than 5 minutes)
  const age = Math.floor(Date.now() / 1000) - Number(timestampHeader);
  if (age > 300) {
    throw new Error('Webhook timestamp too old');
  }

  return crypto.timingSafeEqual(
    Buffer.from(signatureHeader),
    Buffer.from(expected)
  );
}

핸들러 예제

웹훅 핸들러는 원시 바디를 파싱하고, 서명을 검증한 뒤, 즉시 200으로 응답해야 합니다. 무거운 작업은 백그라운드 큐로 미루세요.

Express.js (직접 검증)

webhook-handler.js
const express = require('express');
const crypto = require('crypto');

const app = express();

app.post('/webhooks/crypax', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-crypax-signature'];
  const timestamp = req.headers['x-crypax-timestamp'];
  const eventType = req.headers['x-crypax-event'];
  const secret = process.env.CRYPAX_WEBHOOK_SECRET;

  // Build signed payload: timestamp.rawBody
  const signedPayload = timestamp + '.' + req.body.toString();
  const expected = 'v1=' + crypto
    .createHmac('sha256', secret)
    .update(signedPayload)
    .digest('hex');

  if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
    return res.status(400).json({ error: 'Invalid signature' });
  }

  const payload = JSON.parse(req.body.toString());

  switch (eventType) {
    case 'payment.confirmed':
      console.log('Payment confirmed:', payload.id, payload.txHash);
      break;
    case 'payment.failed':
      console.log('Payment failed:', payload.id);
      break;
    case 'payment.expired':
      console.log('Payment expired:', payload.id);
      break;
    case 'refund.completed':
      console.log('Refund completed:', payload.id);
      break;
  }

  res.json({ received: true });
});

Express.js (@crypax/node SDK 사용)

Node.js SDK는 서명을 검증하고 타입이 지정된 이벤트 객체를 반환하는 <code>constructEvent</code> 헬퍼를 제공합니다.

webhook-handler-sdk.ts
import { Crypax } from '@crypax/node';
import express from 'express';

const app = express();
const crypax = new Crypax('sk_live_...');

app.post('/webhooks/crypax', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-crypax-signature'] as string;

  try {
    const event = crypax.webhooks.constructEvent(
      req.body,           // raw body string
      signature,
      process.env.CRYPAX_WEBHOOK_SECRET!,
    );

    switch (event.type) {
      case 'payment.confirmed':
        console.log('Confirmed:', event.data.id, event.data.txHash);
        break;
      case 'payment.expired':
        console.log('Expired:', event.data.id);
        break;
      case 'refund.completed':
        console.log('Refund completed:', event.data.id);
        break;
      case 'customer.created':
        console.log('New customer:', event.data.id);
        break;
    }

    res.json({ received: true });
  } catch (err) {
    res.status(400).json({ error: 'Invalid signature' });
  }
});

재시도 정책

엔드포인트가 10초 이내에 2xx 상태로 응답하지 않으면 Crypax가 다음 일정으로 재전송합니다:

시도대기 시간비고
10s최초 전송
21s1차 재시도
35s2차 재시도
430s마지막 재시도 — 이후 추가 시도 없음

2xx 응답을 받으면 재시도가 중단됩니다. 각 시도는 대시보드 웹훅 상세 페이지의 전송 로그에 기록됩니다.

권장 사항

  • 서명 검증에는 원시 바디를 사용하세요. express.raw() 또는 동등한 미들웨어를 사용하세요. JSON을 먼저 파싱하면 바이트 표현이 달라져 서명이 맞지 않습니다.
  • 타이밍 안전 비교를 사용하세요. 타이밍 사이드채널 공격 방지를 위해 === 대신 crypto.timingSafeEqual을 사용하세요.
  • 핸들러를 멱등하게 만드세요. 동일한 이벤트가 두 번 이상 전달될 수 있습니다. id 필드로 중복을 제거하세요.
  • 즉시 200으로 응답하세요. DB 쓰기나 다운스트림 호출을 기다리지 마세요. 먼저 응답하고 비동기로 처리하세요.
  • HTTPS 엔드포인트만 허용됩니다. Crypax는 https://를 사용하지 않는 웹훅 URL을 거부합니다.

웹훅 가이드 | Crypax