Receiving Facebook Lead Ads reliably

A webhook is the fast path, not the guarantee. Notes on the three layers it takes before you can honestly tell a customer that no lead goes missing.

Meta's Lead Ads webhook is genuinely good: a form is submitted and the payload is at your endpoint in well under a second. The problem is not latency. The problem is that when a webhook stops arriving, nothing happens — no error, no alert, no gap you can see. Your dashboard simply shows a quiet afternoon, and the advertiser finds out from a customer who says they filled the form yesterday.

Everything below is one answer to that: three layers with genuinely independent failure modes, so no single thing going wrong loses a lead.

Layer 1 — the webhook, with a durability boundary

The instinct is to acknowledge Meta quickly and process afterwards. Half right. The ordering that matters is:

  1. Verify the signature. Cheap, no I/O.
  2. Persist the raw payload. This is the durability boundary.
  3. Then answer 200.
  4. Process the lead in the background.

If step 2 fails, return 500 and let Meta retry — their retry window is narrow, but it is the only second chance on offer. If the process dies during step 4, the stored row is still sitting there unprocessed and a replay tool can re-run it. Acking before persisting inverts this: you have promised Meta the lead is safe while it exists only in memory.

Retries need an idempotency key you did not invent

Meta re-sends the same payload with the same X-Hub-Signature-256. That header is a perfectly good idempotency key: put a unique index on it and let the duplicate insert fail. A failed insert means the original delivery is already done or in flight, so there is nothing to do.

Two details worth stealing. Deduplicating on a hash you compute over the body is more code and more ways to be subtly wrong. And in MySQL a unique index permits multiple NULLs, so unsigned test payloads still flow through instead of colliding with each other.

Layer 2 — polling, because webhooks fail silently

Webhook delivery stops for reasons that never produce an error on your side: an app-level subscription quietly drops, a page token expires, an app-secret rotation makes every HMAC mismatch, or Meta has a bad hour. Each of those looks identical to "nobody filled in the form".

So a second path re-asks the Graph API per form on a cadence and saves anything the webhook did not. A few things this taught us:

The trap: a cursor that moved on

This is the failure that motivated the third layer, and it is not obvious until it happens.

After a platform incident, data does not simply resume — it arrives late, carrying its original timestamps. Your poller, meanwhile, kept running and kept advancing its cursor across a window that was empty at the time. When the backfill lands behind the cursor, nothing ever looks there again. The leads exist in Meta's API and will never exist in yours, and no error was raised at any point.

A cursor is an optimisation, not a source of truth. Give it a floor — never look back less than some minimum, regardless of what the cursor says — and then add something that ignores it entirely.

Layer 3 — a nightly reconcile that ignores cursors

Once a day, re-ask for a wide window across every active form and insert whatever is missing. No cursor, no cleverness. It is not a feature and it has no flag to forget to switch on; it is the standing guarantee that a lead cannot stay invisible just because a cursor moved past it.

Pick the hour from your own traffic, not from habit. Ours runs at 03:00 Tashkent time, chosen off the hourly lead histogram: roughly 80 leads an hour at 03:00 against roughly 800 at peak. The extra Graph calls then compete with almost nothing.

What each layer is actually for

LayerCatchesMisses
Webhook Everything, in under a second, on a normal day. Anything that silently stops delivery.
Polling Subscription drift, token expiry, HMAC mismatch, short outages. Backfill that lands behind an advanced cursor.
Nightly reconcile Everything the other two lost, within a day. Nothing — but it is slow, so it is a net, not a path.

The layers are only worth the code if their failure modes are independent. Three retries of the same request are one layer wearing a disguise.

Once the lead is in

Receiving it reliably is half the job; the other half is getting it to the place the operator is actually looking, and knowing whether that worked. The judgement rules for the outbound side — success conditions, and permanent versus retryable rejections — are on the custom destinations page. If your form lives on your own landing page rather than on Meta, the inbound webhook reference covers that route in.

All of this runs in production at Targenix — free Facebook and Instagram lead automation for Uzbekistan, routing to 50+ destinations (the list) at no cost.