# Freshdesk Webhooks

Freshdesk doesn't have webhook subscriptions. A webhook is an **action** inside
an automation rule: **Admin → Workflows → Automations → Ticket Creation** (or
**Ticket Updates**) **→ New Rule → Trigger Webhook**. When the rule's conditions
match, Freshdesk calls the URL you entered with the method, headers and body
you configured.

This differs from most providers in three ways:

1. **There is no signature.** Freshdesk doesn't sign the body. There's no HMAC,
  signing secret or timestamp header, so don't go looking for an
  `X-Freshdesk-Signature`. It doesn't exist.
2. **You write the payload.** With **Advanced** content you write the JSON body
  yourself using placeholders such as `{{ticket.id}}`. There are no fixed event
  type strings, so you have to put an event name in the body yourself.
3. **You choose the request.** The method (GET/POST/PUT/PATCH/DELETE), encoding
  (JSON, XML or XML-Encoded) and headers are all set in the rule.

## When to Use This Skill

* How do I receive Freshdesk webhooks?
* How do I verify Freshdesk webhook signatures? (You can't. Send a shared secret
  in a custom header instead.)
* How do I set up "Trigger Webhook" in a Freshdesk automation rule?
* What payload does a Freshdesk webhook send? (Whatever you write in the
  Content section.)
* How do I tell a ticket-created webhook from a ticket-updated one?
* Why does my Freshdesk webhook keep arriving every 30 minutes? (Retries after
  a non-2xx response.)

## Verification (core): a shared secret custom header

In the Trigger Webhook action, turn on **Add custom headers** and add one line
in Freshdesk's `X-Sample-CustomHeader1: VALUE` format:

```
X-Freshdesk-Webhook-Secret: 3f9c0e...a long random hex value...

```

The header name is your choice. Freshdesk treats header names as
case-insensitive and sends the value exactly as entered. Check the header in
constant time before you parse the body:

```javascript
const crypto = require('crypto');

// NOT a Freshdesk signature. This is a secret YOU put in the rule's custom headers.
function verifyFreshdeskSecret(headerValue) {
  const expected = process.env.FRESHDESK_WEBHOOK_SECRET;
  if (!expected) throw new Error('FRESHDESK_WEBHOOK_SECRET is not set'); // fail closed
  if (typeof headerValue !== 'string') return false;
  const a = Buffer.from(headerValue);
  const b = Buffer.from(expected);
  // Length check first: timingSafeEqual throws on a length mismatch.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// Express lowercases header names: req.get('X-Freshdesk-Webhook-Secret') works too.

```

> **For complete handlers with tests**, see [examples/express/](https://github.com/hookdeck/webhook-skills/tree/main/skills/freshdesk-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/freshdesk-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/freshdesk-webhooks/examples/fastapi/).

Return `401` for a missing or wrong secret and `500` when the secret isn't
configured, so the endpoint never accepts everything by accident. Use a value
without spaces, such as `openssl rand -hex 32`. Freshdesk's docs say the
trigger won't run if a header has spaces in it.

**"Requires authentication"** is a separate toggle in the same action ("provide
your API key"). Freshdesk doesn't document what it puts on the wire, so use the
custom header described above. See
[references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/freshdesk-webhooks/references/verification.md).

## The Payload: you define it

Choose **Encoding: JSON** and **Content: Advanced**, then write a body with a
fixed `event` field for each rule. Here's the body for the Ticket Creation rule:

```json
{
  "event": "ticket_created",
  "triggered_event": "{{Triggered event}}",
  "ticket": {
    "id": "{{ticket.id}}",
    "subject": "{{ticket.subject}}",
    "status": "{{ticket.status}}",
    "priority": "{{ticket.priority}}",
    "requester_email": "{{ticket.requester.email}}",
    "url": "{{ticket.url}}"
  }
}

```

The Ticket Updates rule uses the same body with `"event": "ticket_updated"`.

* `event` is **this skill's convention**, not a Freshdesk field. Freshdesk
  defines no event-type strings for webhooks.
* `{{Triggered event}}` is "available only in webhooks and returns the name of
  the event that triggered the rule". Freshdesk doesn't list its possible
  values, so log it but don't branch on it.
* Placeholder values are substituted as text. Treat them as strings and
  re-fetch the ticket from the Freshdesk API (`GET /api/v2/tickets/{id}`)
  before you act on anything important.
* **Simple Content** sends a list of ticket properties you pick, in a format
  Freshdesk doesn't document. Use Advanced so the body is predictable.

## Events (this skill's `event` values)

| `event` | Rule tab | Fires when |
| --- | --- | --- |
| `ticket_created` | Ticket Creation | A new ticket matches the rule's conditions |
| `ticket_updated` | Ticket Updates | An update to a ticket matches the rule's conditions (e.g. "Status is updated", "Customer replies to ticket") |

If an `event` value isn't one you handle, log it and return `200`. If the body
has no `event` field, the rule is misconfigured, so return `400`.

## Delivery, Retries and Idempotency

* **Success** is any 2xx response. 3xx responses are followed as redirects.
  Anything else counts as a failure and "will automatically be retried once
  every 30 minutes, totaling 48 calls". Account admins get a failure email.
* **Rate limit:** 1000 webhook calls per hour. Calls over the limit are queued
  until the next hour.
* There's **no delivery ID header**, so dedupe on values you put in the body
  (e.g. `event` + `ticket.id` + a status or timestamp placeholder), or make your
  handler an upsert keyed on `ticket.id`.
* Webhooks run asynchronously to the automation, and Freshdesk ignores your
  response body.

## Environment Variables

```bash
# REQUIRED. The value you put in the rule's custom header. Generate with: openssl rand -hex 32
FRESHDESK_WEBHOOK_SECRET=
# OPTIONAL. The custom header name (case-insensitive). Default: X-Freshdesk-Webhook-Secret
FRESHDESK_WEBHOOK_HEADER=X-Freshdesk-Webhook-Secret

```

## Local Development

```bash
npx hookdeck-cli listen 3000 freshdesk --path /webhooks/freshdesk

```

No account required. The CLI creates a guest account on first run and gives
you a public HTTPS URL to paste into the Trigger Webhook action, plus a web UI
for inspecting requests.

In production, Hookdeck's **Freshdesk** source type checks the header for you.
You enter the header name and its exact value, and requests without them are
rejected. See [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/freshdesk-webhooks/references/setup.md).

## Reference Materials

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/freshdesk-webhooks/references/overview.md) - How Trigger Webhook works, rule types, payload templates, placeholders, retries and limits
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/freshdesk-webhooks/references/setup.md) - Step-by-step rule setup, custom header, testing, Hookdeck source setup
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/freshdesk-webhooks/references/verification.md) - Why there is no signature, the shared-secret header pattern, gotchas