Testing Webhooks
How to test webhook integrations locally and in staging using the HelloJohn dashboard, CLI, and ngrok.
Testing Webhooks
Testing webhook integrations locally requires exposing your local server to the internet. This page covers the tools and techniques to develop and validate your webhook handler before going to production.
Test Event from the Dashboard
The fastest way to test a webhook is to send a test event from the HelloJohn dashboard:
- Go to Developer → Webhooks
- Click your webhook endpoint
- Click Send test event
- Select an event type (e.g.,
user.created) - Click Send
HelloJohn will POST a synthetic payload to your endpoint and display the response in the dashboard.
Test via API
Send a test event programmatically:
curl -X POST "https://api.hellojohn.dev/v1/webhooks/wh_01HABCDEF777001/test" \
-H "Authorization: Bearer sk_live_abc123" \
-H "X-Tenant-ID: tnt_01HABCDEF654321" \
-H "Content-Type: application/json" \
-d '{"event": "user.created"}'Response:
{
"delivery_id": "del_01HABCDEF888099",
"status": "succeeded",
"http_status": 200,
"response_time_ms": 42,
"request": {
"headers": {
"HelloJohn-Event": "user.created",
"HelloJohn-Delivery-ID": "del_01HABCDEF888099",
"HelloJohn-Timestamp": "1705399200",
"HelloJohn-Signature": "sha256=..."
},
"body": { "..." : "..." }
},
"response": {
"body": "OK"
}
}Local Development with ngrok
ngrok creates a public HTTPS tunnel to your local server. HelloJohn can then reach your local endpoint during development.
1. Install ngrok
npm install -g ngrok
# or download from https://ngrok.com2. Start your local server
npm run dev
# Server running on http://localhost:30003. Start ngrok tunnel
ngrok http 3000ngrok will output a public URL:
Forwarding https://abc123.ngrok.io -> http://localhost:30004. Register the ngrok URL as your webhook endpoint
curl -X POST "https://api.hellojohn.dev/v1/webhooks" \
-H "Authorization: Bearer sk_live_abc123" \
-H "X-Tenant-ID: tnt_01HABCDEF654321" \
-H "Content-Type: application/json" \
-d '{
"url": "https://abc123.ngrok.io/webhooks/hellojohn",
"events": ["*"],
"description": "Local dev endpoint"
}'Note: ngrok URLs change every time you restart ngrok (unless you have a paid plan). Update the webhook URL in the dashboard or delete and recreate it as needed.
Local Development with Cloudflare Tunnel
Cloudflare Tunnel is a free alternative to ngrok that provides a stable URL:
# Install cloudflared
brew install cloudflared # macOS
# or download from https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/
# Start a tunnel
cloudflared tunnel --url http://localhost:3000Cloudflare will output a trycloudflare.com URL valid for the session.
HelloJohn CLI (Local Forwarding)
The HelloJohn CLI can forward webhook events directly to your local server without needing a public URL:
# Install the CLI
npm install -g @hellojohn/cli
# Login
hj login
# Start forwarding
hj webhooks forward --port 3000 --path /webhooks/hellojohnThe CLI connects to HelloJohn over a persistent WebSocket and proxies all webhook events to your local server.
✓ Connected to HelloJohn
✓ Forwarding webhook events to http://localhost:3000/webhooks/hellojohn
user.created → 200 OK (38ms)
session.created → 200 OK (12ms)
user.updated → 500 Error (145ms)This is the recommended approach for local development — no public URL required.
Writing a Test Handler
A minimal handler to verify delivery during development:
import express from "express";
import crypto from "crypto";
const app = express();
// Parse raw body for signature verification
app.use("/webhooks/hellojohn", express.raw({ type: "application/json" }));
app.post("/webhooks/hellojohn", (req, res) => {
const signature = req.headers["hellojohn-signature"] as string;
const deliveryId = req.headers["hellojohn-delivery-id"] as string;
const event = req.headers["hellojohn-event"] as string;
const timestamp = req.headers["hellojohn-timestamp"] as string;
// Verify signature
const expected = `sha256=${crypto
.createHmac("sha256", process.env.HELLOJOHN_WEBHOOK_SECRET!)
.update(req.body)
.digest("hex")}`;
if (signature !== expected) {
console.error("❌ Invalid signature");
return res.sendStatus(401);
}
const payload = JSON.parse(req.body.toString());
console.log(`\n📦 Received: ${event}`);
console.log(` Delivery: ${deliveryId}`);
console.log(` Payload:`, JSON.stringify(payload, null, 2));
res.sendStatus(200);
});
app.listen(3000, () => {
console.log("Webhook server listening on http://localhost:3000");
});Simulating Events in Tests
For unit and integration tests, generate a valid signed payload without hitting the HelloJohn API:
import crypto from "crypto";
function createWebhookPayload(event: string, data: object): {
body: string;
headers: Record<string, string>;
} {
const body = JSON.stringify({
id: "evt_test_001",
type: event,
data,
created_at: new Date().toISOString(),
});
const timestamp = Math.floor(Date.now() / 1000).toString();
const secret = process.env.HELLOJOHN_WEBHOOK_SECRET!;
const signature = `sha256=${crypto
.createHmac("sha256", secret)
.update(Buffer.from(body))
.digest("hex")}`;
return {
body,
headers: {
"content-type": "application/json",
"hellojohn-event": event,
"hellojohn-delivery-id": `del_test_${Date.now()}`,
"hellojohn-timestamp": timestamp,
"hellojohn-signature": signature,
},
};
}
// Use in tests
const { body, headers } = createWebhookPayload("user.created", {
user: {
id: "usr_test_001",
email: "test@example.com",
created_at: new Date().toISOString(),
},
});
// With supertest
const res = await request(app)
.post("/webhooks/hellojohn")
.set(headers)
.send(body);
expect(res.status).toBe(200);Verifying Signature Verification
Test that your handler correctly rejects invalid signatures:
describe("Webhook Handler", () => {
it("rejects requests with invalid signature", async () => {
const { body, headers } = createWebhookPayload("user.created", { user: {} });
const res = await request(app)
.post("/webhooks/hellojohn")
.set({ ...headers, "hellojohn-signature": "sha256=invalid" })
.send(body);
expect(res.status).toBe(401);
});
it("processes user.created event", async () => {
const { body, headers } = createWebhookPayload("user.created", {
user: { id: "usr_001", email: "alice@example.com" },
});
const res = await request(app)
.post("/webhooks/hellojohn")
.set(headers)
.send(body);
expect(res.status).toBe(200);
// Assert side effects (DB inserts, emails sent, etc.)
});
});Replay a Past Delivery
Replay a past delivery from the dashboard or API to retest your handler without triggering a new event:
curl -X POST "https://api.hellojohn.dev/v1/webhooks/deliveries/del_01HABCDEF888002/retry" \
-H "Authorization: Bearer sk_live_abc123" \
-H "X-Tenant-ID: tnt_01HABCDEF654321"This sends the exact same payload as the original delivery — useful for debugging a handler that was temporarily broken.
Checklist
- Signature verification implemented and tested
- Raw body preserved for HMAC calculation (not parsed body)
- Handler responds within 30 seconds (or uses async processing)
- Idempotency check using
HelloJohn-Delivery-ID - Error logging for failed event processing
- Separate handler tested per event type