Repository: honeycombio/terraform-provider-honeycombio
Pull request #586 (2024-11-27 12:13:03)

feat: webhook headers
## Short description of the changes

Support headers on the webhook recipient resource.

codecov-commenter: ## [Codecov](https://app.codecov.io/gh/honeycombio/terraform-provider-honeycombio/pull/586?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=honeycombio) Report
Attention: Patch coverage is `96.10390%` with `3 lines` in your changes missing coverage. Please review.
> Project coverage is 75.81%. Comparing base [(`0266e56`)](https://app.codecov.io/gh/honeycombio/terraform-provider-honeycombio/commit/0266e563ad3f74720fc6190f77d1bf2dc0c248f5?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=honeycombio) to head [(`7e8e2f5`)](https://app.codecov.io/gh/honeycombio/terraform-provider-honeycombio/commit/7e8e2f50ac720cf6287ff7bbe6934d82ebdde96d?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=honeycombio).

| [Files with missing lines](https://app.codecov.io/gh/honeycombio/terraform-provider-honeycombio/pull/586?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=honeycombio) | Patch % | Lines |
|---|---|---|
| [internal/provider/webhook\_recipient\_resource.go](https://app.codecov.io/gh/honeycombio/terraform-provider-honeycombio/pull/586?src=pr&el=tree&filepath=internal%2Fprovider%2Fwebhook_recipient_resource.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=honeycombio#diff-aW50ZXJuYWwvcHJvdmlkZXIvd2ViaG9va19yZWNpcGllbnRfcmVzb3VyY2UuZ28=) | 96.10% | [2 Missing and 1 partial :warning: ](https://app.codecov.io/gh/honeycombio/terraform-provider-honeycombio/pull/586?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=honeycombio) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main     #586      +/-   ##
==========================================
+ Coverage   75.69%   75.81%   +0.11%     
==========================================
  Files          87       87              
  Lines        7510     7582      +72     
==========================================
+ Hits         5685     5748      +63     
- Misses       1469     1477       +8     
- Partials      356      357       +1     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/honeycombio/terraform-provider-honeycombio/pull/586?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=honeycombio).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=honeycombio).


Patch:
diff --git a/client/recipient.go b/client/recipient.go
index eb4723e..e5e90e4 100644
--- a/client/recipient.go
+++ b/client/recipient.go
@@ -65,7 +65,8 @@ type RecipientDetails struct {
 	WebhookName string `json:"webhook_name,omitempty"`
 	WebhookURL  string `json:"webhook_url,omitempty"`
 	// webhook only
-	WebhookSecret string `json:"webhook_secret,omitempty"`
+	WebhookSecret  string          `json:"webhook_secret,omitempty"`
+	WebhookHeaders []WebhookHeader `json:"webhook_headers"`
 	// custom webhook
 	WebhookPayloads *WebhookPayloads `json:"webhook_payloads,omitempty"`
 }
@@ -94,6 +95,11 @@ type TemplateVariable struct {
 	Default string `json:"default_value"`
 }
 
+type WebhookHeader struct {
+	Key   string `json:"header"`
+	Value string `json:"value"`
+}
+
 // RecipientType holds all the possible recipient types.
 type RecipientType string
 
diff --git a/docs/resources/webhook_recipient.md b/docs/resources/webhook_recipient.md
index 4dda467..7e68a24 100644
--- a/docs/resources/webhook_recipient.md
+++ b/docs/resources/webhook_recipient.md
@@ -19,7 +19,12 @@ resource "honeycombio_webhook_recipient" "prod" {
   name   = "Production Alerts"
   secret = "a63dab148496ecbe04a1a802ca9b95b8"
   url    = "https://my.url.corp.net"
-
+    
+  header {
+    name = "Authorization"
+    value = "Bearer 123"
+  }
+    
   template {
     type = "trigger"
     body = <<EOT
@@ -50,7 +55,8 @@ The following arguments are supported:
 * `secret` - (Optional) The secret to include when sending the notification to the webhook.
 * `url` - (Required) The URL of the endpoint to send the notification to.
 * `template` - (Optional) Zero or more configuration blocks (described below) to customize the webhook payload if desired.
-* `variable` - (Optional) Zero or m ore configuration blocks (described below) to define variables to be used in the webhook payload if desired. 
+* `variable` - (Optional) Zero or more configuration blocks (described below) to define variables to be used in the webhook payload if desired.
+* `header` - (Optional) Zero or more configuration blocks (described below) to add custom webhook headers if desired.
 
 When configuring custom webhook payloads, use the `template` block, which accepts the following arguments:
 
@@ -64,6 +70,14 @@ The `variable` block accepts the following arguments:
 * `name` - (Required) The name of the custom variable. Must be an alphanumeric string beginning with a lowercase letter.
 * `default_value` - (Optional) The default value for the custom variable, which can be overridden at the alert level.
 
+Optionally, when configuring custom webhooks, use the `header` block to create custom HTTP headers to be included in the webhook request.
+Up to five custom headers can be configured. Reserved headers `Content-Type`, `User-Agent`, and `X-Honeycomb-Webhook-Token` cannot be used.
+The `header` block accepts the following arguments:
+
+* `name` - (Required) The name or key for the header.
+* `value` - (Optional) The value for the header.
+
+
 
 ## Attribute Reference
 
diff --git a/internal/models/recipients.go b/internal/models/recipients.go
index 03b0681..f868b90 100644
--- a/internal/models/recipients.go
+++ b/internal/models/recipients.go
@@ -12,6 +12,7 @@ type WebhookRecipientModel struct {
 	URL       types.String `tfsdk:"url"`
 	Templates types.Set    `tfsdk:"template"` // WebhookTemplateModel
 	Variables types.Set    `tfsdk:"variable"` // TemplateVariableModel
+	Headers   types.Set    `tfsdk:"header"`   //WebhookHeaderModel
 }
 
 type WebhookTemplateModel struct {
@@ -33,3 +34,13 @@ var TemplateVariableAttrType = map[string]attr.Type{
 	"name":          types.StringType,
 	"default_value": types.StringType,
 }
+
+type WebhookHeaderModel struct {
+	Name  types.String `tfsdk:"name"`
+	Value types.String `tfsdk:"value"`
+}
+
+var WebhookHeaderAttrType = map[string]attr.Type{
+	"name":  types.StringType,
+	"value": types.StringType,
+}
diff --git a/internal/provider/webhook_recipient_resource.go b/internal/provider/webhook_recipient_resource.go
index d734a37..7a847ae 100644
--- a/internal/provider/webhook_recipient_resource.go
+++ b/internal/provider/webhook_recipient_resource.go
@@ -18,6 +18,7 @@ import (
 	"github.com/hashicorp/terraform-plugin-framework/schema/validator"
 	"github.com/hashicorp/terraform-plugin-framework/types"
 	"github.com/hashicorp/terraform-plugin-framework/types/basetypes"
+	"golang.org/x/net/http/httpguts"
 
 	"github.com/honeycombio/terraform-provider-honeycombio/client"
 	"github.com/honeycombio/terraform-provider-honeycombio/internal/helper"
@@ -33,6 +34,7 @@ var (
 	_ resource.ResourceWithValidateConfig = &webhookRecipientResource{}
 
 	webhookTemplateTypes     = []string{"trigger", "exhaustion_time", "budget_rate"}
+	webhookHeaderDefaults    = []string{"Content-Type", "User-Agent", "X-Honeycomb-Webhook-Token"}
 	webhookTemplateNameRegex = regexp.MustCompile(`^[a-z](?:[a-zA-Z0-9]+$)?$`)
 )
 
@@ -145,6 +147,33 @@ func (*webhookRecipientResource) Schema(_ context.Context, _ resource.SchemaRequ
 					},
 				},
 			},
+			"header": schema.SetNestedBlock{
+				Description: "Custom headers for webhooks",
+				Validators: []validator.Set{
+					setvalidator.SizeAtMost(5),
+				},
+				NestedObject: schema.NestedBlockObject{
+					Attributes: map[string]schema.Attribute{
+						"name": schema.StringAttribute{
+							Required:    true,
+							Description: "The name or key for the header",
+							Validators: []validator.String{
+								stringvalidator.LengthBetween(1, 64),
+								stringvalidator.NoneOfCaseInsensitive(webhookHeaderDefaults...),
+							},
+						},
+						"value": schema.StringAttribute{
+							Description: "Value for the header",
+							Optional:    true,
+							Computed:    true,
+							Default:     stringdefault.StaticString(""),
+							Validators: []validator.String{
+								stringvalidator.LengthAtMost(512),
+							},
+						},
+					},
+				},
+			},
 		},
 	}
 }
@@ -159,6 +188,7 @@ func (r *webhookRecipientResource) ImportState(ctx context.Context, req resource
 		ID:        types.StringValue(req.ID),
 		Templates: types.SetUnknown(types.ObjectType{AttrTypes: models.WebhookTemplateAttrType}),
 		Variables: types.SetUnknown(types.ObjectType{AttrTypes: models.TemplateVariableAttrType}),
+		Headers:   types.SetUnknown(types.ObjectType{AttrTypes: models.WebhookHeaderAttrType}),
 	})...)
 }
 
@@ -177,6 +207,9 @@ func (r *webhookRecipientResource) ValidateConfig(ctx context.Context, req resou
 	var variables []models.TemplateVariableModel
 	data.Variables.ElementsAs(ctx, &variables, false)
 
+	var headers []models.WebhookHeaderModel
+	data.Headers.ElementsAs(ctx, &headers, false)
+
 	triggerTmplExists := false
 	budgetRateTmplExists := false
 	exhaustionTimeTmplExists := false
@@ -235,6 +268,24 @@ func (r *webhookRecipientResource) ValidateConfig(ctx context.Context, req resou
 		}
 		duplicateMap[name] = true
 	}
+
+	// webhook headers must be valid http headers
+	for i, h := range headers {
+		if !httpguts.ValidHeaderFieldName(h.Name.ValueString()) {
+			resp.Diagnostics.AddAttributeError(
+				path.Root("header").AtListIndex(i).AtName("name"),
+				"Conflicting configuration arguments",
+				"invalid webhook header name",
+			)
+		}
+		if !httpguts.ValidHeaderFieldValue(h.Value.ValueString()) {
+			resp.Diagnostics.AddAttributeError(
+				path.Root("header").AtListIndex(i).AtName("value"),
+				"Conflicting configuration arguments",
+				"invalid webhook header value",
+			)
+		}
+	}
 }
 
 func (r *webhookRecipientResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
@@ -251,6 +302,7 @@ func (r *webhookRecipientResource) Create(ctx context.Context, req resource.Crea
 			WebhookURL:      plan.URL.ValueString(),
 			WebhookSecret:   plan.Secret.ValueString(),
 			WebhookPayloads: webhookTemplatesToClientPayloads(ctx, plan.Templates, plan.Variables, &resp.Diagnostics),
+			WebhookHeaders:  expandWebhookHeaders(ctx, plan.Headers, &resp.Diagnostics),
 		},
 	})
 	if helper.AddDiagnosticOnError(&resp.Diagnostics, "Creating Honeycomb Webhook Recipient", err) {
@@ -270,6 +322,7 @@ func (r *webhookRecipientResource) Create(ctx context.Context, req resource.Crea
 	// to prevent confusing if/else blocks, set null by default and override it if we have that detail on the recipient
 	state.Templates = types.SetNull(types.ObjectType{AttrTypes: models.WebhookTemplateAttrType})
 	state.Variables = types.SetNull(types.ObjectType{AttrTypes: models.TemplateVariableAttrType})
+	state.Headers = types.SetNull(types.ObjectType{AttrTypes: models.WebhookHeaderAttrType})
 
 	if rcpt.Details.WebhookPayloads != nil {
 		state.Templates = plan.Templates
@@ -278,6 +331,10 @@ func (r *webhookRecipientResource) Create(ctx context.Context, req resource.Crea
 		}
 	}
 
+	if rcpt.Details.WebhookHeaders != nil {
+		state.Headers = plan.Headers
+	}
+
 	resp.Diagnostics.Append(resp.State.Set(ctx, state)...)
 }
 
@@ -334,6 +391,12 @@ func (r *webhookRecipientResource) Read(ctx context.Context, req resource.ReadRe
 		state.Variables = types.SetNull(types.ObjectType{AttrTypes: models.TemplateVariableAttrType})
 	}
 
+	if rcpt.Details.WebhookHeaders != nil {
+		state.Headers = flattenWebhookHeaders(ctx, rcpt.Details.WebhookHeaders, &resp.Diagnostics)
+	} else {
+		state.Headers = types.SetNull(types.ObjectType{AttrTypes: models.WebhookHeaderAttrType})
+	}
+
 	resp.Diagnostics.Append(resp.State.Set(ctx, state)...)
 }
 
@@ -351,6 +414,7 @@ func (r *webhookRecipientResource) Update(ctx context.Context, req resource.Upda
 			WebhookName:     plan.Name.ValueString(),
 			WebhookURL:      plan.URL.ValueString(),
 			WebhookSecret:   plan.Secret.ValueString(),
+			WebhookHeaders:  expandWebhookHeaders(ctx, plan.Headers, &resp.Diagnostics),
 			WebhookPayloads: webhookTemplatesToClientPayloads(ctx, plan.Templates, plan.Variables, &resp.Diagnostics),
 		},
 	})
@@ -372,16 +436,21 @@ func (r *webhookRecipientResource) Update(ctx context.Context, req resource.Upda
 	} else {
 		state.Secret = types.StringNull()
 	}
+
+	// to prevent confusing if/else blocks, set null by default and override it if we have that detail on the recipient
+	state.Templates = types.SetNull(types.ObjectType{AttrTypes: models.WebhookTemplateAttrType})
+	state.Variables = types.SetNull(types.ObjectType{AttrTypes: models.TemplateVariableAttrType})
+	state.Headers = types.SetNull(types.ObjectType{AttrTypes: models.WebhookHeaderAttrType})
+
 	if rcpt.Details.WebhookPayloads != nil {
 		state.Templates = plan.Templates
 		if rcpt.Details.WebhookPayloads.TemplateVariables != nil {
 			state.Variables = plan.Variables
-		} else {
-			state.Variables = types.SetNull(types.ObjectType{AttrTypes: models.TemplateVariableAttrType})
 		}
-	} else {
-		state.Templates = types.SetNull(types.ObjectType{AttrTypes: models.WebhookTemplateAttrType})
-		state.Variables = types.SetNull(types.ObjectType{AttrTypes: models.TemplateVariableAttrType})
+	}
+
+	if rcpt.Details.WebhookHeaders != nil {
+		state.Headers = plan.Headers
 	}
 
 	resp.Diagnostics.Append(resp.State.Set(ctx, state)...)
@@ -522,3 +591,45 @@ func webhookVariableToObjectValue(v client.TemplateVariable, diags *diag.Diagnos
 
 	return varObjVal
 }
+
+func expandWebhookHeaders(ctx context.Context, set types.Set, diags *diag.Diagnostics) []client.WebhookHeader {
+	var headers []models.WebhookHeaderModel
+	diags.Append(set.ElementsAs(ctx, &headers, false)...)
+	if diags.HasError() {
+		return nil
+	}
+
+	clientHeaders := make([]client.WebhookHeader, len(headers))
+	for i, h := range headers {
+		hdr := client.WebhookHeader{
+			Key:   h.Name.ValueString(),
+			Value: h.Value.ValueString(),
+		}
+
+		clientHeaders[i] = hdr
+	}
+
+	return clientHeaders
+}
+
+func flattenWebhookHeaders(ctx context.Context, hdrs []client.WebhookHeader, diags *diag.Diagnostics) types.Set {
+	var hdrValues []attr.Value
+	for _, h := range hdrs {
+		hdrValues = append(hdrValues, webhookHeaderToObjectValue(h, diags))
+	}
+	hdrResult, d := types.SetValueFrom(ctx, types.ObjectType{AttrTypes: models.WebhookHeaderAttrType}, hdrValues)
+	diags.Append(d...)
+
+	return hdrResult
+}
+
+func webhookHeaderToObjectValue(h client.WebhookHeader, diags *diag.Diagnostics) basetypes.ObjectValue {
+	headerObj := map[string]attr.Value{
+		"name":  types.StringValue(h.Key),
+		"value": types.StringValue(h.Value),
+	}
+	headerObjVal, d := types.ObjectValue(models.WebhookHeaderAttrType, headerObj)
+	diags.Append(d...)
+
+	return headerObjVal
+}


Test patch:
diff --git a/client/recipient_test.go b/client/recipient_test.go
index 86a5feb..731f9a9 100644
--- a/client/recipient_test.go
+++ b/client/recipient_test.go
@@ -96,9 +96,10 @@ func TestRecipientsCustomWebhook(t *testing.T) {
 			rcpt: client.Recipient{
 				Type: client.RecipientTypeWebhook,
 				Details: client.RecipientDetails{
-					WebhookName:   test.RandomStringWithPrefix("test.", 10),
-					WebhookURL:    test.RandomURL(),
-					WebhookSecret: "secret",
+					WebhookName:    test.RandomStringWithPrefix("test.", 10),
+					WebhookURL:     test.RandomURL(),
+					WebhookSecret:  "secret",
+					WebhookHeaders: []client.WebhookHeader{{Key: "Authorization", Value: "Bearer 123"}},
 					WebhookPayloads: &client.WebhookPayloads{
 						PayloadTemplates:  client.PayloadTemplates{Trigger: &client.PayloadTemplate{Body: body}},
 						TemplateVariables: []client.TemplateVariable{{Name: "severity", Default: "warning"}},
@@ -130,6 +131,10 @@ func TestRecipientsCustomWebhook(t *testing.T) {
 			assert.Equal(t, tr.Details.WebhookSecret, r.Details.WebhookSecret)
 			assert.Equal(t, tr.Details.WebhookPayloads, r.Details.WebhookPayloads)
 			assert.Equal(t, tr.Details.WebhookPayloads.TemplateVariables, r.Details.WebhookPayloads.TemplateVariables)
+			if assert.Len(t, r.Details.WebhookHeaders, 1) {
+				assert.Equal(t, tr.Details.WebhookHeaders[0].Key, r.Details.WebhookHeaders[0].Key)
+				assert.Equal(t, tr.Details.WebhookHeaders[0].Value, r.Details.WebhookHeaders[0].Value)
+			}
 		})
 	}
 }
diff --git a/internal/provider/webhook_recipient_resource_test.go b/internal/provider/webhook_recipient_resource_test.go
index 768cc5e..ca61725 100644
--- a/internal/provider/webhook_recipient_resource_test.go
+++ b/internal/provider/webhook_recipient_resource_test.go
@@ -91,6 +91,11 @@ resource "honeycombio_webhook_recipient" "test" {
   name = "%s"
 	url  = "%s"
 
+	header {
+	  name = "Authorization"
+	  value = "Bearer abc123"
+	}
+
 	variable {
 	  name = "severity"
       default_value = "critical"
@@ -106,6 +111,9 @@ resource "honeycombio_webhook_recipient" "test" {
 						resource.TestCheckResourceAttrSet("honeycombio_webhook_recipient.test", "id"),
 						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "name", name),
 						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "url", url),
+						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "header.#", "1"),
+						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "header.0.name", "Authorization"),
+						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "header.0.value", "Bearer abc123"),
 						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "template.#", "1"),
 						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "template.0.type", "trigger"),
 						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "variable.#", "1"),
@@ -122,6 +130,11 @@ resource "honeycombio_webhook_recipient" "test" {
 
 	secret = "so-secret"
 
+	header {
+	  name = "Authorization"
+	  value = "Bearer abc123"
+	}
+
 	variable {
 	  name = "severity"
       default_value = "warning"
@@ -138,6 +151,9 @@ resource "honeycombio_webhook_recipient" "test" {
 						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "name", name),
 						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "url", url),
 						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "secret", "so-secret"),
+						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "header.#", "1"),
+						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "header.0.name", "Authorization"),
+						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "header.0.value", "Bearer abc123"),
 						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "template.#", "1"),
 						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "template.0.type", "trigger"),
 						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "variable.#", "1"),
@@ -289,6 +305,96 @@ resource "honeycombio_webhook_recipient" "test" {
 		})
 	})
 
+	t.Run("custom webhook succeeds when a header is removed", func(t *testing.T) {
+		name := test.RandomStringWithPrefix("test.", 20)
+		url := test.RandomURL()
+		body := `<<EOT
+		{
+			"name": " {{ .Name }}",
+			"id": " {{ .ID }}"
+		}
+		EOT`
+
+		resource.Test(t, resource.TestCase{
+			PreCheck:                 testAccPreCheck(t),
+			ProtoV5ProviderFactories: testAccProtoV5MuxServerFactory,
+			CheckDestroy:             testAccEnsureRecipientDestroyed(t),
+			Steps: []resource.TestStep{
+				{
+					Config: fmt.Sprintf(`
+resource "honeycombio_webhook_recipient" "test" {
+  name = "%s"
+	url  = "%s"
+	
+	header {
+	  name = "Authorization"
+	  value = "Bearer abc123"
+	}
+
+	variable {
+	  name = "severity"
+      default_value = "critical"
+	}
+
+	template {
+	  type   = "trigger"
+      body = %s
+    }
+}`, name, url, body),
+					Check: resource.ComposeAggregateTestCheckFunc(
+						testAccEnsureRecipientExists(t, "honeycombio_webhook_recipient.test"),
+						resource.TestCheckResourceAttrSet("honeycombio_webhook_recipient.test", "id"),
+						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "name", name),
+						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "url", url),
+						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "template.#", "1"),
+						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "template.0.type", "trigger"),
+						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "variable.#", "1"),
+						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "variable.0.name", "severity"),
+						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "variable.0.default_value", "critical"),
+						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "header.#", "1"),
+						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "header.0.name", "Authorization"),
+						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "header.0.value", "Bearer abc123"),
+						resource.TestCheckNoResourceAttr("honeycombio_webhook_recipient.test", "secret"),
+					),
+				},
+				{
+					Config: fmt.Sprintf(`
+resource "honeycombio_webhook_recipient" "test" {
+  name = "%s"
+	url  = "%s"
+
+	variable {
+	  name = "severity"
+      default_value = "critical"
+	}
+
+	template {
+	  type   = "trigger"
+      body = %s
+    }
+}`, name, url, body),
+					Check: resource.ComposeAggregateTestCheckFunc(
+						testAccEnsureRecipientExists(t, "honeycombio_webhook_recipient.test"),
+						resource.TestCheckResourceAttrSet("honeycombio_webhook_recipient.test", "id"),
+						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "name", name),
+						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "url", url),
+						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "template.#", "1"),
+						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "template.0.type", "trigger"),
+						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "variable.#", "1"),
+						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "variable.0.name", "severity"),
+						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "variable.0.default_value", "critical"),
+						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "header.#", "0"),
+						resource.TestCheckNoResourceAttr("honeycombio_webhook_recipient.test", "secret"),
+					),
+				},
+				{
+					ResourceName: "honeycombio_webhook_recipient.test",
+					ImportState:  true,
+				},
+			},
+		})
+	})
+
 	t.Run("custom webhook succeeds when a variable has no default value", func(t *testing.T) {
 		name := test.RandomStringWithPrefix("test.", 20)
 		url := test.RandomURL()
@@ -379,6 +485,53 @@ resource "honeycombio_webhook_recipient" "test" {
 			},
 		})
 	})
+
+	t.Run("custom webhook succeeds when a header has no value", func(t *testing.T) {
+		name := test.RandomStringWithPrefix("test.", 20)
+		url := test.RandomURL()
+		body := `<<EOT
+		{
+			"name": " {{ .Name }}",
+			"id": " {{ .ID }}"
+		}
+		EOT`
+
+		resource.Test(t, resource.TestCase{
+			PreCheck:                 testAccPreCheck(t),
+			ProtoV5ProviderFactories: testAccProtoV5MuxServerFactory,
+			CheckDestroy:             testAccEnsureRecipientDestroyed(t),
+			Steps: []resource.TestStep{
+				{
+					Config: fmt.Sprintf(`
+resource "honeycombio_webhook_recipient" "test" {
+  name = "%s"
+	url  = "%s"
+
+	header {
+	  name = "Authorization"
+	}
+
+	template {
+	  type   = "trigger"
+      body = %s
+    }
+}`, name, url, body),
+					Check: resource.ComposeAggregateTestCheckFunc(
+						testAccEnsureRecipientExists(t, "honeycombio_webhook_recipient.test"),
+						resource.TestCheckResourceAttrSet("honeycombio_webhook_recipient.test", "id"),
+						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "name", name),
+						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "url", url),
+						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "template.#", "1"),
+						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "template.0.type", "trigger"),
+						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "header.#", "1"),
+						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "header.0.name", "Authorization"),
+						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "header.0.value", ""),
+						resource.TestCheckNoResourceAttr("honeycombio_webhook_recipient.test", "secret"),
+					),
+				},
+			},
+		})
+	})
 }
 
 func TestAcc_WebhookRecipientResource_validateDuplicateTemplateType(t *testing.T) {
@@ -559,7 +712,42 @@ resource "honeycombio_webhook_recipient" "test" {
 			},
 		})
 	})
+}
 
+func TestAcc_WebhookRecipientResource_validateInvalidWebhookHeader(t *testing.T) {
+	name := test.RandomStringWithPrefix("test.", 20)
+	url := test.RandomURL()
+
+	resource.Test(t, resource.TestCase{
+		PreCheck:                 testAccPreCheck(t),
+		ProtoV5ProviderFactories: testAccProtoV5MuxServerFactory,
+		CheckDestroy:             testAccEnsureRecipientDestroyed(t),
+		Steps: []resource.TestStep{
+			{
+				Config: fmt.Sprintf(`
+resource "honeycombio_webhook_recipient" "test" {
+  name = "%s"
+	url  = "%s"
+
+	template {
+	  type   = "trigger"
+      body = "body"
+    }
+
+	header {
+	  name   = "é"
+      value = "test"
+    }
+
+	variable {
+	  name   = "severity"
+      default_value = "warning"
+    }
+}`, name, url),
+				ExpectError: regexp.MustCompile(`invalid webhook header name`),
+			},
+		},
+	})
 }
 
 // TestAcc_WebhookRecipientResource_UpgradeFromVersion027 tests the migration case from the