Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ Technical constraints:
on restart, by design. No durable store, no migration path.
- Request body limit is 1 MiB.
- The request list returns at most 128 requests, newest first.
- Rate limit is 125 requests per minute per client IP in production, bucketed
- Rate limit is 150 requests per minute per client IP in production, bucketed
on the platform-resolved IP.
- Client IP resolution is a trust decision driven by the `PLATFORM` env var;
setting it trusts that platform's header unconditionally.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@

## Docs

[Scripts](docs/scripts.md)
[API](docs/api.md) · [Scripts](docs/scripts.md)

## Configuration

Expand Down
96 changes: 96 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# API

httphq's JSON API needs no account, no key and no create step. An endpoint
exists as soon as something addresses it, so the only thing a caller needs is an
endpoint ID.

Every URL below is relative to the host serving the page. A self-hosted
deployment answers on its own host.

## Routes

| Method | Path | Purpose |
| -------- | -------------------------------------------- | ----------------------------- |
| `ANY` | `/to/:endpoint` | Capture a request |
| `GET` | `/api/endpoints/:endpoint/requests` | List captures |
| `DELETE` | `/api/endpoints/:endpoint/requests` | Delete an endpoint's captures |
| `DELETE` | `/api/endpoints/:endpoint/requests/:request` | Delete one capture by UUID |

An endpoint ID is lowercase words and digits joined by hyphens, up to 64
characters. Anything else is a 404.

## Capturing

Send anything at all to `/to/:endpoint`. Every method is accepted, the response
is always `200`, and the capture's UUID comes back on the
`Httphq-Request-Uuid` header.

```bash
curl -X POST -d '{"hello":"world"}' https://httphq.com/to/purple-frog-0691
```

## Listing

```
GET /api/endpoints/:endpoint/requests?search=&since=
```

| Parameter | Notes |
| --------- | ----------------------------------------------------------------- |
| `search` | Optional. Substring match across headers, query string and body. |
| `since` | Optional RFC 3339 timestamp. Returns only captures newer than it. |

<!-- prettier-ignore -->
```jsonc
{
"requests": [ /* newest 128, or the oldest 128 newer than `since` */ ],
"total": 42, // the endpoint's, ignoring search and since
"cursor": "2026-08-09T07:20:05.559121660Z",
"hasMore": false
}
```

`total` is what the endpoint holds regardless of `search`, `since` or the page
size, so a caller can say what a delete-all would affect.

A malformed `since` is a `400` rather than a silently ignored parameter: a
caller that mistyped one would otherwise be handed its whole history back and
have no way to tell.

## Polling with the cursor

Echo `cursor` back as `since` and you are handed each capture exactly once.

1. Call the listing with no `since`. Read `requests` and `cursor`.
2. Call again with `?since=<cursor>` from the previous response.
3. If `hasMore` is true, call again immediately: a burst is still draining.
Otherwise wait 2 seconds.

```bash
curl -s "https://httphq.com/api/endpoints/purple-frog-0691/requests?since=2026-08-09T07:20:05Z"
```

Three things are worth knowing:

- **The cursor is opaque.** It is a timestamp today. Echo it back rather than
parsing one or building your own, or a change of format will break you.
- **It is server time.** Substituting your own clock reintroduces the skew the
cursor exists to remove.
- **With `since`, captures come back oldest first**, and without it, newest
first. A cursored caller is draining a stream in order; an uncursored one is
looking at the latest activity.

An endpoint with no traffic still returns a cursor, so a poller that starts
before the first request has something to advance from.

## Limits

- **128 captures** per listing response.
- **1 MiB** request body. Larger captures are rejected.
- **150 requests per minute per client IP** in production, across everything:
page loads, captures and API calls share one budget. The recommended
2 second poll is 30 a minute, which leaves room for the traffic under test.
- **4 hour retention.** Captures are deleted after that, and on restart.

Anyone holding an endpoint's URL can read everything sent to it. Nothing secret
should go through one.
60 changes: 60 additions & 0 deletions e2e/tests/endpoint-screen.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -653,6 +653,66 @@ test.describe("Endpoint screen", () => {
});
});

test.describe("Connecting an agent", () => {
test("the panel is collapsed until it is opened", async ({ page }) => {
await expect(page.locator('[data-test="agent-prompt"]')).toBeHidden();

await page.locator('[data-test="agent-toggle"]').click();

await expect(page.locator('[data-test="agent-prompt"]')).toBeVisible();
});

// The prompt is built from the request, so it has to name the host the
// page was actually served from rather than a hardcoded one.
test("the prompt carries this endpoint's own URLs", async ({ page }) => {
await page.locator('[data-test="agent-toggle"]').click();

const prompt = page.locator('[data-test="agent-prompt"]');
await expect(prompt).toContainText(endpointUrl);
await expect(prompt).toContainText(
`/api/endpoints/${endpointId}/requests`,
);
});

test("the prompt states the cursor loop and the poll interval", async ({
page,
}) => {
await page.locator('[data-test="agent-toggle"]').click();

const prompt = page.locator('[data-test="agent-prompt"]');
await expect(prompt).toContainText("?since=");
await expect(prompt).toContainText("hasMore");
await expect(prompt).toContainText("2 seconds");
});

// Copied verbatim into another tool, so stray edge whitespace from the
// template would travel with it.
test("the copy button writes the prompt with no stray whitespace", async ({
page,
}) => {
await page.locator('[data-test="agent-toggle"]').click();
const shown = await page
.locator('[data-test="agent-prompt"]')
.textContent();

await page.locator('[data-test="copy-agent-prompt"]').click();

const copied = await readClipboard(page);
expect(copied).toBe(shown);
expect(copied).toBe(copied.trim());
expect(copied).toContain(endpointUrl);
});

test("the copy button label flips to Copied!", async ({ page }) => {
await page.locator('[data-test="agent-toggle"]').click();
await page.locator('[data-test="copy-agent-prompt"]').click();

await expect(
page.locator('[data-test="copy-agent-prompt-label"]'),
).toContainText("Copied!");
});
});

test.describe("Sending a test request", () => {
test("submitting the panel produces a captured request", async ({
page,
Expand Down
1 change: 1 addition & 0 deletions e2e/tests/home-screen.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ test.describe("Home screen", () => {
await expect(section).toBeVisible();
await expect(section).toContainText("Test webhooks");
await expect(section).toContainText("Inspect payloads");
await expect(section).toContainText("Debug with an agent");
});

test("the example capture shows a rendered request", async ({ page }) => {
Expand Down
2 changes: 1 addition & 1 deletion public/app.css

Large diffs are not rendered by default.

64 changes: 64 additions & 0 deletions src/agent.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package main

import (
"fmt"
"time"
)

// agentPromptTemplate is the text an endpoint page offers for pasting into a
// coding agent. It is a prompt rather than documentation: it is read by
// something that will act on it, so it states the loop, the interval and the
// stopping condition rather than describing the API.
//
// Every figure in it is substituted, never typed. A prompt that restated the
// rate limit or the retention window in prose would keep quoting the old number
// after the constant behind it moved, and the reader has no way to tell.
const agentPromptTemplate = `You are debugging HTTP requests with httphq.

Capture URL: %[1]s
Read captures: %[2]s

Anything sent to the capture URL (a webhook provider, a form action, a script,
your own code) is stored and readable as JSON from the read URL.

To watch requests as they arrive:

1. Call GET %[2]s and read ` + "`requests`" + ` and ` + "`cursor`" + ` from the response.
2. On every call after the first, send the previous response's cursor back as
?since=<cursor>. You will only be given captures you have not seen.
3. If ` + "`hasMore`" + ` is true, call again immediately: a burst is still draining.
Otherwise wait 2 seconds before the next call.
4. Keep polling until you have what you need, then stop.

Example:
curl -s "%[2]s?since=2026-08-09T07:20:05Z"

Each capture carries method, path, query string, headers, body, client IP and
timestamp. With ?since they come back oldest first.

Do not poll faster than every 2 seconds. httphq allows %[3]d requests per minute
per IP and that budget is shared with everything else you and this browser send
it.

Captures are deleted after %[4]s and anyone with the URL can read them, so do not
send anything secret through this endpoint.`

// agentPrompt builds that text for one endpoint.
func agentPrompt(endpointURL, apiURL string, requestsPerMinute int, retention time.Duration) string {
return fmt.Sprintf(agentPromptTemplate,
endpointURL, apiURL, requestsPerMinute, retentionPhrase(retention))
}

// retentionPhrase renders the window for prose, in whichever unit divides it
// evenly. It exists because Go's own duration formatting would offer a reader
// "4h0m0s".
func retentionPhrase(d time.Duration) string {
unit, count := "hour", int(d.Hours())
if count < 1 || d%time.Hour != 0 {
unit, count = "minute", int(d.Minutes())
}
if count == 1 {
return "1 " + unit
}
return fmt.Sprintf("%d %ss", count, unit)
}
74 changes: 74 additions & 0 deletions src/agent_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package main

import (
"strings"
"testing"
"time"

"github.com/stretchr/testify/assert"
)

func TestAgentPrompt(t *testing.T) {
const (
endpointURL = "https://example.com/to/purple-frog-0691"
apiURL = "https://example.com/api/endpoints/purple-frog-0691/requests"
)
prompt := agentPrompt(endpointURL, apiURL, 150, 4*time.Hour)

t.Run("carries both of the endpoint's URLs", func(t *testing.T) {
assert.Contains(t, prompt, endpointURL)
assert.Contains(t, prompt, apiURL)
})

// The prompt is read by something that will act on it, so the loop has to
// be stated rather than implied.
t.Run("states the cursor loop", func(t *testing.T) {
assert.Contains(t, prompt, "?since=")
assert.Contains(t, prompt, "cursor")
assert.Contains(t, prompt, "hasMore")
})

// A prompt that restated these in prose would keep quoting the old number
// after the constant behind it moved.
t.Run("quotes the limits it was given", func(t *testing.T) {
assert.Contains(t, prompt, "150 requests per minute")
assert.Contains(t, prompt, "deleted after 4 hours")
})

t.Run("tracks the figures it is given rather than restating fixed ones", func(t *testing.T) {
other := agentPrompt(endpointURL, apiURL, 60, 30*time.Minute)

assert.Contains(t, other, "60 requests per minute")
assert.Contains(t, other, "deleted after 30 minutes")
assert.NotContains(t, other, "150")
assert.NotContains(t, other, "4 hours")
})

// A self-hosted deployment has to produce a prompt pointing at itself.
t.Run("names no hardcoded host", func(t *testing.T) {
assert.NotContains(t, prompt, "httphq.com")
})

// The whole block is copied verbatim into another tool, so stray edge
// whitespace travels with it.
t.Run("has no leading or trailing whitespace", func(t *testing.T) {
assert.Equal(t, strings.TrimSpace(prompt), prompt)
})
}

func TestRetentionPhrase(t *testing.T) {
for _, testCase := range []struct {
in time.Duration
want string
}{
{4 * time.Hour, "4 hours"},
{time.Hour, "1 hour"},
{90 * time.Minute, "90 minutes"},
{30 * time.Minute, "30 minutes"},
{time.Minute, "1 minute"},
} {
t.Run(testCase.want, func(t *testing.T) {
assert.Equal(t, testCase.want, retentionPhrase(testCase.in))
})
}
}
Loading
Loading