Edge / EmailOpen source

Webhook in. Pretty email out.

One Cloudflare Worker, Zod-validated payloads, React Email templates, Resend for delivery. Sub-50ms cold starts, free tier covers most teams.

This is the template I copy any time a client says: I want to know when X happens. Stripe payment, GitHub release, Calendly booking, form submission, anything. The Worker takes the webhook, validates the body, picks the right template, and sends an email. The whole thing is one repo, one deploy, and costs nothing under a few thousand events a month.

01 / 03

What it does

A single Cloudflare Worker exposes one HTTPS endpoint per source. The path tells the Worker which provider sent the hook: stripe, github, calendly. The Worker verifies the source signature, parses the body with a Zod schema specific to that provider, and routes to a handler.

The handler picks a React Email template, hydrates it with the payload, renders to HTML and plain text, and hands it to Resend. The whole call path is async, runs at the edge, and returns 200 to the provider in under a hundred milliseconds. The email itself is queued and sent from a separate Resend job, so a slow SMTP day never blocks the webhook.

It is the kind of glue you should never build twice. Once you have the template, you bolt new providers on in twenty minutes.

02 / 03

The problem it solves

Every product I ship needs three or four of these notifications. Without the template I would write a tiny Express app, deploy it to Render, set up DNS, configure SPF and DKIM, write the email by hand in HTML tables, and burn a day. Then I would do it again next month for a different client.

With this template the work is exactly: clone, add a Zod schema, write a one-page React Email component, push to main, paste the URL into the provider. Done before lunch.

03 / 03

Architecture

Edge-first. The Worker is the only persistent thing. No database. No queue you have to operate. Resend handles the actual delivery, retries and bounce reporting. If you want history, log to a Cloudflare D1 table; the template includes the migration.

  • Cloudflare Worker on the free tier, 100k requests per day.
  • Hono router with one handler per provider path.
  • Provider-specific signature verification, sync.
  • Zod schemas as the single source of truth for payload shape.
  • React Email templates compiled at build time into the Worker bundle.
  • Resend SDK for delivery, with one shared API key.
  • Optional D1 logging for replay and audit.
Code

The interesting bits.

typescript·src/index.ts
import { Hono } from 'hono'
import { Resend } from 'resend'
import { render } from '@react-email/render'

import { verifyStripe, StripeEvent } from './providers/stripe'
import { verifyGithub, GithubEvent } from './providers/github'
import { PaymentSucceeded } from './emails/payment-succeeded'
import { ReleasePublished } from './emails/release-published'

type Env = { RESEND_KEY: string; STRIPE_SECRET: string; GITHUB_SECRET: string }

const app = new Hono<{ Bindings: Env }>()

app.post('/stripe', async (c) => {
  const raw = await c.req.text()
  const event = await verifyStripe(raw, c.req.header('stripe-signature')!, c.env.STRIPE_SECRET)
  const parsed = StripeEvent.parse(event)

  if (parsed.type === 'payment_intent.succeeded') {
    const html = await render(<PaymentSucceeded payment={parsed.data.object} />)
    await new Resend(c.env.RESEND_KEY).emails.send({
      from: 'alerts@sarmalinux.dev',
      to: 'sai@sarmalinux.dev',
      subject: `Payment received: ${parsed.data.object.amount / 100}`,
      html,
    })
  }

  return c.text('ok')
})

app.post('/github', async (c) => {
  const raw = await c.req.text()
  await verifyGithub(raw, c.req.header('x-hub-signature-256')!, c.env.GITHUB_SECRET)
  const parsed = GithubEvent.parse(JSON.parse(raw))

  if (parsed.action === 'published' && 'release' in parsed) {
    const html = await render(<ReleasePublished release={parsed.release} />)
    await new Resend(c.env.RESEND_KEY).emails.send({
      from: 'alerts@sarmalinux.dev',
      to: 'team@sarmalinux.dev',
      subject: `${parsed.repository.full_name} ${parsed.release.tag_name}`,
      html,
    })
  }

  return c.text('ok')
})

export default app
tsx·src/emails/payment-succeeded.tsx
import { Body, Container, Heading, Html, Section, Text } from '@react-email/components'

export function PaymentSucceeded({ payment }: { payment: any }) {
  const amount = (payment.amount / 100).toFixed(2)
  const customer = payment.charges?.data?.[0]?.billing_details?.name ?? 'unknown'

  return (
    <Html>
      <Body style={{ fontFamily: 'ui-sans-serif, system-ui', background: '#0b0b0c', color: '#f5f5f5' }}>
        <Container style={{ padding: 32, maxWidth: 560 }}>
          <Heading style={{ fontSize: 22, margin: 0 }}>Payment received</Heading>
          <Text style={{ fontSize: 16, color: '#a0a0a0' }}>
            {customer} paid {payment.currency.toUpperCase()} {amount}.
          </Text>
          <Section style={{ borderTop: '1px solid #222', paddingTop: 16, marginTop: 24 }}>
            <Text style={{ fontFamily: 'ui-monospace', fontSize: 12, color: '#888' }}>
              {payment.id}
            </Text>
          </Section>
        </Container>
      </Body>
    </Html>
  )
}
Tech stack

Tools, picked deliberately.

Cloudflare WorkersHonoZodReact EmailResendTypeScriptWranglerCloudflare D1 (optional)
Run it yourself

From clone to working.

01

Clone the template

git clone https://github.com/sarmakska/webhook-to-email and pnpm install. Open wrangler.toml.

02

Add your secrets

wrangler secret put RESEND_KEY, then STRIPE_SECRET and GITHUB_SECRET as needed. Each provider has a verify function expecting its own secret.

03

Pick a from address

Verify a sending domain on Resend. Update the from in the handlers. Add SPF and DKIM via Resend dashboard.

04

Write the email

Build a React Email component in src/emails. Import it in the handler. Preview locally with pnpm email dev.

05

Deploy

wrangler deploy. The Worker URL is your webhook endpoint. Paste it into Stripe, GitHub or Calendly with the matching path.

06

Wire up logging

Optional: enable the D1 binding in wrangler.toml and run the included migration. Every event is logged with a deduplication key.

Read the source.

The repository ships with a working example, env file template and a short README. Star it if it helps.