-
Notifications
You must be signed in to change notification settings - Fork 0
Per Source Templates
How to add a custom formatter for any webhook source.
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.
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 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.
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 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
}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.
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.)
{
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.