Agent skill

Freshsales Webhooks Skill

Receive and authenticate Freshsales webhooks sent by the "Trigger webhook" action in Freshsales workflows. Use when building a Freshsales webhook receiver, securing it with a secret custom header (Freshsales has no HMAC signature), writing the Advanced content body with placeholders like {{deal.id}}, or handling deal and contact payloads sent with POST, PUT, PATCH, GET or DELETE.

Install this skill

npx skills add hookdeck/webhook-skills --skill freshsales-webhooks


Freshsales (Freshworks CRM) has no webhook subscriptions. A webhook is an action inside a workflow: when the workflow's trigger and conditions match, the Trigger webhook action calls the URL you entered with the request type, headers and content you configured.

This differs from most providers in three ways:

  1. Webhooks are not signed. There is no HMAC, signing secret or timestamp header. You authenticate the sender with a secret custom header you add to the action.
  2. You choose the request. The request type (POST, PUT, PATCH, GET or DELETE) and encoding (JSON, XML or X-FORM-ENCODED) are set in the workflow, so the endpoint must not be POST-only, and GET and DELETE have no body.
  3. There is no event type. The body is Simple (selected fields) or Advanced (content you write with placeholders). Which workflow fired is whatever you put in the body.

When to Use This Skill

  • How do I receive Freshsales webhooks?
  • How do I verify Freshsales webhook signatures? (You can't: use a secret custom header)
  • How do I set up "Trigger webhook" in a Freshsales workflow?
  • What payload does a Freshsales webhook send? (Whatever Simple or Advanced content you chose)
  • Why does my Freshsales webhook endpoint return 401 or never fire?

Verification (core)

On the Trigger webhook action choose Add custom headers and add one line in Freshsales' X-Sample-CustomHeader1: VALUE format, for example X-Webhook-Secret: 3f9c0e... (a long random hex value, no spaces). The header name is yours to choose; the whole header value is the secret.

const crypto = require('crypto');

// Hash both sides: equal-length buffers, so timingSafeEqual never throws.
function safeEqual(a, b) {
  const ha = crypto.createHash('sha256').update(String(a)).digest();
  const hb = crypto.createHash('sha256').update(String(b)).digest();
  return crypto.timingSafeEqual(ha, hb);
}

function verifyFreshsalesRequest(headers, secret = process.env.FRESHSALES_WEBHOOK_SECRET,
    headerName = process.env.FRESHSALES_WEBHOOK_HEADER || 'X-Webhook-Secret') {
  if (!secret) return false;                          // never accept when unconfigured
  const received = headers[headerName.toLowerCase()]; // Node lowercases header names
  return Boolean(received) && safeEqual(received, secret);
}
  • Compare the entire value with the exact string you typed. Don't strip or parse a Bearer prefix: if you typed one, it's part of the secret.
  • Look the header up case-insensitively. Missing header or mismatch: return 401.
  • Python: hmac.compare_digest on bytes (encode both sides; str raises on non-ASCII).
  • A static shared credential authenticates the sender, not the body, and gives no replay protection (no signature, no timestamp). Use HTTPS, keep the secret out of logs, and rotate it (change it in the workflow and your env var) if it leaks.
  • Freshsales also has an optional Authentication field (username and password, or API key), but how it's sent on the wire isn't documented. Use a custom header instead.
  • Read the body raw (express.raw({ type: '*/*' })) and authenticate before parsing it. Accept POST, PUT, PATCH, GET and DELETE, and treat an empty body as valid.

For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.

Payloads

Freshsales defines no event type strings. The examples assume an illustrative Advanced body, with an endpoint-style JSON document and placeholders (Deals workflows offer Deal and related account fields, Contacts workflows offer Contact and primary Account fields, Accounts workflows offer Account fields):

{ "deal": { "id": "{{deal.id}}", "name": "{{deal.name}}", "amount": "{{deal.amount}}" } }
{ "contact": { "id": "{{contact.id}}", "email": "{{contact.email}}" } }
Payload shapeMeaning in the examples
top-level dealA deal workflow fired (illustrative)
top-level contactA contact workflow fired (illustrative)

This deal / contact shape is a convention of the examples, not something Freshsales sends. If you need to tell "created" from "updated", add a literal field to each workflow's body.

Environment Variables

FRESHSALES_WEBHOOK_HEADER=X-Webhook-Secret       # name of the custom header (default shown)
FRESHSALES_WEBHOOK_SECRET=your-long-random-hex   # exact, whole header value

Delivery Behavior

  • Freshsales expects a response within 6 seconds (otherwise a read timeout).
  • On failure it emails you the failed Callback URL, retries for an hour, then for the next 2 days, then drops the request. Make handlers idempotent.
  • The Test button sends sample data (for example 12345 for ID placeholders).

Local Development

Freshsales can't reach localhost. Tunnel with the Hookdeck CLI (no account required):

npx hookdeck-cli listen 3000 freshsales --path /webhooks/freshsales

Use the printed URL as the Callback URL, and add the secret custom header to the action.

Resources


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Nov 4, 2026

View on GitHub →