Repository: honeycombio/terraform-provider-honeycombio
Pull request #583 (2024-11-22 20:47:11)

feat: template variables
## Short description of the changes

Support webhook recipient template variables in Terraform


jharley: > made some changes to the test file that are a bit annoying to read. I pulled the "errors when they should" `t.Run` out of the main test and into individual tests that have descriptive names about what exactly is being tested as the number of validations/tests was getting a bit unwieldy

This looks great, thank you!

Patch:
diff --git a/client/recipient.go b/client/recipient.go
index 27ee4f4..eb4723e 100644
--- a/client/recipient.go
+++ b/client/recipient.go
@@ -75,7 +75,8 @@ type NotificationRecipientDetails struct {
 }
 
 type WebhookPayloads struct {
-	PayloadTemplates PayloadTemplates `json:"payload_templates"`
+	PayloadTemplates  PayloadTemplates   `json:"payload_templates"`
+	TemplateVariables []TemplateVariable `json:"template_variables"`
 }
 
 type PayloadTemplates struct {
@@ -88,6 +89,11 @@ type PayloadTemplate struct {
 	Body string `json:"body"`
 }
 
+type TemplateVariable struct {
+	Name    string `json:"name"`
+	Default string `json:"default_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 5030c92..4dda467 100644
--- a/docs/resources/webhook_recipient.md
+++ b/docs/resources/webhook_recipient.md
@@ -34,6 +34,11 @@ resource "honeycombio_webhook_recipient" "prod" {
 		}
 		EOT
   }
+    
+  variable {
+      name          = "severity"
+      default_value = "critical"
+  }
 }
 ```
 
@@ -45,12 +50,20 @@ 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. 
 
 When configuring custom webhook payloads, use the `template` block, which accepts the following arguments:
 
 * `type` - (Required) The template type, allowed types are `trigger`, `exhaustion_time`, and `budget_rate`. Only one template block of each type is allowed on a single recipient.
 * `body` - (Required) A JSON formatted string to represent the webhook payload.
 
+Optionally, when configuring custom webhooks, use the `variable` block to create custom variables that can be interpolated in a template. 
+To configure a variable, at least one `template` block must also be configured.
+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.
+
 
 ## Attribute Reference
 
diff --git a/internal/models/recipients.go b/internal/models/recipients.go
index bef6859..03b0681 100644
--- a/internal/models/recipients.go
+++ b/internal/models/recipients.go
@@ -11,6 +11,7 @@ type WebhookRecipientModel struct {
 	Secret    types.String `tfsdk:"secret"`
 	URL       types.String `tfsdk:"url"`
 	Templates types.Set    `tfsdk:"template"` // WebhookTemplateModel
+	Variables types.Set    `tfsdk:"variable"` // TemplateVariableModel
 }
 
 type WebhookTemplateModel struct {
@@ -22,3 +23,13 @@ var WebhookTemplateAttrType = map[string]attr.Type{
 	"type": types.StringType,
 	"body": types.StringType,
 }
+
+type TemplateVariableModel struct {
+	Name         types.String `tfsdk:"name"`
+	DefaultValue types.String `tfsdk:"default_value"`
+}
+
+var TemplateVariableAttrType = map[string]attr.Type{
+	"name":          types.StringType,
+	"default_value": types.StringType,
+}
diff --git a/internal/provider/webhook_recipient_resource.go b/internal/provider/webhook_recipient_resource.go
index 895fd7a..d734a37 100644
--- a/internal/provider/webhook_recipient_resource.go
+++ b/internal/provider/webhook_recipient_resource.go
@@ -3,7 +3,9 @@ package provider
 import (
 	"context"
 	"errors"
+	"regexp"
 
+	"github.com/hashicorp/terraform-plugin-framework-validators/setvalidator"
 	"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
 	"github.com/hashicorp/terraform-plugin-framework/attr"
 	"github.com/hashicorp/terraform-plugin-framework/diag"
@@ -11,6 +13,7 @@ import (
 	"github.com/hashicorp/terraform-plugin-framework/resource"
 	"github.com/hashicorp/terraform-plugin-framework/resource/schema"
 	"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
+	"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault"
 	"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
 	"github.com/hashicorp/terraform-plugin-framework/schema/validator"
 	"github.com/hashicorp/terraform-plugin-framework/types"
@@ -29,7 +32,8 @@ var (
 	_ resource.ResourceWithImportState    = &webhookRecipientResource{}
 	_ resource.ResourceWithValidateConfig = &webhookRecipientResource{}
 
-	webhookTemplateTypes = []string{"trigger", "exhaustion_time", "budget_rate"}
+	webhookTemplateTypes     = []string{"trigger", "exhaustion_time", "budget_rate"}
+	webhookTemplateNameRegex = regexp.MustCompile(`^[a-z](?:[a-zA-Z0-9]+$)?$`)
 )
 
 type webhookRecipientResource struct {
@@ -114,6 +118,33 @@ func (*webhookRecipientResource) Schema(_ context.Context, _ resource.SchemaRequ
 					},
 				},
 			},
+			"variable": schema.SetNestedBlock{
+				Description: "Variables for webhook templates",
+				Validators: []validator.Set{
+					setvalidator.SizeAtMost(10),
+				},
+				NestedObject: schema.NestedBlockObject{
+					Attributes: map[string]schema.Attribute{
+						"name": schema.StringAttribute{
+							Required:    true,
+							Description: "The name of the variable",
+							Validators: []validator.String{
+								stringvalidator.LengthBetween(1, 64),
+								stringvalidator.RegexMatches(webhookTemplateNameRegex, "must be an alphanumeric string beginning with a lowercase letter"),
+							},
+						},
+						"default_value": schema.StringAttribute{
+							Description: "An optional default value for the variable",
+							Optional:    true,
+							Computed:    true,
+							Default:     stringdefault.StaticString(""),
+							Validators: []validator.String{
+								stringvalidator.LengthAtMost(256),
+							},
+						},
+					},
+				},
+			},
 		},
 	}
 }
@@ -127,6 +158,7 @@ func (r *webhookRecipientResource) ImportState(ctx context.Context, req resource
 	resp.Diagnostics.Append(resp.State.Set(ctx, &models.WebhookRecipientModel{
 		ID:        types.StringValue(req.ID),
 		Templates: types.SetUnknown(types.ObjectType{AttrTypes: models.WebhookTemplateAttrType}),
+		Variables: types.SetUnknown(types.ObjectType{AttrTypes: models.TemplateVariableAttrType}),
 	})...)
 }
 
@@ -139,8 +171,70 @@ func (r *webhookRecipientResource) ValidateConfig(ctx context.Context, req resou
 		return
 	}
 
-	// only allow one template of each type (trigger, budget_rate, exhaustion_time)
-	validateAttributesWhenTemplatesIncluded(ctx, data, resp)
+	var templates []models.WebhookTemplateModel
+	data.Templates.ElementsAs(ctx, &templates, false)
+
+	var variables []models.TemplateVariableModel
+	data.Variables.ElementsAs(ctx, &variables, false)
+
+	triggerTmplExists := false
+	budgetRateTmplExists := false
+	exhaustionTimeTmplExists := false
+	for i, t := range templates {
+		// only allow one template of each type (trigger, budget_rate, exhaustion_time)
+		switch t.Type {
+		case types.StringValue("trigger"):
+			if triggerTmplExists {
+				resp.Diagnostics.AddAttributeError(
+					path.Root("template").AtListIndex(i).AtName("type"),
+					"Conflicting configuration arguments",
+					"cannot have more than one \"template\" of type \"trigger\"",
+				)
+			}
+			triggerTmplExists = true
+		case types.StringValue("exhaustion_time"):
+			if exhaustionTimeTmplExists {
+				resp.Diagnostics.AddAttributeError(
+					path.Root("template").AtListIndex(i).AtName("type"),
+					"Conflicting configuration arguments",
+					"cannot have more than one \"template\" of type \"exhaustion_time\"",
+				)
+			}
+			exhaustionTimeTmplExists = true
+		case types.StringValue("budget_rate"):
+			if budgetRateTmplExists {
+				resp.Diagnostics.AddAttributeError(
+					path.Root("template").AtListIndex(i).AtName("type"),
+					"Conflicting configuration arguments",
+					"cannot have more than one \"template\" of type \"budget_rate\"",
+				)
+			}
+			budgetRateTmplExists = true
+		}
+	}
+
+	// template variables cannot be configured without a template
+	if len(variables) >= 1 && len(templates) == 0 {
+		resp.Diagnostics.AddAttributeError(
+			path.Root("variable").AtListIndex(0),
+			"Conflicting configuration arguments",
+			"cannot configure a \"variable\" without also configuring a \"template\"",
+		)
+	}
+
+	// variable names cannot be duplicated
+	duplicateMap := make(map[string]bool)
+	for i, v := range variables {
+		name := v.Name.ValueString()
+		if duplicateMap[name] {
+			resp.Diagnostics.AddAttributeError(
+				path.Root("variable").AtListIndex(i).AtName("name"),
+				"Conflicting configuration arguments",
+				"cannot have more than one \"variable\" with the same \"name\"",
+			)
+		}
+		duplicateMap[name] = true
+	}
 }
 
 func (r *webhookRecipientResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
@@ -156,7 +250,7 @@ func (r *webhookRecipientResource) Create(ctx context.Context, req resource.Crea
 			WebhookName:     plan.Name.ValueString(),
 			WebhookURL:      plan.URL.ValueString(),
 			WebhookSecret:   plan.Secret.ValueString(),
-			WebhookPayloads: webhookTemplatesToClientPayloads(ctx, plan.Templates, &resp.Diagnostics),
+			WebhookPayloads: webhookTemplatesToClientPayloads(ctx, plan.Templates, plan.Variables, &resp.Diagnostics),
 		},
 	})
 	if helper.AddDiagnosticOnError(&resp.Diagnostics, "Creating Honeycomb Webhook Recipient", err) {
@@ -172,10 +266,16 @@ func (r *webhookRecipientResource) Create(ctx context.Context, req resource.Crea
 	} 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})
+
 	if rcpt.Details.WebhookPayloads != nil {
 		state.Templates = plan.Templates
-	} else {
-		state.Templates = types.SetNull(types.ObjectType{AttrTypes: models.WebhookTemplateAttrType})
+		if rcpt.Details.WebhookPayloads.TemplateVariables != nil {
+			state.Variables = plan.Variables
+		}
 	}
 
 	resp.Diagnostics.Append(resp.State.Set(ctx, state)...)
@@ -226,10 +326,12 @@ func (r *webhookRecipientResource) Read(ctx context.Context, req resource.ReadRe
 	} else {
 		state.Secret = types.StringNull()
 	}
+
 	if rcpt.Details.WebhookPayloads != nil {
-		state.Templates = clientPayloadsToWebhookTemplates(ctx, rcpt.Details.WebhookPayloads, &resp.Diagnostics)
+		state.Templates, state.Variables = clientPayloadsToWebhookTemplateSets(ctx, rcpt.Details.WebhookPayloads, &resp.Diagnostics)
 	} else {
 		state.Templates = types.SetNull(types.ObjectType{AttrTypes: models.WebhookTemplateAttrType})
+		state.Variables = types.SetNull(types.ObjectType{AttrTypes: models.TemplateVariableAttrType})
 	}
 
 	resp.Diagnostics.Append(resp.State.Set(ctx, state)...)
@@ -249,7 +351,7 @@ func (r *webhookRecipientResource) Update(ctx context.Context, req resource.Upda
 			WebhookName:     plan.Name.ValueString(),
 			WebhookURL:      plan.URL.ValueString(),
 			WebhookSecret:   plan.Secret.ValueString(),
-			WebhookPayloads: webhookTemplatesToClientPayloads(ctx, plan.Templates, &resp.Diagnostics),
+			WebhookPayloads: webhookTemplatesToClientPayloads(ctx, plan.Templates, plan.Variables, &resp.Diagnostics),
 		},
 	})
 	if helper.AddDiagnosticOnError(&resp.Diagnostics, "Updating Honeycomb Webhook Recipient", err) {
@@ -272,8 +374,14 @@ func (r *webhookRecipientResource) Update(ctx context.Context, req resource.Upda
 	}
 	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})
 	}
 
 	resp.Diagnostics.Append(resp.State.Set(ctx, state)...)
@@ -306,9 +414,15 @@ func (r *webhookRecipientResource) Delete(ctx context.Context, req resource.Dele
 	}
 }
 
-func webhookTemplatesToClientPayloads(ctx context.Context, set types.Set, diags *diag.Diagnostics) *client.WebhookPayloads {
+func webhookTemplatesToClientPayloads(ctx context.Context, templateSet types.Set, variableSet types.Set, diags *diag.Diagnostics) *client.WebhookPayloads {
 	var templates []models.WebhookTemplateModel
-	diags.Append(set.ElementsAs(ctx, &templates, false)...)
+	diags.Append(templateSet.ElementsAs(ctx, &templates, false)...)
+	if diags.HasError() {
+		return nil
+	}
+
+	var variables []models.TemplateVariableModel
+	diags.Append(variableSet.ElementsAs(ctx, &variables, false)...)
 	if diags.HasError() {
 		return nil
 	}
@@ -332,19 +446,37 @@ func webhookTemplatesToClientPayloads(ctx context.Context, set types.Set, diags
 		}
 	}
 
+	clientVars := make([]client.TemplateVariable, len(variables))
+	for i, v := range variables {
+		tmplVar := client.TemplateVariable{
+			Name:    v.Name.ValueString(),
+			Default: v.DefaultValue.ValueString(),
+		}
+
+		clientVars[i] = tmplVar
+	}
+	clientWebhookPayloads.TemplateVariables = clientVars
+
 	return clientWebhookPayloads
 }
 
-func clientPayloadsToWebhookTemplates(ctx context.Context, p *client.WebhookPayloads, diags *diag.Diagnostics) types.Set {
+func clientPayloadsToWebhookTemplateSets(ctx context.Context, p *client.WebhookPayloads, diags *diag.Diagnostics) (types.Set, types.Set) {
 	if p == nil {
-		return types.SetNull(types.ObjectType{AttrTypes: models.WebhookTemplateAttrType})
+		return types.SetNull(types.ObjectType{AttrTypes: models.WebhookTemplateAttrType}), types.SetNull(types.ObjectType{AttrTypes: models.TemplateVariableAttrType})
 	}
 
-	values := webhookTemplatesToObjectValues(p.PayloadTemplates, diags)
-	result, d := types.SetValueFrom(ctx, types.ObjectType{AttrTypes: models.WebhookTemplateAttrType}, values)
+	tmplValues := webhookTemplatesToObjectValues(p.PayloadTemplates, diags)
+	tmplResult, d := types.SetValueFrom(ctx, types.ObjectType{AttrTypes: models.WebhookTemplateAttrType}, tmplValues)
+	diags.Append(d...)
+
+	var tmplVarValues []attr.Value
+	for _, v := range p.TemplateVariables {
+		tmplVarValues = append(tmplVarValues, webhookVariableToObjectValue(v, diags))
+	}
+	varResult, d := types.SetValueFrom(ctx, types.ObjectType{AttrTypes: models.TemplateVariableAttrType}, tmplVarValues)
 	diags.Append(d...)
 
-	return result
+	return tmplResult, varResult
 }
 
 func webhookTemplatesToObjectValues(templates client.PayloadTemplates, diags *diag.Diagnostics) []basetypes.ObjectValue {
@@ -380,43 +512,13 @@ func webhookTemplatesToObjectValues(templates client.PayloadTemplates, diags *di
 	return templateObjs
 }
 
-func validateAttributesWhenTemplatesIncluded(ctx context.Context, data models.WebhookRecipientModel, resp *resource.ValidateConfigResponse) {
-	var templates []models.WebhookTemplateModel
-	data.Templates.ElementsAs(ctx, &templates, false)
-
-	triggerTmplExists := false
-	budgetRateTmplExists := false
-	exhaustionTimeTmplExists := false
-	for i, t := range templates {
-		switch t.Type {
-		case types.StringValue("trigger"):
-			if triggerTmplExists {
-				resp.Diagnostics.AddAttributeError(
-					path.Root("template").AtListIndex(i).AtName("type"),
-					"Conflicting configuration arguments",
-					"cannot have more than one \"template\" of type \"trigger\"",
-				)
-			}
-			triggerTmplExists = true
-		case types.StringValue("exhaustion_time"):
-			if exhaustionTimeTmplExists {
-				resp.Diagnostics.AddAttributeError(
-					path.Root("template").AtListIndex(i).AtName("type"),
-					"Conflicting configuration arguments",
-					"cannot have more than one \"template\" of type \"exhaustion_time\"",
-				)
-			}
-			exhaustionTimeTmplExists = true
-		case types.StringValue("budget_rate"):
-			if budgetRateTmplExists {
-				resp.Diagnostics.AddAttributeError(
-					path.Root("template").AtListIndex(i).AtName("type"),
-					"Conflicting configuration arguments",
-					"cannot have more than one \"template\" of type \"budget_rate\"",
-				)
-			}
-			budgetRateTmplExists = true
-		}
-
+func webhookVariableToObjectValue(v client.TemplateVariable, diags *diag.Diagnostics) basetypes.ObjectValue {
+	variableObj := map[string]attr.Value{
+		"name":          types.StringValue(v.Name),
+		"default_value": types.StringValue(v.Default),
 	}
+	varObjVal, d := types.ObjectValue(models.TemplateVariableAttrType, variableObj)
+	diags.Append(d...)
+
+	return varObjVal
 }


Test patch:
diff --git a/client/recipient_test.go b/client/recipient_test.go
index adde531..86a5feb 100644
--- a/client/recipient_test.go
+++ b/client/recipient_test.go
@@ -100,7 +100,9 @@ func TestRecipientsCustomWebhook(t *testing.T) {
 					WebhookURL:    test.RandomURL(),
 					WebhookSecret: "secret",
 					WebhookPayloads: &client.WebhookPayloads{
-						PayloadTemplates: client.PayloadTemplates{Trigger: &client.PayloadTemplate{Body: body}}},
+						PayloadTemplates:  client.PayloadTemplates{Trigger: &client.PayloadTemplate{Body: body}},
+						TemplateVariables: []client.TemplateVariable{{Name: "severity", Default: "warning"}},
+					},
 				},
 			},
 		},
@@ -127,6 +129,7 @@ func TestRecipientsCustomWebhook(t *testing.T) {
 			assert.Equal(t, tr.Details.WebhookURL, r.Details.WebhookURL)
 			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)
 		})
 	}
 }
diff --git a/internal/provider/webhook_recipient_resource_test.go b/internal/provider/webhook_recipient_resource_test.go
index b80bcb6..768cc5e 100644
--- a/internal/provider/webhook_recipient_resource_test.go
+++ b/internal/provider/webhook_recipient_resource_test.go
@@ -35,6 +35,7 @@ resource "honeycombio_webhook_recipient" "test" {
 						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "url", url),
 						resource.TestCheckNoResourceAttr("honeycombio_webhook_recipient.test", "secret"),
 						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "template.#", "0"),
+						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "variable.#", "0"),
 					),
 				},
 				{
@@ -90,6 +91,11 @@ resource "honeycombio_webhook_recipient" "test" {
   name = "%s"
 	url  = "%s"
 
+	variable {
+	  name = "severity"
+      default_value = "critical"
+	}
+
 	template {
 	  type   = "trigger"
       body = %s
@@ -102,6 +108,9 @@ resource "honeycombio_webhook_recipient" "test" {
 						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.TestCheckNoResourceAttr("honeycombio_webhook_recipient.test", "secret"),
 					),
 				},
@@ -113,6 +122,11 @@ resource "honeycombio_webhook_recipient" "test" {
 
 	secret = "so-secret"
 
+	variable {
+	  name = "severity"
+      default_value = "warning"
+	}
+
 	template {
 	  type   = "trigger"
       body = %s
@@ -126,6 +140,8 @@ resource "honeycombio_webhook_recipient" "test" {
 						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "secret", "so-secret"),
 						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"),
 					),
 				},
 				{
@@ -136,9 +152,15 @@ resource "honeycombio_webhook_recipient" "test" {
 		})
 	})
 
-	t.Run("custom webhook validations error when they should", func(t *testing.T) {
+	t.Run("custom webhook succeeds when a template 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),
@@ -151,6 +173,229 @@ resource "honeycombio_webhook_recipient" "test" {
   name = "%s"
 	url  = "%s"
 
+	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.TestCheckNoResourceAttr("honeycombio_webhook_recipient.test", "secret"),
+					),
+				},
+				{
+					Config: fmt.Sprintf(`
+resource "honeycombio_webhook_recipient" "test" {
+  name = "%s"
+	url  = "%s"
+
+	secret = "so-secret"
+}`, name, url),
+					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", "secret", "so-secret"),
+						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "template.#", "0"),
+					),
+				},
+				{
+					ResourceName: "honeycombio_webhook_recipient.test",
+					ImportState:  true,
+				},
+			},
+		})
+	})
+
+	t.Run("custom webhook succeeds when a variable 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"
+
+	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.TestCheckNoResourceAttr("honeycombio_webhook_recipient.test", "secret"),
+					),
+				},
+				{
+					Config: fmt.Sprintf(`
+resource "honeycombio_webhook_recipient" "test" {
+  name = "%s"
+	url  = "%s"
+
+	secret = "so-secret"
+
+	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", "secret", "so-secret"),
+						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.#", "0"),
+					),
+				},
+				{
+					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()
+		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"
+
+	variable {
+	  name = "variable1"
+	}
+
+	variable {
+	  name = "variable2"
+      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.#", "2"),
+						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "variable.0.name", "variable1"),
+						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "variable.0.default_value", ""),
+						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "variable.1.name", "variable2"),
+						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "variable.1.default_value", "critical"),
+						resource.TestCheckNoResourceAttr("honeycombio_webhook_recipient.test", "secret"),
+					),
+				},
+				{
+					Config: fmt.Sprintf(`
+resource "honeycombio_webhook_recipient" "test" {
+  name = "%s"
+	url  = "%s"
+
+	variable {
+	  name = "variable1"
+	}
+
+	variable {
+	  name = "variable2"
+	}
+
+	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.#", "2"),
+						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "variable.0.name", "variable1"),
+						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "variable.0.default_value", ""),
+						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "variable.1.name", "variable2"),
+						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "variable.1.default_value", ""),
+					),
+				},
+				{
+					ResourceName: "honeycombio_webhook_recipient.test",
+					ImportState:  true,
+				},
+			},
+		})
+	})
+}
+
+func TestAcc_WebhookRecipientResource_validateDuplicateTemplateType(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"
@@ -161,11 +406,160 @@ resource "honeycombio_webhook_recipient" "test" {
       body = "another body"
     }
 }`, name, url),
-					ExpectError: regexp.MustCompile(`cannot have more than one "template" of type "trigger"`),
+				ExpectError: regexp.MustCompile(`cannot have more than one "template" of type "trigger"`),
+			},
+		},
+	})
+}
+
+func TestAcc_WebhookRecipientResource_validateDuplicateVariableName(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"
+    }
+
+	variable {
+	  name   = "severity"
+      default_value = "critical"
+    }
+
+	variable {
+	  name   = "severity"
+      default_value = "warning"
+    }
+}`, name, url),
+				ExpectError: regexp.MustCompile(`cannot have more than one "variable" with the same "name"`),
+			},
+		},
+	})
+}
+
+func TestAcc_WebhookRecipientResource_validateVariableMustHaveTemplate(t *testing.T) {
+	name := test.RandomStringWithPrefix("test.", 20)
+	url := test.RandomURL()
+
+	t.Run("validation on create", func(t *testing.T) {
+		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"
+    }
+
+	variable {
+	  name   = "severity"
+      default_value = "critical"
+    }
+
+	variable {
+	  name   = "severity"
+      default_value = "warning"
+    }
+}`, name, url),
+					ExpectError: regexp.MustCompile(`cannot have more than one "variable" with the same "name"`),
+				},
+			},
+		})
+	})
+
+	t.Run("validation on update", func(t *testing.T) {
+		name := test.RandomStringWithPrefix("test.", 20)
+		url := test.RandomURL()
+		body := `<<EOT
+		{
+			"name": " {{ .Name }}",
+			"id": " {{ .ID }}",
+			"description": " {{ .Description }}",
+		}
+		EOT`
+
+		resource.Test(t, resource.TestCase{
+			PreCheck:                 testAccPreCheck(t),
+			ProtoV5ProviderFactories: testAccProtoV5MuxServerFactory,
+			Steps: []resource.TestStep{
+				{
+					Config: fmt.Sprintf(`
+		resource "honeycombio_webhook_recipient" "test" {
+		  name = "%s"
+			url  = "%s"
+
+			template {
+			  type = "trigger"
+			  body = %s
+			}
+
+			variable {
+			  name   = "severity"
+		      default_value = "critical"
+		    }
+		}`, 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"),
+					),
+				},
+				{
+					Config: fmt.Sprintf(`
+		resource "honeycombio_webhook_recipient" "test" {
+		  name = "%s"
+			url  = "%s"
+
+			variable {
+			  name   = "severity"
+		      default_value = "critical"
+		    }
+		}`, name, url),
+					Destroy:     true,
+					ExpectError: regexp.MustCompile(`cannot configure a "variable" without also configuring a "template"`),
+				},
+				// final successful configuration to prevent dangling resource error
+				{
+					Config: fmt.Sprintf(`
+		resource "honeycombio_webhook_recipient" "test" {
+		  name = "%s"
+			url  = "%s"
+		}`, name, url),
+					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),
+					),
 				},
 			},
 		})
 	})
+
 }
 
 // TestAcc_WebhookRecipientResource_UpgradeFromVersion027 tests the migration case from the