webhooks vs polling for feedback integrations
the tradeoff isn't "push vs pull" — it's which failure mode you'll debug at 2am. when each is right, and why serious integrations do both.
most "webhooks vs polling" posts are written by people selling webhooks. they leave with a clear winner and the reader feels good about that.
we've shipped both for spirby. we've watched both fail. we run a webhook delivery system with retries, and we also keep all read endpoints fast enough to poll. that's not hedging. it's the conclusion you reach after watching a few production integrations break in interesting ways.
this post is the honest version: when webhooks are the right answer, when polling is, and the third pattern almost everyone eventually adopts.
the surface comparison
the surface comparison is what every "webhooks vs polling" post stops at. it's not wrong. it's just incomplete.
| dimension | webhooks | polling |
|---|---|---|
| latency | seconds | as fast as your interval |
| your req/s to us | zero | one per poll |
| we own | delivery | nothing |
| you own | a public endpoint | a cron |
| failure mode | missed event | missed window |
| debugging | log replay | log diff |
webhooks win on latency and quota. polling wins on simplicity and ownership. if those were the only axes, the choice would be obvious and this post wouldn't need to exist.
what the surface comparison misses
three things, in order of how often they bite people.
the first is reachability. a webhook requires a public https endpoint that we can reach from our infrastructure. in 2026 that's not a high bar, but it's a real bar. local dev, on-prem deploys, networks behind a vpn, vercel functions on a free tier that scale to zero. all of these break webhooks in ways that polling doesn't notice.
the second is replay. when we fire a webhook, we retry on a 30s → 2m → 10m → 1h schedule across five attempts (about 72 minutes of total wall clock before we give up). after that, the delivery is terminal and it's your problem. if you're down for two hours, you've lost an event. polling doesn't have this issue: when you come back up, the data is still there to fetch. the api never forgot.
the third is order. webhooks arrive in the order they happen, mostly. retries can violate that. if a vote.created for post 42 retries and lands after the post.status_changed to "shipped" for the same post, your handler sees them out of order. polling doesn't have an order problem: you scan a sorted endpoint and you get whatever's there now.
these three failure modes are why nobody who's run integrations at scale picks one mode and stops thinking. they pick a primary mode, and they use the other one to cover the failure cases.
when webhooks are the right primary
webhooks are right when latency matters and the event is unique and self-describing.
three concrete examples from the spirby api:
- a customer just voted. you want to update their dashboard in your own product showing "you're vote 47 of 50 for this feature." polling that with a five-minute interval feels broken. webhook delivery is sub-second.
- a post just shipped. you want to post to slack within ten seconds of the change so the team sees it land. polling at one-minute intervals adds a minute of perceived lag for no reason.
- a new post was created on a board that gets two posts a week. polling every five minutes to discover two events a week is absurd.
in all three, the events are rare, the latency target is tight, and each event is fully described by its own payload. you don't need state from anywhere else to act on the webhook. that's the sweet spot.
when polling is the right primary
polling is right when you're building a view, not reacting to an event.
three more concrete examples:
- you want a dashboard inside your own product that shows every roadmap post in "planned" or "in progress" with current vote counts. you don't care about the moment a vote was cast. you care about the current state. polling the list endpoint every five minutes is the right shape.
- you're building a weekly digest of the top ten posts. there's no "event". there's a snapshot you take on monday morning. polling once a week is the right shape.
- you're computing analytics over the full set of posts. you want everything in your warehouse, sorted, queryable. you incrementally poll
?sort=recently_activeonce an hour, walk pages until you hit posts older than your last poll, and write rows. webhooks would also work but you'd be writing the merge logic yourself.
the rule of thumb: if you find yourself thinking "i need to remember which events i've seen so i can build a current state from them," you're trying to solve a polling problem with webhooks. stop. just poll.
the third pattern: webhooks for push, polling for reconcile
every serious integration we've watched in production ends up running both.
the shape is:
- webhook handler reacts in real time. it's fast and idempotent (see idempotency below).
- a polling job runs every fifteen minutes against the same resource, walking
?sort=recently_activewithcursorand stopping when it hits a post older than the last reconcile. - when the poll finds something the webhook handler hasn't touched, it processes it through the same handler.
this isn't elegant. it's correct. you get the latency of webhooks in the happy path, and the durability of polling in the unhappy one.
here's a concrete example. say the workflow is "mark a post as shipped in your crm when it ships on spirby." the webhook fires on post.status_changed. the reconcile job walks shipped posts ordered by activity. both call the same handler.
verifySpirbyWebhook below is the copy-paste helper from the webhook signature docs. the spirby signature header is t=<unix>,v1=<sha256-hex> and the hmac is computed over ${ts}.${rawBody}, so a generic sha256= check won't verify. use the documented helper.
import { verifySpirbyWebhook } from './verify-spirby-webhook'
const SECRET = process.env.SPIRBY_WEBHOOK_SECRET
const API_KEY = process.env.SPIRBY_API_KEY
const BOARD_ID = process.env.SPIRBY_BOARD_ID
export async function webhookHandler(req, res) {
const ok = verifySpirbyWebhook(
req.rawBody,
req.headers['x-spirby-signature'] ?? '',
SECRET,
)
if (!ok) return res.status(400).end()
const event = JSON.parse(req.rawBody)
if (event.event !== 'post.status_changed') return res.status(200).end()
if (event.post.status !== 'shipped') return res.status(200).end()
await markShipped({ id: event.post.id, status: event.post.status })
return res.status(200).end()
}
export async function reconcileShipped() {
const since = await db.getLastReconcileAt()
const now = new Date()
const url = new URL(`https://api.spirby.com/v1/boards/${BOARD_ID}/posts`)
url.searchParams.set('status', 'shipped')
url.searchParams.set('sort', 'recently_active')
url.searchParams.set('limit', '50')
let cursor = null
walk: do {
if (cursor) url.searchParams.set('cursor', cursor)
const res = await fetch(url, {
headers: { authorization: `Bearer ${API_KEY}` },
})
const { data, nextCursor } = await res.json()
for (const post of data) {
if (new Date(post.updatedAt) < since) break walk
await markShipped({ id: post.id, status: post.status })
}
cursor = nextCursor
} while (cursor)
await db.setLastReconcileAt(now)
}
sort=recently_active orders by updatedAt desc. the loop breaks the first time it sees a post older than the last reconcile, which is the substitute for the ?updated_after filter you wish existed.
the reconcile job is the thing that lets you sleep through a multi-hour outage that exceeds the retry window and wake up with nothing missing. it's also the thing that catches the cases where a webhook fired but your handler crashed before it could persist anything.
the price you pay is that markShipped has to be idempotent.
idempotency: the quiet third pillar
both paths will hand you the same logical update twice. webhooks because of retries. polling because your reconcile window overlapped with the previous run. idempotency is the property that makes both safe.
webhook deliveries carry an X-Spirby-Delivery header (unique per delivery), but the reconcile path never sees it. the rule of thumb: pick a dedupe key that both paths can derive identically from the resource state. delivery ids are great for webhook-only dedupe, useless for cross-path dedupe.
for the shipped-post workflow above, the key is ${post.id}:${post.status}. both paths compute it from the same fields:
async function markShipped(post) {
// key from resource state, computed identically on webhook + reconcile.
const key = `${post.id}:${post.status}`
const inserted = await db
.insert(processedShippedPosts)
.values({ key, processedAt: new Date() })
.onConflictDoNothing()
.returning()
if (inserted.length === 0) return // already handled
await pushToCrm(post)
}
one row per logical update. postgres handles the concurrency. the webhook handler and the reconcile job can run at the same time on the same post and exactly one of them will call pushToCrm, because both paths compute the same key against the same unique index.
the general shape: figure out what slice of resource state your workflow cares about (here, (id, status)), and key on that. for "count unique votes," it's vote.id. for "react to every comment," it's comment.id. for "track every change to a contact," it's ${contact.id}:${contact.updatedAt} (contacts expose updatedAt in their REST shape. posts only expose it on the list endpoint, not in webhook payloads, which is why the shipped example keys on status instead).
the questions worth asking before you pick
people pick webhooks because they're fashionable. people pick polling because they're afraid of webhooks. neither of those is a reason. these four questions usually settle it:
- what's the highest acceptable latency between something happening on spirby and your system reacting? under a minute → webhooks primary. over five minutes → polling primary. between → either, and you might still want both.
- can your system reliably accept inbound https in production? if no, polling primary. don't fight reachability problems with retry tricks.
- are you reacting to events, or building a view? events → webhooks. views → polling.
- what's your tolerance for missing one event? zero → run reconcile no matter what your primary is. low → run reconcile weekly as a safety net. moderate → webhooks alone are probably fine.
the first three pick your primary mode. the fourth tells you whether to add the safety net.
what we'd do differently if we were building today
we shipped webhooks first because the marketing story is cleaner. it's the thing that distinguishes a real api from a read endpoint with a key. we still believe that.
but if we were building the integration story from scratch today, the docs page we'd write first is the reconcile pattern, not the webhook handler. webhooks alone are easy to demo and hard to operate. the reconcile pattern is harder to demo and easy to operate. we'd rather optimize for the second one.
we're going to rewrite our own integration examples in that order. the linear sync example, the slack-on-ship example, and the customer dashboard mirror will each pick up a "reconcile job" section. expect that next month.
the takeaway
webhooks are the right primary for reacting to events with tight latency. polling is the right primary for building views and snapshots. running both is what nontrivial integrations actually do, because the failure modes of each mode are covered by the strengths of the other.
the api is on every spirby plan, including the $19 starter. the webhook delivery system, the cursor-paginated list endpoints, and the openapi spec all ship together. pick the pattern that matches the problem. add the other one when the problem grows up.
if you want to see what calling the api looks like, mint a key from /app/settings/api-keys and curl /v1/boards. ten minutes. no upgrade required.
related posts
- why your feedback tool should have a real api — what changes when the api ships on every plan instead of behind enterprise.
- building feedback workflows with the spirby api and webhooks — three workflows that use both webhooks and polling in production.