Skip to content

REST API

The UI is built on this API, so anything it does you can script. All bodies are JSON. When AUTH_TOKEN is set, send Authorization: Bearer <token>.

Errors look like {"error": "endpoint not found"}. A missing record is 404; a database failure is 500.

POST /api/endpoints

curl -X POST http://localhost:8080/api/endpoints \
-H 'Content-Type: application/json' \
-d '{"name":"stripe test","slug_name":"stripe","mode":"server"}'
Field
name optional A label, up to 255 characters. Doesn’t change the URL
slug_name optional Start of the URL: up to 20 lowercase letters, numbers and single dashes, no dash at the start or end. Uppercase is lowercased
mode optional server (default) or browser
config optional See endpoint config

Returns 201 with the endpoint:

{
"id": "251982e0-021a-4b2e-a8fd-50e505e0bd37",
"slug": "stripe-acid-fable-gully",
"name": "stripe test",
"mode": "server",
"created_at": "2026-10-16T23:03:47.672606Z",
"config": {}
}

The webhook URL is /h/<slug>. The slug is slug_name followed by three random short words, or just the three words without a slug_name, so two endpoints never clash and a slug never collides with a page of the app. The words come from the EFF short word list. They make the URL easy to read, not secret: anyone who has it can send to it. Endpoints created before readable slugs keep their 8-character slugs.

A slug_name that isn’t allowed gets 400 with {"error": "Use up to 20 lowercase letters, numbers and single dashes, with no dash at the start or end."}. In the rare case that every slug tried is already taken, the response is 409; send the request again.

An optional X-Client-ID header tags the endpoint so GET /api/endpoints with the same header lists only that client’s endpoints. It’s a convenience for the UI, not access control.

GET /api/endpoints?limit=50&offset=0

Returns {"endpoints": [...], "limit": 50, "offset": 0}. limit is at most 200.

GET /api/endpoints/{id} or GET /api/slug/{slug}

PATCH /api/endpoints/{id}

curl -X PATCH http://localhost:8080/api/endpoints/<id> \
-H 'Content-Type: application/json' \
-d '{"mode":"browser"}'

Send any of name, slug_name, mode and config. Fields you leave out are unchanged, but config is replaced as a whole: include every setting you want to keep.

Sending slug_name gives the endpoint a new slug: that name followed by three new random words, or only the words when slug_name is "". The words change even when the name is the same.

curl -X PATCH http://localhost:8080/api/endpoints/<id> \
-H 'Content-Type: application/json' \
-d '{"slug_name":"orders"}'

The old URL stops working at once: senders still using it get 404 until you update them. Captured requests, settings and the endpoint id stay the same. The response has the new slug; the same 400 and 409 as creating apply.

DELETE /api/endpoints/{id} deletes the endpoint and all its requests. Returns 204.

GET /api/endpoints/{id}/requests?limit=50&offset=0

Returns {"requests": [...], "total": 123, "limit": 50, "offset": 0}, newest first. limit is at most 200.

GET /api/requests/{reqId}

{
"id": "299e2e41-2c91-4baf-bc2d-9e38034667fe",
"endpoint_id": "251982e0-021a-4b2e-a8fd-50e505e0bd37",
"method": "POST",
"path": "/",
"headers": { "Content-Type": ["application/json"] },
"query": { "debug": ["1"] },
"body": "{\"hello\":\"world\"}",
"body_encoding": "text",
"content_type": "application/json",
"ip": "203.0.113.7",
"size": 17,
"created_at": "2026-10-16T23:04:01.12Z",
"signature": { "status": "verified", "scheme": "v1", "webhook_id": "msg_123" },
"forward_result": {
"request_id": "299e2e41-2c91-4baf-bc2d-9e38034667fe",
"url": "https://example.com/hook",
"status_code": 202,
"ok": true,
"latency_ms": 84,
"response_body": "{\"received\":true}",
"response_headers": { "Content-Type": "application/json" },
"content_type": "application/json",
"resigned": true
},
"response_info": {
"request_id": "299e2e41-2c91-4baf-bc2d-9e38034667fe",
"status": 200,
"body": "{\"status\":\"ok\",\"id\":\"299e2e41-2c91-4baf-bc2d-9e38034667fe\"}",
"content_type": "application/json",
"source": "default"
}
}
  • body is plain text when it looks like text (body_encoding: "text"), otherwise base64 (body_encoding: "base64").
  • signature appears only when signature verification is configured.
  • headers leaves out headers the hosting platform added (HIDE_HEADERS); hidden_headers counts them. Add ?include_hidden=true to GET /api/requests/{reqId} or GET /api/endpoints/{id}/requests to get them too.
  • forward_result is the forward target’s reply and response_info the response sent back to the caller, in the same shape as the forward_result and response_info WebSocket messages. They’re stored for server-mode requests (and browser-mode ones with persist_requests). An async forward fills in forward_result when it finishes, so it can be missing just after capture. While it runs, forward_result holds only url and in_progress: true; if that is still there a minute after created_at, the server stopped before the forward finished. Bodies longer than MAX_STORED_RESPONSE_SIZE are cut, with response_body_truncated or body_truncated set. With signature.resign on, forward_result.resigned is true when the forward carried a fresh signature, and resign_error says why it didn’t.

POST /api/endpoints/{id}/resign

Returns fresh Standard Webhooks headers for a body you are about to forward, signed with the endpoint’s stored secret. Browser mode uses it, since the tab forwards but never sees the secret. The endpoint must have signature.resign on.

curl -X POST http://localhost:8080/api/endpoints/<id>/resign \
-H 'Content-Type: application/json' \
-d '{
"request": {
"id": "299e2e41-2c91-4baf-bc2d-9e38034667fe",
"headers": {"webhook-id": ["msg_123"], "webhook-timestamp": ["1790000000"], "webhook-signature": ["v1,..."]},
"body": "{\"hello\":\"world\"}",
"body_encoding": "text"
},
"body": "{\"hello\":\"world\",\"transformed\":true}"
}'
Field
request The request as captured: headers, body and body_encoding as the API returns them. id names the message ID (msg_<id>) when the headers have none
body The body to sign, as it will be sent

request must carry a v1 signature made with the endpoint’s secret, with a timestamp within 5 minutes of now, so this can’t sign a body nobody signed. Returns:

{ "headers": { "webhook-id": "msg_123", "webhook-timestamp": "1790000123", "webhook-signature": "v1,..." } }

Replace the request’s three signature headers with these. They use the endpoint’s header names (signature.id_header and the others), webhook-* by default. 422 with an error when request doesn’t verify; 400 when re-signing is off.

  • DELETE /api/requests/{reqId} deletes one request.
  • DELETE /api/endpoints/{id}/requests deletes all of an endpoint’s requests.

GET /api/limits

{
"max_requests_per_endpoint": 500,
"max_endpoint_storage_bytes": 10485760,
"max_body_size": 524288,
"upgrade_note": "A paid plan would keep 10,000 requests for 30 days.",
"forward_private_addresses": false
}

The three numbers are the server’s MAX_REQUESTS_PER_ENDPOINT, MAX_ENDPOINT_STORAGE_BYTES and MAX_BODY_SIZE. The UI shows them next to the stored request count. upgrade_note appears only when UPGRADE_NOTE is set. forward_private_addresses is true when ALLOW_PRIVATE_FORWARD_TARGETS lets server-side forwarding reach private and loopback addresses.

POST /api/upgrade-interest

curl -X POST http://localhost:8080/api/upgrade-interest \
-H 'Content-Type: application/json' \
-H 'X-Client-ID: my-browser' \
-d '{"endpoint_id":"251982e0-021a-4b2e-a8fd-50e505e0bd37"}'

This is the “I’d pay for that” button. endpoint_id is optional and records where the click happened. One X-Client-ID counts once however often it is sent; a call without one counts once per call. Returns {"count": 3}, the number of people who have asked so far. With PostgreSQL the rows are in the upgrade_interest table; with in-memory storage they live in the snapshot file.

Field
forward_url string Forward target
forward_mode async | sync Defaults to async. With sync the sender gets the forward target’s reply; in browser mode that is your local app’s reply, relayed by the tab
wasm_script string Server-side JavaScript transform
transform_language javascript Language of the transform. Older lua and jsonnet scripts are kept but not run
custom_response.enabled bool Turn the response handler on
custom_response.script string Response handler script
custom_response.language javascript Language of the handler. Older lua and jsonnet scripts are kept but not run
persist_requests bool Store browser-mode requests in the database too
signature.secret string HMAC secret for signature verification. Write-only: omit it to keep the stored secret, send "" to remove it. Never returned
signature.secret_set bool Returned instead of the secret when one is stored. Ignored on input
signature.public_key string Hex Ed25519 public key
signature.enforce bool Reject invalid signatures with 401
signature.resign bool Sign forwarded requests again with the secret, after the transform. Only requests the secret signed are re-signed. Needs signature.secret; saving it without one is 400
signature.id_header string Header that holds the message ID, for senders with their own header names such as Svix’s svix-id. Default webhook-id
signature.timestamp_header string Header that holds the timestamp. Default webhook-timestamp
signature.signature_header string Header that holds the signatures. Default webhook-signature. The three names must be valid header names and differ from each other, or saving is 400. Re-signing writes the same three

Any method to /h/{slug} or /h/{slug}/{path...} is captured. See Status codes for what the sender gets back.

GET /healthz returns 200 {"status":"ok"}, or 503 when the database is unreachable. It never requires authentication.