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?

Endpoint Activation

A webhook created in Console starts inactive, so nothing is delivered while you still need the signing secret. Follow the order below:

  1. Fill in the endpoint and save it — the document is created inactive.
  2. Reveal the signing secret once and copy it out.
  3. Deploy the receiver with that WEBHOOK_SECRET.
  4. Open the saved webhook and use Activate webhook.

Activation revalidates the endpoint the same way a create does: HTTPS URL without embedded credentials, supported subscriptions, your role and active workspace, no other active endpoint on the same URL in the workspace, and the plan's active-endpoint limit. An inactive endpoint does not consume that limit.

Deactivate webhook asks for confirmation first, because it permanently cancels the endpoint's queued, waiting-retry, and failed (dead) deliveries. A request already in flight may still complete, and activating the endpoint again later does not restore canceled deliveries. Canceling the confirmation changes nothing.

Creating an endpoint through the API is unchanged: isActive still defaults to true, and an explicit isActive value is still honored. Send isActive: false on create if you want the same inactive-first flow there.

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. The initial reveal is one-time: once it has been revealed, Console cannot show that value again and Rotate secret is the only recovery.

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 handleSignedWebhook.

Use handleSignedWebhook() for signed endpoints. It requires secret in TypeScript and checks it again at runtime: a missing, empty, or whitespace-only value returns a configuration 500 before the body or handler is processed, while missing or invalid signature metadata returns 401. The deprecated handleWebhook() name remains a signed fail-closed alias and no longer selects unsigned mode when its secret is absent.

Only a receiver for an endpoint intentionally configured without a signing secret should opt into handleUnsignedWebhook(). That explicit API performs no signature authentication:

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

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

Endpoint URLs must not contain https://user:password@host credentials. Query parameters are supported for routing, but never place tokens, API keys, signatures, or passwords in them; use the endpoint signing secret instead. Full URL and query strings are excluded from logs, audit records, DLQ inspection, and health output.

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.

Delivery Contract

Ordinary collection webhooks are delivered at least once and are not ordered. If a receiver accepts a request but the worker cannot record that success, the same delivery can arrive again. Deduplicate one endpoint lifecycle by deliveryId, and use the event's domain identifiers for business idempotency.

The JSON body and deliveryId are immutable for automatic retries and an operator DLQ redrive. The body keeps the public envelope that existed when the source event committed; it does not add a public eventId. The destination URL and subscription match are also snapshots from that commit, so later URL or subscription edits do not reroute retained work.

The platform reads the endpoint's current live signing secret for every attempt. A secret-less endpoint is legitimately unsigned. When a secret is configured, decryption or signing failure fails the attempt and never falls back to an unsigned request. Rotating a secret therefore affects later attempts, while an attempt already past final preflight cannot be recalled.

Each attempt regenerates the x-webhook-timestamp and x-webhook-delivery-id headers. The delivery ID value remains stable across retries. x-webhook-signature is present only when a signing secret is configured. When present, verify the HMAC over timestamp.deliveryId.rawBody, using the timestamp header, the stable x-webhook-delivery-id, and the exact request bytes. Do not reserialize JSON before verification.

Deactivating or deleting an endpoint cancels attempts that have not passed the final database preflight. An attempt already past that boundary can still reach the receiver: success remains delivered, while failure is canceled without another retry. Reactivation affects future events only and does not resurrect canceled work.

Terminal delivery state is retained for 30 days. A DLQ clear dismisses the item from operator lists but does not delete it early; a redrive reuses the same destination, body, and deliveryId with fresh attempt headers.

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.

Required Source Subscription

Scope the endpoint to its exact source collection before you deploy the first commerce handler. Open Console → Integrations → Webhooks and add one subscription row per source collection you need:

Commerce notificationRequired subscription row
orderPaid, orderCanceled, orderDeliveredorders
fulfillmentShippedfulfillments
returnRequested, returnApproved, returnRejected, returnCanceled, returnCompletedreturns
paymentReconciliationRequiredpayment-sessions
paymentRefundRequiresAttentionpayment-refund-attempts

An empty subscription list is a wildcard for ordinary collection create/update events only. It matches no commerce.notification, so an endpoint saved with no subscription row keeps receiving ordinary orders create/update traffic while receiving zero commerce notifications. Empty, unscoped, and all-collection endpoints do not receive v1 semantic commerce notifications.

The two payment events are operator incidents, not customer lifecycle notifications. paymentReconciliationRequired identifies a managed capture with durable provider evidence but no attributable Order. paymentRefundRequiresAttention identifies an ambiguous provider/local refund completion; a provider REQUESTED state alone does not emit it. Their bounded payload contains source and PaymentSession identity, optional Checkout/Order/refund-attempt identity, provider object identity, observed Money, status, reason, and observation time. It contains no credentials or raw provider body. Treat either event as an investigation signal, never as authority to execute a refund or infer orderPaid, orderCanceled, or returnCompleted.

A commerce notification that matches no scoped endpoint is skipped at dispatch. It leaves no delivery record and is never replayed, so events emitted before the subscription row existed are not caught up later. Add the row before the first commerce event fires.

Verify the First Event

Confirm one real delivery before wiring more workers.

  1. Save the subscription row for the exact source collection and keep the endpoint active.
  2. Trigger one real source event, such as paying a test order for orders.
  3. Read the result back in your receiver: a commerce.notification request whose notification.event is the one you triggered, recorded under notification.intentId + notification.dedupeKey. Console keeps a redacted delivery-attempt record for every attempt as the support-side delivery history.
  4. If nothing arrives, fix the subscription and trigger a new event. The skipped event is not resent.

First Commerce 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() },
  )
}

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,
  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(),
  })
}

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

This handoff applies only to an explicitly configured custom_bridge capture or a bounded legacy NULL-origin drain with no registered payment account. A registered managed Toss or PortOne PaymentSession is refunded by Console only; resolveCancelRefund rejects caller attestation with 409 managed_refund_attestation_forbidden and preserves the pending marker.

Managed ownership follows the captured Transaction's exact tenant-bound PaymentSession and historical registered account. Order/provider/environment drift remains Console-owned corruption; unresolved or cross-tenant identity fails closed opaquely as payment_transaction_mismatch. Never search another session or tenant as replacement evidence.

For eligible legacy 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. Neither the notification, refundPending, a defaulted sessionOrigin, nor provider-observed cancellation proves external refund authority. Establish legacy/custom-bridge eligibility from trusted deployment configuration or migration inventory before provider I/O, then refetch order and transaction context from a trusted storefront/BFF worker with server SDK credentials.

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.

A 2xx response only acknowledges delivery — it does not tell the platform that your handler ran. If your handler map has no entry for the delivered notification.event, the platform records a successful delivery and the side effect silently never happens. When your handler cannot complete work that must still happen, throw so the attempt is retried instead of acking it as a skip. Workspace admins can confirm which semantic event actually reached your endpoint in Console under Webhooks → Delivery diagnostics, which shows eventType, notification.event, the source object, the intent id and dedupe key, and the attempt/retry state without exposing the payload.

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 PaymentSessions are owned and refunded by Console. A webhook
        // observation must never elevate this worker into a provider executor.
        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
      }

      // 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 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 }
}

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. fulfillmentShipped payloads also carry data.carrier and data.trackingNumber so handlers can sync shipment tracking, matching common platform fulfillment webhooks. Do not rely on PII, payment/provider fields, metadata, 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 stable across automatic delivery retries and DLQ redrive for one endpoint lifecycle, but a newly emitted semantic event creates a new lifecycle; do not use it as the business idempotency key. 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.

Order-Change Events

Console 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 {
  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() },
  )
}

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