# Guide to Facebook Webhooks: Features and Best Practices

Facebook webhooks let your app react to activity on the Graph API in real time: a new comment on a Page, an incoming Messenger message, a lead submitted through a lead ad. Meta pushes each change to your endpoint as a JSON POST instead of making you poll the Graph API for it.

This guide covers how Facebook webhooks work, the events you can subscribe to, the two-step setup (a verification handshake followed by signed deliveries), the limitations worth planning around, and the best practices that keep a Messenger, Pages, or lead-gen integration reliable in production.

## What are Facebook webhooks?

Facebook webhooks are HTTP callbacks delivered through the Graph API's webhooks product. You subscribe an app to changes on an object (such as `page`, `user`, or `permissions`) and to specific fields on that object (such as `feed` or `messages`). When a subscribed field changes, Meta sends a POST to your configured callback URL with a JSON body describing what happened.

Facebook uses its own `X-Hub-Signature-256` header for authenticity and a `hub.challenge` GET handshake to verify your endpoint at registration.

## Facebook webhook features

| Feature | Details |
| --- | --- |
| Webhook configuration | App Dashboard (Webhooks product) or Graph API subscriptions |
| Endpoint verification | GET handshake echoing `hub.challenge` |
| Signature header | `X-Hub-Signature-256` (SHA-256); legacy `X-Hub-Signature` is SHA-1 |
| Signature scheme | `sha256=` + hex HMAC-SHA256 of the raw body, keyed with the App Secret |
| Replay protection | None; signature carries no timestamp |
| Payload shape | `{object, entry[]}`; up to 1,000 updates batched per POST |
| Event model | (object, field) subscriptions, not dotted event names |
| Retry behavior | Immediate retry, then decreasing frequency over 36 hours, then dropped |
| Optional mTLS | Meta client cert, CN `client.webhooks.fbclientcerts.com` |
| SDKs | `facebook-nodejs-business-sdk`, `facebook-business` (no HMAC helper) |

## Common events

Facebook webhook events are (object, field) pairs rather than dotted event names. You subscribe an app to an object, then enable the fields you care about. The `page` object is the most common for developer integrations:

| Object.field | Fires when |
| --- | --- |
| `page.feed` | A post, comment, reaction, or share happens on a Page you manage |
| `page.messages` | A user sends your Page a Messenger message |
| `page.messaging_postbacks` | A user taps a postback button in a Messenger conversation |
| `page.leadgen` | A user submits a Facebook lead ad form |
| `page.mention` | Your Page is mentioned in a post or comment |
| `user.feed` | A subscribed user's own feed changes |
| `permissions` | A user grants or revokes a permission your app requested |

Each field's exact payload shape is documented on the Page and User webhooks reference. Enable only the fields you process, and consult Meta's reference for the authoritative, current list.

## Setting up Facebook webhooks

### Verify your endpoint (GET handshake)

When you add a callback URL in the App Dashboard (or via the Graph API), Meta sends a GET request with three query parameters: `hub.mode` (always `subscribe`), `hub.verify_token` (the Verify Token you set in the dashboard), and `hub.challenge`. Confirm the verify token matches, then echo the `hub.challenge` value back with a 200.

```javascript
const VERIFY_TOKEN = process.env.FB_VERIFY_TOKEN;

app.get("/webhook", (req, res) => {
  const mode = req.query["hub.mode"];
  const token = req.query["hub.verify_token"];
  const challenge = req.query["hub.challenge"];

  if (mode === "subscribe" && token === VERIFY_TOKEN) {
    return res.status(200).send(challenge);
  }
  res.sendStatus(403);
});

```

### Subscribe to fields

Configure object and field subscriptions in the App Dashboard's Webhooks product, or via the Graph API. To receive a Page's events you also need `pages_manage_metadata` and must subscribe the app to that Page:

```bash
curl -X POST "https://graph.facebook.com/v21.0/{page-id}/subscribed_apps" \
  -H "Content-Type: application/json" \
  -d '{"subscribed_fields": "feed,messages,leadgen", "access_token": "{page-access-token}"}'

```

Apps in Development mode receive only test notifications; you need a Live app for real Page traffic.

## Securing Facebook webhooks

Every POST carries an `X-Hub-Signature-256` header: the string `sha256=` followed by a hex-encoded HMAC-SHA256 of the raw request body, keyed with your app's App Secret. (A legacy `X-Hub-Signature` header carries the SHA-1 equivalent; prefer the SHA-256 header.)

The critical detail: compute the HMAC over the exact raw bytes Meta sent. Meta signs an escaped-unicode form of the payload, so a body you've parsed and re-serialized will not match. Capture the raw body before any JSON middleware touches it.

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

const APP_SECRET = process.env.FB_APP_SECRET;

function verifySignature(rawBody, signatureHeader) {
  const expected =
    "sha256=" +
    crypto.createHmac("sha256", APP_SECRET).update(rawBody).digest("hex");
  const received = Buffer.from(signatureHeader || "");
  const computed = Buffer.from(expected);
  return (
    received.length === computed.length &&
    crypto.timingSafeEqual(received, computed)
  );
}

app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
  if (!verifySignature(req.body, req.headers["x-hub-signature-256"])) {
    return res.sendStatus(403);
  }

  const payload = JSON.parse(req.body);
  res.sendStatus(200); // acknowledge fast

  for (const entry of payload.entry) {
    processQueue.add(entry); // process each entry asynchronously
  }
});

```

The same check in Python (Flask):

```python
import hashlib
import hmac
import os
from flask import Flask, request

app = Flask(__name__)
APP_SECRET = os.environ["FB_APP_SECRET"].encode()

def verify_signature(raw_body: bytes, signature_header: str) -> bool:
    expected = "sha256=" + hmac.new(APP_SECRET, raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature_header or "")

@app.post("/webhook")
def webhook():
    if not verify_signature(request.get_data(), request.headers.get("X-Hub-Signature-256")):
        return "", 403

    payload = request.get_json()
    for entry in payload["entry"]:
        pass  # enqueue each entry for async processing
    return "", 200

```

For an extra layer, Meta can present a client certificate (mTLS) with the common name `client.webhooks.fbclientcerts.com`, which you can pin at your load balancer.

## Facebook webhook limitations and pain points

### Payloads are batched, and each POST holds many updates

The Problem: A single POST can carry up to 1,000 updates in its `entry` array, spanning multiple objects and fields. A handler that assumes one event per request will silently drop everything after the first.

Why It Happens: Meta batches changes for delivery efficiency, especially for high-traffic Pages.

Workarounds:

* Iterate the full `entry` array and process each change independently.
* Enqueue each entry as its own unit of work so one bad entry doesn't fail the batch.
* Keep the synchronous handler to verification and enqueue only.

How Hookdeck Can Help: Hookdeck can [split a batched payload](/docs/transformations) into individual events with a transformation, so your application receives one clean event per delivery instead of unpacking arrays itself.

### The signature has no timestamp, so no replay protection

The Problem: `X-Hub-Signature-256` signs the body only. A captured, validly signed payload can be replayed and it will still verify. Signature verification alone doesn't prove freshness.

Why It Happens: Meta's scheme predates the timestamped designs that bake a replay window into the signature.

Workarounds:

* Make processing idempotent so a replayed delivery has no additional effect.
* Dedupe on identifiers inside the payload (message IDs, change IDs) rather than trusting each POST as unique.

How Hookdeck Can Help: Hookdeck deduplicates deliveries at the edge with configurable rules, filtering replays and retries before they reach your app. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Deliveries expire after 36 hours

The Problem: If your endpoint is down or returning errors, Meta retries immediately, then at decreasing frequency for about 36 hours, after which the notification is dropped for good. There's no delivery log to recover it from.

Why It Happens: Meta bounds how long it will keep retrying a failing endpoint.

Workarounds:

* Acknowledge with a 200 fast and keep the endpoint highly available.
* Reconcile against the Graph API for anything you can't afford to miss.
* Log every raw delivery on receipt for your own audit trail.

How Hookdeck Can Help: Hookdeck accepts and acknowledges deliveries immediately, then retries to your downstream on a schedule you control and preserves failed events for replay, so a short outage on your side no longer means lost activity.

### Raw-body handling is easy to break

The Problem: Meta signs an escaped-unicode form of the payload. If any middleware parses and re-serializes the body before you verify, the recomputed HMAC won't match and every delivery looks forged.

Why It Happens: Most web frameworks parse JSON automatically, discarding the exact bytes the signature was computed over.

Workarounds:

* Capture the raw body (`express.raw`, `request.get_data()`) and verify before parsing.
* Never re-serialize the payload prior to the signature check.

How Hookdeck Can Help: Hookdeck verifies the `X-Hub-Signature-256` signature at the edge against the bytes as received, so your application receives already-verified events and never has to wrangle raw-body middleware.

## Best practices

### Verify against the raw body with the App Secret

Recompute `sha256=` + hex HMAC-SHA256 over the untouched bytes, keyed with the App Secret, and compare with a constant-time comparison. Prefer `X-Hub-Signature-256` over the SHA-1 `X-Hub-Signature`.

### Handle the GET handshake and POST deliveries separately

The GET verifies the endpoint via `hub.challenge`; the POST delivers signed events. Implement and test both, and keep the verify token secret.

### Acknowledge fast, process asynchronously

Return 200 quickly and defer real work to a queue. Meta expects a prompt acknowledgement, and slow handlers turn into retries. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Process every entry in the batch

Loop the `entry` array and treat each change as its own unit. Don't assume one event per request.

### Dedupe and reconcile

With no replay window and a 36-hour delivery ceiling, dedupe on payload identifiers and reconcile against the Graph API for critical data. Treat webhooks as notifications, not a system of record.

## Conclusion

Facebook webhooks are how Pages, Messenger, and lead-gen activity reach your app without polling the Graph API. The mechanics are specific: an object-and-field subscription model, a `hub.challenge` GET handshake, and `X-Hub-Signature-256` deliveries you have to verify against the exact raw bytes Meta signed. The delivery guarantees are modest too: batched payloads, no replay protection, and a 36-hour ceiling before events are dropped.

That leaves the operational work to you. Verifying signatures against the raw body, unpacking batches, deduplicating, acknowledging fast, and reconciling for the data you can't miss are all your responsibility unless you offload them.

For teams that would rather build on Facebook's activity than manage its delivery mechanics, [Hookdeck](https://hookdeck.com) verifies signatures, splits batches, deduplicates, and durably queues every event at the edge, so your app only ever sees clean, verified changes.

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