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.
Endpoints
Section titled “Endpoints”Create an endpoint
Section titled “Create an endpoint”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.
List endpoints
Section titled “List endpoints”GET /api/endpoints?limit=50&offset=0
Returns {"endpoints": [...], "limit": 50, "offset": 0}. limit is at most 200.
Get an endpoint
Section titled “Get an endpoint”GET /api/endpoints/{id} or GET /api/slug/{slug}
Update an endpoint
Section titled “Update an endpoint”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.
Change the URL
Section titled “Change the URL”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 an endpoint
Section titled “Delete an endpoint”DELETE /api/endpoints/{id} deletes the endpoint and all its requests. Returns 204.
Requests
Section titled “Requests”List requests
Section titled “List requests”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 a request
Section titled “Get a request”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" }}bodyis plain text when it looks like text (body_encoding: "text"), otherwise base64 (body_encoding: "base64").signatureappears only when signature verification is configured.headersleaves out headers the hosting platform added (HIDE_HEADERS);hidden_headerscounts them. Add?include_hidden=truetoGET /api/requests/{reqId}orGET /api/endpoints/{id}/requeststo get them too.forward_resultis the forward target’s reply andresponse_infothe response sent back to the caller, in the same shape as theforward_resultandresponse_infoWebSocket messages. They’re stored for server-mode requests (and browser-mode ones withpersist_requests). An async forward fills inforward_resultwhen it finishes, so it can be missing just after capture. While it runs,forward_resultholds onlyurlandin_progress: true; if that is still there a minute aftercreated_at, the server stopped before the forward finished. Bodies longer thanMAX_STORED_RESPONSE_SIZEare cut, withresponse_body_truncatedorbody_truncatedset. Withsignature.resignon,forward_result.resignedistruewhen the forward carried a fresh signature, andresign_errorsays why it didn’t.
Re-sign a forwarded body
Section titled “Re-sign a forwarded body”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 requests
Section titled “Delete requests”DELETE /api/requests/{reqId}deletes one request.DELETE /api/endpoints/{id}/requestsdeletes all of an endpoint’s requests.
Limits
Section titled “Limits”Read the storage limits
Section titled “Read the storage limits”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.
Say you would pay for more
Section titled “Say you would pay for more”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.
Endpoint config
Section titled “Endpoint config”| 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 |
Capture
Section titled “Capture”Any method to /h/{slug} or /h/{slug}/{path...} is captured. See Status codes for what the sender gets back.
Health
Section titled “Health”GET /healthz returns 200 {"status":"ok"}, or 503 when the database is unreachable. It never requires authentication.