building feedback workflows with the spirby api and webhooks
three concrete workflows: linear sync on vote thresholds, slack alerts on shipped posts, and a dashboard mirror with hmac verification. copy-paste ready.
we wrote a post on why the api should be on every plan. this is the practical follow-up. three workflows we run for our own boards. all three are under fifty lines of code.
every example below assumes you've minted an api key from /app/settings/api-keys and saved it as SPIRBY_API_KEY, and that you've registered a webhook secret as SPIRBY_WEBHOOK_SECRET.
workflow 1: linear issue when a post crosses 50 votes
your roadmap has 200 ideas. five of them have crossed the threshold where you should actually ship them. you want a linear issue auto-created when that happens, with a link back to the post and the vote count baked in.
the trigger is the vote.created webhook. on each new vote, check if the post just crossed your threshold. if so, fire a linear issue.
import { LinearClient } from '@linear/sdk'
import crypto from 'node:crypto'
const linear = new LinearClient({ apiKey: process.env.LINEAR_API_KEY })
const SECRET = process.env.SPIRBY_WEBHOOK_SECRET
const THRESHOLD = 50
export async function handler(req, res) {
const signature = req.headers['x-spirby-signature']
const expected = `sha256=${crypto
.createHmac('sha256', SECRET)
.update(req.rawBody)
.digest('hex')}`
const sigBuf = Buffer.from(signature ?? '')
const expBuf = Buffer.from(expected)
if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) {
return res.status(401).end()
}
const event = JSON.parse(req.rawBody)
if (event.event !== 'vote.created') return res.status(200).end()
const post = event.data.post
if (post.vote_count !== THRESHOLD) return res.status(200).end()
await linear.createIssue({
teamId: process.env.LINEAR_TEAM_ID,
title: post.title,
description: `from spirby: ${post.url}\n\nvotes: ${post.vote_count}\n\n${post.body}`,
})
return res.status(200).end()
}
the post.vote_count !== THRESHOLD check (rather than >=) means you fire exactly once per post: when it crosses 50, not on every subsequent vote. this is the kind of nuance you can tune to your own thresholds. forty? a hundred? you decide. canny's "create linear issue" integration fires on every status change. ours fires on whatever signal you actually care about.
failed deliveries retry on an exponential backoff: 30s, 2m, 10m, 1h across five attempts (about 72 minutes of total wall clock before terminal). so if linear is down for half an hour, the issue still gets created. after fifty consecutive failures the webhook is disabled and you get an email.
workflow 2: slack alert when a post ships
simpler. on post.status_changed, if the new status is shipped, post in slack.
import crypto from 'node:crypto'
const SECRET = process.env.SPIRBY_WEBHOOK_SECRET
const SLACK_URL = process.env.SLACK_WEBHOOK_URL
export async function handler(req, res) {
const signature = req.headers['x-spirby-signature']
const expected = `sha256=${crypto
.createHmac('sha256', SECRET)
.update(req.rawBody)
.digest('hex')}`
const sigBuf = Buffer.from(signature ?? '')
const expBuf = Buffer.from(expected)
if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) {
return res.status(401).end()
}
const event = JSON.parse(req.rawBody)
if (event.event !== 'post.status_changed') {
return res.status(200).end()
}
if (event.data.status !== 'shipped') {
return res.status(200).end()
}
const post = event.data
await fetch(SLACK_URL, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
text: `:rocket: shipped: <${post.url}|${post.title}> (${post.vote_count} votes)`,
}),
})
return res.status(200).end()
}
twenty-five lines. the equivalent canny integration is a paid integration on a paid tier with a two-week setup time and approximately the same behavior. you can write yours in a lunch break.
workflow 3: customer dashboard mirror
this one's the most useful and the least obvious. you want logged-in customers in your own product to see the status of feature requests they've voted for, without leaving your app.
read endpoint. you call /v1/posts?voter_email=... and render the results in your dashboard.
async function fetchPostsForUser(email) {
const url = new URL('https://api.spirby.com/v1/posts')
url.searchParams.set('voter_email', email)
url.searchParams.set('status', 'planned,in_progress,shipped')
url.searchParams.set('limit', '20')
const res = await fetch(url, {
headers: {
authorization: `Bearer ${process.env.SPIRBY_API_KEY}`,
},
})
if (!res.ok) throw new Error(`spirby api: ${res.status}`)
const { data, nextCursor } = await res.json()
return { posts: data, nextCursor }
}
cursor-paginated. cache the result for five minutes per user. render a list in the customer dashboard: "feature requests you've voted for." each entry shows the current status. when something they voted for ships, they see the green "shipped" badge in their own dashboard the next time they log in.
this is the kind of thing that meaningfully changes how customers feel about your product. they voted, the team listened, they can see what happened. the hard work is the api call. the rest is a <ul>.
with canny, this dashboard requires either screen-scraping or moving to enterprise. with spirby, it requires twenty lines.
a note on retries and idempotency
webhooks retry. that's the whole point of the retry schedule. but a retry only helps you if your handler is idempotent: if the same payload arrives twice, the second one shouldn't double-create the linear issue or double-post in slack.
each webhook delivery includes a delivery_id in the payload. cache it for an hour and check it on each request:
const seen = new Set()
if (seen.has(event.delivery_id)) {
return res.status(200).end()
}
seen.add(event.delivery_id)
an in-memory set is fine for a small handler. for anything serious, persist the id with a two-hour ttl in postgres or redis. the retry window maxes out at about 72 minutes, but practical duplicates almost always happen within the first thirty seconds.
stripe's docs are the canonical reference if you're curious about the patterns at scale. ours follow the same shape.
the pattern
every workflow above follows the same three-step shape:
- listen to a webhook (or call a read endpoint).
- verify and filter.
- fan out to whatever system actually needs to know.
once you've written the first one, the second and third are copy-paste with different filters. that's the deliberate design choice. we'd rather have five primitives that compose well than fifty pre-built integrations that each do half of what you want.
if you build something with the api, tell us. we want to feature real workflows on this blog. the more we hear, the more confident we get that the api-on-every-plan bet was the right one.
related posts
- why your feedback tool should have a real api — why the api ships on the entry plan and not behind enterprise.
- webhooks vs polling for feedback integrations — the two delivery models and where each one shines.