How to Build a Mock Webhook Server in 2 Minutes

Published March 1, 202610 min read

At some point during integration work, you need the other side of the HTTP call to behave in a specific way. Your app sends a payment confirmation to a callback URL, and you need that URL to return a 200 with a success payload. Then a 402. Then a 500 after a three-second hang.

The default move is to write a small mock server. This article walks through using Hooklistener's mock response rules to stand up a conditional mock server on a public URL, with full request capture, in about two minutes.

The Problem: Testing Against HTTP Endpoints You Don't Control

This comes up constantly in integration development:

  • Your app sends webhooks to a partner's callback URL, and you need to test how it handles different responses.
  • You're building a payment flow that posts to a gateway, and you need to simulate success, failure, and timeout scenarios.
  • A microservice calls another service's API, and you want to test edge cases without deploying the real thing.

The Old Way: 40 Lines of Boilerplate

Here's what a basic conditional mock server looks like in Express:

mock-server.js
const express = require("express");
const app = express();
app.use(express.json());

// Log all incoming requests
app.use((req, res, next) => {
  console.log(`${req.method} ${req.path}`);
  console.log("Headers:", JSON.stringify(req.headers, null, 2));
  console.log("Body:", JSON.stringify(req.body, null, 2));
  next();
});

app.post("/callback", (req, res) => {
  // Simulate different responses based on the payload
  if (req.body.status === "paid") {
    return res.status(200).json({
      result: "accepted",
      transaction_id: "txn_mock_12345",
    });
  }

  if (req.body.status === "failed") {
    return res.status(402).json({
      error: "payment_declined",
      message: "Insufficient funds",
    });
  }

  // Simulate a timeout
  if (req.headers["x-test-timeout"]) {
    return setTimeout(() => {
      res.status(500).json({ error: "internal_error" });
    }, 3000);
  }

  // Default response
  res.status(200).json({ result: "ok" });
});

app.listen(9999, () => console.log("Mock server on :9999"));

This works. But consider what you've just committed to:

Local only

If your app runs in staging, CI, or a teammate's machine, it can't reach localhost:9999. You need a tunnel or a deployment — or a free ngrok alternative that gives you a public URL without the usual setup.

Manual logging

That console.log middleware captures request data, but you're reading it from a terminal. No search, no filtering, no persistent history.

One-off code

Every new project gets its own copy of this boilerplate, slightly different each time, checked into a repo nobody maintains.

No UI

Changing the mock response means editing code and restarting the server.

The Hooklistener Way: Zero Code, Full Inspection

Hooklistener gives you a public URL — called a debug endpoint — that captures every incoming HTTP request for inspection. On its own, that's useful for seeing what your app actually sends. But the feature that replaces the mock server above is mock response rules.

A response rule tells the endpoint: when an incoming request matches these conditions, return this status code, this body, and these headers. You can add multiple rules with different conditions, set their priority order, and optionally add a response delay to simulate latency. Every request is still captured regardless of which rule matches.

Tutorial: Mock a Payment Callback Gateway

Let's rebuild the Express example from above — a payment callback that returns different responses based on the request payload. No code required.

Step 1: Create a Debug Endpoint

Sign up at hooklistener.com (the free tier works for this) and create a new debug endpoint. You'll get a public URL like:

https://hooklistener.com/w/payment-callback-test

Any HTTP request to this URL is captured and visible in the dashboard immediately.

Step 2: Add Response Rules

Navigate to the endpoint's response rules section and create three rules:

Rule 1: Payment Success (Priority 10)

  • Conditions: [{"body.status": {"$eq": "paid"}}]
  • Status Code: 200
  • Response Body: {"result": "accepted", "transaction_id": "txn_mock_12345"}

Rule 2: Payment Failure (Priority 5)

  • Conditions: [{"body.status": {"$eq": "failed"}}]
  • Status Code: 402
  • Response Body: {"error": "payment_declined", "message": "Insufficient funds"}

Rule 3: Timeout Simulation (Priority 1)

  • Conditions: [{"headers.x-test-timeout": {"$exist": true}}]
  • Status Code: 500
  • Response Delay: 3000ms

If no rule matches, the endpoint returns a default 200 response. That covers the “everything else” case from the Express code without any additional configuration.

Step 3: Test It

Point your app at the endpoint URL, or test manually with curl:

# Test: payment success
curl -X POST https://hooklistener.com/w/payment-callback-test \
  -H "Content-Type: application/json" \
  -d '{"status": "paid", "amount": 4999, "currency": "usd"}'
# => {"result":"accepted","transaction_id":"txn_mock_12345"}

# Test: payment failure
curl -X POST https://hooklistener.com/w/payment-callback-test \
  -H "Content-Type: application/json" \
  -d '{"status": "failed", "amount": 4999, "currency": "usd"}'
# => {"error":"payment_declined","message":"Insufficient funds"}

# Test: timeout (takes ~3 seconds)
curl -X POST https://hooklistener.com/w/payment-callback-test \
  -H "Content-Type: application/json" \
  -H "X-Test-Timeout: true" \
  -d '{"status": "paid", "amount": 4999}'
# => {"error":"internal_error"} (after 3 second delay)

# Test: default response (no matching rule)
curl -X POST https://hooklistener.com/w/payment-callback-test \
  -H "Content-Type: application/json" \
  -d '{"status": "pending", "amount": 4999}'
# => 200 default response

All four requests show up in the endpoint's request log with full details — method, headers, body, query parameters, timing, and which response rule matched (if any). If you want a simpler starting point without rules, you can verify your integration with our online webhook tester and graduate to response rules once you need conditional behavior.

How Conditions Work: The Filter Syntax

Conditions use a filter syntax inspired by MongoDB queries. Each condition is a JSON object that maps a field path to an operator expression. When a rule has multiple conditions in its array, all must match (implicit AND).

Field Paths

PathWhat it matches
body.field_nameA field in the JSON request body
body.nested.fieldNested fields using dot notation
headers.x-api-keyA request header (lowercase)
query.param_nameA URL query parameter
methodThe HTTP method (GET, POST, etc.)
pathThe request path after the endpoint slug

Operators

Comparison

{"body.amount": {"$eq": 100}}
{"body.amount": {"$neq": 0}}
{"body.amount": {"$gt": 50}}
{"body.amount": {"$gte": 50}}
{"body.amount": {"$lt": 1000}}
{"body.amount": {"$lte": 1000}}

String matching

{"body.email": {"$contains": "@example.com"}}
{"body.url": {"$startsWith": "https://"}}
{"body.filename": {"$endsWith": ".pdf"}}
{"body.sku": {"$regex": "^SKU-[0-9]{4}$"}}

Membership

{"body.status": {"$in": ["paid", "refunded", "partially_refunded"]}}
{"body.country": {"$nin": ["US", "CA"]}}

Existence

{"headers.authorization": {"$exist": true}}
{"body.metadata.internal_id": {"$exist": false}}

Logical operators

{"$or": [
  {"body.status": {"$eq": "paid"}},
  {"body.status": {"$eq": "refunded"}}
]}

{"$not": {"body.test_mode": {"$eq": true}}}

Combined operators on the same field

{"body.amount": {"$gte": 10, "$lte": 100}}

Priority Ordering: First Match Wins

Rules are evaluated in descending priority order — the highest priority number is checked first. The first rule whose conditions all match is the one that produces the response. Remaining rules are not evaluated.

For example, if you have:

  • Rule A (Priority 10): body.status equals "paid" AND body.amountgreater than 1000 — returns 200
  • Rule B (Priority 5): body.status equals "paid" — returns 200 with a different body

A request with {"status": "paid", "amount": 5000} matches both rules, but Rule A fires because it has higher priority. This gives you the same “first match” routing logic as an if/else chain, but you can reorder rules by adjusting priority values rather than rewriting code.

The Hidden Superpower: Full Request Capture on Every Request

Every request is captured and inspectable regardless of which rule matched. When your payment integration sends a callback and gets a 402 back, you can open the Hooklistener dashboard and see:

  • The exact headers your app sent (including authentication headers, content types, custom headers)
  • The full request body, formatted and searchable
  • Which response rule matched and what response was sent
  • Timing information

With a homemade mock server, you'd be cross-referencing terminal logs with your application logs to piece together what happened. Here, the request and the response are in the same view.

Advanced Patterns

Route Different Responses by Path

The endpoint captures the subpath after the slug, so you can use path-based routing:

// Rule: Success path
{"path": {"$eq": "/success"}}
// Status: 200, Body: {"result": "ok"}

// Rule: Failure path
{"path": {"$eq": "/failure"}}
// Status: 500, Body: {"error": "server_error"}

Then configure your app to hit /w/my-endpoint/success or /w/my-endpoint/failure depending on the scenario you're testing.

Simulate Flaky Endpoints

Set a rule that matches when headers.x-simulate-error exists, then have your test harness send that header on a percentage of requests:

[{"headers.x-simulate-error": {"$exist": true}}]

Status code 503, delay 1500ms, body {"error": "service_unavailable"}. Your app's retry logic gets a real workout.

Test Timeout Handling with Realistic Latency

Response delays up to 10,000ms let you test what your HTTP client does when a server is slow. Set up rules for different delay tiers:

  • headers.x-delay equals "slow"— 2000ms delay, 200 response
  • headers.x-delay equals "timeout" — 10000ms delay, 200 response
  • headers.x-delay equals "error" — 5000ms delay, 503 response

Validate Request Content While Mocking

Set up a rule with narrow conditions that match only well-formed requests:

[
  {"body.transaction_id": {"$regex": "^txn_[a-f0-9]{12}$"}},
  {"headers.content-type": {"$contains": "application/json"}},
  {"body.amount": {"$gt": 0}}
]

If your app sends a malformed request, this rule won't match and the endpoint returns the default response instead. Check the dashboard — if you see requests hitting the default, your app isn't sending what you think it's sending.

When a Full Mock Server Still Makes Sense

Response rules cover the majority of “I need a URL that returns X when I send Y” scenarios. But there are cases where a custom server is still the right call:

  • Stateful behavior. If the mock needs to remember previous requests and change responses accordingly, you need real code.
  • Dynamic response generation. If the response body needs to include data computed from the request, static response bodies won't do it.
  • Protocol-specific handling. WebSocket upgrades, streaming responses, or gRPC need a real server.

For everything else — and that covers most integration testing, webhook development, and API prototyping — response rules on a Hooklistener endpoint get you there faster.

From Zero to Mock Server in Two Minutes

Instead of writing, running, and exposing a mock server:

  1. Create a debug endpoint on Hooklistener (30 seconds).
  2. Add response rules with conditions, status codes, bodies, and optional delays (60 seconds).
  3. Point your app at the public URL and test (30 seconds).

Every request is captured. Every response is configurable. Nothing to deploy, nothing to maintain, and nothing to restart when you need to change a response.

Try Mock Response Rules Free →

Related Resources