# Guide to Favro Webhooks: Features and Best Practices

Favro webhooks notify your systems about board activity: a card is created, moved, or updated, a comment is added. If you're building on Favro, webhooks are how you react to project changes without polling.

This guide covers how Favro webhooks work, the events you'll handle, its unusual verification (the signed message isn't the body), the ping handshake, and the best practices for production.

## What are Favro webhooks?

Favro webhooks are HTTP POSTs delivered to a URL you configure per board/widget. The verification is unusual: the signature in the `X-Favro-Webhook` header is a base64 HMAC-SHA1 where the signed message is `payloadId + webhookUrl` (the payload's `payloadId` concatenated with the webhook's own target URL), keyed with the webhook secret, not the raw body, and SHA1 rather than SHA256.

## Favro webhook features

| Feature | Details |
| --- | --- |
| Configuration | Per board/widget: `POST` create-webhook (`name`, `widgetCommonId`, `postToUrl`, `secret`) or UI automations |
| Signature header | `X-Favro-Webhook` |
| Signature scheme | base64 HMAC-SHA1 over `payloadId + webhookUrl`, keyed with the webhook secret |
| Setup handshake | A `Ping` event on creation, return 2xx to validate |
| Events | `action` field (card and comment actions) |
| Caveat | UI-automation-triggered webhooks send partial data (no pre-update state) |
| SDK | None (community `@bscotch/bravo` implements verification) |

## Common events

Favro payloads carry an `action` field:

| Action | Fires when |
| --- | --- |
| Card `created` | A card is created |
| Card `committed` | A card is committed |
| Card `moved` | A card is moved |
| Card `updated` | A card is updated |
| Card `deleted` | A card is deleted |
| Comment `created` / `updated` / `deleted` | A comment changes |

Branch on the `action` value.

## Setting up Favro webhooks

Create webhooks per board/widget via the create-webhook API (`name`, `widgetCommonId`, `postToUrl`, `secret`) or through Favro's UI automations. On creation, Favro sends a `Ping` event, return a 2xx to validate the setup.

## Securing Favro webhooks

The `X-Favro-Webhook` header is `base64(HMAC-SHA1(secret, payloadId + webhookUrl))`. To verify, concatenate the payload's `payloadId` with the webhook's own target URL (the `postToUrl` you registered), HMAC-SHA1 it with the webhook secret, base64-encode, and compare. The webhook URL is a required input, which is why you must know it at verification time.

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

const SECRET = process.env.FAVRO_WEBHOOK_SECRET;
const WEBHOOK_URL = process.env.FAVRO_WEBHOOK_URL; // exactly as registered (postToUrl)

function verify(payloadId, signature) {
  const message = payloadId + WEBHOOK_URL; // NOT the raw body
  const expected = crypto.createHmac("sha1", SECRET).update(message).digest("base64");
  const a = Buffer.from(expected);
  const b = Buffer.from(signature || "");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

app.post("/webhook", express.json(), (req, res) => {
  // Favro sends a Ping on creation; accept it to validate setup
  if (req.body && req.body.hook === "ping") {
    return res.sendStatus(200);
  }

  if (!verify(req.body.payloadId, req.headers["x-favro-webhook"])) {
    return res.sendStatus(401);
  }

  res.sendStatus(200); // acknowledge fast
  processQueue.add(req.body); // branch on action, async
});

```

The same check in Python:

```python
import base64
import hashlib
import hmac
import os

SECRET = os.environ["FAVRO_WEBHOOK_SECRET"].encode()
WEBHOOK_URL = os.environ["FAVRO_WEBHOOK_URL"]

def verify(payload_id: str, signature: str) -> bool:
    message = (payload_id + WEBHOOK_URL).encode()  # NOT the raw body
    expected = base64.b64encode(hmac.new(SECRET, message, hashlib.sha1).digest()).decode()
    return hmac.compare_digest(expected, signature or "")

```

The header name (`X-Favro-Webhook`) comes from the community SDK; Favro's live doc is JS-rendered and has shown inconsistent header strings, so confirm the exact header against a real delivery.

## Favro webhook limitations and pain points

### The signed message is `payloadId + webhookUrl`, not the body

The Problem: Verification is over the `payloadId` concatenated with the webhook's own URL, not the raw body, and it's SHA1, not SHA256. Standard "HMAC the raw body with SHA256" logic finds nothing that matches.

Why It Happens: Favro signs a small identifier plus the destination URL, with SHA1.

Workarounds:

* Concatenate `payloadId + webhookUrl`, HMAC-SHA1, base64, and compare, using the exact registered URL.

How Hookdeck Can Help: Hookdeck can verify provider signatures at the edge, so your app receives pre-verified events without reconstructing the payloadId-plus-URL message.

### The header name is inconsistent in the docs

The Problem: Favro's live doc (JS-rendered) has shown different header strings; the trusted value is `X-Favro-Webhook` (from the community SDK). Checking the wrong header name misses the signature.

Why It Happens: The documentation is inconsistent.

Workarounds:

* Use `X-Favro-Webhook`, and confirm against a real delivery.

How Hookdeck Can Help: Hookdeck verifies at the edge, so your app isn't coupled to the header name.

### The Ping handshake

The Problem: Favro sends a `Ping` on creation that isn't a real event; a handler that tries to verify or process it as a card/comment event errors out.

Why It Happens: Favro validates the endpoint with a Ping.

Workarounds:

* Detect the Ping and return 2xx without verifying it as a data event.

How Hookdeck Can Help: Hookdeck can accept the Ping and forward only real events to your handler.

### UI-automation webhooks send partial data

The Problem: Webhooks triggered by UI automations send partial data with no pre-update state, so diff-based logic breaks.

Why It Happens: UI-automation triggers carry a reduced payload.

Workarounds:

* Don't assume full or pre-update state; fetch current state via the API when you need it.

How Hookdeck Can Help: Hookdeck durably queues events so your worker can enrich them via the API at its own pace.

## Best practices

### Verify `payloadId + webhookUrl` with HMAC-SHA1

Concatenate `payloadId` with the exact registered webhook URL, HMAC-SHA1 with the secret, base64, and compare against `X-Favro-Webhook` in constant time.

### Handle the Ping

Detect the `Ping` on creation and return 2xx without treating it as a data event.

### Acknowledge fast, branch on action

Return 200 quickly, defer work to a queue, and branch on the `action` field. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Don't assume full data

Fetch current state via the API for UI-automation webhooks that send partial data.

## Conclusion

Favro webhooks verify with an `X-Favro-Webhook` base64 HMAC-SHA1 over `payloadId + webhookUrl`, not the raw body, so you need the registered URL at verification time. Handle the `Ping` on creation, branch on the `action` field, confirm the header name against a real delivery, and don't assume full data from UI-automation webhooks.

[Hookdeck](https://hookdeck.com) verifies the signature, handles the Ping, deduplicates, and durably queues every event at the edge, so your app only ever processes verified board events.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Favro webhooks reliably in minutes.