01.software 문서

웹훅

이벤트 전달, 검증, 재시도, 운영 소유권을 계획합니다.

다른 시스템이 워크스페이스 이벤트에 반응해야 할 때 웹훅을 사용합니다. 핸들러는 하는 일을 좁게 두고, 요청을 검증하고, 같은 이벤트를 여러 번 받아도 안전하고, 처리 결과를 추적할 수 있게 만드세요.

설정 결정

결정담당자에게 물을 것
엔드포인트 URL가동 상태와 배포를 누가 책임지는가?
이벤트 범위출시에 꼭 필요한 이벤트는 무엇인가?
secret 교체노출되거나 외부 업체와 계약이 끝나면 누가 교체하는가?
재시도 처리실패한 전송을 누가 검토하는가?

엔드포인트 활성화

콘솔에서 만든 웹훅은 비활성 상태로 생성되므로, signing secret을 준비하는 동안에는 아무것도 전송되지 않습니다. 다음 순서를 따르세요.

  1. 엔드포인트 정보를 입력하고 저장합니다. 비활성 상태로 생성됩니다.
  2. signing secret을 한 번 표시하고 값을 복사합니다.
  3. 그 값을 WEBHOOK_SECRET으로 설정해 수신 서버를 배포합니다.
  4. 저장한 웹훅을 열고 웹훅 활성화를 누릅니다.

활성화할 때도 생성할 때와 같은 검사를 다시 합니다.

  • URL이 HTTPS이고 자격 증명을 포함하지 않는지
  • 지원하는 구독만 선택했는지
  • 사용자 역할과 활성 워크스페이스
  • 같은 워크스페이스에 같은 URL의 다른 활성 엔드포인트가 없는지
  • 플랜의 활성 엔드포인트 한도. 비활성 엔드포인트는 한도에 포함되지 않습니다.

웹훅 비활성화는 먼저 확인을 요청합니다. 일반 컬렉션 웹훅과 커머스 알림은 비활성화하면 그 엔드포인트의 대기 중(queued), 재시도 대기 중(retry), 실패(dead) 전송이 영구히 취소됩니다. 이미 보내는 중인 요청은 완료될 수 있고, 나중에 다시 활성화해도 취소된 전송은 복원되지 않습니다. 확인 창에서 취소하면 아무것도 바뀌지 않습니다.

API로 엔드포인트를 만드는 방식은 그대로입니다. isActive의 기본값은 true이고, 명시한 isActive 값도 그대로 적용됩니다. API에서도 비활성 상태로 먼저 만들려면 생성할 때 isActive: false를 보내세요.

현재 사용 중이며 준비가 끝난 Project Sandbox에서도 엔드포인트를 저장하고 활성화하세요. Sandbox의 활성 웹훅 수신처 한도는 유료 플랜에서도 1개입니다. 구독한 컬렉션의 데이터를 생성하거나 수정하면 등록한 수신처로의 외부 전송 없이 내부 기록이 남습니다. URL 중복 검사는 그대로 적용됩니다.

전송 진단에서 전송 내역의 행을 열어 errorKind: sandbox_captured와 errorMessage의 세대 표시를 확인하세요. Sandbox capture는 dead 상태로 표시되는 정상 결과입니다. 기록에는 경로 메타데이터만 포함되며 본문, 토큰, signing secret은 포함되지 않습니다.

Signing Secret 설정

콘솔 웹훅 엔드포인트마다 별도의 HMAC signing secret이 있습니다. Console → 개발자 → 웹훅에서 엔드포인트를 저장한 뒤 다음 중 하나를 사용하세요.

  • 서명 비밀 표시: 최초 값을 한 번 확인합니다.
  • 비밀 회전: 이전 secret을 무효화하고 새 평문 값을 한 번 받습니다.

받은 값을 수신 서버 배포 환경의 WEBHOOK_SECRET에 넣습니다. 최초 확인은 한 번만 할 수 있습니다. 이미 표시한 값은 콘솔에서 다시 볼 수 없으며, 비밀 회전으로만 복구할 수 있습니다. 서버 전용 시크릿 설정에 보관하고 공개 변수, 명령행 인자, 소스 파일에 넣지 마세요.

일반 웹훅 read / find API 응답에는 signing secret 필드가 없습니다. 평문은 Admin의 reveal·rotate 동작에서만 반환됩니다.

WEBHOOK_SECRET은 수신 앱이 정하는 환경 변수입니다. SOFTWARE_SECRET_KEY가 아니며, 플랫폼 전체가 공유하는 secret도 아닙니다. 수신 URL 하나가 여러 웹훅 엔드포인트를 처리한다면 엔드포인트별 secret을 보관하고, 들어온 경로에 맞는 값을 골라 handleSignedWebhook에 넘기세요.

서명된 엔드포인트에는 handleSignedWebhook()을 사용하세요. TypeScript에서 secret이 필수이고, 실행 중에도 다시 검사합니다.

  • secret이 없거나 빈 문자열이거나 공백뿐이면 본문과 핸들러를 처리하기 전에 설정 오류 500을 반환합니다.
  • 서명 메타데이터가 없거나 올바르지 않으면 401을 반환합니다.
  • 더 이상 권장하지 않는 handleWebhook() 이름도 같은 방식으로 서명을 검증하고, 검증할 수 없으면 요청을 거부합니다. secret이 없다고 서명 없는 모드로 바뀌지 않습니다.

signing secret 없이 의도적으로 구성한 엔드포인트의 수신 서버만 handleUnsignedWebhook()을 명시적으로 사용하세요. 이 API는 서명을 검증하지 않습니다.

import { handleUnsignedWebhook } from '@01.software/sdk/webhook'

export async function POST(request: Request) {
  return handleUnsignedWebhook(request, handler)
}

엔드포인트 URL에는 https://user:password@host 형식의 자격 증명을 넣을 수 없습니다. 쿼리 파라미터는 라우팅 용도로 쓸 수 있지만, 토큰, API 키, 서명, 비밀번호는 넣지 말고 엔드포인트 signing secret을 사용하세요. 전체 URL과 쿼리 문자열은 로그, 감사 기록, DLQ 조회, 상태 점검 출력에 남지 않습니다.

핸들러 규칙

  • 업무 처리 전에 이벤트를 검증합니다.
  • 빠르게 응답하고, 오래 걸리는 작업은 비동기로 처리합니다.
  • 같은 전송을 여러 번 받아도 안전하게 만듭니다.
  • 운영과 감사에 필요한 필드만 저장합니다.

전달 계약

일반 collection 웹훅은 최소 한 번(at-least-once) 전송되며 순서는 보장되지 않습니다. 수신 서버가 요청을 받았더라도 워커가 성공을 기록하지 못하면 같은 전송이 다시 올 수 있습니다. 엔드포인트 수명 주기 하나 안에서는 deliveryId로 중복을 거르고, 업무상 중복 처리 방지에는 이벤트의 도메인 식별자를 사용하세요.

자동 재시도와 운영자의 DLQ redrive에서는 JSON 본문과 deliveryId가 바뀌지 않습니다. 본문은 원본 이벤트가 commit된 시점의 공개 envelope 그대로이며, 공개 eventId를 추가하지 않습니다. 대상 URL과 구독 일치 여부도 commit 시점에 고정되므로, 이후에 URL이나 구독을 바꿔도 이미 쌓인 전송의 대상은 바뀌지 않습니다.

일반 collection 웹훅은 각 attempt마다 현재 live signing secret을 읽습니다. secret이 없는 endpoint는 정상적으로 unsigned delivery를 사용합니다. secret이 설정된 경우 복호화나 서명에 실패하면 그 attempt는 실패하며 unsigned request로 fallback하지 않습니다. 따라서 secret을 교체하면 이후 attempt부터 적용되고, 최종 사전 검사를 이미 통과한 attempt는 되돌릴 수 없습니다.

각 attempt는 x-webhook-timestamp와 x-webhook-delivery-id header를 다시 생성합니다. Delivery ID 값은 재시도에서도 안정적으로 유지됩니다. x-webhook-signature는 signing secret이 설정된 경우에만 포함됩니다. 서명이 있으면 timestamp 헤더, 바뀌지 않는 x-webhook-delivery-id, 받은 요청 바이트 그대로를 사용해 timestamp.deliveryId.rawBody에 대한 HMAC을 검증하세요. 검증 전에 JSON을 다시 직렬화하지 마세요.

일반 컬렉션 웹훅에서 엔드포인트를 비활성화하거나 삭제하면 최종 데이터베이스 사전 검사를 통과하지 않은 시도는 취소됩니다. 이미 그 단계를 지난 시도는 수신 서버에 도달할 수 있습니다. 이때 성공하면 전송 완료로 남고, 실패하면 더 재시도하지 않고 취소로 처리됩니다. 다시 활성화해도 이후 이벤트에만 적용되며 취소된 전송은 되살아나지 않습니다.

종료된 전송 상태는 30일 동안 보존됩니다. DLQ clear는 운영자 목록에서 항목을 숨길 뿐 일찍 삭제하지 않습니다. Redrive는 같은 대상, 본문, deliveryId를 새 시도 헤더와 함께 다시 사용합니다.

고객 주문 통계(totalOrderCount, lifetimeValue, firstOrderAt, lastOrderAt)는 주문이 생성되거나 주문의 test 표시가 바뀔 때 다시 집계됩니다. 이 값 중 하나가 실제로 바뀐 경우에만 customers update 웹훅이 발송되고 고객의 updatedAt이 갱신됩니다. 값이 그대로면 아무것도 발송하지 않습니다.

고객 lifetimeValue는 숫자 또는 null입니다. 통화와 기여 주문이 없는 경우의 규칙은 고객 주문 통계에서 확인하세요. 웹훅 재시도는 저장된 본문 바이트를 전송합니다.

커머스 알림 이벤트

V1 커머스 알림 웹훅은 eventType: "commerce.notification", operation: "notification"을 쓰는 의미 단위(semantic) 이벤트입니다. 커머스 전용 필드를 읽기 전에 @01.software/sdk/webhook의 SDK guard나 createCommerceNotificationWebhookHandler()를 사용하세요.

commerce.notification은 워크스페이스가 직접 처리하는 후속 작업을 시작하는 신호입니다. 대표적인 워커는 거래 이메일, 배송 처리 흐름 갱신, 외부 PG 환불 처리, 분석 데이터 전달, 내부 운영 큐입니다.

필수 원본 구독

첫 커머스 핸들러를 배포하기 전에 엔드포인트가 정확한 원본 collection을 구독하도록 설정하세요. Console → 개발자 → 웹훅에서 필요한 원본 collection마다 구독 행을 하나씩 추가합니다.

커머스 알림필요한 구독 행
orderPaid, orderCanceled, orderDeliveredorders
fulfillmentShippedfulfillments
returnRequested, returnApproved, returnRejected, returnCanceled, returnCompletedreturns
paymentReconciliationRequiredpayment-sessions
paymentRefundRequiresAttentionpayment-refund-attempts
membershipActivated, membershipPastDue, membershipExpired, membershipCancelledcustomer-memberships

비어 있는 구독 목록은 일반 collection 생성/수정 이벤트에만 적용되는 와일드카드입니다. commerce.notification과는 하나도 일치하지 않으므로, 구독 행 없이 저장한 엔드포인트는 일반 orders 생성/수정 이벤트는 계속 받지만 커머스 알림은 하나도 받지 못합니다. 구독이 비어 있거나, 범위가 지정되지 않았거나, 모든 collection을 대상으로 하는 엔드포인트는 v1 커머스 알림을 받지 않습니다.

customer-memberships는 raw collection 웹훅이 아니라 의미 단위 이벤트 주제입니다. 멤버십 수명 주기 이벤트를 받으려면 이 행을 명시적으로 선택해야 하며, 선택하기 전에 건너뛴 상태 변경은 다시 보내지 않습니다.

두 결제 이벤트는 고객 수명 주기 알림이 아니라 운영자용 장애 신호입니다. paymentReconciliationRequired는 결제사 증거가 저장돼 있지만 귀속할 Order가 없는 관리형 매입(capture)을 알립니다. paymentRefundRequiresAttention은 결제사와 로컬 기록에서 환불 완료 여부가 불분명한 경우를 알리며, 결제사의 REQUESTED 상태만으로는 발행되지 않습니다.

제한된 payload에는 원본과 PaymentSession 식별자, 선택적인 Checkout/Order/환불 시도 식별자, 결제사 객체 식별자, 관찰된 Money, 상태, 사유, 관찰 시각만 들어 있으며, 자격 증명이나 결제사 원본 응답 본문은 들어 있지 않습니다. 두 이벤트는 조사 신호로만 쓰고, 환불 실행 권한이나 orderPaid, orderCanceled, returnCompleted의 근거로 쓰지 마세요.

구독 범위가 맞는 엔드포인트가 없는 커머스 알림은 발송 단계에서 건너뜁니다. 전송 기록이 남지 않고 다시 발행되지도 않으므로, 구독 행이 생기기 전에 발행된 이벤트는 나중에 따라잡히지 않습니다. 첫 커머스 이벤트가 발생하기 전에 행을 추가하세요.

첫 이벤트 확인

워커를 더 연결하기 전에 실제 전송 한 건을 확인하세요.

  1. 정확한 원본 collection의 구독 행을 저장하고 엔드포인트를 활성 상태로 둡니다.
  2. 실제 원본 이벤트를 한 번 발생시킵니다. 예를 들어 orders는 테스트 주문을 결제해 발생시킵니다.
  3. 수신 서버에서 결과를 다시 확인합니다. 발생시킨 notification.event를 가진 commerce.notification 요청이 notification.intentId + notification.dedupeKey 기준으로 기록되어야 합니다. 콘솔은 시도마다 민감 정보를 가린 전송 기록을 남기며, 이것이 지원 측 전송 이력입니다.
  4. 아무것도 도착하지 않으면 구독을 고치고 새 이벤트를 발생시킵니다. 이미 건너뛴 이벤트는 다시 전송되지 않습니다.

첫 커머스 핸들러

import {
  handleSignedWebhook,
  isCommerceNotificationWebhookEvent,
} from '@01.software/sdk/webhook'

function getWebhookSecret(): string {
  const secret = process.env.WEBHOOK_SECRET
  if (!secret?.trim()) throw new Error('WEBHOOK_SECRET must be nonblank')
  return secret
}

export async function POST(request: Request) {
  return handleSignedWebhook(
    request,
    async (event) => {
      if (!isCommerceNotificationWebhookEvent(event)) return

      const idempotencyKey = `${event.notification.intentId}:${event.notification.dedupeKey}`
      const processed = await processOnce(idempotencyKey, async () => {
        if (event.notification.event === 'orderPaid') {
          const orderId = event.notification.orderId ?? event.data.orderId
          if (orderId) await updateOrderWorkflow(orderId)
        }

        if (event.notification.event === 'fulfillmentShipped') {
          const fulfillmentId =
            event.notification.fulfillmentId ?? event.data.fulfillmentId
          if (fulfillmentId) await updateFulfillmentWorkflow(fulfillmentId)
        }
      })

      if (!processed) return
    },
    { secret: getWebhookSecret() },
  )
}

processOnce()는 데이터베이스 unique key나 큐의 멱등성 저장소 같은 영속 저장소로 구현하세요. 서버리스나 여러 인스턴스로 배포할 때는 프로세스 메모리로 웹훅 중복을 거르지 마세요.

커머스 알림 워커

새 워커에는 createCommerceNotificationWebhookHandler()를 사용하세요. 기존 이메일 워커와의 호환을 위해 createCommerceEmailWebhookHandler()도 계속 제공하지만, 이 경로는 orderCanceled를 포함한 모든 CommerceNotificationEventName을 처리합니다.

01.software는 이벤트 발생 시점, 웹훅 전송 재시도, 서명, 중복 판별 신호를 책임집니다. 워크스페이스 워커는 템플릿 렌더링, 외부 서비스 자격 증명, 외부 PG 호출, 추가 주문·고객 데이터 조회, 최종 후속 작업을 책임집니다.

import {
  createCommerceNotificationWebhookHandler,
  defineCommerceEmailConfig,
  handleSignedWebhook,
} from '@01.software/sdk/webhook'
import { createServerClient } from '@01.software/sdk/server'
import { Resend } from 'resend'

function requiredEnv(name: string): string {
  const value = process.env[name]
  if (!value?.trim()) throw new Error(`${name} must be nonblank`)
  return value
}

const commerceEmailConfig = defineCommerceEmailConfig({
  version: 1,
  commerceNotifications: {
    orderPaid: {
      enabled: true,
      channel: 'webhook',
      template: 'order-paid',
    },
  },
})

const server = createServerClient({
  publishableKey: requiredEnv('SOFTWARE_PUBLISHABLE_KEY'),
  secretKey: requiredEnv('SOFTWARE_SECRET_KEY'),
})

const resend = new Resend(requiredEnv('RESEND_API_KEY'))

const commerceNotificationHandler = createCommerceNotificationWebhookHandler({
  async orderPaid({ event, idempotencyKey }) {
    await processOnce(idempotencyKey, async () => {
      const orderId = event.notification.orderId
      if (!orderId) return

      const order = await server.collections
        .from('orders')
        .findById(orderId, { depth: 1 })

      const template = commerceEmailConfig.commerceNotifications.orderPaid
      if (!template?.enabled) return

      const recipientEmail = order.customerSnapshot?.email
      if (!recipientEmail) return

      await resend.emails.send({
        from: 'Store <orders@example.com>',
        to: recipientEmail,
        subject: `Order ${order.orderNumber} is paid`,
        html: await renderOrderPaidEmail({ order, template }),
      })
    })
  },
})

function getWebhookSecret(): string {
  return requiredEnv('WEBHOOK_SECRET')
}

export async function POST(request: Request) {
  return handleSignedWebhook(request, commerceNotificationHandler, {
    secret: getWebhookSecret(),
  })
}

processOnce(idempotencyKey, ...) 자리는 데이터베이스 unique key, 큐 멱등성 저장소, 발송 서비스에서도 중복 전송을 막는 발송 기록 같은 영속 방식으로 구현하세요. 서버리스나 여러 인스턴스 워커에서 이메일 중복 발송을 막으려고 프로세스 메모리에 의존하지 마세요.

orderCanceled external PG refund handoff

이 처리는 매입된 결제의 PaymentSession에 등록된 결제 계정이 없을 때만 적용됩니다. 대상은 명시적으로 구성한 custom_bridge 매입(capture)이나 범위가 제한된 레거시 NULL-origin drain입니다. 등록된 관리형 Toss·PortOne PaymentSession의 환불은 콘솔만 실행합니다. 이 경우 resolveCancelRefund로 보내는 호출자 증명은 409 managed_refund_attestation_forbidden으로 거부되고, 환불 대기 표시는 그대로 남습니다.

관리형 여부는 매입된 Transaction에 정확히 연결된 그 워크스페이스의 PaymentSession과, 당시 등록돼 있던 결제 계정으로 판단합니다. 그 PaymentSession의 주문·결제사·환경 정보가 더 이상 맞지 않아도 관리형 결제로 남습니다. 이때도 resolveCancelRefund는 409 managed_refund_attestation_forbidden을 반환하며, 불일치는 콘솔만 해결합니다.

식별자를 확인할 수 없거나 다른 워크스페이스의 것이면 세부 정보 없이 payment_transaction_mismatch로 거부됩니다. 다른 세션이나 워크스페이스를 대신할 증거로 검색하지 마세요.

해당 레거시 CMS/Admin 주문 취소에서는 외부 PG 환불 같은 취소 후속 작업에 commerce.notification/orderCanceled를 사용하세요. orders/update collection 웹훅은 캐시 갱신과 로컬 사본 갱신에만 사용합니다.

콘솔은 서버에서 확정한 취소 commit이 성공한 뒤 orderCanceled를 발행하며, payload에는 data.status: "canceled"가 들어 있습니다. 알림 payload는 의도적으로 개인정보를 최소화했으며 결제사 결제 secret을 포함하지 않습니다.

알림, refundPending, 기본값이 채워진 sessionOrigin, 결제사에서 관찰한 취소만으로는 외부 환불을 실행할 자격이 생기지 않습니다. PG를 호출하기 전에 신뢰할 수 있는 배포 설정이나 이관 목록으로, 등록된 결제 계정이 없는 custom_bridge 또는 제한된 레거시 NULL-origin drain에 해당하는지 확인하세요. 그다음 신뢰할 수 있는 storefront/BFF 워커에서 서버 SDK 자격 증명으로 주문과 거래 정보를 다시 조회하세요.

크레딧 없이 콘솔 결제 연결로 받은 Toss 결제는 취소가 커밋된 뒤 콘솔이 직접 환불합니다. 취소 응답에는 refundPending 대신 managedRefund.outcome(completed, convergence_pending, operator_required)이 담깁니다. 이런 주문은 orderCanceled에서 외부 PG 환불 작업을 시작하지 말고, 결제 대행사 관리자에서도 직접 환불하지 마세요.

orders.transactions는 Payload join 필드입니다. findById(..., { depth: 2 })만으로 환불 검증에 필요한 결제 완료 거래가 함께 조회된다고 가정하지 마세요. 신뢰할 수 있는 조회에 joins.transactions와 명시적인 결제 필터를 사용하세요. 그래야 join이 비어 있는 것을 no_refund_required로 오인해 필요한 PG 환불을 건너뛰지 않습니다.

notification.intentId + notification.dedupeKey가 커머스 알림의 의미 단위 중복 판별 기준입니다. deliveryId는 관측과 전송 시도를 구분하는 값일 뿐이므로, 환불을 이미 했는지 판단하는 데 쓰지 마세요.

2xx 응답은 전송을 받았다는 뜻일 뿐, 핸들러가 실행됐다는 뜻이 아닙니다. 핸들러 맵에 전달된 notification.event 항목이 없으면 플랫폼에는 전송 성공으로 기록되지만 후속 작업은 조용히 누락됩니다. 아직 해야 할 작업을 핸들러가 끝낼 수 없다면 skip으로 응답하지 말고 throw해서 재시도되게 하세요.

워크스페이스 데이터를 읽을 권한이 있는 워크스페이스 관리자(tenant-admin)는 콘솔 웹훅 → 전송 진단에서 어떤 이벤트가 실제로 엔드포인트에 도달했는지 확인할 수 있습니다. 이 화면은 payload를 보여 주지 않고 eventType, notification.event, 원본 객체, intent id와 dedupe key, 시도·재시도 상태를 보여 줍니다.

import {
  createCommerceNotificationWebhookHandler,
  handleSignedWebhook,
} from '@01.software/sdk/webhook'
import { createServerClient } from '@01.software/sdk/server'

type CapturedPayment = {
  amount: number
  pgPaymentId: string
  pgProvider: 'toss' | 'portone'
}

type RefundResult = {
  amount: number
  pgProvider: 'toss' | 'portone'
  pgRefundId: string
}

const server = createServerClient({
  publishableKey: requiredEnv('SOFTWARE_PUBLISHABLE_KEY'),
  secretKey: requiredEnv('SOFTWARE_SECRET_KEY'),
})

const commerceNotificationHandler = createCommerceNotificationWebhookHandler({
  async orderCanceled({ event, idempotencyKey }) {
    await processOnce(idempotencyKey, async () => {
      const orderId = event.notification.orderId ?? event.data.orderId
      const orderNumber = event.data.orderNumber

      if (!orderId || !orderNumber) {
        throw new Error('orderCanceled payload missing order identity')
      }

      if (!(await isConfiguredExternalRefundDrain({ orderId, orderNumber }))) {
        // Managed PaymentSession은 Console이 소유하고 환불합니다. Webhook
        // 관측만으로 이 worker에 provider 실행 권한을 부여하면 안 됩니다.
        return
      }

      if (event.data.status !== 'canceled') {
        throw new Error(`order ${orderNumber} cancel commit is not ready`)
      }

      const lookup = await server.collections.from('orders').find({
        where: { id: { equals: orderId } },
        limit: 1,
        depth: 2,
        joins: {
          transactions: {
            where: {
              type: { equals: 'payment' },
              status: { equals: 'paid' },
            },
            limit: 5,
            sort: '-createdAt',
          },
        },
      })

      const order = lookup.docs[0]
      if (!order) {
        throw new Error(`order ${orderNumber} not found on trusted refetch`)
      }

      const paymentSelection = selectCapturedPaymentForCancelRefund(order)
      if (paymentSelection.state === 'not_ready') {
        throw new Error(paymentSelection.reason)
      }
      if (paymentSelection.state === 'no_refund_required') {
        return
      }
      const payment = paymentSelection.payment

      const reportKey = `refund-cancel-${idempotencyKey}`

      let refund: RefundResult
      try {
        refund = await refundCapturedPayment(payment, {
          idempotencyKey: reportKey,
        })
      } catch (error) {
        if (isProviderAlreadyCanceledOrRefunded(error)) {
          // 결제사의 기존 환불 ID를 확보했을 때만 성공으로 보고하고,
          // 그 환불이 조회될 때까지는 throw해서 재시도를 받습니다.
          const existingRefundId = await findProviderRefundIdForPayment(payment)
          if (!existingRefundId) {
            throw new Error(
              `order ${orderNumber} already refunded but provider refund id not found`,
            )
          }
          await server.commerce.orders.resolveCancelRefund({
            orderNumber,
            idempotencyKey: `${reportKey}:already-refunded`,
            outcome: 'succeeded',
            refundedAmount: payment.amount,
            pgProvider: payment.pgProvider,
            pgRefundId: existingRefundId,
          })
          return
        }

        if (isRetryableProviderState(error)) {
          throw error
        }

        await server.commerce.orders.resolveCancelRefund({
          orderNumber,
          idempotencyKey: `${reportKey}:failed`,
          outcome: 'failed',
          refundedAmount: 0,
          pgProvider: payment.pgProvider,
        })
        return
      }

      // PG 환불 성공 결과는 provider catch 밖에서 콘솔에 보고해
      // platform/API 실패가 provider 실패로 오분류되지 않도록 합니다.
      await server.commerce.orders.resolveCancelRefund({
        orderNumber,
        idempotencyKey: `${reportKey}:succeeded`,
        outcome: 'succeeded',
        refundedAmount: refund.amount,
        pgProvider: refund.pgProvider,
        pgRefundId: refund.pgRefundId,
      })
    })
  },
})

export async function POST(request: Request) {
  return handleSignedWebhook(request, commerceNotificationHandler, {
    secret: requiredEnv('WEBHOOK_SECRET'),
  })
}

type CancelRefundPaymentSelection =
  | { state: 'no_refund_required' }
  | { state: 'not_ready'; reason: string }
  | { state: 'captured_payment'; payment: CapturedPayment }

function selectCapturedPaymentForCancelRefund(
  order: unknown,
): CancelRefundPaymentSelection {
  const trusted = order as {
    status?: string
    transactions?: { docs?: unknown[] }
  }
  const status = trusted.status
  if (status !== 'canceled' && status !== 'refunded') {
    return {
      state: 'not_ready',
      reason: `order cancel state not confirmed on trusted lookup (status=${status ?? 'missing'})`,
    }
  }

  const joinedTransactions = trusted.transactions?.docs
  if (!joinedTransactions) {
    return {
      state: 'not_ready',
      reason: 'paid payment transaction join not loaded on trusted lookup',
    }
  }

  const payment = readCapturedPaymentFromOrderLedger(order)
  if (!payment) {
    return { state: 'no_refund_required' }
  }
  return { state: 'captured_payment', payment }
}

워커가 최종 결과에 도달했을 때만 2xx를 반환하세요. 최종 결과는 다음 중 하나입니다.

  • PG 환불에 성공하고 resolveCancelRefund({ outcome: 'succeeded' })로 보고함
  • 이미 환불된 상태를 중복 없이 성공으로 처리함
  • 환불이 필요 없어 의도적으로 건너뛰고 종료함
  • 재시도해도 해결되지 않는 결제사 실패를 resolveCancelRefund({ outcome: 'failed' })로 기록함

신뢰할 수 있는 재조회에서 status: "canceled" / "refunded"나 환불에 필요한 결제 완료 거래를 아직 확인하지 못했다면 2xx로 건너뛰지 말고 throw해서 웹훅 재시도를 받으세요.

결제사의 already-canceled / already-refunded 응답은 PG catch 안에서 중복 없는 성공으로 처리하세요. 재시도할 만한 결제사 not-ready 상태와, PG catch 밖에서 플랫폼 보고가 실패한 경우는 throw하세요. 최종 outcome: 'failed' 환불 보고를 기록한 뒤에는 2xx를 반환해 콘솔이 실패 결과를 저장하고 끝없이 재시도하지 않게 하세요.

표준 Envelope

예시는 공개 운영 식별자만 사용합니다. fulfillmentShipped payload에는 배송 추적 연동을 위해 data.carrier와 data.trackingNumber도 들어 있습니다(일반적인 커머스 플랫폼의 배송 웹훅과 같습니다). 커머스 알림 웹훅 핸들러에서 개인정보, 결제·결제사 필드, metadata, 배송 추적 URL에 의존하지 마세요.

{
  "eventType": "commerce.notification",
  "collection": "orders",
  "operation": "notification",
  "data": {
    "orderId": "order_123",
    "orderNumber": "ORD-1001",
    "status": "paid",
    "totalAmount": 5000,
    "currency": "KRW"
  },
  "notification": {
    "event": "orderPaid",
    "intentId": "intent_123",
    "dedupeKey": "orderPaid:order_123",
    "orderId": "order_123"
  },
  "timestamp": "2026-05-29T00:00:00.000Z",
  "deliveryId": "delivery_attempt_123"
}

의미 단위 중복 판별 기준은 notification.intentId와 notification.dedupeKey입니다. deliveryId는 엔드포인트 수명 주기 하나 안에서 자동 재시도와 DLQ redrive 동안 바뀌지 않지만, 새로 발행된 이벤트는 새 수명 주기를 만듭니다. deliveryId를 업무상 중복 판별 키로 사용하지 마세요. orderCanceled에서 라우팅에 쓸 고정 필드는 notification.orderId, data.orderId, data.orderNumber, data.status: "canceled"입니다. 이 이벤트는 서버에서 확정한 취소 commit이 성공한 뒤 발행됩니다. 일부 정규화된 전송에는 data.source나 change metadata가 있을 수 있지만, 핸들러는 이 필드를 선택 항목으로 다뤄야 합니다. 라우팅에는 notification.orderId, notification.fulfillmentId, notification.returnId 또는 대응하는 typed data.*Id 필드를 사용하세요.

순서 변경 이벤트

콘솔에서 끌어 놓기로 순서를 바꾸면 의미 metadata가 붙은 일반 collection update로 전송됩니다. SDK helper로 분기하고 change.scope, change.moved.id 같은 공개 필드를 기준으로 처리하세요.

import {
  handleSignedWebhook,
  isOrderChangedWebhookEvent,
} from '@01.software/sdk/webhook'

function getWebhookSecret(): string {
  const secret = process.env.WEBHOOK_SECRET
  if (!secret?.trim()) throw new Error('WEBHOOK_SECRET must be nonblank')
  return secret
}

export async function POST(request: Request) {
  return handleSignedWebhook(
    request,
    async (event) => {
      if (isOrderChangedWebhookEvent(event)) {
        if (event.change.scope.kind === 'join') {
          console.log('Join order changed', {
            collection: event.change.scope.collection,
            field: event.change.scope.field,
            parentId: event.change.scope.id,
            movedCollection: event.change.moved.collection,
            movedId: event.change.moved.id,
          })
        }
        return
      }
    },
    { secret: getWebhookSecret() },
  )
}

순서 변경 이벤트는 operation: "update"를 유지하고 eventType: "collection.orderChanged"를 추가합니다. join 순서 변경에서는 옮겨진 공개 항목이 있으면 change.moved가 그 항목을 가리킵니다. 핸들러는 숨겨진 Payload 순서 필드나 비공개 보조 collection으로 분기하지 마세요.

고객 그룹 멤버 순서는 현재 지원하지 않는 숨은 join 순서이므로 순서 변경 웹훅을 발송하지 않습니다.

자동 재시도와 복구

일반 컬렉션 웹훅과 커머스 알림은 최초 전송을 포함해 최대 8회 시도합니다. 재시도할 수 있는 실패가 발생하면 다음 시도까지 아래 순서로 기다립니다.

재시도직전 실패 후 대기 시간
130초
22분
310분
41시간
56시간
612시간
712시간

명목 대기 시간 합계는 31시간 12분 30초이며, 스케줄러와 처리 지연만큼 실제 경과 시간이 늘어날 수 있습니다. 2xx 응답은 전송을 완료하며 401, 403, 404, 410 응답과 안전하지 않은 대상 URL은 즉시 전송을 종료합니다. 서명 실패를 포함한 나머지 실패는 8번째 시도 후 종료합니다.

비밀번호 재설정 전송은 별도의 기존 재시도 정책을 사용합니다.

실패 보관함(DLQ)에 남은 복구 가능한 전송은 현재 플랫폼 운영자만 다시 보낼 수 있습니다. 워크스페이스 관리자는 전송 진단을 확인한 뒤 복구를 지원팀에 요청하세요. 자동 재시도는 원래 본문, 대상 URL, deliveryId를 유지합니다. dead나 canceled로 종료된 전송은 자동으로 재개되지 않습니다.

커머스 알림의 최초 직접 전송이 즉시 종료되는 응답을 받으면 DLQ에 재전송할 원문이 남지 않습니다.

커머스 알림도 엔드포인트를 비활성화하거나 삭제하면 대기 중인 재시도가 취소됩니다. 이미 전송 중인 요청은 완료될 수 있습니다. 다시 활성화해도 취소된 재시도는 복원되지 않습니다.

재시도 저장소에 장애가 있어도 비활성화·삭제는 가능합니다. 보관된 커머스 재시도의 부활을 막을 수 있을 때까지 재활성화는 일시적으로 실패할 수 있습니다.

커머스 재시도는 실패 전송을 대기열에 넣을 당시의 서명 비밀을 사용합니다. 비밀을 교체해도 늘어난 재시도 기간 동안 이전 비밀로 서명될 수 있습니다. 수신 서버가 새 비밀만 허용하면 이 서명의 검증이 실패할 수 있습니다. 일반 컬렉션 재시도는 각 시도 전에 읽은 현재 엔드포인트 비밀을 사용합니다.

운영

웹훅 전달 시도 기록은 생성 시점부터 90일 동안 보존된 뒤 영구 삭제됩니다. 워크스페이스에서 디버깅이나 감사에 이 기록을 사용한다면 보존 기간 안에 필요한 기록을 별도로 저장하세요.

  • 고객이 쓰는 흐름이면 전송 실패를 출시 차단 문제로 다룹니다.
  • 주문, 결제, 계정 변경, 배송 처리에는 수동 복구 방법을 마련합니다.
  • 담당자가 불분명하면 웹훅 secret을 교체합니다.

웹훅은 운영 대상입니다. 고객이 쓰는 흐름이 웹훅에 의존하기 전에 담당자와 복구 방법을 문서로 남기세요.

다음 액션

목차