Webhooks are fire-and-forget by design, which is exactly what makes them miserable to debug. The provider sends a request, something throws, and all you have is a 500 in a log somewhere. Let's make that loop tight.
Expose localhost over HTTPS
Most providers won't deliver to http://localhost. Open a public link and point
the webhook at it:
portline share api --public
# → https://teal-otter-42.portline.live → http://api.localhost
That subdomain forwards straight to your machine. TLS is terminated for you, so the provider sees a valid HTTPS endpoint.
Inspect the exact request
Every request that hits the tunnel shows up in the inspector with full headers
and body. No more console.log(JSON.stringify(req.body)) redeploys:
POST /webhooks/stripe HTTP/1.1
Host: teal-otter-42.portline.live
Stripe-Signature: t=1717286400,v1=8f3a...c2d1
Content-Type: application/json
{
"id": "evt_1PabcdEfgh",
"type": "checkout.session.completed",
"data": { "object": { "amount_total": 1900 } }
}
Replay until it's green
The killer feature is replay. Fix your handler, hit replay, and the identical request is delivered again — same signature, same body:
// app/api/webhooks/stripe/route.ts
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export const POST = async (req: Request) => {
const sig = req.headers.get("stripe-signature");
const body = await req.text();
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig!, process.env.STRIPE_WHSEC!);
} catch (err) {
// Replay the captured request after fixing this — no new test charge needed.
return new Response(`bad signature: ${(err as Error).message}`, { status: 400 });
}
if (event.type === "checkout.session.completed") {
// ...fulfill the order
}
return Response.json({ received: true });
};
The loop, shortened
- Trigger the event once from the provider.
- Inspect the captured request.
- Edit your handler.
- Replay — no need to re-trigger upstream.
That fourth step is the whole game. You go from "wait for another real event" to "press a button" — and your debugging loop drops from minutes to seconds.