Skip to content
kestrel-devagentPublic

About

Hookkeep — hosted webhook inbox for n8n by Kestrel Ops

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Hookkeep

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.

5-minute local start

  1. Install & start

    git clone https://github.com/kestrel-devagent/hookkeep && cd hookkeep
    npm install
    npm start          # listens on http://127.0.0.1:8787
  2. 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.

  3. 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"}'
  4. Open the dashboard (/app.html) — see the event, headers, raw body.

  5. 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.

  6. (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.

Marketing site (durable)

GitHub Pages: https://kestrel-devagent.github.io/hookkeep/

Live demo (ephemeral tunnel)

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).

Pricing

Tier Price Inboxes Retention Alerts
Free $0 1 Last 50 events No
Pro $9/mo 5 5,000 events/mo Keyword / status match

How to pay

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.)

API sketch

  • POST /api/workspace → create workspace + first inbox + owner token
  • POST /hook/:inboxId → public ingest (any method)
  • GET /api/workspace + header x-hookkeep-token
  • PATCH /api/inboxes/:id { name?, forwardUrl?, autoForward?, alertKeyword?, alertEmail?, notifyWebhookUrl?, responseStatus?, responseBody?, responseContentType?, signingScheme?, signingSecret?, signingHeader?, dedupeMode?, dedupeHeader?, forwardRetries?, forwardBackoffMs? }
  • Custom ingest response — set responseStatus (200–599; 0/null = default JSON ack), optional responseBody (≤10k chars, placeholders {{eventId}} {{method}} {{receivedAt}} {{json.some.path}}) and responseContentType (auto JSON/text when blank). /hook/:inboxId then answers exactly that (204/205/304 send no body) plus header x-hookkeep-event-id. Use it to make the sender see a 503 and exercise its retry/backoff, or echo a handshake (Slack url_verification: status 200, body {{json.challenge}}, text/plain). Capture still happens; each event records respondedStatus / respondedCustom. Bad status → 400 bad_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/:inboxId is verified against the raw body bytes as received and the event records signature: { scheme, valid, reason, header, timestampSkewSec? } — reasons ok · missing_header · bad_format · mismatch · timestamp_out_of_tolerance · no_secret. Stripe: stripe-signature: t=…,v1=… (multiple v1 OK), HMAC-SHA256 of ${t}.${rawBody}, 300s tolerance. GitHub: x-hub-signature-256: sha256=<hex>. Generic hmac-sha256: header signingHeader (default x-signature) with <hex> or sha256=<hex>. Constant-time compare via WebCrypto (shared src/signature.js, Node + Workers). The secret is write-only — APIs return only hasSigningSecret + signingSecretHint (•••• + last 4); signingSecret: "" clears it. Capture is always stored and the ingest response is unchanged (custom response still applies). Bad scheme → 400 bad_signing_scheme (patch not applied). Free tier OK. Dashboard: sidebar Signature check panel; event rows show ✓ sig / ✗ sig; detail shows the reason.
  • Ingest dedupe — PATCH /api/inboxes/:id { dedupeMode: none|idempotency|body-hash|either, dedupeHeader? } (header default idempotency-key, ≤64 chars). Provider retries reuse the prior capture instead of filling the keep-window twice. After signature verify, Hookkeep computes a dedupeKey (idempotency: trimmed header ≤256 chars; body-hash: sha256:<hex> of raw body bytes — empty body has a stable hash; method is NOT part of the key; either: header first, else body-hash). On a hit: no new event, no eventCount bump, no alerts/auto-forward re-fire; the ingest response matches a fresh capture for the original eventId (custom templates included) plus headers x-hookkeep-event-id and x-hookkeep-deduped: 1. Default JSON ack adds deduped: true and eventId. On a miss the event stores dedupeKey. Missing header in idempotency mode → store as new. Bad mode → 400 bad_dedupe_mode. Free tier OK (core webhook hygiene). Shared src/dedupe.js (Node + Workers; Workers KV dedupe:<inboxId>:<sha256> → eventId, ~7d TTL). Dashboard: sidebar Dedupe panel; muted dedupe badge on events with a key.
  • 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 pass includePinned: 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 → 409 pin_limit. Bad input → 400 bad_pinned / bad_note / empty_patch (nothing applied). List/export rows carry pinned + note; CSV adds trailing pinned,note. Shared src/event-pin.js (Node + Workers; Workers tracks ids in KV pins:<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=all answers "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. amount 900 → "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 unless headers=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: 404 no_baseline / against_not_found / not_found, 400 same_event / bad_ignore. Read-only, free tier OK. Shared src/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 / signature valid|invalid|unchecked / pinned=1|0 / auto-forward forward=failed|pending|delivered|none)
  • GET /api/inboxes/:id/events/export?format=json|csv&q=&method=&statusMin=&sig=&pinned=&forward=&limit= → bulk download of the current filtered set (same filters/cap as list; free tier OK; JSON rows carry signature, CSV adds signatureValid,signatureReason,pinned,note,dedupeKey,forwardState,forwardAttempts,redactedHeaders,redactedJsonPaths)
  • 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 = targetUrl else inbox forwardUrl; neither → 400 no_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 unless includePinned: true). Missing confirm: true → 400 confirm_required. Returns { ok, inboxId, filters, deleted, ids }; inbox eventCount decremented.
  • GET /api/inboxes/:id/alerts?limit=20 → recent Pro alert records
  • GET /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 to forwardUrl (same headers/body as replay, ~8s timeout). Result is stored on the event as autoForward: { ok, status, ms, error?, target } and returned on the ingest response. Available on free tier (core job — not soft-paywalled).
  • Auto-forward retries with backoff — PATCH /api/inboxes/:id { forwardRetries: 0–5, forwardBackoffMs: 100–60000 } (defaults 0 / 1000; forwardBackoffMs: 0 = default). Your n8n/Zapier/backend endpoint blips for a minute and the provider already got its 200 from Hookkeep — so Hookkeep owns the retry. Only transient failures retry: network error / timeout, HTTP 408, 425, 429, 5xx; any other 4xx, bad URL or bad protocol fails at once (gaveUp: "non_retryable"). Gap before retry n = forwardBackoffMs · 2^(n-1), each capped at 5 min (default schedule 1s, 2s, 4s, 8s, 16s; inbox responses include forwardSchedule). Each forwarded request carries x-hookkeep-attempt: <n>. event.autoForward keeps the last attempt's { ok, status, ms, target, error? } plus state: delivered|pending|failed, attempts[] ({ n, at, ok, status?, ms?, error?, retryable, manual? }, newest 12), attemptCount, maxAttempts, nextAttemptAt (pending) and gaveUp: non_retryable|exhausted|budget. The ingest response reports attempt 1 plus state / maxAttempts / nextAttemptAt. Filter with ?forward=failed|pending|delivered|none on list + export (list rows carry forwardState + forwardAttempts; CSV adds trailing forwardState,forwardAttempts). Retry failed forwards: POST /api/inboxes/:id/events/retry-forwards { targetUrl?, q?, method?, statusMin?, limit?, includePending? } → one immediate attempt per filtered event whose auto-forward ended failed (default/hard max 50, serial). Target = targetUrl, else the inbox's current forwardUrl (so a fixed URL wins), else the event's original target. Returns { ok, attempted, succeeded, failed, results: [{ id, ok, state, status?, error?, target }] } (207 when any still fail). Bad config → 400 bad_forward_retries / bad_forward_backoff (patch not applied). Free tier OK. Runtime limits (honest): Node runs attempt 1 in-request and later retries on in-process timers; pending retries are re-armed at boot (resumePendingForwards), deleted/trimmed events cancel theirs. Workers runs attempt 1 + retries inside ctx.waitUntil under a ~25s budget (Cloudflare cancels waitUntil work ~30s after the response) — a retry that won't fit ends the event failed with gaveUp: "budget"; use retry-forwards to finish the job. Shared src/forward-retry.js (Node + Workers). Dashboard: sidebar Retries / backoff ms under Auto-forward with the live schedule, ✓ fwd ×3 / ↻ fwd retrying / ✗ fwd ×3 badges, a Forward failed / retrying / delivered / not forwarded filter, Retry failed forwards button, and an attempts timeline on the event detail.
  • Inbox stats — GET /api/inboxes/:id/stats?window=1h|24h|7d|all (default 24h; same owner-token auth). Counts events whose receivedAt falls in the window: total, pinned, byMethod, byStatusBucket (2xx/3xx/4xx/5xx/other from statusGuess), byForwardState (delivered/pending/failed/none via forwardStateOf), bySignature (valid/invalid/unchecked), plus oldestReceivedAt / newestReceivedAt / windowStartedAt (null when window=all) / generatedAt. Bad window → 400 bad_window. Free tier OK. Shared src/inbox-stats.js (Node + Workers). Dashboard: compact Stats panel with a window selector.
  • Capture redaction — PATCH /api/inboxes/:id { redactHeaders?, redactJsonPaths? } (array or comma/newline string; empty = none). Header names are case-insensitive (≤40 names, each ≤80 chars, stored lowercase). JSON paths use dot + optional [index] (≤40 paths, each ≤120 chars). After signature verify + dedupe key compute, Hookkeep redacts a copy used for storage and auto-forward: matching headers → "[REDACTED]" (presence preserved); JSON object/array bodies replace matching paths with "[REDACTED]" and re-serialize (non-JSON: header redaction only). Event records redaction: { headers, jsonPaths } for names/paths that matched. Bad paths → 400 bad_redact_json_paths / bad_redact_headers (patch not applied). Free tier OK (security hygiene). Shared src/redact.js (Node + Workers). Dashboard: sidebar Redact panel; muted redacted badge on events that were scrubbed. List/export include the summary; CSV adds trailing redactedHeaders,redactedJsonPaths.
  • 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 from docs/demo.json
  • Dashboard: filter events by text (q), HTTP method, and numeric statusMin (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 / statusMin filters as Node (incl. /events/export + /events/replay-bulk + /events/delete-bulk)
  • Workers POST /api/stripe/fulfill mirrors 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, billing
  • POST /api/stripe/fulfill → billing bridge, gated by HOOKKEEP_FULFILL_SECRET

Pro alerts

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.

Deploy notes

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).

Related

Brand

Public UI/copy uses Kestrel Ops / Hookkeep only. Contact/pay: hudson.gouge@projxon.ai.

About

Hookkeep — hosted webhook inbox for n8n by Kestrel Ops

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages