01.software 문서

웹훅

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

웹훅

다른 시스템이 워크스페이스 이벤트에 반응해야 할 때 웹훅을 사용합니다. handler는 작고, 검증 가능하고, idempotent하고, 관측 가능하게 유지합니다.

설정 결정

결정담당자 질문
endpoint URLuptime과 deploy를 누가 소유하는가?
event scope출시 필수 이벤트는 무엇인가?
secret rotation노출 또는 외부 업체 종료 후 누가 회전하는가?
retry handling실패한 delivery를 누가 검토하는가?

Endpoint 활성화

콘솔에서 만든 webhook은 비활성 상태로 생성됩니다. Signing secret이 아직 필요한 동안에는 아무것도 전달되지 않습니다. 다음 순서를 따르세요.

  1. Endpoint 정보를 입력하고 저장합니다. 문서는 비활성으로 생성됩니다.
  2. Signing secret을 한 번 표시하고 값을 복사합니다.
  3. 그 WEBHOOK_SECRET으로 receiver를 배포합니다.
  4. 저장된 webhook을 열어 웹훅 활성화를 사용합니다.

활성화는 생성과 동일하게 endpoint를 다시 검증합니다. 자격 증명이 없는 HTTPS URL, 지원되는 subscription, 사용자 role과 활성 워크스페이스, 같은 워크스페이스 안에서 동일 URL의 다른 활성 endpoint 부재, plan의 활성 endpoint 한도를 확인합니다. 비활성 endpoint는 그 한도를 차지하지 않습니다.

웹훅 비활성화는 먼저 확인을 요청합니다. 비활성화하면 해당 endpoint의 대기(queued), 재시도 대기(retry), 실패(dead) 전송이 영구 취소되기 때문입니다. 이미 전송 중인 요청은 완료될 수 있으며, 나중에 다시 활성화해도 취소된 전송은 복원되지 않습니다. 확인을 취소하면 아무것도 바뀌지 않습니다.

API로 endpoint를 만드는 방식은 그대로입니다. isActive의 기본값은 여전히 true이고 명시한 isActive 값도 그대로 적용됩니다. API에서도 같은 비활성 우선 흐름을 쓰려면 생성 시 isActive: false를 보내세요.

Signing Secret 설정

각 콘솔 webhook endpoint에는 별도의 HMAC signing secret이 있습니다. 콘솔 → Integrations → Webhooks에서 endpoint를 저장한 뒤 Reveal signing secret으로 최초 값을 한 번 확인하거나, Rotate secret으로 이전 secret을 무효화하고 새 평문 값을 한 번 받습니다. 그 값을 receiver 배포 환경의 WEBHOOK_SECRET에 복사합니다. 최초 reveal은 일회성입니다. 이미 표시된 뒤에는 Console이 같은 값을 다시 보여줄 수 없으며 Rotate secret이 유일한 복구 경로입니다.

일반 webhook read / find API 응답에는 signing secret 필드가 포함되지 않습니다. 평문은 Admin의 reveal/rotate 동작에서만 반환됩니다.

WEBHOOK_SECRET은 receiver app이 정하는 환경 변수입니다. SOFTWARE_SECRET_KEY가 아니며 platform 전체에서 공유하는 secret도 아닙니다. 하나의 receiver URL이 여러 webhook endpoint를 처리한다면 endpoint별 secret을 보관하고, 들어온 route에 맞는 값을 골라 handleSignedWebhook에 전달하세요.

서명된 endpoint에는 handleSignedWebhook()을 사용하세요. TypeScript에서 secret을 필수로 요구하고 runtime에서도 다시 검사합니다. 값이 누락되거나 빈 문자열 또는 공백뿐이면 body와 handler를 처리하기 전에 구성 오류 500을 반환하고, signature metadata가 누락되거나 올바르지 않으면 401을 반환합니다. Deprecated된 handleWebhook() 이름도 같은 signed fail-closed alias로 유지되며, secret이 없다고 unsigned mode를 선택하지 않습니다.

Signing secret 없이 의도적으로 구성한 endpoint의 receiver만 handleUnsignedWebhook()으로 명시적으로 opt in하세요. 이 API는 signature 인증을 수행하지 않습니다.

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

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

Endpoint URL에는 https://user:password@host 형식의 자격 증명을 포함할 수 없습니다. Query parameter는 routing 용도로 지원하지만 token, API key, signature, password를 넣지 말고 endpoint signing secret을 사용하세요. 전체 URL과 query string은 log, audit record, DLQ inspection, health output에서 제외됩니다.

Handler 규칙

  • 업무 처리를 하기 전에 이벤트를 검증합니다.
  • 빠르게 응답하고 느린 작업은 비동기로 처리합니다.
  • 반복 전달이 안전하도록 만듭니다.
  • 운영과 audit에 필요한 필드만 저장합니다.

전달 계약

일반 collection webhook은 at-least-once로 전달되며 전달 순서를 보장하지 않습니다. Receiver가 요청을 수락한 뒤 worker가 성공 상태를 기록하지 못하면 같은 delivery가 다시 올 수 있습니다. 하나의 endpoint lifecycle은 deliveryId로 중복 제거하고, 비즈니스 멱등성에는 event의 domain identifier를 사용하세요.

자동 재시도와 운영자 DLQ redrive에서는 JSON body와 deliveryId가 변하지 않습니다. Body는 source event가 commit될 때의 public envelope를 유지하며 public eventId를 추가하지 않습니다. 대상 URL과 subscription match도 commit 시점 snapshot이므로 이후 URL 또는 subscription 수정이 retained work를 reroute하지 않습니다.

플랫폼은 각 attempt마다 현재 live signing secret을 읽습니다. secret이 없는 endpoint는 정상적으로 unsigned delivery를 사용합니다. secret이 설정된 경우 decrypt 또는 signing 실패는 해당 attempt를 실패시키며 unsigned request로 fallback하지 않습니다. 따라서 secret rotation은 이후 attempt에 적용되지만, 최종 preflight를 이미 통과한 attempt는 회수할 수 없습니다.

각 attempt는 x-webhook-timestamp와 x-webhook-delivery-id header를 다시 생성합니다. Delivery ID 값은 재시도에서도 안정적으로 유지됩니다. x-webhook-signature는 signing secret이 설정된 경우에만 포함됩니다. Signature가 있다면 timestamp header, 안정적인 x-webhook-delivery-id, 정확한 request bytes를 사용해 timestamp.deliveryId.rawBody에 대한 HMAC을 검증하세요. 검증 전에 JSON을 다시 serialize하지 마세요.

Endpoint를 비활성화하거나 삭제하면 최종 database preflight를 통과하지 않은 attempt가 취소됩니다. 이미 이 경계를 지난 attempt는 receiver에 도달할 수 있습니다. 성공 응답은 delivered로 확정되고, 실패 응답은 추가 재시도 없이 canceled로 확정됩니다. 재활성화는 미래 event에만 적용되며 canceled work를 되살리지 않습니다.

Terminal delivery state는 30일간 보존됩니다. DLQ clear는 operator list에서 항목을 dismiss하지만 조기 삭제하지 않습니다. Redrive는 같은 destination, body, deliveryId를 새 attempt header와 함께 다시 사용합니다.

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

커머스 알림 이벤트

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

commerce.notification은 tenant-owned side effect를 시작하는 trigger입니다. 대표 worker는 transactional email, fulfillment workflow update, external PG refund handoff, analytics fanout, 내부 운영 queue입니다.

필수 원본 구독

첫 커머스 handler를 배포하기 전에 endpoint를 정확한 원본 collection으로 scope하세요. 콘솔 → Integrations → Webhooks에서 필요한 원본 collection마다 subscription 행을 하나씩 추가합니다.

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

비어 있는 subscription 목록은 일반 collection 생성/수정 이벤트에만 적용되는 wildcard입니다. commerce.notification과는 하나도 match하지 않으므로, subscription 행 없이 저장한 endpoint는 일반 orders 생성/수정 이벤트는 계속 받으면서 커머스 알림은 하나도 받지 못합니다. empty, unscoped, all-collection endpoint는 v1 semantic commerce notification을 받지 않습니다.

customer-memberships는 raw collection webhook이 아닌 shaped semantic topic입니다. 멤버십 수명주기 이벤트를 받으려면 이 행을 명시적으로 선택해야 하며, 선택 전 건너뛴 전이는 재생되지 않습니다.

두 payment event는 customer lifecycle notification이 아니라 operator incident입니다. paymentReconciliationRequired는 durable provider evidence가 있지만 귀속할 Order가 없는 managed capture를 나타냅니다. paymentRefundRequiresAttention은 provider/local refund 완료가 모호한 경우를 나타내며 provider REQUESTED 상태만으로는 발행되지 않습니다. 제한된 payload에는 source와 PaymentSession identity, 선택적인 Checkout/Order/refund-attempt identity, provider object identity, 관찰된 Money, status, reason, observation time만 포함되며 credential이나 raw provider body는 포함되지 않습니다. 두 event 모두 조사 신호로만 사용하고 refund 실행 권한이나 orderPaid, orderCanceled, returnCompleted의 근거로 사용하지 마세요.

scope된 endpoint와 match하지 않은 커머스 알림은 dispatch 단계에서 skip됩니다. delivery 기록이 남지 않고 재발행되지도 않으므로, subscription 행이 생기기 전에 발행된 이벤트는 나중에 따라잡히지 않습니다. 첫 커머스 이벤트가 발생하기 전에 행을 추가하세요.

첫 이벤트 확인

worker를 더 붙이기 전에 실제 delivery 한 건을 확인하세요.

  1. 정확한 원본 collection에 대한 subscription 행을 저장하고 endpoint를 활성 상태로 둡니다.
  2. 실제 원본 이벤트를 한 번 발생시킵니다. 예를 들어 orders는 테스트 주문 결제로 발생시킵니다.
  3. Receiver에서 결과를 다시 확인합니다. 발생시킨 notification.event를 가진 commerce.notification 요청이 notification.intentId + notification.dedupeKey 기준으로 기록되어야 합니다. 콘솔은 attempt마다 redacted delivery 기록을 남기며 이것이 지원 측 delivery history입니다.
  4. 아무것도 도착하지 않으면 subscription을 고치고 새 이벤트를 발생시킵니다. 이미 skip된 이벤트는 다시 전송되지 않습니다.

첫 커머스 Handler

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()는 database unique key 또는 queue idempotency store 같은 durable storage로 구현하세요. Serverless 또는 multi-instance 배포에서 webhook idempotency에 process-local memory를 사용하지 마세요.

Commerce notification worker

새 worker에는 createCommerceNotificationWebhookHandler()를 사용하세요. 기존 email worker 호환성을 위해 createCommerceEmailWebhookHandler()는 계속 제공되지만, 실제 route는 orderCanceled를 포함한 모든 CommerceNotificationEventName을 처리합니다.

01.software는 event timing, webhook delivery retry, signing, idempotency signal을 소유합니다. Tenant worker는 template rendering, provider credentials, external PG call, 추가 order/customer data fetch, 최종 side effect를 소유합니다.

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, ...) placeholder는 database unique key, queue idempotency store, provider-safe send ledger 같은 durable mechanism으로 구현하세요. Serverless 또는 multi-instance worker에서 중복 email을 막기 위해 process memory에 의존하지 마세요.

orderCanceled external PG refund handoff

이 handoff는 등록 결제 계정이 없는 명시적 custom_bridge capture 또는 제한된 레거시 NULL-origin drain에만 적용됩니다. 등록 managed Toss/PortOne PaymentSession의 환불은 Console만 실행하며, resolveCancelRefund caller attestation은 409 managed_refund_attestation_forbidden으로 거부되고 pending marker는 유지됩니다.

Managed 소유권은 captured Transaction의 exact tenant-bound PaymentSession과 historical registered account를 따릅니다. Order/provider/environment drift는 Console 소유 corruption으로 유지되고, 해석 불가 또는 cross-tenant identity는 opaque payment_transaction_mismatch로 fail-closed합니다. 다른 session이나 tenant를 대체 증거로 검색하지 마세요.

해당 레거시 CMS/Admin 주문 취소에서는 external PG refund 같은 취소 side effect에 commerce.notification/orderCanceled를 사용하세요. orders/update collection webhook은 cache refresh와 local projection 갱신에만 사용합니다.

현재 timing contract는 콘솔이 서버에서 파생된 cancel commit이 성공한 뒤 orderCanceled를 발행하고, payload에는 data.status: "canceled"가 들어간다는 것입니다. Notification payload는 의도적으로 PII-light이며 provider payment secret을 포함하지 않습니다. Webhook payload만 보고 바로 환불하지 마세요. PG를 호출하기 전에 신뢰할 수 있는 storefront/BFF worker에서 server SDK credentials로 order와 transaction context를 다시 조회하세요.

orders.transactions는 Payload join field입니다. findById(..., { depth: 2 })만으로 환불 검증에 필요한 paid payment transaction이 붙는다고 가정하지 마세요. Trusted lookup에 joins.transactions와 명시적 payment filter를 사용해, join 누락을 no_refund_required로 오인해 필수 PG refund를 skip하지 않도록 하세요.

notification.intentId + notification.dedupeKey가 commerce notification의 semantic idempotency identity입니다. deliveryId는 observability 및 delivery attempt identity일 뿐이며, refund 수행 여부를 판단하는 semantic idempotency key로 사용하지 마세요.

2xx 응답은 전달을 ack할 뿐, handler가 실행되었다는 뜻이 아닙니다. Handler map에 전달된 notification.event 항목이 없으면 플랫폼에는 성공한 전송으로 기록되고 side effect는 조용히 누락됩니다. 아직 수행해야 하는 작업을 handler가 끝낼 수 없다면 skip으로 ack하지 말고 throw해서 재시도되게 하세요. Tenant-scoped read 권한이 있는 워크스페이스 관리자(tenant-admin)는 콘솔 웹훅 → 전송 진단에서 어떤 semantic event가 실제로 엔드포인트에 도달했는지 확인할 수 있습니다. 여기서는 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)) {
          await server.commerce.orders.resolveCancelRefund({
            orderNumber,
            idempotencyKey: `${reportKey}:already-refunded`,
            outcome: 'succeeded',
            refundedAmount: payment.amount,
            pgProvider: payment.pgProvider,
          })
          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 }
}

Worker가 terminal outcome에 도달한 경우에만 2xx를 반환하세요: PG 환불 성공 후 resolveCancelRefund({ outcome: 'succeeded' }), idempotent already-refunded success, terminal no-refund skip, 또는 non-retryable provider failure를 resolveCancelRefund({ outcome: 'failed' })로 기록한 경우.

Trusted refetch에서 status: "canceled" / "refunded" 또는 환불에 필요한 paid payment transaction을 아직 확인하지 못하면 2xx로 skip하지 말고 throw하여 webhook retry를 유도하세요.

PG already-canceled / already-refunded 응답은 PG catch 안에서 idempotent success로 처리하고, PG catch 밖의 platform reporting 실패와 retry-worthy provider not-ready 상태는 throw하세요. Terminal outcome: 'failed' refund report를 기록한 뒤에는 2xx를 반환해 콘솔에 실패 결과를 저장하고 무한 retry를 막으세요.

표준 Envelope

예시는 public 운영 식별자만 사용합니다. fulfillmentShipped payload에는 배송 추적 연동을 위해 data.carrier와 data.trackingNumber가 함께 포함됩니다(일반적인 커머스 플랫폼의 fulfillment webhook과 동일). 커머스 알림 웹훅 handler에서 PII, 결제/provider 필드, metadata, tracking 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"
}

semantic idempotency identity는 notification.intentId와 notification.dedupeKey입니다. deliveryId는 하나의 endpoint lifecycle에서 자동 delivery retry와 DLQ redrive 동안 안정적이지만, 새로 발행된 semantic event는 새 lifecycle을 만듭니다. 이를 비즈니스 idempotency key로 사용하지 마세요. orderCanceled의 stable routing field는 notification.orderId, data.orderId, data.orderNumber, data.status: "canceled"입니다. 이 event는 서버에서 파생된 cancel commit이 성공한 뒤 발행됩니다. 일부 normalized delivery에는 data.source 또는 change metadata가 포함될 수 있지만 handler는 이 필드를 optional로 취급해야 합니다. Routing에는 notification.orderId, notification.fulfillmentId, notification.returnId 또는 대응되는 typed data.*Id 필드를 사용하세요.

순서 변경 이벤트

콘솔의 drag-and-drop 순서 변경은 semantic metadata가 포함된 일반 collection update로 전달됩니다. SDK helper로 분기하고 change.scope, change.moved.id 같은 public semantics를 기준으로 처리합니다.

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 ordering에서는 가능한 경우 change.moved가 public moved entity를 가리킵니다. handler는 숨겨진 Payload order fields나 private backing collections로 분기하지 마세요.

고객 그룹 멤버 순서는 현재 지원되지 않는 hidden join-order surface로 처리하며 semantic order-change webhook을 발송하지 않습니다.

운영

  • 고객 노출 흐름이면 delivery failure를 출시 blocker로 봅니다.
  • 주문, 결제, 계정 변경, fulfillment에는 수동 복구 경로를 둡니다.
  • 소유권이 불명확하면 webhook secret을 회전합니다.

웹훅은 운영 표면입니다. 고객 노출 워크플로에 의존하기 전에 담당자와 복구 경로를 문서화합니다.

다음 액션

목차