Reference

REST API reference

Every Pixelhooks endpoint — capture, bins, requests, and the live stream.

The Pixelhooks API has two surfaces: the capture endpoint under the /h/ prefix, where requests are recorded, and the management API under /api, where you read bins and their captured requests. Capturing and reading are anonymous — the bin id is the only credential. A small account-scoped surface (listing, labelling, and removing the bins you own) sits behind authentication — see Authenticated endpoints.

All examples use https://hooks.pixelhop.io, the hosted Pixelhooks origin. The app, the management API, and the capture endpoint all share this single origin, so there are no cross-origin cookies and no CORS to worry about.

Capture endpoint

ANY /h/:binId
ANY /h/:binId/*

Records the incoming request against the bin and, by default, responds 200 OK with the body OK. Accepts any HTTP method and any sub-path beneath the bin id; the sub-path is stored as the request's path (the bare bin URL records /). The bin's owner can change what this endpoint returns — status, headers, body, content-type, and an artificial delay — see Configure the response.

For each request Pixelhooks stores the method, request headers, raw body, query string, the path that was hit, and a received-at timestamp.

Requests to a bin id that doesn't exist also return 200 OK — nothing is stored, and the response doesn't reveal whether the bin exists.

Limits

  • Each bin keeps only its most recent 100 requests. When a new request would exceed the cap, the oldest is dropped.
  • Bodies are captured up to 1 MB. Larger bodies are truncated to the cap and flagged with truncated: true; the request is never rejected.
  • Anonymous bins idle-expire after 30 days. A bin that goes 30 days without receiving a request is purged automatically — its captured requests and any configured response are deleted, and the bin id stops resolving (capture and read both behave as if it never existed). Each captured request resets the countdown. Bins created while signed in are claimed by your account and never auto-expire.

Create a bin

POST /api/bins
{ "id": "abc123cuid", "createdAt": "2026-06-19T09:00:00.000Z" }

The id doubles as the bin's capture token. The capture URL is the origin plus /h/ and the id: https://hooks.pixelhop.io/h/abc123cuid.

Get bin metadata

GET /api/bins/:binId

Returns { "id", "createdAt" }, or 404 if the bin doesn't exist.

List captured requests

GET /api/bins/:binId/requests

Returns lightweight summaries, newest first. Use get request for full detail.

[
  {
    "id": "req_1",
    "method": "POST",
    "path": "/webhooks/stripe",
    "query": "attempt=2",
    "contentType": "application/json",
    "bodySize": 41,
    "truncated": false,
    "createdAt": "2026-06-19T09:00:05.000Z"
  }
]

Get one request

GET /api/bins/:binId/requests/:requestId

Returns the full request. Text-like bodies (text, JSON, form, XML, or anything that decodes as valid UTF-8) are returned decoded in bodyText. Binary bodies return isText: false and bodyText: null — fetch the bytes from the raw endpoint instead.

{
  "id": "req_1",
  "binId": "abc123cuid",
  "method": "POST",
  "path": "/webhooks/stripe",
  "query": "?attempt=2",
  "headers": { "content-type": "application/json", "user-agent": "Stripe/1.0" },
  "contentType": "application/json",
  "bodySize": 41,
  "truncated": false,
  "ip": "203.0.113.10",
  "kind": "json",
  "isText": true,
  "bodyText": "{\"event\":\"payment_intent.succeeded\"}",
  "fields": null,
  "hasBody": true,
  "createdAt": "2026-06-19T09:00:05.000Z"
}

Beyond the raw body, the detail response categorizes the body and, for the two key/value shapes, parses it into fields:

  • kind — a coarse body category, chosen from the content-type and falling back to byte-sniffing when it's unknown. One of: json, form (application/x-www-form-urlencoded), multipart (multipart/form-data), xml, graphql, sparql, text, binary, or none (empty/absent body).
  • fields — parsed key/value entries for form and multipart bodies only; null for every other kind. Each entry is { "name", "value" } for a plain field, or { "name", "filename", "contentType", "size" } for a file part (the bytes themselves are not embedded — use the raw endpoint). Values are decoded UTF-8, capped at 8 KB each.

These two fields are detail-only; the list endpoint summary is unchanged.

Download a raw body

GET /api/bins/:binId/requests/:requestId/raw

Returns the stored body bytes as application/octet-stream (forced, regardless of the captured content-type, so an attacker-controlled HTML/JS body can't execute). 404 if the request has no body.

Live stream

GET /api/bins/:binId/stream

A Server-Sent Events stream of the bin's captured requests. Each new request is delivered as a default message event whose data is the request summary (same shape as the list endpoint). A named ping event is sent periodically as a heartbeat and can be ignored. Returns 404 before the stream opens if the bin doesn't exist.

const es = new EventSource("https://hooks.pixelhop.io/api/bins/abc123cuid/stream");
es.onmessage = (e) => console.log("captured:", JSON.parse(e.data));

Configure the response

By default the capture endpoint returns 200 OK. A bin's owner can configure a custom response so the endpoint can drive client logic that branches on it — retry on 5xx, follow a redirect, parse a JSON ack, or test timeout handling. These three endpoints are owner-only: they require the session cookie or an x-api-key personal access token of the user who owns the bin (see Authenticated endpoints), and return 404 for anyone else so a bin's configuration never leaks. Reading and serving the configured response over /h/:binId stays fully public.

GET    /api/bins/:binId/response
PUT    /api/bins/:binId/response
DELETE /api/bins/:binId/response

GET returns { "configured": boolean, "config": { ... } }. PUT upserts the config (full replacement). DELETE clears it, reverting the endpoint to 200 OK.

PUT /api/bins/abc123cuid/response
{
  "status": 503,
  "contentType": "application/json",
  "body": "{\"error\":\"try again\"}",
  "headers": { "Retry-After": "5" },
  "delayMs": 250
}

Fields

  • status — integer 200599. For 204, 205, and 304 the body and content-type are dropped (those statuses forbid a body).
  • contentType — defaults to text/plain; charset=utf-8. HTML-ish types (text/html, image/svg+xml, application/xhtml+xml) are rejected, not downgraded — the capture endpoint shares an origin with the app, so a rendered HTML body would be script execution against that origin. Every configured response is also sent with X-Content-Type-Options: nosniff and Content-Security-Policy: sandbox.
  • body — a UTF-8 string, up to 64 KB.
  • headers — an allowlist of representational/caching headers (Cache-Control, Content-Disposition, Content-Language, ETag, Expires, Last-Modified, Vary, Retry-After, Link, WWW-Authenticate, Age, Location) plus any custom X-* header. Location must be an absolute http(s) URL or a / path. Cookies, CORS, security/CSP/framing, hop-by-hop, and runtime-managed headers are rejected. Up to 20 headers.
  • delayMs — artificial delay before responding, 010000 (10s).

Invalid config is rejected with 400 and a message explaining why.

You can also set this from the bin's page in the app (the Configure response panel, visible only to the owner).

Authenticated endpoints

Creating, capturing, and reading bins needs no account. But if you sign in, the bins you create are saved to your account, and a small set of endpoints lets you manage that list. These are the only authenticated endpoints — everything above is anonymous.

Authentication. Authenticate with either the better-auth session cookie (set automatically in the browser after sign-in) or an x-api-key header carrying a personal access token:

x-api-key: ph_xxxxxxxx…

Because the app, the API, and the capture endpoint share one origin, there are no cross-origin cookies and no CORS; the session cookie is SameSite=Lax. A request with no valid credential gets 401. A bin that exists but isn't yours returns 404, never 403 — ownership never leaks.

Personal access tokens. Mint a PAT from the app once signed in. The raw ph_… token is shown exactly once at creation and is never retrievable again; it carries a ~1-year expiry. PATs are managed through GET/POST /api/pat and DELETE /api/pat/:id, which are browser-session-only (you can't mint a PAT with a PAT), so they're driven from the app rather than scripted.

List your bins

GET /api/bins?limit=&cursor=

Returns the bins you own, newest first, metadata only (no per-bin request counts). Keyset (cursor) paginated. Two optional query params:

  • limit — page size, an integer 1500 (default 500). Out of range → 400.
  • cursor — an opaque pagination cursor; pass back the nextCursor from a previous response to fetch the next page. Treat it as opaque (don't construct or parse it). Malformed → 400.

hasMore is true when more bins exist beyond this page; when it is, the response also includes nextCursor. This backs the MCP list_my_bins tool.

{
  "bins": [
    {
      "binId": "abc123cuid",
      "label": "Stripe staging",
      "createdAt": 1750323600000
    }
  ],
  "hasMore": true,
  "nextCursor": "MTc1MDMyMzYwMDAwMDphYmMxMjNjdWlk"
}

Label a bin

PATCH /api/bins/:binId

Sets or clears a bin's label. Body is { "label": "…" }; an empty string, null, or an omitted label clears it. Max 255 characters. Returns { "ok": true }, or 404 if the bin isn't yours. Backs the MCP rename_bin tool.

curl -X PATCH https://hooks.pixelhop.io/api/bins/abc123cuid \
  -H 'x-api-key: ph_xxxxxxxx' \
  -H 'content-type: application/json' \
  -d '{"label":"Stripe staging"}'
# { "ok": true }

Remove a bin

DELETE /api/bins/:binId

Removes the bin from your list. This is an unlink, not a destructive delete — the bin and its captured requests stay readable by id; only the ownership link is dropped. Because the bin is now unowned, it reverts to the 30-day anonymous idle expiry (an owned bin never auto-expires), so an unlinked bin left unused is eventually purged. Returns { "ok": true }, or 404 if the bin isn't yours. Backs the MCP delete_bin tool.

What's next

  • Quickstart — the create → send → read loop.
  • MCP setup — drive all of the above from an AI agent.