Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
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
28 changes: 25 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1040,6 +1040,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 +1212,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_product = "event_gateway" | "outpost" | "console"
```

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

[prod]
api_key = "api_key_yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy"
project_id = "tm_U9Zod13qtsHp"
project_mode = "inbound"
project_product = "event_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
22 changes: 22 additions & 0 deletions 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
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
31 changes: 23 additions & 8 deletions pkg/cmd/connection_upsert.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,8 +158,7 @@ func newConnectionUpsertCmd() *connectionUpsertCmd {
cu.cmd.Flags().StringVar(&cu.DestinationGCPScope, "destination-gcp-scope", "", "GCP scope for service account authentication")

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

addConnectionRuleFlags(cu.cmd, &cu.connectionCreateCmd.connectionRuleFlags)

Expand Down Expand Up @@ -245,6 +244,8 @@ func (cu *connectionUpsertCmd) hasAnyDestinationFlag() bool {
cu.destinationURL != "" || cu.destinationCliPath != "" ||
cu.destinationPathForwardingDisabled != nil || cu.destinationHTTPMethod != "" ||
cu.DestinationRateLimit != 0 || cu.DestinationRateLimitPeriod != "" ||
cu.DestinationDeliveryGroupKey != "" || cu.DestinationDeliveryGroupRate != 0 ||
cu.DestinationDeliveryGroupRatePeriod != "" || cu.DestinationDeliveryGroupOverrides != "" ||
cu.DestinationAuthMethod != ""
}

Expand All @@ -256,7 +257,9 @@ func (cu *connectionUpsertCmd) hasAnyRuleFlag() bool {

// Helper to check if any rate limit flags are set
func (cu *connectionUpsertCmd) hasAnyRateLimitFlag() bool {
return cu.DestinationRateLimit != 0 || cu.DestinationRateLimitPeriod != ""
return cu.DestinationRateLimit != 0 || cu.DestinationRateLimitPeriod != "" ||
cu.DestinationDeliveryGroupKey != "" || cu.DestinationDeliveryGroupRate != 0 ||
cu.DestinationDeliveryGroupRatePeriod != "" || cu.DestinationDeliveryGroupOverrides != ""
}

// Validate source flags for consistency
Expand Down Expand Up @@ -310,7 +313,9 @@ func (cu *connectionUpsertCmd) runConnectionUpsertCmd(cmd *cobra.Command, args [

hasDestinationConfigOnly := (cu.destinationURL != "" || cu.destinationCliPath != "" ||
cu.destinationPathForwardingDisabled != nil || cu.destinationHTTPMethod != "" ||
cu.DestinationRateLimit != 0 || cu.DestinationAuthMethod != "") &&
cu.DestinationRateLimit != 0 || cu.DestinationRateLimitPeriod != "" || cu.DestinationDeliveryGroupKey != "" ||
cu.DestinationDeliveryGroupRate != 0 || cu.DestinationDeliveryGroupRatePeriod != "" ||
cu.DestinationDeliveryGroupOverrides != "" || cu.DestinationAuthMethod != "") &&
cu.destinationName == "" && cu.destinationType == "" && cu.destinationID == ""

// Also need to fetch existing when name is provided without type (to fill in the type)
Expand Down Expand Up @@ -478,6 +483,8 @@ func (cu *connectionUpsertCmd) buildUpsertRequest(existing *hookdeck.Connection,
cu.destinationPathForwardingDisabled != nil ||
cu.destinationHTTPMethod != "" ||
cu.DestinationRateLimit != 0 || cu.DestinationRateLimitPeriod != "" ||
cu.DestinationDeliveryGroupKey != "" || cu.DestinationDeliveryGroupRate != 0 ||
cu.DestinationDeliveryGroupRatePeriod != "" || cu.DestinationDeliveryGroupOverrides != "" ||
cu.DestinationAuthMethod != ""

if hasDestinationConfigUpdate {
Expand Down Expand Up @@ -592,11 +599,19 @@ func (cu *connectionUpsertCmd) buildDestinationInputForUpdate(existingDest *hook
destConfig["http_method"] = method
}

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

// Apply authentication config if provided
if cu.DestinationAuthMethod != "" {
Expand Down
50 changes: 50 additions & 0 deletions pkg/cmd/connection_upsert_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,56 @@ func TestUpsertValidateDestinationFlagsAllowsNameOnly(t *testing.T) {
assert.NoError(t, err, "validateDestinationFlags should allow --destination-name alone for upsert")
}

func TestConnectionDestinationDeliveryPolicy(t *testing.T) {
cc := &connectionCreateCmd{
DestinationRateLimit: 100,
DestinationRateLimitPeriod: "minute",
DestinationDeliveryGroupKey: "headers.x-tenant-id",
DestinationDeliveryGroupRate: 10,
DestinationDeliveryGroupRatePeriod: "second",
DestinationDeliveryGroupOverrides: `{"priority":{"rate":50,"rate_period":"second"}}`,
}

config, err := cc.buildDestinationConfig()
require.NoError(t, err)
policy, ok := config["delivery_policy"].(map[string]interface{})
require.True(t, ok)
assert.Equal(t, 100, policy["rate"])
groups, ok := policy["groups"].(map[string]interface{})
require.True(t, ok)
assert.Equal(t, "headers.x-tenant-id", groups["key"])
assert.Equal(t, 10, groups["rate"])
}

func TestConnectionUpsertMergesDeliveryPolicy(t *testing.T) {
cu := &connectionUpsertCmd{connectionCreateCmd: &connectionCreateCmd{
DestinationDeliveryGroupKey: "body.customer_id",
DestinationDeliveryGroupRate: 5,
DestinationDeliveryGroupRatePeriod: "second",
}}
existing := &hookdeck.Destination{
Name: "api",
Type: "HTTP",
Config: map[string]interface{}{
"url": "https://api.example.com",
"delivery_policy": map[string]interface{}{
"rate": 100,
"period": "minute",
},
},
}

input, err := cu.buildDestinationInputForUpdate(existing)
require.NoError(t, err)
policy, ok := input.Config["delivery_policy"].(map[string]interface{})
require.True(t, ok)
assert.Equal(t, 100, policy["rate"])
assert.Equal(t, "minute", policy["period"])
groups, ok := policy["groups"].(map[string]interface{})
require.True(t, ok)
assert.Equal(t, "body.customer_id", groups["key"])
}

// TestUpsertBuildRequestFillsSourceTypeFromExisting verifies that when
// --source-name is provided without --source-type during an update,
// the existing source type is used.
Expand Down
Loading
Loading