Skip to content

Per Source Templates

sarmakska edited this page Jun 7, 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, markdown }
  // OR return null to fall through to the default formatter.
}

POST to /hooks/<source> and the template fires. The renderer turns your markdown into a styled HTML body and a plain-text fallback, so you only write Markdown. All payload values are HTML-escaped during rendering.

Return shape

{
  subject: string,    // email subject (no Markdown)
  markdown: string,   // rich body; HTML and text are derived from it
}

You may also return explicit text and html fields to override the derived ones, but the Markdown path is the recommended one. Returning null falls through to the default formatter, which renders the JSON payload as a Markdown code block. Throwing logs a warning and also falls through, so a broken template never errors the request.

There is one more return shape: { skip: true }. It tells the service to drop the event entirely. Use it for noisy or low-value events you never want to see, where null would still email the raw JSON. The endpoint acknowledges a skip with 202 {"ok":true,"skipped":true} and nothing is queued, delivered or dead-lettered. The bundled GitHub template uses this for zero-commit pushes, which is how branch deletes and tag-only pushes arrive.

module.exports = function format(p) {
  if (p.type === 'ping') return { skip: true }   // drop, do not email
  if (!WANTED.includes(p.type)) return null       // fall through to JSON dump
  // ...real formatting
}

Supported Markdown

The in-repo renderer covers what notification emails need: #, ##, ### headings, **bold**, *italic*, `inline code`, fenced code blocks, [links](https://...), bullet and numbered lists, blockquotes, and horizontal rules. Anything else is treated as paragraph text.

Bundled: Stripe

Taken from src/templates/stripe.js.

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()
    const lines = [
      '# Invoice paid',
      '',
      `**Amount:** ${amount} ${ccy}`,
      `**Customer:** ${inv.customer_email || '?'}`,
      `**Invoice:** ${inv.number || inv.id || '?'}`,
    ]
    if (inv.hosted_invoice_url) lines.push('', `[View on Stripe](${inv.hosted_invoice_url})`)
    return { subject: `Invoice paid: ${amount} ${ccy}`, markdown: lines.join('\n') }
  }
  return null
}

Use the Stripe webhook signing secret as WEBHOOK_SECRET. The stripe verifier profile validates the timestamped signature.

Bundled: GitHub

Taken from src/templates/github.js. Handles pushes and pull requests.

module.exports = function format(p) {
  if (p.commits && p.repository) {
    const repo = p.repository.full_name
    const branch = (p.ref || '').replace('refs/heads/', '')
    const count = p.commits.length
    if (count === 0) return { skip: true }   // branch deletes and tag-only pushes
    const lines = [
      `# ${count} commit${count === 1 ? '' : 's'} to ${repo}@${branch}`,
      '',
      ...p.commits.map((c) => `- ${c.message.split('\n')[0]} (${c.author?.name || 'unknown'})`),
    ]
    if (p.compare) lines.push('', `[View diff on GitHub](${p.compare})`)
    return { subject: `${count} commit${count === 1 ? '' : 's'}: ${repo}@${branch}`, markdown: lines.join('\n') }
  }
  if (p.pull_request) {
    const pr = p.pull_request
    return {
      subject: `PR ${p.action}: #${pr.number} ${pr.title}`,
      markdown: [`# PR #${pr.number}: ${pr.title}`, '', `**${pr.user?.login}** ${p.action} this pull request.`, '', `[${pr.html_url}](${pr.html_url})`].join('\n'),
    }
  }
  return null
}

Bundled: Cal.com

Taken from src/templates/cal.js.

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 || 'Someone'} - ${b.title || ''}`.trim(),
      markdown: [
        '# New booking',
        '',
        `**Title:** ${b.title || '-'}`,
        `**Attendee:** ${a.name || '?'} (${a.email || ''})`,
        `**Start:** ${b.startTime || '?'}`,
      ].join('\n'),
    }
  }
  return null
}

Bundled: Linear

Taken from src/templates/linear.js. Handles issue create and update.

module.exports = function format(p) {
  if (p.type === 'Issue' && (p.action === 'create' || p.action === 'update')) {
    const d = p.data || {}
    return {
      subject: `Linear ${p.action}: ${d.identifier || ''} ${d.title || ''}`.trim(),
      markdown: [
        `# ${d.identifier || 'Issue'}: ${d.title || 'Untitled'}`,
        '',
        `**State:** ${d.state?.name || '?'}`,
        `**Priority:** ${d.priorityLabel || 'None'}`,
        d.url ? `\n[Open in Linear](${d.url})` : '',
      ].filter(Boolean).join('\n'),
    }
  }
  return null
}

Filtering events

To handle only some event types, return null for the rest:

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

When the formatter returns null, the default formatter renders the payload as JSON.

Testing a template

Templates are plain functions, so test them directly. The repo's test/smoke.test.js shows the pattern:

const format = require('../src/templates/stripe.js')
const out = format({ type: 'invoice.paid', data: { object: { amount_paid: 4200, currency: 'gbp' } } })
assert.match(out.subject, /42\.00 GBP/)

Clone this wiki locally