Meta Compliance and the Public-by-Default Middleware Rewrite
The Compliance Checkbox That Forced Better Architecture
When you integrate with Meta's platform APIs, there are three webhook endpoints you must implement: a deauthorization callback, a data deletion request handler, and a data deletion status page. These aren't optional. Without them, your app review gets rejected.
The implementation itself is straightforward โ parse a signed webhook payload, look up the user's data, delete or disconnect it, return a confirmation code. But adding these endpoints exposed a deeper problem in our middleware.
The Middleware Problem
Our authentication middleware had grown into a blocklist. Every new public route โ API endpoints, blog pages, webhook handlers, static assets โ required an explicit exception. The logic read like a growing list of "except this, except that":
const isPublicApi =
pathname.startsWith("/api/health") ||
pathname.startsWith("/api/whatsapp") ||
pathname.startsWith("/api/cron") ||
pathname.startsWith("/api/subscribe") ||
pathname === "/api/business/onboard-wa";
Adding three more webhook routes would have meant three more exceptions. Instead, we inverted the model. Only two routes in the entire application actually require authentication: /dashboard and /business. Everything else is public by design โ landing pages, blog posts, API endpoints (which handle their own auth via signatures and rate limiting), and static assets.
The new middleware is five lines of logic instead of fifteen:
const isProtectedRoute =
pathname.startsWith("/dashboard") || pathname.startsWith("/business");
if (isProtectedRoute && !isLoggedIn) {
return redirectTo("/login", req);
}
Webhook Signature Verification
Meta signs every webhook payload with HMAC-SHA256 using the app secret. We built a shared verification function that handles the full flow: extract the x-hub-signature-256 header, compute the expected signature, and compare using crypto.timingSafeEqual to prevent timing attacks.
Both the deauthorization and data deletion endpoints share this verification. The data deletion status endpoint is a simple GET with no signature โ it just looks up a confirmation code.
Data Deletion as a Background Job
Meta's data deletion callback has an interesting constraint. The webhook expects a response within a few seconds, but deleting a business's data requires cascading deletes across multiple tables โ posts, Instagram accounts, API cost logs, payment transactions, and finally the business record itself.
We handle this by returning the confirmation code immediately and performing the deletion asynchronously. The response includes a status URL where Meta (or the user) can check progress. The deletion request starts as IN_PROGRESS and transitions to COMPLETED or FAILED.
The cascade delete itself is batched โ all child table deletions run in parallel using { in: businessIds } filters, then the business records are deleted. For a user with multiple connected businesses, this reduces database round trips from five per business to five total.
Fixing the Fonnte Image Preview
A smaller but user-visible fix in this release: WhatsApp image messages sent through Fonnte weren't showing link previews. The Fonnte API requires an explicit type: "image" parameter when sending image URLs. Without it, the message goes through but the image renders as a plain link on some devices. A two-line fix across the single and batch send functions.
Re-onboarding Without Conflicts
We also resolved a constraint violation that occurred when businesses tried to re-onboard after deleting their account. Product IDs were derived from the business name and product name โ deterministic but not unique across attempts. Adding a short random token to the product ID ensures each onboarding attempt creates distinct records, even for the same business and product combination.
Why This Matters
Compliance endpoints are table stakes for any Meta integration. But the middleware rewrite is the more interesting change. A blocklist middleware is a maintenance trap โ every new route is a potential lockout bug, and every developer has to remember to add exceptions. A whitelist middleware that only gates the two routes that actually need authentication is simpler to reason about, harder to break, and doesn't need to change when new public endpoints are added.