Freshworks fires webhooks via the Workflow Automator and Marketplace apps. Both use a similar retry policy: three attempts with exponential backoff. Miss the third attempt and the event is gone. Build for that constraint.
Always queue before processing
Never do business logic in the webhook handler. Accept, enqueue, return 200. Use SQS, Redis Streams, or even a database table.
app.post("/freshworks/ticket-updated", async (req, res) => {
await db.events.insert({
payload: req.body,
received_at: new Date(),
processed: false
});
res.status(200).send();
});
If your processor crashes, the event is safe. If Freshworks retries, the dedupe table catches the duplicate.
Idempotency via composite keys
Freshworks does not send a stable delivery ID across retries. Build your own from (object_id, event_type, updated_at).
const key = `${payload.id}:${payload.event}:${payload.updated_at}`;
if (await seen(key)) return;
await markSeen(key);
Validate the signature
Marketplace app webhooks include an HMAC signature in the X-Freshworks-Signature header. Validate against the shared secret, in constant time.
const expected = crypto
.createHmac("sha256", SECRET)
.update(rawBody)
.digest("hex");
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) {
return res.status(401).send();
}
Handle the 200-but-degraded case
Sometimes you accept the webhook but cannot reach a downstream system. Do not 500 to Freshworks; you have already accepted. Mark the event as “needs retry” in your queue and run a side-channel reconciliation job.
Reconciliation as a backstop
Once an hour, list updated tickets via /api/v2/tickets?updated_since=... and diff against your processed event log. Webhook gaps become visible within an hour, not when a customer screams.
What to do this week
Move webhook handlers to accept-and-enqueue, add the composite idempotency key, validate signatures with timingSafeEqual, and schedule the hourly reconciliation job.