What is a webhook?
A webhook is a mechanism that enables an application to receive automatic notifications or data updates by sending a request to a specified URL when a particular event or trigger occurs.
There are various types of webhooks that serve different purposes. One such type is the responder, which is a special webhook that responds to requests with a certain predefined response. A responder is a handy tool when you need to simulate an HTTP endpoint that's not yet implemented or even create a quick "honeypot" endpoint. Responders can also serve as a quick and easy way to test HTML, JavaScript, and CSS code.
On this page, you can find several guides on how to create different types of responders.
Each user on secutils.dev is assigned a randomly generated dedicated subdomain. This subdomain can host user-specific responders at any path, including the root path. For instance, if your dedicated subdomain is abcdefg, creating a responder at /my-responder would make it accessible via https://abcdefg.webhooks.secutils.dev/my-responder.
Return a static HTML page
In this guide you'll create a simple responder that returns a static HTML page:
![[object Object]](../img/docs/guides/webhooks/html_step1_empty.png)
Navigate to Webhooks → Responders and click Create responder.
![[object Object]](../img/docs/guides/webhooks/html_step2_form.png)
Fill in the responder form and click Save.
| Name | |
| Path | |
| Headers | |
| Body | |

The new HTML responder appears in the grid with its unique URL.
![[object Object]](../img/docs/guides/webhooks/html_step4_result.png)
Click the responder URL to open it in a new tab and verify it renders Hello World.
Emulate a JSON API endpoint
In this guide you'll create a simple responder that returns a JSON value:
![[object Object]](../img/docs/guides/webhooks/json_step1_empty.png)
Navigate to Webhooks → Responders and click Create responder.
![[object Object]](../img/docs/guides/webhooks/json_step2_form.png)
Fill in the responder form and click Save.
| Name | |
| Path | |
| Headers | |
| Body | |

The JSON responder appears in the grid with its unique URL.
Use an HTTP client like cURL to verify the responder returns the expected JSON value:
$ curl -s https://<subdomain>.webhooks.secutils.dev/json-responder | jq .
{
"message": "Hello World"
}
Use the honeypot endpoint to inspect incoming requests
In this guide, you'll create a responder that returns an HTML page with custom Iframely meta-tags, providing a rich preview in Notion. Additionally, the responder will track the five most recent incoming requests, allowing you to see exactly how Notion communicates with the responder's endpoint:
Navigate to Webhooks → Responders and click Create responder.
Enable Advanced mode, set Tracking to 5, and fill in the rest of the form. Click Save.
| Name | |
| Path | |
| Tracking | |
| Headers | |
| Body | |
The honeypot responder appears in the grid with its unique URL.
Copy its URL and create a bookmark in Notion to test the rich preview. Expand the responder's row to view tracked incoming requests.
Generate a dynamic response
In this guide, you'll build a responder that uses a custom JavaScript script to generate a dynamic response based on the request's query string parameter:
The script should be provided in the form of an Immediately Invoked Function Expression (IIFE). It runs within a restricted version of the Deno JavaScript runtime for each incoming request, producing an object capable of modifying the default response's status code, headers, or body. Request details are accessible through the global context variable. Refer to the Annex: Responder script examples for a list of script examples, expected return value, and properties available in the global context variable. See Deno Sandbox Runtime for the full reference of available Deno.core APIs.
![[object Object]](../img/docs/guides/webhooks/dynamic_step1_empty.png)
Navigate to Webhooks → Responders and click Create responder.
![[object Object]](../img/docs/guides/webhooks/dynamic_step2_form.png)
Enable Advanced mode, set Tracking to 5, and fill in the form with a custom script.
| Name | |
| Path | |
| Tracking | |
| Headers | |
| Script | |

The dynamic responder appears in the grid with its unique URL.
![[object Object]](../img/docs/guides/webhooks/dynamic_step4_no_arg.png)
Click the responder URL to open it in a new tab. Without the arg query parameter the script returns the default message.
![[object Object]](../img/docs/guides/webhooks/dynamic_step5_with_arg.png)
Append ?arg=hello to the URL and reload. The script reads the query parameter and returns the dynamic reply.
Annex: Responder script examples
In this section, you'll discover examples of responder scripts capable of constructing dynamic responses based on incoming request properties. Essentially, each script defines a JavaScript function running within a restricted version of the Deno JavaScript runtime. This function has access to incoming request properties through the global context variable. The returned value can override default responder's status code, headers, and body. For a complete reference of the Deno.core utilities available inside the sandbox (encoding, Base64, type checking, and more), see Deno Sandbox Runtime.
The context argument has the following interface:
interface Context {
// An internet socket address of the client that made the request, if available.
clientAddress?: string;
// HTTP method of the received request.
method: string;
// HTTP headers of the received request.
headers: Record<string, string>;
// HTTP path of the received request.
path: string;
// Raw query string of the received request (without the leading `?`), if present.
rawQuery?: string;
// Parsed query string of the received request.
query: Record<string, string>;
// HTTP body of the received request in binary form.
body: number[];
// User secrets (decrypted key-value pairs). Manage secrets in Settings → Secrets.
secrets: Record<string, string>;
}
By default, no secrets are exposed to a responder. To enable secrets, open the responder's advanced settings and set the Secrets → Access mode to All secrets or Selected secrets. Once enabled, you can reference secrets in scripts via context.secrets.KEY and in static body/headers via ${secrets.KEY} template syntax.
The returned value has the following interface:
interface ScriptResult {
// HTTP status code to respond with. If not specified, the default status code of responder is used.
statusCode?: number;
// Optional HTTP headers of the response. If not specified, the default headers of responder are used.
headers?: Record<string, string>;
// Optional HTTP body of the response. If not specified, the default body of responder is used.
// Accepts Uint8Array, string, object, or array - see Body auto-conversion.
body?: Uint8Array | string | object;
// When true, the request is not recorded in the responder's tracked request history.
skipRequest?: boolean;
// When true, the response sent to the client is also stored alongside the tracked request.
trackResponse?: boolean;
}
The body field accepts multiple types: Uint8Array for raw bytes, a string (auto-encoded to UTF-8), a plain object or array (auto-serialized to JSON), or a number/boolean (auto-stringified). See Body auto-conversion for the full conversion table.
Override response properties
The script overrides the responder's response with a custom status code, headers, and body:
(async () => {
return {
statusCode: 201,
headers: { "Content-Type": "application/json" },
body: { a: 1, b: 2 },
};
})();
Inspect request properties
This script inspects the incoming request properties and returns them as a JSON value:
(async () => {
// Decode request body as JSON.
const parsedJsonBody = context.body.length > 0
? JSON.parse(Deno.core.decode(new Uint8Array(context.body)))
: {};
// Override response with a custom HTML body.
return {
body: `
<h2>Request headers</h2>
<table>
<tr><th>Header</th><th>Value</th></tr>
${Object.entries(context.headers).map(([key, value]) => `
<tr>
<td>${key}</td>
<td>${value}</td>
</tr>`
).join('')}
</table>
<h2>Request query</h2>
<table>
<tr><th>Key</th><th>Value</th></tr>
${Object.entries(context.query ?? {}).map(([key, value]) => `
<tr>
<td>${key}</td>
<td>${value}</td>
</tr>`
).join('')}
</table>
<h2>Request body</h2>
<pre>${JSON.stringify(parsedJsonBody, null, 2)}</pre>
`
};
})();
Use secrets in a response
This script reads a user secret and includes it in the response. Secrets are managed in Settings → Secrets and are available via context.secrets:
(async () => {
const apiKey = context.secrets.THIRD_PARTY_API_KEY ?? 'not-set';
return {
headers: { 'Content-Type': 'application/json' },
body: { apiKey }
};
})();
Proxy requests to an upstream service (MITM)
Responder scripts can forward incoming requests to a real backend using Deno.core.ops.op_proxy_request(), inspect or modify the response, and return it to the client. This turns the responder into a man-in-the-middle (MITM) proxy - useful for debugging, testing, and API traffic inspection.
Pure proxy - forward every request as-is (with optional insecure for self-signed certs and timeout in milliseconds):
(async () => {
return await Deno.core.ops.op_proxy_request({
url: 'https://real-backend:9200' + context.path,
method: context.method,
headers: context.headers,
body: context.body,
insecure: true, // accept self-signed certificates
timeout: 5000, // fail after 5 seconds
});
})()
Transform the response - e.g., inject a field into JSON responses. Compressed responses (gzip, deflate, Brotli) are automatically decompressed, so JSON.parse works regardless of the upstream's Content-Encoding:
(async () => {
const resp = await Deno.core.ops.op_proxy_request({
url: `https://real-backend:9200${context.path}`,
method: context.method,
headers: context.headers,
body: context.body,
});
if (resp.headers['content-type']?.includes('application/json')) {
const body = JSON.parse(Deno.core.decode(new Uint8Array(resp.body)));
body._proxied = true;
return { statusCode: resp.statusCode, headers: resp.headers, body };
}
return resp;
})()
Conditional proxy - proxy only certain paths, mock the rest:
(async () => {
if (context.path.startsWith('/_cluster')) {
return await Deno.core.ops.op_proxy_request({
url: `https://real-backend:9200${context.path}`,
method: context.method,
headers: context.headers,
body: context.body,
});
}
return { statusCode: 200, body: { mock: true } };
})()
See Deno.core.ops.op_proxy_request() for the full API reference including request/response interfaces, error handling, and security considerations.
Selectively track requests
By default every incoming request is recorded in the responder's tracked history (up to the configured limit). If you only care about certain requests, the script can return skipRequest: true to suppress tracking for the current request while still returning a normal response:
(async () => {
// Only track non-health-check requests.
const skip = context.path === '/healthz' || context.path === '/readyz';
return { body: 'ok', skipRequest: skip };
})();
Track responses
By default, only the incoming request is stored in the history. If you also need to inspect the response sent back to the client (for example, to debug upstream proxy results), the script can return trackResponse: true. The response status code, headers, and body are then stored alongside the request in the same history row:
(async () => {
const resp = await Deno.core.ops.op_proxy_request({
url: `https://backend.example.com${context.path}`,
method: context.method,
headers: context.headers,
body: context.body,
});
return {
...resp,
// Only track the response when the upstream returned an error.
trackResponse: resp.statusCode >= 400,
};
})()
The tracked response body is subject to a size limit (default 1 MB). If the response exceeds this limit, the stored copy is truncated, but the full response is still sent to the client.
When a script fails (throws an error, times out, etc.), the error response is automatically captured in the tracked request without needing trackResponse. This provides crucial debugging visibility for script failures.
If both skipRequest: true and trackResponse: true are set, skipRequest takes precedence and no tracking occurs at all.
Export request history as HAR
The request history table includes an Export as HAR button that downloads the tracked requests (and responses, when available) as an HTTP Archive (HAR) file. HAR files can be imported into browser DevTools, Charles Proxy, or other HTTP analysis tools for further inspection.
The duration column shows the total server-side processing time for each request, and this value is included in the HAR file's timing information.
Generate images and other binary content
Responders can return not only JSON, HTML, or plain text, but also binary data, such as images. This script demonstrates how you can generate a simple PNG image on the fly. PNG generation requires quite a bit of code, so for brevity, this guide assumes that you have already downloaded and edit the png-generator.js script from the Secutils.dev Sandbox repository (you can find the full source code here). The part you might want to edit is located at the bottom of the script:
(() => {
// …[Skipping definition of the `PngImage` class for brevity]…
// Generate a custom 100x100 PNG image with a white background and red rectangle in the center.
const png = new PngImage(100, 100, 10, {
r: 255,
g: 255,
b: 255,
a: 1
});
const color = png.createRGBColor({
r: 255,
g: 0,
b: 0,
a: 1
});
png.drawRect(25, 25, 75, 75, color);
return {
body: png.getBuffer()
};
})();