Skip to content

Per Source Templates

sarmakska edited this page May 3, 2026 · 3 revisions

Per-Source Templates

How to add a custom formatter for any webhook source.

Basics

Put a JS file in src/templates/<source>.js exporting a single function:

module.exports = function format(payload) {
  // Inspect payload, return { subject, text, html }
  // OR return null to fall through to the default formatter.
}

POST to /hooks/<source> and your template fires.

Stripe

Verify signature header: Stripe-Signature: t=<ts>,v1=<sig> (the verifier in src/index.js handles this).

module.exports = function format(p) {
  if (p.type === 'invoice.paid') {
    const inv = p.data?.object || {}
    const amount = ((inv.amount_paid || 0) / 100).toFixed(2)
    const ccy = (inv.currency || 'gbp').toUpperCase()
    return {
      subject: `Invoice paid · ${amount} ${ccy}`,
      text: `Customer: ${inv.customer_email}\nInvoice: ${inv.number}\nAmount: ${amount} ${ccy}`,
      html: `<h2>Invoice paid</h2>
<p><b>Customer:</b> ${inv.customer_email}</p>
<p><b>Amount:</b> ${amount} ${ccy}</p>
<p><a href="${inv.hosted_invoice_url}">View on Stripe</a></p>`,
    }
  }
  if (p.type === 'customer.subscription.created') {
    return { subject: `New subscription · ${p.data.object.id}`, text: '...', html: '...' }
  }
  return null
}

Use Stripe webhook signing secret as WEBHOOK_SECRET.

GitHub

GitHub sends X-Hub-Signature-256: sha256=<hex> (handled).

module.exports = function format(p) {
  if (p.commits && p.repository) {
    const repo = p.repository.full_name
    const branch = (p.ref || '').replace('refs/heads/', '')
    return {
      subject: `${p.commits.length} commit · ${repo}@${branch}`,
      text: p.commits.map((c) => `- ${c.message.split('\n')[0]} (${c.author.name})`).join('\n'),
      html: `<h2>${repo} · ${branch}</h2>
<ul>${p.commits.map((c) => `<li><b>${c.author.name}:</b> ${c.message.split('\n')[0]}</li>`).join('')}</ul>
<p><a href="${p.compare}">View on GitHub</a></p>`,
    }
  }
  if (p.pull_request) {
    return {
      subject: `PR ${p.action} · #${p.pull_request.number} · ${p.pull_request.title}`,
      text: `${p.pull_request.user.login} ${p.action} PR\n${p.pull_request.html_url}`,
      html: `<h2>${p.pull_request.title}</h2><p>${p.pull_request.user.login} ${p.action}</p>`,
    }
  }
  return null
}

Configure in your repo: Settings → Webhooks → Add webhook. Content type JSON, secret = your WEBHOOK_SECRET.

Cal.com

module.exports = function format(p) {
  if (p.triggerEvent === 'BOOKING_CREATED' || p.payload?.title) {
    const b = p.payload || p
    const a = b.attendees?.[0] || {}
    return {
      subject: `New booking · ${a.name} · ${b.title}`,
      text: `With: ${a.name} <${a.email}>\nWhen: ${b.startTime}\nTimezone: ${a.timeZone}`,
      html: `<h2>New booking</h2>
<p><b>Title:</b> ${b.title}</p>
<p><b>Attendee:</b> ${a.name} (${a.email})</p>
<p><b>When:</b> ${b.startTime}</p>`,
    }
  }
  return null
}

Configure: cal.com Workflows → Webhooks. Pick BOOKING_CREATED event. Set secret if you want signature verification.

Typeform

Typeform doesn't sign webhooks by default. Either skip WEBHOOK_SECRET or add Typeform's HMAC secret in their UI.

module.exports = function format(p) {
  if (p.event_type === 'form_response') {
    const r = p.form_response
    const answers = (r.answers || []).map((a) => {
      const q = (r.definition.fields.find((f) => f.id === a.field.id) || {}).title || a.field.ref
      const val = a.text || a.email || a.number || a.choice?.label || a.boolean
      return `${q}: ${val}`
    }).join('\n')
    return {
      subject: `Typeform · ${r.definition.title}`,
      text: answers,
      html: `<h2>${r.definition.title}</h2><pre>${answers}</pre>`,
    }
  }
  return null
}

Linear

module.exports = function format(p) {
  if (p.type === 'Issue' && p.action === 'create') {
    return {
      subject: `New Linear issue · ${p.data.identifier} · ${p.data.title}`,
      text: `${p.data.title}\n\n${p.data.description || ''}`,
      html: `<h2>${p.data.title}</h2>
<p><b>${p.data.identifier}</b> · ${p.data.priorityLabel || 'No priority'}</p>
<p>${p.data.description || ''}</p>`,
    }
  }
  return null
}

Linear → Settings → Integrations → Webhooks. Subscribe to Issue events. HMAC SHA-256 in the webhook secret field.

Stripe Tip: filter by event type

If you don't want every Stripe event, return null for unwanted ones:

const WANTED = ['invoice.paid', 'customer.subscription.created', 'customer.subscription.deleted']
module.exports = function format(p) {
  if (!WANTED.includes(p.type)) return null
  // ...
}

When the formatter returns null, the default formatter takes over (sends pretty-printed JSON). Or you could make it return { subject: 'ignored', skip: true } and modify index.js to check for skip and return 200 without emailing. (Not implemented in the starter.)

Anatomy of a formatter return

{
  subject: string,    // email subject
  text: string,       // plain text body
  html: string,       // html body
}

If you only return text, the email client will render it as monospace. Provide both for the best result.

Return null when the payload doesn't match what your template handles. The default formatter takes over.

Throwing an exception in the template logs a warning and falls through to the default formatter. The webhook still gets a 200 back.

Clone this wiki locally