How to Build a Mock Webhook Server in 2 Minutes
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:
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.
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-testAny 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 responseAll 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).
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
| Path | What it matches |
|---|---|
body.field_name | A field in the JSON request body |
body.nested.field | Nested fields using dot notation |
headers.x-api-key | A request header (lowercase) |
query.param_name | A URL query parameter |
method | The HTTP method (GET, POST, etc.) |
path | The 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.statusequals"paid"ANDbody.amountgreater than 1000 — returns 200 - Rule B (Priority 5):
body.statusequals"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-delayequals"slow"— 2000ms delay, 200 response - •
headers.x-delayequals"timeout"— 10000ms delay, 200 response - •
headers.x-delayequals"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:
- Create a debug endpoint on Hooklistener (30 seconds).
- Add response rules with conditions, status codes, bodies, and optional delays (60 seconds).
- 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 →