Skip to content

Webhook Handler

Receives an incoming HTTP webhook, validates the signature, processes the payload durably, and acknowledges the sender.

Why use a workflow for webhooks?

A plain Worker can receive a webhook and return a 200 immediately, but if downstream processing fails, the event is lost. Wrapping webhook processing in a Cloudflare Workflow gives you automatic retries, durable state, and a full execution trace.

Workflow structure

Nodes

#Node nameTypePurpose
1Validate SignatureStepVerify HMAC-SHA256 signature
2Parse EventStepExtract event type and payload
3Route EventBranchDifferent handlers per event type
4Handle Order CreatedStepProcess order.created events
5Handle Order CancelledStepProcess order.cancelled events
6AcknowledgeHTTP RequestPOST 200 acknowledgement to the sender

Trigger

HTTP trigger. The sending service POSTs to the Worker's URL.

Generated TypeScript

typescript
import { WorkflowEntrypoint, WorkflowEvent, WorkflowStep } from 'cloudflare:workers'

export class WebhookHandlerWorkflow extends WorkflowEntrypoint<Env, Params> {
  async run(event: WorkflowEvent<Params>, step: WorkflowStep) {
    const validate_signature = await step.do('Validate Signature', async () => {
      const { rawBody, signature } = event.payload ?? {}
      if (!rawBody || !signature) throw new Error('Missing body or signature')

      const key = await crypto.subtle.importKey(
        'raw',
        new TextEncoder().encode(env.WEBHOOK_SECRET),
        { name: 'HMAC', hash: 'SHA-256' },
        false,
        ['verify'],
      )
      const bodyBytes = new TextEncoder().encode(rawBody)
      const sigBytes = Uint8Array.from(atob(signature), (c) => c.charCodeAt(0))
      const valid = await crypto.subtle.verify('HMAC', key, sigBytes, bodyBytes)
      if (!valid) throw new Error('Invalid signature')
      return { verified: true }
    })

    const parse_event = await step.do('Parse Event', async () => {
      const body = JSON.parse(event.payload?.rawBody ?? '{}')
      return {
        eventType: body.type,
        eventId: body.id,
        data: body.data,
      }
    })

    if (parse_event.eventType === 'order.created') {
      await step.do(
        'Handle Order Created',
        {
          retries: { limit: 5, delay: '10 seconds', backoff: 'exponential' },
        },
        async () => {
          const res = await fetch(`${env.API_BASE_URL}/orders`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(parse_event.data),
          })
          if (!res.ok) throw new Error(`Failed to create order: ${res.status}`)
        },
      )
    } else if (parse_event.eventType === 'order.cancelled') {
      await step.do(
        'Handle Order Cancelled',
        {
          retries: { limit: 5, delay: '10 seconds', backoff: 'exponential' },
        },
        async () => {
          const res = await fetch(`${env.API_BASE_URL}/orders/${parse_event.data?.id}/cancel`, {
            method: 'POST',
          })
          if (!res.ok) throw new Error(`Failed to cancel order: ${res.status}`)
        },
      )
    }

    await step.do('Acknowledge', async () => {
      await fetch(`${env.WEBHOOK_ACK_URL}/${parse_event.eventId}`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ received: true }),
      })
    })
  }
}

Required env vars

VariableDescription
WEBHOOK_SECRETHMAC secret shared with the sending service
API_BASE_URLBase URL of your backend API
WEBHOOK_ACK_URLURL to POST acknowledgements to

Customising the HTTP trigger code

The default trigger code immediately creates a Workflow instance and returns the instanceId. For webhook senders that expect a 200 OK with no body latency, this is fast enough. If the sender requires a specific response body, customise the trigger code in Settings → Trigger Code:

typescript
if (request.method === 'POST') {
  const rawBody = await request.text()
  const signature = request.headers.get('X-Webhook-Signature') ?? ''
  const instance = await env.WORKFLOW.create({
    params: { rawBody, signature },
  })
  return Response.json({ received: true, instanceId: instance.id })
}
return new Response(null, { status: 405 })