API & SDK Documentation
Authenticate every request with your API key in the X-API-Key header (a Bearer token works too). The capture URL itself (/api/h/{id}) needs no auth — that is the point: any webhook provider can reach it. Every example below is shown in cURL, JavaScript (fetch) and Python (requests).
Prefer a machine-readable contract? The full OpenAPI 3.1 spec is published at https://gethooklab.dev/api/openapi.json — import it into Postman, Insomnia, Swagger UI or any client generator.
1. Create an endpoint
POST /api/v1/endpoints. Your account's first endpoint is permanent — a forever URL that never idle-expires (permanent: true, expiresInDays: null), free tier included. Additional endpoints live 30 days of silence (each capture refreshes the clock); anonymous demo endpoints live 7. List yours any time with GET /api/v1/endpoints.
curl -X POST "https://gethooklab.dev/api/v1/endpoints" \
-H "X-API-Key: hkl_your_key_here"const res = await fetch("https://gethooklab.dev/api/v1/endpoints", {
method: "POST",
headers: { "X-API-Key": "hkl_your_key_here" },
});
const endpoint = await res.json();
console.log(endpoint.url); // https://gethooklab.dev/api/h/<id>import requests
res = requests.post(
"https://gethooklab.dev/api/v1/endpoints",
headers={"X-API-Key": "hkl_your_key_here"},
)
endpoint = res.json()
print(endpoint["url"]) # https://gethooklab.dev/api/h/<id>{
"id": "k3v9x2m1qa",
"url": "https://gethooklab.dev/api/h/k3v9x2m1qa",
"inspectUrl": "https://gethooklab.dev/inspect/k3v9x2m1qa",
"createdAt": "2026-06-19T18:00:00.000Z",
"permanent": true,
"expiresInDays": null
}2. Send it anything
GET, POST, PUT, PATCH and DELETE are all captured: method, query, headers and up to 64KB of body (larger bodies are stored truncated and flagged). Cookie, Authorization and X-API-Key headers are stripped before storage. Each endpoint keeps its last 100 requests, and every capture refreshes the endpoint's lifetime — an endpoint that keeps receiving traffic never expires. Point any real webhook (Stripe, GitHub, Shopify…) at this URL and watch it arrive in the live inspector.
curl -X POST "https://gethooklab.dev/api/h/k3v9x2m1qa?source=stripe" \
-H "Content-Type: application/json" \
-d '{"event":"order.paid","amount":4200}'await fetch("https://gethooklab.dev/api/h/k3v9x2m1qa?source=stripe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ event: "order.paid", amount: 4200 }),
});requests.post(
"https://gethooklab.dev/api/h/k3v9x2m1qa",
params={"source": "stripe"},
json={"event": "order.paid", "amount": 4200},
)Live tail — stream captures in real time (SSE)
GET /api/v1/endpoints/{id}/tail streams every NEW capture as a Server-Sent Event the moment it lands — curl -N turns your terminal into a live webhook console. Each streamed window lasts ~50s and ends with an event: timeout; SSE clients (EventSource, curl --retry) reconnect automatically, so a long session is seamless.
curl -N "https://gethooklab.dev/api/v1/endpoints/k3v9x2m1qa/tail" \
-H "X-API-Key: hkl_your_key_here"
# event: open
# data: {"endpoint":"k3v9x2m1qa","pollMs":1500}
#
# event: capture
# data: {"id":"req_...","method":"POST","bodyPretty":"{\n \"event\": \"order.paid\"...}// Node 18+: stream with fetch (the key travels in a header, never in the URL)
const res = await fetch("https://gethooklab.dev/api/v1/endpoints/k3v9x2m1qa/tail", {
headers: { "X-API-Key": "hkl_your_key_here", Accept: "text/event-stream" },
});
for await (const chunk of res.body.pipeThrough(new TextDecoderStream())) {
process.stdout.write(chunk); // parse SSE frames as needed
}3. List & search captured requests
GET /api/v1/endpoints/{id}/requests. Newest first. Narrow the result with ?q= (case-insensitive grep over method, body, header names/values and query), ?method= (exact verb) and ?limit=(1–100). Filters combine with AND.
curl "https://gethooklab.dev/api/v1/endpoints/k3v9x2m1qa/requests?q=order.paid&method=POST&limit=50" \
-H "X-API-Key: hkl_your_key_here"const params = new URLSearchParams({ q: "order.paid", method: "POST", limit: "50" });
const res = await fetch(
`https://gethooklab.dev/api/v1/endpoints/k3v9x2m1qa/requests?${params}`,
{ headers: { "X-API-Key": "hkl_your_key_here" } },
);
const { requests } = await res.json();res = requests.get(
"https://gethooklab.dev/api/v1/endpoints/k3v9x2m1qa/requests",
headers={"X-API-Key": "hkl_your_key_here"},
params={"q": "order.paid", "method": "POST", "limit": 50},
)
captured = res.json()["requests"]{
"id": "k3v9x2m1qa",
"count": 1,
"query": { "q": "order.paid", "method": "POST" },
"requests": [
{
"id": "req_mbqj0e_a1b2c3",
"method": "POST",
"ts": "2026-06-19T18:00:05.000Z",
"ip": "203.0.113.9",
"query": { "source": "stripe" },
"headers": { "content-type": "application/json" },
"bodyRaw": "{\"event\":\"order.paid\",\"amount\":4200}",
"bodyPretty": "{\n \"event\": \"order.paid\",\n \"amount\": 4200\n}",
"bodyIsJson": true,
"truncated": false,
"contentType": "application/json"
}
]
}4. Export captured requests
GET /api/v1/endpoints/{id}/export?format=json|csv downloads the whole retained window as an attachment. The same ?q= / ?method= filters apply, so you can export exactly the slice you searched. CSV is RFC-4180 escaped and formula-injection safe.
# JSON
curl "https://gethooklab.dev/api/v1/endpoints/k3v9x2m1qa/export?format=json" \
-H "X-API-Key: hkl_your_key_here" -o captures.json
# CSV (only POSTs matching a query)
curl "https://gethooklab.dev/api/v1/endpoints/k3v9x2m1qa/export?format=csv&method=POST&q=order" \
-H "X-API-Key: hkl_your_key_here" -o captures.csvconst res = await fetch(
"https://gethooklab.dev/api/v1/endpoints/k3v9x2m1qa/export?format=csv",
{ headers: { "X-API-Key": "hkl_your_key_here" } },
);
const csv = await res.text();res = requests.get(
"https://gethooklab.dev/api/v1/endpoints/k3v9x2m1qa/export",
headers={"X-API-Key": "hkl_your_key_here"},
params={"format": "csv"},
)
open("captures.csv", "wb").write(res.content)5. Configure a custom response
By default the capture route replies 200 {"ok":true}. Override it per endpoint to simulate a flaky downstream — return a 4xx/5xx, a custom body, or add artificial latency. PATCH /api/v1/endpoints/{id} sets it; DELETE restores the default ack, as does the same PATCH with {"responseConfig":null}. Status 100–599, body ≤ 8KB, delay 0–15s. Both resets touch only the mock response — to remove the endpoint itself, see section 6.
Failure simulation — test your retry/backoff logic with mode: "fail-first-n" (first failCount captures return failStatus, then the normal response), "flaky" (each capture fails with probability flakyPercent%), or "timeout" (respond after timeoutMs, 100–10000ms). failStatus defaults to 500.
# Make the endpoint answer 503 after a 2s delay
curl -X PATCH "https://gethooklab.dev/api/v1/endpoints/k3v9x2m1qa" \
-H "X-API-Key: hkl_your_key_here" \
-H "Content-Type: application/json" \
-d '{"responseConfig":{"status":503,"body":"{\"error\":\"down\"}","contentType":"application/json","delayMs":2000}}'
# Failure simulation: fail the first 2 deliveries with 503, then succeed
curl -X PATCH "https://gethooklab.dev/api/v1/endpoints/k3v9x2m1qa" \
-H "X-API-Key: hkl_your_key_here" \
-H "Content-Type: application/json" \
-d '{"responseConfig":{"status":200,"body":"{\"ok\":true}","mode":"fail-first-n","failCount":2,"failStatus":503}}'
# Restore the default ack
curl -X DELETE "https://gethooklab.dev/api/v1/endpoints/k3v9x2m1qa" \
-H "X-API-Key: hkl_your_key_here"
# ...or the same reset as a PATCH with a null config
curl -X PATCH "https://gethooklab.dev/api/v1/endpoints/k3v9x2m1qa" \
-H "X-API-Key: hkl_your_key_here" \
-H "Content-Type: application/json" \
-d '{"responseConfig":null}'await fetch("https://gethooklab.dev/api/v1/endpoints/k3v9x2m1qa", {
method: "PATCH",
headers: {
"X-API-Key": "hkl_your_key_here",
"Content-Type": "application/json",
},
body: JSON.stringify({
responseConfig: { status: 503, body: '{"error":"down"}', contentType: "application/json", delayMs: 2000 },
}),
});requests.patch(
"https://gethooklab.dev/api/v1/endpoints/k3v9x2m1qa",
headers={"X-API-Key": "hkl_your_key_here"},
json={"responseConfig": {
"status": 503,
"body": '{"error":"down"}',
"contentType": "application/json",
"delayMs": 2000,
}},
)6. Delete an endpoint
POST /api/v1/endpoints/{id}/delete permanently removes an endpoint and frees a slot against your plan's endpoint limit — this is how you make room when you hit ENDPOINT_LIMIT_REACHED. Owner only. Deletion has its own path rather than reusing DELETE on the endpoint, which resets the mock response (section 5) and always has.
This cannot be undone. The endpoint's captured requests, its custom response, and any forwarding destination or alert webhook attached to it are erased with it. Export anything you still need first (GET /api/v1/endpoints/{id}/export). Deleting an endpoint that is already gone is safe — it answers 200 with {"deleted":false} rather than an error.
One thing survives: any public share link you created from a captured request. Those are independent redacted snapshots, not views onto the endpoint, so they keep working until their own 7-day expiry. Deleting the endpoint does not revoke them.
curl -X POST "https://gethooklab.dev/api/v1/endpoints/k3v9x2m1qa/delete" \
-H "X-API-Key: hkl_your_key_here"
# → {"id":"k3v9x2m1qa","deleted":true}await fetch("https://gethooklab.dev/api/v1/endpoints/k3v9x2m1qa/delete", {
method: "POST",
headers: { "X-API-Key": "hkl_your_key_here" },
});requests.post(
"https://gethooklab.dev/api/v1/endpoints/k3v9x2m1qa/delete",
headers={"X-API-Key": "hkl_your_key_here"},
)7. Replay a request
POST /api/v1/replay. requestIndex is the position in the list above (0 = most recent). The captured method, sanitized headers and body are re-sent to your targetUrl with a 10s timeout. Private/internal targets are rejected (SSRF-guarded, including redirects). Throttled to 10 replays per endpoint per minute.
curl -X POST "https://gethooklab.dev/api/v1/replay" \
-H "X-API-Key: hkl_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"endpointId": "k3v9x2m1qa",
"requestIndex": 0,
"targetUrl": "https://staging.your-app.dev/webhooks/stripe"
}'const res = await fetch("https://gethooklab.dev/api/v1/replay", {
method: "POST",
headers: {
"X-API-Key": "hkl_your_key_here",
"Content-Type": "application/json",
},
body: JSON.stringify({
endpointId: "k3v9x2m1qa",
requestIndex: 0,
targetUrl: "https://staging.your-app.dev/webhooks/stripe",
}),
});requests.post(
"https://gethooklab.dev/api/v1/replay",
headers={"X-API-Key": "hkl_your_key_here"},
json={
"endpointId": "k3v9x2m1qa",
"requestIndex": 0,
"targetUrl": "https://staging.your-app.dev/webhooks/stripe",
},
){
"ok": true,
"replayed": { "endpointId": "k3v9x2m1qa", "requestIndex": 0, "method": "POST" },
"status": 200,
"durationMs": 184,
"bodyPreview": "{\"received\":true}"
}8. Verify a webhook signature
POST /api/v1/verify-signature checks an HMAC-SHA256 signature against a shared secret for Stripe (Stripe-Signature, hex + timestamp tolerance), GitHub (X-Hub-Signature-256, hex) and Shopify (X-Shopify-Hmac-Sha256, base64). Verify a stored capture by id+index (the signature header is pulled automatically), or paste a literal rawBody + signature. Your secret is used in-process only — never stored or logged.
# Verify a captured Shopify webhook by index (header pulled from the capture)
curl -X POST "https://gethooklab.dev/api/v1/verify-signature" \
-H "X-API-Key: hkl_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"provider": "shopify",
"secret": "shpss_your_app_secret",
"endpointId": "k3v9x2m1qa",
"requestIndex": 0
}'
# Verify a literal Stripe payload you pasted
curl -X POST "https://gethooklab.dev/api/v1/verify-signature" \
-H "X-API-Key: hkl_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"provider": "stripe",
"secret": "whsec_...",
"rawBody": "{\"id\":\"evt_1\"}",
"signature": "t=1700000000,v1=abc...",
"toleranceSeconds": 300
}'// GitHub: verify the most recent capture
const res = await fetch("https://gethooklab.dev/api/v1/verify-signature", {
method: "POST",
headers: {
"X-API-Key": "hkl_your_key_here",
"Content-Type": "application/json",
},
body: JSON.stringify({
provider: "github",
secret: process.env.GITHUB_WEBHOOK_SECRET,
endpointId: "k3v9x2m1qa",
requestIndex: 0,
}),
});
const { valid, reason } = await res.json();res = requests.post(
"https://gethooklab.dev/api/v1/verify-signature",
headers={"X-API-Key": "hkl_your_key_here"},
json={
"provider": "shopify",
"secret": "shpss_your_app_secret",
"endpointId": "k3v9x2m1qa",
"requestIndex": 0,
},
)
print(res.json()) # { "valid": true, "reason": "OK", ... }{
"provider": "shopify",
"source": "captured",
"valid": true,
"reason": "OK",
"details": {}
}Failure reasons are machine-readable: SIGNATURE_MISMATCH, TIMESTAMP_OUT_OF_TOLERANCE (Stripe), MALFORMED_SIGNATURE_HEADER, MISSING_SECRET, UNSUPPORTED_SHA1 (GitHub legacy).
Error reference
Errors carry a stable machine-readable code. Branch on error.code, never on the message text — messages are written for humans and may change.
{
"error": {
"code": "ENDPOINT_LIMIT_REACHED",
"message": "Your plan includes up to 3 endpoints. …"
}
}| Code | HTTP | When |
|---|---|---|
BAD_REQUEST | 400 | Malformed JSON body, or a required field is missing or unusable (for example an alert url that fails the SSRF check). |
TARGET_BLOCKED | 400 | Replay refused the target URL: private, internal or otherwise non-public addresses are not reachable. |
UNAUTHORIZED | 401 | The X-API-Key header is missing, malformed, or the key has been revoked. |
UPGRADE_REQUIRED | 402 | The feature exists but is not included in your plan — alert webhooks and forwarding are paid features. |
FORBIDDEN | 403 | The endpoint exists but belongs to another account. |
NOT_FOUND | 404 | The endpoint does not exist, or its retention window has expired. |
NO_CAPTURES | 404 | The endpoint exists but has captured nothing yet, so there is no payload to work from. |
REQUEST_NOT_FOUND | 404 | The referenced capture id is unknown or has aged out of retention. |
CONFLICT | 409 | The request conflicts with current state — for example a coupon already redeemed. |
PAYLOAD_TOO_LARGE | 413 | The request body exceeds the 1 MB limit. |
NOT_JSON | 422 | Type inference needs a JSON capture; the latest capture on this endpoint is not JSON. |
RATE_LIMITED | 429 | Per-second burst limit. Retry after the Retry-After header (1 second). |
RATE_LIMIT_EXCEEDED | 429 | Ingress rate limit on /api/h/{id}. Same meaning as RATE_LIMITED; both names are in use. |
QUOTA_EXCEEDED | 429 | Monthly captured-request quota for your plan is exhausted. Resets at the start of the next UTC month. Writes only — reading your captures (any GET) keeps working, since the quota is spent by whoever POSTs to your inspect URL and locking you out of your own data would be the wrong answer to that. |
ENDPOINT_LIMIT_REACHED | 429 | You already hold the maximum number of endpoints your plan allows. Delete one or upgrade. |
MONITOR_LIMIT_REACHED | 429 | You already hold the maximum number of alert webhooks your plan allows. |
FORWARD_LIMIT_REACHED | 429 | You already hold the maximum number of forwarding rules your plan allows. |
INTERNAL_ERROR | 500 | An unhandled failure on our side. Safe to retry with backoff; if it persists the status page carries the incident. |
REPLAY_FAILED | 502 | The replay target could not be reached, or refused the connection. |
REPLAY_TIMEOUT | 504 | The replay target did not respond within 10 seconds. |
Retry on 429 after the Retry-After header, and on 500/502/504 with backoff. A 4xx other than 429 will not succeed on retry — fix the request first.