You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fix: correct the edge-rejection claim, and name a missing retry rule
From the n8n plugin's live findings. Three of theirs bear on this
codebase; each was re-measured here rather than taken on trust, and one
did not reproduce.
Corrected, because we stated it too generally. This repo claimed Hookdeck
"answers 200 at the edge while refusing a request", from measuring
Stripe. They measured GitHub on the same project rejecting outright with
a non-2xx. So the edge does NOT refuse uniformly, and a test asserting
either shape passes for one provider and fails for the other. The docs
now say that, and keep only the rule that survives both: read `verified`
and `rejection_cause`, and never infer verification from the status code
in either direction.
Acted on: `hookdeck listen` creates connections with `rules: []`, so
anyone who followed the quickstart's tunnel step has no retries at all —
nothing this plugin answers with a retryable status is ever redelivered.
`uncoveredStatuses` already fails that case, but the message read "does
NOT cover 400, 401, …", which describes a narrow rule rather than an
absent one. Doctor now distinguishes them and names `hookdeck listen` as
the likely cause.
Did not reproduce: they found `/requests` list omits `verified`. On
2025-07-01 it is present and populated — checked directly, and our
doctor's provider-verification check depends on it. Headers ARE omitted
from the list, which matches. Reported back rather than quietly assumed.
Checked and already correct: rate_limit is sent under `destination.config`
rather than at the top level; nothing branches on an `attemptTrigger` of
`RETRY`, which never occurs; and the CLI child is SIGTERMed then SIGKILLed
after a grace period.
671 tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copy file name to clipboardExpand all lines: docs/agent-tools.md
+21-21Lines changed: 21 additions & 21 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -4,16 +4,16 @@ The eight tools an agent can call, what each returns, and the rails on the ones
4
4
5
5
Eight tools. Five are the operator verbs — `setup`, `status`, `pause`/`resume`, `replay`, `doctor` — plus three an agent host benefits from more than a CLI does. Two of those correlate what Hookdeck saw with what we did (`hookdeck_recent_deliveries`, `hookdeck_inspect_event`); the third, `hookdeck_issues`, is the dead-letter queue's own lifecycle.
6
6
7
-
| Tool | Answers |
8
-
|---|---|
9
-
|`hookdeck_status`| "Are webhooks working?" — routes, capacity, ledger persistence, dead-letter count, open issues, transport state, config warnings |
10
-
|`hookdeck_recent_deliveries`| "Did anything break overnight?" — open Hookdeck Issues, plus failures Hookdeck cannot see |
11
-
|`hookdeck_inspect_event`| "Why did *this* one fail?" — our row and reason beside Hookdeck's status and full attempt history; payload on request |
12
-
|`hookdeck_doctor`| What's misconfigured, including whether each connection's retry rule still covers every status we emit |
13
-
|`hookdeck_setup`| Provisions connections. Dry run by default |
14
-
|`hookdeck_pause`| Pause/resume a connection. Auto-resumes within an hour |
15
-
|`hookdeck_replay`|**Retries** specific events (`eventIds`), or runs a scoped bulk **replay** of requests (`routeId` + `sinceMinutes`). Dry run unless `confirm: true`. Caps at 100 ids per call and says what it dropped |
16
-
|`hookdeck_issues`| The dead-letter queue's lifecycle: list, acknowledge, resolve, ignore, dismiss. Replays nothing, and says so |
|`hookdeck_status`| "Are webhooks working?" — routes, capacity, ledger persistence, dead-letter count, open issues, transport state, config warnings|
10
+
|`hookdeck_recent_deliveries`| "Did anything break overnight?" — open Hookdeck Issues, plus failures Hookdeck cannot see |
11
+
|`hookdeck_inspect_event`| "Why did _this_ one fail?" — our row and reason beside Hookdeck's status and full attempt history; payload on request|
12
+
|`hookdeck_doctor`| What's misconfigured, including whether each connection's retry rule still covers every status we emit|
13
+
|`hookdeck_setup`| Provisions connections. Dry run by default|
14
+
|`hookdeck_pause`| Pause/resume a connection. Auto-resumes within an hour|
15
+
|`hookdeck_replay`|**Retries** specific events (`eventIds`), or runs a scoped bulk **replay** of requests (`routeId` + `sinceMinutes`). Dry run unless `confirm: true`. Caps at 100 ids per call and says what it dropped |
16
+
|`hookdeck_issues`| The dead-letter queue's lifecycle: list, acknowledge, resolve, ignore, dismiss. Replays nothing, and says so|
17
17
18
18
`tools.allowMutations: false` reduces this to the five read tools — `hookdeck_issues` stays, able to list and inspect but not acknowledge, resolve or dismiss — for an agent that can diagnose but not act.
19
19
@@ -39,16 +39,16 @@ Each result carries `source: "live" | "disk"`. On a disk view, in-flight capacit
39
39
Hookdeck distinguishes them, so this plugin does too:
40
40
41
41
-**Retry** (`POST /events/{id}/retry`) makes a new delivery attempt for an existing event. The event id is unchanged and the attempt count goes up.
42
-
-**Replay** (`POST /bulk/requests/replay`) re-ingests the original *requests* through the pipeline, producing **new events with new ids**. The originals are untouched.
42
+
-**Replay** (`POST /bulk/requests/replay`) re-ingests the original _requests_ through the pipeline, producing **new events with new ids**. The originals are untouched.
43
43
44
44
Almost everything here is a retry: crash recovery re-queuing interrupted work, an agent run asking for another delivery, and `hookdeck_replay` when given explicit `eventIds`. Only catch-up after an outage is a true replay, because the events it needs never existed — the requests arrived while no CLI session was attached, so Hookdeck discarded them rather than creating events to retry.
45
45
46
46
That distinction decides whether deduplication can protect you:
47
47
48
-
|| Ledger sees | Suppressed? |
49
-
|---|---|---|
50
-
| Retry | Same event id, higher attempt | Admitted by the attempt rule, and a duplicate of an already-handled attempt is rejected |
51
-
| Replay | A brand-new event id | Admitted as a first delivery — **the ledger has no way to know it is related to anything**|
| Retry | Same event id, higher attempt | Admitted by the attempt rule, and a duplicate of an already-handled attempt is rejected|
51
+
| Replay | A brand-new event id | Admitted as a first delivery — **the ledger has no way to know it is related to anything**|
52
52
53
53
So a replay of requests that already ran successfully **will run the work again**. That is why every replay path here is scoped to requests that produced no event at all (`cli_events_count: 0`, `ignored_count >= 1`) rather than to a bare time window, and why the tool insists on `confirm: true`. If you need protection against a broader replay, `route.dedupe.idPath` keys deduplication on a provider-native id in the payload, which survives re-ingestion.
54
54
@@ -61,18 +61,18 @@ The local log holds only the residue Hookdeck is structurally blind to, created
61
61
- an agent run that failed **after** we returned `202`, once its retry budget is spent;
62
62
- work interrupted by a crash between the acknowledgement and completion.
63
63
64
-
In both cases Hookdeck recorded a *successful* delivery, so no Issue will ever open and nothing else knows they happened. Those come back as `unreportedFailures`.
64
+
In both cases Hookdeck recorded a _successful_ delivery, so no Issue will ever open and nothing else knows they happened. Those come back as `unreportedFailures`.
65
65
66
66
Pre-acknowledgement rejections — a cancelled retry, a final failed attempt — are mirrored locally only as a convenience where Issues are unreachable, and are returned separately as `locallyRecorded` so a reader knows to prefer the Issue. Two cases make that mirror worth keeping: deployments with no API key, and **CLI destinations, which support no issue triggers at all** — so in local development the local log is the only record there is.
67
67
68
68
## What else we let Hookdeck do
69
69
70
70
Deliberately not reimplemented, listed because the temptation is real:
71
71
72
-
-**Provider signature verification** (Stripe, GitHub, Shopify, ~145 others) happens at the Hookdeck Source via `verification.provider` + `credentials`. An unverified request is rejected at the Request layer, so no event is created and nothing reaches the agent. `signingSecret` is a different thing entirely — Hookdeck's own secret for signing deliveries *to us*.
72
+
-**Provider signature verification** (Stripe, GitHub, Shopify, ~145 others) happens at the Hookdeck Source via `verification.provider` + `credentials`. An unverified request is rejected at the Request layer, so no event is created and nothing reaches the agent. `signingSecret` is a different thing entirely — Hookdeck's own secret for signing deliveries _to us_.
73
73
-**Retries and backoff** are the connection's retry rule. We only choose the status code that decides what it does next.
74
-
-**Concurrency limiting** is pushed into the destination as `rate_limit_period: "concurrent"` in HTTP mode, because Hookdeck paces delivery where our local admission control has to answer `503` — spending one of the event's finite attempts to say "not now". The local limit stays as a backstop, and is the *only* control under CLI transport, where destinations carry no `rate_limit` field.
75
-
-**Payload deduplication** of a double-firing provider is the connection's `deduplicate` rule. Our ledger solves a different problem — deciding whether an incoming *attempt* is a legitimate redelivery or a duplicate — which no server-side rule can answer for us.
74
+
-**Concurrency limiting** is pushed into the destination as `rate_limit_period: "concurrent"` in HTTP mode, because Hookdeck paces delivery where our local admission control has to answer `503` — spending one of the event's finite attempts to say "not now". The local limit stays as a backstop, and is the _only_ control under CLI transport, where destinations carry no `rate_limit` field.
75
+
-**Payload deduplication** of a double-firing provider is the connection's `deduplicate` rule. Our ledger solves a different problem — deciding whether an incoming _attempt_ is a legitimate redelivery or a duplicate — which no server-side rule can answer for us.
76
76
-**Holding events during a restart** is `PUT /connections/{id}/pause`; **catch-up** is bulk replay. Both are API calls, not local queues.
77
77
78
78
Route `filters` are the one deliberate overlap. Hookdeck can filter server-side and doing it there is better — a filtered event never reaches the agent and costs nothing — so the local ones exist only for decisions a connection cannot express.
@@ -84,7 +84,7 @@ Payload text from a webhook is third-party input, and the tools treat it that wa
84
84
- Signature, `Authorization`, cookie and token headers are redacted before an inspected event's headers are returned.
85
85
- The delivered body is **opt-in** (`includeBody`), truncated at 4,000 characters, and labelled as data rather than presented as something addressed to the reader.
86
86
- The `hookdeck listen` child's output is scrubbed of the API key as it is captured, not as it is read — that output is surfaced by `hookdeck_status` and we do not write it, so a future CLI version echoing a key into a banner would otherwise land it in a model's context with nothing here having changed.
87
-
- A test asserts that no configured secret appears in *any* tool's result, so the next tool added inherits the check.
87
+
- A test asserts that no configured secret appears in _any_ tool's result, so the next tool added inherits the check.
88
88
89
89
## Counts are counted
90
90
@@ -98,7 +98,7 @@ A status tool that returns a page and lets the reader infer a total is worse tha
98
98
99
99
Beyond the obvious config validation:
100
100
101
-
-**Provider verification is actually in force.** Setting a source's *type* to STRIPE or GITHUB does not enable signature verification — the provider's signing secret has to be set on the source as well. A source with one is byte-identical to a source without it over the API, because the secret is never returned. So the only evidence is whether the requests that arrived were verified, and that is what this check reads.
101
+
-**Provider verification is actually in force.** Setting a source's _type_ to STRIPE or GITHUB does not enable signature verification — the provider's signing secret has to be set on the source as well. A source with one is byte-identical to a source without it over the API, because the secret is never returned. So the only evidence is whether the requests that arrived were verified, and that is what this check reads.
102
102
-**The retry rule still covers every status the plugin emits.** A rule narrower than the emitted codes turns admission control into silent data loss.
103
103
-**The CLI and the API key point at the same project.** See [Transport](transport.md#the-two-projects-problem).
104
104
-**The burst each route can absorb**, from `maxConcurrent` and the connection's retry count.
0 commit comments