01.software Docs

Webhooks

Plan event delivery, verification, retries, and operational ownership.

Webhooks

Use webhooks when another system must react to workspace events. Keep handlers narrow, verified, idempotent, and observable.

Setup Decisions

DecisionOwner question
endpoint URLwho owns uptime and deploys?
event scopewhich events are required for launch?
secret rotationwho rotates after exposure or vendor offboarding?
retry handlingwho reviews failed deliveries?

Signing Secret Setup

Each Console webhook endpoint has its own HMAC signing secret. Open Console → Integrations → Webhooks, save the endpoint, then use Reveal signing secret once for the initial value or Rotate secret to invalidate the previous value and receive a new one-time plaintext secret. Copy the value into the receiver deployment as WEBHOOK_SECRET.

Ordinary webhook read / find API responses never include the signing secret field. Only the reveal and rotate Admin actions return plaintext.

WEBHOOK_SECRET is app-defined receiver configuration. It is not SOFTWARE_SECRET_KEY, and it is not a platform-wide secret. If one receiver URL handles multiple webhook endpoints, store a secret per endpoint and choose the right value for the incoming route before calling handleWebhook.

Handler Rules

  • Verify the event before doing business work.
  • Return quickly and process slow work asynchronously.
  • Make repeated deliveries safe.
  • Store only the fields needed for operations and audit.

Commerce Notification Events

V1 commerce notification webhooks are semantic events with eventType: "commerce.notification" and operation: "notification". Use the SDK guard or createCommerceNotificationWebhookHandler() from @01.software/sdk/webhook before reading commerce-specific fields.

commerce.notification is the trigger for tenant-owned side effects. Common workers include transactional email, fulfillment workflow updates, external PG refund handoff, analytics fanout, and internal operations queues.

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

function getWebhookSecret(): string {
  const secret = process.env.WEBHOOK_SECRET
  if (!secret) throw new Error('WEBHOOK_SECRET is required')
  return secret
}

export async function POST(request: Request) {
  return handleWebhook(
    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() },
  )
}

Back processOnce() with durable storage such as a database unique key or queue idempotency store. Do not use process-local memory for webhook idempotency in serverless or multi-instance deployments.

Commerce notification workers

Use createCommerceNotificationWebhookHandler() for new workers. createCommerceEmailWebhookHandler() remains available as a compatibility alias for existing email workers, but the route handles every CommerceNotificationEventName, including orderCanceled.

01.software owns event timing, webhook delivery retries, signing, and idempotency signals. Your tenant worker owns template rendering, provider credentials, external PG calls, extra order/customer data fetches, and final side effects.

import {
  createCommerceNotificationWebhookHandler,
  defineCommerceEmailConfig,
  handleWebhook,
} 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) throw new Error(`${name} is required`)
  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 handleWebhook(request, commerceNotificationHandler, {
    secret: getWebhookSecret(),
  })
}

Back the processOnce(idempotencyKey, ...) placeholder with a durable database unique key, queue idempotency store, or provider-safe send ledger. Do not rely on process memory to prevent duplicate email in serverless or multi-instance workers.

orderCanceled external PG refund handoff

For CMS/Admin order cancellation, use commerce.notification/orderCanceled for cancellation side effects such as external PG refund work. Use orders/update collection webhooks only for cache refreshes and local projections.

The current timing contract is: Console emits orderCanceled after the server-derived cancel commit succeeds, with data.status: "canceled". The notification payload is intentionally PII-light and does not include provider payment secrets. Do not refund from the webhook payload alone. Refetch order and transaction context from a trusted storefront/BFF worker with server SDK credentials before calling the PG.

orders.transactions is a Payload join field. Do not assume findById(..., { depth: 2 }) loads the paid payment transaction needed for refund validation. Use an explicit joins.transactions filter in the trusted lookup so the worker does not treat a missing join as no_refund_required and skip a required PG refund.

notification.intentId + notification.dedupeKey is the semantic idempotency identity for the commerce notification. deliveryId is observability and delivery-attempt identity only; do not use deliveryId to decide whether a refund was already performed.

import {
  createCommerceNotificationWebhookHandler,
  handleWebhook,
} 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 (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
      }

      // Report successful PG refund to Console outside the provider catch so
      // platform/API failures throw for retry instead of being misclassified.
      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 handleWebhook(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 }
}

Return 2xx only after the worker reaches a terminal outcome: successful PG refund with resolveCancelRefund({ outcome: 'succeeded' }), idempotent already-refunded success, intentional terminal no-refund skip, or a non-retryable provider failure recorded through resolveCancelRefund({ outcome: 'failed' }).

Throw for webhook retry when the trusted refetch cannot yet confirm status: "canceled" / "refunded" or the paid payment transaction needed for refund work. Do not return 2xx while those preconditions are still unresolved.

Treat provider already-canceled / already-refunded responses as idempotent success inside the PG catch. Throw for retry-worthy provider not-ready states and for platform reporting failures outside the PG catch. After recording a terminal outcome: 'failed' refund report, return 2xx so Console stores the failed refund outcome instead of retrying forever.

Canonical Envelope

Examples intentionally use public operational identifiers only. Do not rely on PII, payment/provider fields, metadata, tracking numbers, or tracking URLs in commerce notification webhook handlers.

{
  "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 plus notification.dedupeKey is the semantic idempotency identity. deliveryId is a delivery-attempt and observability id; do not assume it is stable across semantic retries. For orderCanceled, stable routing fields are notification.orderId, data.orderId, data.orderNumber, and data.status: "canceled". The event is emitted after the server-derived cancel commit succeeds. Some normalized deliveries may include data.source or change metadata, but handlers should treat those fields as optional. Route from notification.orderId, notification.fulfillmentId, notification.returnId, or the matching typed data.*Id field.

Source Subscription Semantics

Commerce notifications are only delivered to scoped endpoint subscriptions for their v1 source collection:

  • orderPaid, orderCanceled, and orderDelivered are delivered through orders-scoped endpoints.
  • fulfillmentShipped is delivered through fulfillments-scoped endpoints.
  • returnRequested and returnCompleted are delivered through returns-scoped endpoints.
  • Empty, unscoped, and all-collection endpoints do not receive v1 semantic commerce notifications.

Order-Change Events

Admin Panel drag-and-drop ordering is delivered as a normal collection update with semantic metadata. Branch through the SDK helper and route on public semantics such as change.scope and change.moved.id.

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

function getWebhookSecret(): string {
  const secret = process.env.WEBHOOK_SECRET
  if (!secret) throw new Error('WEBHOOK_SECRET is required')
  return secret
}

export async function POST(request: Request) {
  return handleWebhook(
    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() },
  )
}

Order-change events keep operation: "update" and add eventType: "collection.orderChanged". For join ordering, change.moved identifies the public moved entity when one exists. Handlers should not branch on hidden Payload order fields or private backing collections.

Customer group member ordering is currently treated as an unsupported hidden join-order surface and does not emit a semantic order-change webhook.

Operations

  • Track delivery failures as launch blockers when the flow is customer-facing.
  • Keep a manual recovery path for orders, payments, account changes, and fulfillment.
  • Rotate webhook secrets when ownership is unclear.

Webhooks are an operations surface. Document the owner and recovery path before depending on them for customer-facing workflows.

Next Actions

On this page