Hosted webhook inbox for n8n / automation builders — by Kestrel Ops.
Get a durable HTTPS webhook URL, store payloads, search recent events, open details, and replay/forward to your n8n webhook.
-
Install & start
git clone https://github.com/kestrel-devagent/hookkeep && cd hookkeep npm install npm start # listens on http://127.0.0.1:8787
-
Create a workspace — open http://127.0.0.1:8787 → Create free inbox (or
curl -X POST localhost:8787/api/workspace -d '{}'). Copy the owner token when prompted — it's your only login. -
Send a webhook at the URL shown in the dashboard:
curl -X POST http://127.0.0.1:8787/hook/<inboxId> \ -H 'content-type: application/json' -d '{"hello":"hookkeep"}'
-
Open the dashboard (
/app.html) — see the event, headers, raw body. -
Replay / export — paste your n8n webhook into Forward URL in the sidebar, optionally enable Auto-forward on receive, hit Save inbox, open the event, click Replay / forward. Or Copy as curl / Download JSON to share the capture offline.
-
(Optional) Smoke test the whole path:
npm test # create → ingest → list → replay → alerts; prints SMOKE OK
Data lives in data/hookkeep.json (atomic writes). On a host, set
HOOKKEEP_DATA to a mounted volume so restarts keep workspaces.
GitHub Pages: https://kestrel-devagent.github.io/hookkeep/
Durable marketing: https://kestrel-devagent.github.io/hookkeep/
trycloudflare quick tunnel was rate-limited (HTTP 429 / error 1015) on 2026-09-21 — no public ephemeral inbox URL until cool-down. Update docs/demo.json when a tunnel is back (Pages reads that file). Prefer HF Space Docker / any Node host / Workers (see DEPLOY.md).
| Tier | Price | Inboxes | Retention | Alerts |
|---|---|---|---|---|
| Free | $0 | 1 | Last 50 events | No |
| Pro | $9/mo | 5 | 5,000 events/mo | Keyword / status match |
Stripe Checkout (preferred): /subscribe?product=hookkeep on a deployed
host redirects to the Kestrel Ops stripe-billing service when
BILLING_PUBLIC_URL is set. If billing isn't wired on that host, the page
explains how to get an unlock code by email — it never 404s.
Fallback: email hudson.gouge@projxon.ai with your workspace email → receive a
one-time unlock code → paste in dashboard Unlock Pro. (Reusable HOOKKEEP-PRO-* demo codes exist for testing only — see UNLOCK.md.)
POST /api/workspace→ create workspace + first inbox + owner tokenPOST /hook/:inboxId→ public ingest (any method)GET /api/workspace+ headerx-hookkeep-tokenPATCH /api/inboxes/:id{ name?, forwardUrl?, autoForward?, alertKeyword?, alertEmail?, notifyWebhookUrl?, responseStatus?, responseBody?, responseContentType? }- Custom ingest response — set
responseStatus(200–599;0/null= default JSON ack), optionalresponseBody(≤10k chars, placeholders{{eventId}}{{method}}{{receivedAt}}{{json.some.path}}) andresponseContentType(auto JSON/text when blank)./hook/:inboxIdthen answers exactly that (204/205/304 send no body) plus headerx-hookkeep-event-id. Use it to make the sender see a 503 and exercise its retry/backoff, or echo a handshake (Slackurl_verification: status 200, body{{json.challenge}},text/plain). Capture still happens; each event recordsrespondedStatus/respondedCustom. Bad status → 400bad_response_status(patch not applied). Free tier OK. Dashboard: sidebar Custom response panel with Default ack / 503 retry test / Echo challenge presets. - Signature check —
PATCH /api/inboxes/:id{ signingScheme: none|stripe|github|hmac-sha256, signingSecret?, signingHeader? }. Every capture on/hook/:inboxIdis verified against the raw body bytes as received and the event recordssignature: { scheme, valid, reason, header, timestampSkewSec? }— reasonsok·missing_header·bad_format·mismatch·timestamp_out_of_tolerance·no_secret. Stripe:stripe-signature: t=…,v1=…(multiplev1OK), HMAC-SHA256 of${t}.${rawBody}, 300s tolerance. GitHub:x-hub-signature-256: sha256=<hex>. Generichmac-sha256: headersigningHeader(defaultx-signature) with<hex>orsha256=<hex>. Constant-time compare via WebCrypto (sharedsrc/signature.js, Node + Workers). The secret is write-only — APIs return onlyhasSigningSecret+signingSecretHint(••••+ last 4);signingSecret: ""clears it. Capture is always stored and the ingest response is unchanged (custom response still applies). Bad scheme → 400bad_signing_scheme(patch not applied). Free tier OK. Dashboard: sidebar Signature check panel; event rows show✓ sig/✗ sig; detail shows the reason. - Pin + note an event —
PATCH /api/events/:id{ pinned?: boolean, note?: string|null }(note ≤ 280 chars). A pinned event is exempt from the keep-window trim (free keeps the newest 50 unpinned events, Pro 5000) and is skipped by bulk delete unless you passincludePinned: true— so the one failing payload you're debugging doesn't scroll away during a retry storm. Pins per inbox: free 5, Pro 100 (limits.maxPinned); over the limit → 409pin_limit. Bad input → 400bad_pinned/bad_note/empty_patch(nothing applied). List/export rows carrypinned+note; CSV adds trailingpinned,note. Sharedsrc/event-pin.js(Node + Workers; Workers tracks ids in KVpins:<inboxId>). Dashboard: 📌 Pin + note field on the event detail, 📌 badge in the list, Pinned only filter. - Diff two events —
GET /api/events/:id/diff?against=<eventId>&ignore=<paths>&headers=allanswers "this delivery broke my flow, the one before it didn't — what changed?". Baseline =against(any event in your workspace, e.g. a pinned known-good capture or one from another inbox) or, when omitted, the previous event in the same inbox. JSON bodies diff structurally with dot/[index]paths (added/removed/changed/type— e.g.amount900 → "900" number→string); other bodies get a line diff. Headers compare case-insensitively with per-delivery noise (date, content-length, request ids, CDN hops, Stripe/GitHub/Shopify/Slack/Svix signatures) hidden unlessheaders=all; meta diff covers method / contentType / statusGuess / size / respondedStatus / signatureValid.ignore= up to 20 comma-separated paths (a path hides its children;*matches one segment:data.items[*].id). Returns{ base, target, identical, summary, body: { mode, changes, truncated, ignoredChanges }, headers: { changes, volatileSkipped }, meta, baselineMode }(first 200 changes, values previewed to 200 chars). Errors: 404no_baseline/against_not_found/not_found, 400same_event/bad_ignore. Read-only, free tier OK. Sharedsrc/event-diff.js(Node + Workers). Dashboard: event detail ⇄ Compare with another event (previous / pinned / recent picker, ignore paths, all-headers toggle, colored +/−/~ list). GET /api/inboxes/:id/events?q=&method=&statusMin=&sig=&pinned=(text / HTTP method / status ≥ N / signaturevalid|invalid|unchecked/pinned=1|0)GET /api/inboxes/:id/events/export?format=json|csv&q=&method=&statusMin=&sig=&pinned=&limit=→ bulk download of the current filtered set (same filters/cap as list; free tier OK; JSON rows carrysignature, CSV addssignatureValid,signatureReason,pinned,note)POST /api/inboxes/:id/events/replay-bulk{ targetUrl?, q?, method?, statusMin?, limit? }→ serially replay the current filtered set via the same forward path (default/hard max 50; free tier OK). Target =targetUrlelse inboxforwardUrl; neither → 400no_forward_url.POST /api/inboxes/:id/events/delete-bulk{ q?, method?, statusMin?, limit?, confirm: true, includePinned? }→ delete the current filtered set (same filters; default/hard max 50; free tier OK; pinned events skipped unlessincludePinned: true). Missingconfirm: true→ 400confirm_required. Returns{ ok, inboxId, filters, deleted, ids }; inboxeventCountdecremented.GET /api/inboxes/:id/alerts?limit=20→ recent Pro alert recordsGET /api/events/:id·PATCH /api/events/:id(pin/note) ·GET /api/events/:id/diff(compare)POST /api/events/:id/replay{ "targetUrl?: string" }- Auto-forward on ingest — when an inbox has
forwardUrl+autoForward: true, each captured request is POSTed toforwardUrl(same headers/body as replay, ~8s timeout). Result is stored on the event asautoForward: { ok, status, ms, error?, target }and returned on the ingest response. Available on free tier (core job — not soft-paywalled). POST /api/unlock{ "code" }POST /api/waitlist{ "email", "note?" }- Operator:
npm run digest-waitlist→ mailto draft of waitlist + Stripe unlock path (no SMTP) - Operator:
npm run status→ health + waitlist count + demo URL fromdocs/demo.json - Dashboard: filter events by text (
q), HTTPmethod, and numericstatusMin(GET /api/inboxes/:id/events); Export JSON / Export CSV downloads the filtered set; Replay filtered bulk-forwards it; Delete filtered removes it (confirm dialog) - Workers KV path mirrors the same
q/method/statusMinfilters as Node (incl./events/export+/events/replay-bulk+/events/delete-bulk) - Workers
POST /api/stripe/fulfillmirrors Node (gate:FULFILL_SECRET/HOOKKEEP_FULFILL_SECRET+x-fulfill-secret) GET /subscribe→ redirect to billing host (or helpful setup page)GET /api/health→dataDir,persistOk,workspaceCount,billingPOST /api/stripe/fulfill→ billing bridge, gated byHOOKKEEP_FULFILL_SECRET
On Pro workspaces, an ingested event fires an alert when the inbox alertKeyword
matches the body/statusGuess or an HTTP status ≥ 400 is extracted from the
payload. Delivery: POST to inbox notifyWebhookUrl (Discord/Slack-compatible
{ "content": … }, ~8s timeout), one line appended to data/alerts.ndjson
(+ [hookkeep:alert] log), and a mailtoHint fallback returned in the alert
record and ingest response. Free tier never fires alerts.
See DEPLOY.md for free durable paths (HF Space Docker, Node hosts, Cloudflare Workers + optional GH Actions deploy).
- Node + JSON file store — zero native deps; works on any free Node host (Render free, Fly free, Railway trial, HF Docker Space, VPS).
- Env:
PORT,HOOKKEEP_PUBLIC_URL(optional; when unset, webhook URLs use the request host — safer than a stale tunnel),HOOKKEEP_DATA(persistent volume path — mount it or you lose workspaces on restart),BILLING_PUBLIC_URL/STRIPE_BILLING_URL(Stripe billing host for/subscribe),HOOKKEEP_FULFILL_SECRET(billing fulfill bridge).
- Free offline Catcher: https://kestrel-devagent.github.io/n8n-webhook-catcher/
- Ops Pack (workflows): https://kestrelops.itch.io/n8n-ops-pack-v1
Public UI/copy uses Kestrel Ops / Hookkeep only. Contact/pay: hudson.gouge@projxon.ai.