Catch webhooks that returned 200 but didn't process
The webhook bug class proxies physically can't see. An early return inside an if. A swallowed exception in a try/catch. A missing await. Provider thinks delivery succeeded. Dashboard says success. State is wrong. Nobody knows for hours — until a customer notices.
Why this matters
- Other tools can't see this. Reverse-proxy webhook tools (Hookdeck, Svix Ingest) only observe the HTTP exchange between the provider and your endpoint. A 200 looks like success regardless of what your handler actually did.
- Our SDK runs inside your handler. Because we wrap the handler instead of sitting in front of it, the SDK can require an explicit
track.processed()call. Events that return 200 but never make that call get flagged. - It's the #1 cited webhook failure mode on dev-survey lists. Until v1.5 nobody had it tooled.
Opt in with one option, one call
Set requireProcessingMark: true, accept { track } as the second argument to your handler, and call track.processed() when your business logic actually completes:
import { withWebhookMonitoring } from "@outworx/hooks/nextjs";
export const POST = withWebhookMonitoring(
{
provider: "stripe",
signatureSecret: process.env.STRIPE_WEBHOOK_SECRET!,
requireProcessingMark: true, // ← enable silent-drop detection
},
async (req, { track }) => {
const event = await stripe.webhooks.constructEventAsync(...);
if (event.type !== "charge.succeeded") {
// Silent drop in disguise — handler returns 200, but never marks
// the event as processed. Outworx flags this in the dashboard.
return Response.json({ received: true });
}
await chargeCustomer(event.data.object);
track.processed(); // ← explicit ack
return Response.json({ received: true });
}
);Setup — Python / FastAPI
Same shape — track is exposed on request.state.outworx_track (FastAPI), flask.g.outworx_track (Flask), or request.outworx_track (Django).
from fastapi import FastAPI, Request
from outworx_hooks import init, TrackOptions
from outworx_hooks.integrations.fastapi import OutworxHooksMiddleware
init(api_key=os.environ["OUTWORX_HOOKS_API_KEY"])
app = FastAPI()
app.add_middleware(
OutworxHooksMiddleware,
options=TrackOptions(
provider="stripe",
signature_secret=os.environ["STRIPE_WEBHOOK_SECRET"],
require_processing_mark=True, # ← opt in
),
)
@app.post("/webhooks/stripe")
async def stripe_webhook(request: Request):
body = await request.json()
if body["type"] != "charge.succeeded":
return {"received": True} # silent_drop if unintended
charge_customer(body["data"]["object"])
request.state.outworx_track.processed()
return {"received": True}Application failures: track.failed()
Sometimes you intentionally swallow an error and return 200 so the provider doesn't retry. Use track.failed(reason) to flag those — they show up in the dashboard distinct from real HTTP failures.
try {
await chargeCustomer(event.data.object);
track.processed();
} catch (err) {
// 200 (so the provider doesn't retry) but flagged as
// application failure with a reason. Surfaces in the dashboard
// distinctly from genuine HTTP failures.
track.failed(err.message);
return Response.json({ received: true });
}Alert when silent drops accumulate
Once you've opted into silent-drop detection, add an alert rule with the Silent Drops condition (Alerts → New rule) to get paged on Slack / Discord / email when the count exceeds your threshold. Different signal from failure rate — silent drops never showed up as HTTP failures, so the existing failure-rate alert can't catch them.
FAQ
Will this break my existing handlers?
No. requireProcessingMark defaults to false — if you don't set it, behavior is unchanged from 1.4.x. Silent-drop detection is opt-in per project / per route.
What if I genuinely want to ignore some events?
Call track.processed({ reason: "ignored" }) for the ignore branch. The metadata is preserved on the event row, and the dashboard counts it as completed (not a silent drop).
How is this different from monitoring HTTP failures?
An HTTP failure is a 4xx/5xx that the provider sees and retries. A silent drop is a 200 that the provider doesn't retry — it thinks delivery succeeded. The two have completely different business consequences and need separate signals.
Does this work with my framework?
v1.5 supports Next.js (App Router + Pages), Express, Fastify, FastAPI, Flask, and Django. See the framework guides under Documentation for setup specifics.