HelloJohn / docs
Webhooks

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:

  1. Go to Developer → Webhooks
  2. Click your webhook endpoint
  3. Click Send test event
  4. Select an event type (e.g., user.created)
  5. 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.com

2. Start your local server

npm run dev
# Server running on http://localhost:3000

3. Start ngrok tunnel

ngrok http 3000

ngrok will output a public URL:

Forwarding  https://abc123.ngrok.io -> http://localhost:3000

4. 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:3000

Cloudflare 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/hellojohn

The 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

On this page