Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
1aaa7ed
feat: support 2026-09-01 gateway API
alexbouchardd Sep 9, 2026
1de7bb3
fix: review follow-ups for the 2026-09-01 API migration
leggetter Sep 10, 2026
0149af6
Merge branch 'main' into feat/api-2026-09-01
leggetter Sep 10, 2026
667d72b
Merge branch 'feat/api-2026-09-01' into review/api-2026-09-01-fixes
leggetter Sep 10, 2026
008688d
refactor: follow the API's rename of project product to type
leggetter Sep 10, 2026
3269429
Merge branch 'main' into feat/api-2026-09-01
leggetter Sep 11, 2026
ef96113
Merge branch 'feat/api-2026-09-01' into review/api-2026-09-01-fixes
leggetter Sep 11, 2026
4f6bfd3
docs: correct why --delivery-group is withheld, from testing against …
leggetter Sep 14, 2026
b04a99f
fix: stop offering metrics filters the endpoint silently ignores
leggetter Sep 14, 2026
b7cc2a9
test: assert attempts refuses --connection-id instead of ignoring it
leggetter Sep 14, 2026
34a6ca3
refactor: speak project type, not legacy mode, inside listen
leggetter Sep 14, 2026
cde5685
fix: keep writing v2.5.0's project_type values to config
leggetter Sep 14, 2026
d9ed49c
test: cover GET /projects against the real API with a CLI key
leggetter Sep 14, 2026
787390f
test: pin the credential restriction on GET /projects
leggetter Sep 14, 2026
f7b6268
test: drop my duplicate project-list tests, keep one added assertion
leggetter Sep 14, 2026
c94662e
ci: pass HOOKDECK_CLI_TESTING_CLI_KEY to the acceptance jobs
leggetter Sep 14, 2026
8638838
ci: unwire the CLI key again, and stop the tests echoing a project li…
leggetter Sep 14, 2026
f8b737d
ci: wire the CLI key back in, now that it belongs to the test account
leggetter Sep 14, 2026
9ff211a
fix: fail immediately when a rejected key meets no terminal
leggetter Sep 14, 2026
9be1c38
test: stop a gateway 502 reading as an auth regression
leggetter Sep 14, 2026
0db7ff3
docs: say which key each CLI auth path accepts, and what it reaches
leggetter Sep 14, 2026
34b3b24
fix: prefer the API's account of a 401 over our guess about it
leggetter Sep 14, 2026
6b7df1b
fix(mcp): apply the metrics filter matrix to the MCP tools too
leggetter Sep 14, 2026
d67c3a8
docs: stop advertising metrics filters where they do not apply
leggetter Sep 14, 2026
ddf9a32
refactor: drop the team_product fallback, which never shipped
leggetter Sep 14, 2026
a3dafe0
test: assert login refuses a stale key headlessly, not that it starts…
leggetter Sep 14, 2026
160533f
test: make the issue-get telemetry failure diagnosable
leggetter Sep 14, 2026
0dbe3bc
fix: act on the Copilot and code-review findings
leggetter Sep 14, 2026
16a28e2
fix: unknown project types survive disk, and URL-only login is not bl…
leggetter Sep 14, 2026
589c015
docs: trim the comments so the diff is reviewable
leggetter Sep 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/acceptance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,16 @@ jobs:
env:
ACCEPTANCE_SLICE: ${{ matrix.slice }}
HOOKDECK_CLI_TESTING_API_KEY: ${{ secrets[matrix.api_key_secret] }}
# Account-wide CLI key, used only by the project list/use tests in slice 0.
# It has to be account-wide: the project-scoped key `hookdeck ci` issues
# gets a 403 from GET /projects, so those tests skip themselves without it.
#
# It must also belong to the test-only account, not to a person. Such a key
# reaches every org its owner belongs to, and this repository is public, so
# anything a failing test prints is world-readable. The tests are written
# not to echo a project listing, but the account is the real control:
# scoped to its own org, a disclosure is worth nothing.
HOOKDECK_CLI_TESTING_CLI_KEY: ${{ secrets.HOOKDECK_CLI_TESTING_CLI_KEY }}
HOOKDECK_CLI_TELEMETRY_DISABLED: "1"
steps:
- name: Check out code
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ This repository contains the Hookdeck CLI, a Go-based command-line tool for mana
- `REFERENCE.md` - Complete CLI documentation and examples

### Key Files
- `https://api.hookdeck.com/2025-07-01/openapi` - API specification (source of truth for all API interactions)
- `https://api.hookdeck.com/2026-09-01/openapi` - API specification (source of truth for all API interactions)
- `pkg/cmd/sources/` - Fetches and caches the OpenAPI spec for source type enum and auth rules; use for validation and help in source and connection management
- `pkg/cmd/helptext.go` - Shared Short/Long help for resource commands (sources, connections); use when adding or editing command help to avoid duplication
- `.plans/` - Implementation plans and architectural decisions
Expand Down
44 changes: 39 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,19 @@ $ hookdeck listen 3000 stripe --cli-key <your-cli-key>
$ hookdeck listen 3000 stripe --api-key <your-project-api-key>
```

Both flags are global, so they work with any command. A **CLI key** is tied to your user account and can navigate across projects; a **project API key** is scoped to a single project. Within the CLI both are stored and used the same way (see [Credential Types](#security-config-files-and-source-control)).
Which key you hold decides what the CLI can do. The supported ways to supply one are `hookdeck login` for a CLI key, `hookdeck login --cli-key` to paste an existing one, and `hookdeck ci --api-key` for a project API key:

| Key | Where it comes from | Reach | `project list` / `project use` |
| --- | --- | --- | --- |
| **CLI key** | `hookdeck login`, or `hookdeck login --cli-key` | every project in every organization you belong to | yes |
| **Project API key** | dashboard, or `hookdeck ci --api-key` | the one project it belongs to | no |
| **Organization API key** | dashboard | its organization's projects, given the `projects.read` scope | no |

Only a CLI key can list or switch projects. The others are scoped below the level that question is asked at, so `hookdeck project list` answers `this credential is scoped to a single project`, and you need `hookdeck login` for account-wide access.

`hookdeck ci --api-key` takes a **project** API key specifically. It exchanges it for a project-scoped CLI key, which is why keys minted that way cannot list projects either. An organization API key is rejected by `hookdeck ci` and by every other CLI sign-in path, so it cannot be used to authenticate the CLI at all — use it against the REST API directly.

See also [Credential Types](#security-config-files-and-source-control) for how each is stored.

The Event Gateway routes events received for a given `source` (e.g. Shopify, GitHub) to a `destination` via a `connection`. `hookdeck listen` is a standalone command that works with whichever product you're authenticated with — Hookdeck Console or the Event Gateway — receiving events for a given connection and forwarding them to your localhost at the specified port or any valid URL.

Expand Down Expand Up @@ -511,7 +523,7 @@ To install completions permanently, redirect the output to your shell's completi

### Running in CI

If you want to use Hookdeck in CI for tests or any other purposes, authenticate with a Project API key from the dashboard. The `ci` command exchanges it for a CLI client key stored in your config.
If you want to use Hookdeck in CI for tests or any other purposes, authenticate with a Project API key from the dashboard. The `ci` command exchanges it for a CLI client key stored in your config. It must be a *project* key: organization API keys are rejected, and the resulting CLI key is project-scoped, so it cannot list or switch projects.

```sh
$ hookdeck ci --api-key $HOOKDECK_API_KEY
Expand Down Expand Up @@ -1040,6 +1052,28 @@ $ hookdeck gateway connection create \
--destination-rate-limit-period minute
```

#### Configure delivery groups

Isolate delivery queues by a payload field and optionally give selected groups a different maximum rate:

```sh
$ hookdeck gateway connection create \
--name "tenant-aware-delivery" \
--source-name "events" \
--source-type HTTP \
--destination-name "tenant-aware-api" \
--destination-type HTTP \
--destination-url "https://api.example.com/endpoint" \
--destination-rate-limit 100 \
--destination-rate-limit-period second \
--destination-delivery-group-key body.customer_id \
--destination-delivery-group-rate 5 \
--destination-delivery-group-rate-period second \
--destination-delivery-group-overrides '{"cus_priority":{"rate":50,"rate_period":"second"}}'
```

Use `--config` or `--config-file` when you need to set `delivery_policy.groups` directly, including setting `groups` to `null` to disable grouping.

#### Upsert connections

Create or update connections idempotently based on connection name - perfect for CI/CD and infrastructure-as-code workflows:
Expand Down Expand Up @@ -1190,7 +1224,7 @@ The Hookdeck CLI configuration file is stored in TOML format and typically inclu
```toml
api_key = "api_key_xxxxxxxxxxxxxxxxxxxx"
project_id = "tm_xxxxxxxxxxxxxxx"
project_mode = "inbound" | "console"
project_type = "Gateway" | "Outpost" | "Console"
```

### Local Configuration
Expand Down Expand Up @@ -1221,12 +1255,12 @@ profile = "dev"
[dev]
api_key = "api_key_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
project_id = "tm_5JxTelcYxOJy"
project_mode = "inbound"
project_type = "Gateway"

[prod]
api_key = "api_key_yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy"
project_id = "tm_U9Zod13qtsHp"
project_mode = "inbound"
project_type = "Gateway"
```

This allows you to run commands against different projects. For example, to listen to the `webhooks` source in the `dev` profile, run:
Expand Down
39 changes: 38 additions & 1 deletion REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,10 @@ hookdeck gateway connection create [flags]
| `--destination-cli-path` | `string` | CLI path for CLI destinations (default: /) (default "/") |
| `--destination-custom-signature-key` | `string` | Key/header name for custom signature |
| `--destination-custom-signature-secret` | `string` | Signing secret for custom signature |
| `--destination-delivery-group-key` | `string` | Payload field path used to group deliveries (for example body.customer_id) |
| `--destination-delivery-group-overrides` | `string` | JSON object of group-specific delivery rate overrides |
| `--destination-delivery-group-rate` | `int` | Default maximum delivery rate for each delivery group (default "0") |
| `--destination-delivery-group-rate-period` | `string` | Delivery group rate period (second, minute, hour) |
| `--destination-description` | `string` | Destination description |
| `--destination-gcp-scope` | `string` | GCP scope for service account authentication |
| `--destination-gcp-service-account-key` | `string` | GCP service account key JSON for destination authentication |
Expand Down Expand Up @@ -610,6 +614,10 @@ hookdeck gateway connection upsert <name> [flags]
| `--destination-cli-path` | `string` | CLI path for CLI destinations (default: / for new connections) |
| `--destination-custom-signature-key` | `string` | Key/header name for custom signature |
| `--destination-custom-signature-secret` | `string` | Signing secret for custom signature |
| `--destination-delivery-group-key` | `string` | Payload field path used to group deliveries (for example body.customer_id) |
| `--destination-delivery-group-overrides` | `string` | JSON object of group-specific delivery rate overrides |
| `--destination-delivery-group-rate` | `int` | Default maximum delivery rate for each delivery group (default "0") |
| `--destination-delivery-group-rate-period` | `string` | Delivery group rate period (second, minute, hour) |
| `--destination-description` | `string` | Destination description |
| `--destination-gcp-scope` | `string` | GCP scope for service account authentication |
| `--destination-gcp-service-account-key` | `string` | GCP service account key JSON for destination authentication |
Expand Down Expand Up @@ -1085,6 +1093,10 @@ hookdeck gateway destination create [flags]
| `--config-file` | `string` | Path to JSON file for destination config (overrides individual flags if set) |
| `--custom-signature-key` | `string` | Key/header name for custom signature |
| `--custom-signature-secret` | `string` | Signing secret for custom signature |
| `--delivery-group-key` | `string` | Payload field path used to group deliveries (for example body.customer_id) |
| `--delivery-group-overrides` | `string` | JSON object of group-specific delivery rate overrides |
| `--delivery-group-rate` | `int` | Default maximum delivery rate for each delivery group (default "0") |
| `--delivery-group-rate-period` | `string` | Delivery group rate period (second, minute, hour) |
| `--description` | `string` | Destination description |
| `--http-method` | `string` | HTTP method for HTTP destinations (GET, POST, PUT, PATCH, DELETE) |
| `--name` | `string` | Destination name (required) |
Expand Down Expand Up @@ -1152,6 +1164,10 @@ hookdeck gateway destination update <destination-id> [flags]
| `--config-file` | `string` | Path to JSON file for destination config (overrides individual flags if set) |
| `--custom-signature-key` | `string` | Key/header name for custom signature |
| `--custom-signature-secret` | `string` | Signing secret for custom signature |
| `--delivery-group-key` | `string` | Payload field path used to group deliveries (for example body.customer_id) |
| `--delivery-group-overrides` | `string` | JSON object of group-specific delivery rate overrides |
| `--delivery-group-rate` | `int` | Default maximum delivery rate for each delivery group (default "0") |
| `--delivery-group-rate-period` | `string` | Delivery group rate period (second, minute, hour) |
| `--description` | `string` | New destination description |
| `--http-method` | `string` | HTTP method for HTTP destinations |
| `--name` | `string` | New destination name |
Expand Down Expand Up @@ -1216,6 +1232,10 @@ hookdeck gateway destination upsert <name> [flags]
| `--config-file` | `string` | Path to JSON file for destination config (overrides individual flags if set) |
| `--custom-signature-key` | `string` | Key/header name for custom signature |
| `--custom-signature-secret` | `string` | Signing secret for custom signature |
| `--delivery-group-key` | `string` | Payload field path used to group deliveries (for example body.customer_id) |
| `--delivery-group-overrides` | `string` | JSON object of group-specific delivery rate overrides |
| `--delivery-group-rate` | `int` | Default maximum delivery rate for each delivery group (default "0") |
| `--delivery-group-rate-period` | `string` | Delivery group rate period (second, minute, hour) |
| `--description` | `string` | Destination description |
| `--dry-run` | `bool` | Preview changes without applying |
| `--http-method` | `string` | HTTP method for HTTP destinations |
Expand Down Expand Up @@ -1574,6 +1594,7 @@ hookdeck gateway event list [flags]
| `--connection-id` | `string` | Filter by connection ID |
| `--created-after` | `string` | Filter events created after (ISO date-time) |
| `--created-before` | `string` | Filter events created before (ISO date-time) |
| `--delivery-group` | `string` | Filter by delivery group |
| `--destination-id` | `string` | Filter by destination ID |
| `--dir` | `string` | Sort direction (asc, desc) |
| `--error-code` | `string` | Filter by error code |
Expand Down Expand Up @@ -1791,6 +1812,7 @@ hookdeck gateway request events <request-id> [flags]

| Flag | Type | Description |
|------|------|-------------|
| `--delivery-group` | `string` | Filter by delivery group |
| `--limit` | `int` | Limit number of results (default "100") |
| `--next` | `string` | Pagination cursor for next page |
| `--output` | `string` | Output format (json) |
Expand Down Expand Up @@ -1912,7 +1934,22 @@ Query Event Gateway metrics (events, requests, attempts, queue depth, pending ev
| Events grouped by issue (debugging) | `hookdeck gateway metrics events-by-issue iss_xxx --start 2026-02-01T00:00:00Z --end 2026-02-25T00:00:00Z --measures count` |
| Transformation errors | `hookdeck gateway metrics transformations --start 2026-02-01T00:00:00Z --end 2026-02-25T00:00:00Z --measures count,failed_count,error_rate` |

**Common flags (all metrics subcommands):** `--start`, `--end` (required), `--granularity` (e.g. 1h, 5m, 1d), `--measures`, `--dimensions`, `--source-id`, `--destination-id`, `--connection-id`, `--status`, `--output` (json).
**Common flags (all metrics subcommands):** `--start`, `--end` (required), `--granularity` (e.g. 1h, 5m, 1d), `--measures`, `--dimensions`, `--output` (json).

**Filter flags differ per subcommand**, because each metrics endpoint accepts a different set. A filter is only offered where the endpoint honours it:

| Filter | events | requests | attempts | transformations |
| --- | --- | --- | --- | --- |
| `--source-id` | yes | yes | — | — |
| `--destination-id` | yes | — | yes | — |
| `--connection-id` | yes | — | — | yes |
| `--status` | yes | yes | yes | — |
| `--issue-id` | yes | — | — | yes |
| `--delivery-group` | yes | — | yes | — |

Passing one where it does not apply is an `unknown flag` error rather than a silently ignored filter: the API drops filters it does not recognise and answers with unfiltered totals, which would otherwise look like a filtered result.

`metrics events` routes to a different endpoint depending on `--measures` and `--dimensions`, so some of its filters are rejected for a given query — `--delivery-group` and `--status` cannot be combined with `--measures pending`, for example. The error names the flag and the route.

## Utilities

Expand Down
77 changes: 77 additions & 0 deletions pkg/cmd/actionable_error_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"errors"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"testing"

"github.com/hookdeck/hookdeck-cli/pkg/hookdeck"
Expand Down Expand Up @@ -61,3 +63,78 @@ func TestActionableErrorDoesNotCaptureOrdinaryErrors(t *testing.T) {
assert.False(t, errors.As(plain, &actionable),
"an unmarked error must still get Execute's generic recovery message")
}

// TestUnauthorizedServerMessage: a 401 carrying an explanation should show it
// rather than the CLI's guess, which is often wrong - a project API key is valid,
// just not accepted here. See #283.
func TestUnauthorizedServerMessage(t *testing.T) {
tests := []struct {
name string
err error
expected string
}{
{
name: "a real explanation is surfaced",
err: &hookdeck.APIError{StatusCode: 401, Message: "This credential is scoped to a single project"},
expected: "This credential is scoped to a single project",
},
{
name: "the bare status word adds nothing",
err: &hookdeck.APIError{StatusCode: 401, Message: "Unauthorized"},
expected: "",
},
{
// What checkAndPrintError produces for a non-JSON body, which is what
// these endpoints send. The first version of the helper let this
// through and printed it as the explanation.
name: "our own synthesized boilerplate is not a server message",
err: &hookdeck.APIError{StatusCode: 401, Message: "unexpected http status code: 401, raw response body: Unauthorized"},
expected: "",
},
{
name: "case does not matter",
err: &hookdeck.APIError{StatusCode: 401, Message: " unauthorized "},
expected: "",
},
{
name: "no message at all",
err: &hookdeck.APIError{StatusCode: 401},
expected: "",
},
{
name: "not an API error",
err: errors.New("dial tcp: connection refused"),
expected: "",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, unauthorizedServerMessage(tt.err))
})
}
}

// TestUnauthorizedServerMessageThroughTheRealClient drives the helper with an
// error the client genuinely produced. The hand-built cases above all passed
// while the helper was broken, because they supplied a Message the real client
// never generates for these endpoints.
func TestUnauthorizedServerMessageThroughTheRealClient(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte("Unauthorized"))
}))
t.Cleanup(ts.Close)

baseURL, err := url.Parse(ts.URL)
require.NoError(t, err)

client := &hookdeck.Client{BaseURL: baseURL, APIKey: "hk_test_key", TelemetryDisabled: true}
_, err = client.ValidateAPIKey()
require.Error(t, err)
require.True(t, hookdeck.IsUnauthorizedError(err), "should be recognized as a 401")

assert.Empty(t, unauthorizedServerMessage(err),
"a plain-text 401 carries no explanation, so the caller must fall back to guidance")
}
35 changes: 28 additions & 7 deletions pkg/cmd/connection_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,11 @@ type connectionCreateCmd struct {
DestinationRateLimit int
DestinationRateLimitPeriod string

DestinationDeliveryGroupKey string
DestinationDeliveryGroupRate int
DestinationDeliveryGroupRatePeriod string
DestinationDeliveryGroupOverrides string

// Rule flags shared with update/upsert
connectionRuleFlags

Expand Down Expand Up @@ -223,8 +228,7 @@ func newConnectionCreateCmd() *connectionCreateCmd {
cc.cmd.Flags().StringVar(&cc.DestinationGCPScope, "destination-gcp-scope", "", "GCP scope for service account authentication")

// Destination rate limiting flags
cc.cmd.Flags().IntVar(&cc.DestinationRateLimit, "destination-rate-limit", 0, "Rate limit for destination (requests per period)")
cc.cmd.Flags().StringVar(&cc.DestinationRateLimitPeriod, "destination-rate-limit-period", "", "Rate limit period (second, minute, hour, concurrent)")
addConnectionDestinationDeliveryPolicyFlags(cc.cmd, cc)

addConnectionRuleFlags(cc.cmd, &cc.connectionRuleFlags)

Expand Down Expand Up @@ -406,7 +410,16 @@ func (cc *connectionCreateCmd) validateRateLimiting() error {
// Let API validate the period value (supports: second, minute, hour, concurrent)
}

return nil
_, err := buildDeliveryPolicy(
cc.DestinationRateLimit,
cc.DestinationRateLimitPeriod,
cc.DestinationDeliveryGroupKey,
cc.DestinationDeliveryGroupRate,
cc.DestinationDeliveryGroupRatePeriod,
cc.DestinationDeliveryGroupOverrides,
"destination-",
)
return err
}

func (cc *connectionCreateCmd) runConnectionCreateCmd(cmd *cobra.Command, args []string) error {
Expand Down Expand Up @@ -595,11 +608,19 @@ func (cc *connectionCreateCmd) buildDestinationConfig() (map[string]interface{},
config["auth"] = auth
}

// Add rate limiting configuration
if cc.DestinationRateLimit > 0 {
config["rate_limit"] = cc.DestinationRateLimit
config["rate_limit_period"] = cc.DestinationRateLimitPeriod
policy, err := buildDeliveryPolicy(
cc.DestinationRateLimit,
cc.DestinationRateLimitPeriod,
cc.DestinationDeliveryGroupKey,
cc.DestinationDeliveryGroupRate,
cc.DestinationDeliveryGroupRatePeriod,
cc.DestinationDeliveryGroupOverrides,
"destination-",
)
if err != nil {
return nil, err
}
mergeDeliveryPolicy(config, policy)

if len(config) == 0 {
return make(map[string]interface{}), nil
Expand Down
Loading
Loading