Repository: honeycombio/terraform-provider-honeycombio
Pull request #580 (2024-11-20 13:28:39)

feat: webhook payloads templates on honeycombio_webhook_recipient
## Short description of the changes

Expands the honeycombio_webhook_recipient resource to be aware of custom webhook templates. Follow-up PRs will include template variables and custom headers.


codecov-commenter: ## [Codecov](https://app.codecov.io/gh/honeycombio/terraform-provider-honeycombio/pull/580?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 `45.00000%` with `66 lines` in your changes missing coverage. Please review.
> Project coverage is 75.01%. Comparing base [(`5175a60`)](https://app.codecov.io/gh/honeycombio/terraform-provider-honeycombio/commit/5175a60f4ebb1050640178969c1933b93c68f4b7?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=honeycombio) to head [(`27352b0`)](https://app.codecov.io/gh/honeycombio/terraform-provider-honeycombio/commit/27352b0c8b1281e23fc128440a27107b42a38280?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=honeycombio).
> Report is 1 commits behind head on main.

| [Files with missing lines](https://app.codecov.io/gh/honeycombio/terraform-provider-honeycombio/pull/580?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/580?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=) | 45.00% | [57 Missing and 9 partials :warning: ](https://app.codecov.io/gh/honeycombio/terraform-provider-honeycombio/pull/580?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     #580      +/-   ##
==========================================
- Coverage   75.62%   75.01%   -0.61%     
==========================================
  Files          87       87              
  Lines        7277     7388     +111     
==========================================
+ Hits         5503     5542      +39     
- Misses       1423     1486      +63     
- Partials      351      360       +9     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/honeycombio/terraform-provider-honeycombio/pull/580?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).

----
🚨 Try these New Features:

- [Flaky Tests Detection](https://docs.codecov.com/docs/test-result-ingestion-beta) - Detect and resolve failed and flaky tests
samnottingham-hc: This looks great! What would be a way, if any, that I could test this?
brookesargent: > This looks great! What would be a way, if any, that I could test this?

I added a test and that test is an actual integration test. TBH I was using the integration tests and debugger as an easy way to see my resource create/destroy appropriately. There's a bit in CONTRIBUTING.md about building the provider locally and using that build to play around with and I was struggling to get it to work this morning but maybe we could both learn/re-learn how to do this from @jharley sometime before we release this feature set so we have more confidence prior to release
samnottingham-hc: > > This looks great! What would be a way, if any, that I could test this?
> 
> I added a test and that test is an actual integration test. TBH I was using the integration tests and debugger as an easy way to see my resource create/destroy appropriately. There's a bit in CONTRIBUTING.md about building the provider locally and using that build to play around with and I was struggling to get it to work this morning but maybe we could both learn/re-learn how to do this from @jharley sometime before we release this feature set so we have more confidence prior to release

Yup would love to get that lesson! I've been eager to get some work in the Terraform stuff but I know i'll be quiteeee rusty when I do 🤓 

Patch:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index cd9012f..7ef3bdb 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -28,7 +28,7 @@ Any new additions should be built with the Plugin Framework.
  * The Plugin SDK-based code is contained in the `honeycombio/` directory in the root of the repository.
  * The Plugin Framework-based code is contained in the `internal/provider` directory.
 
-Any PRs reimplmenting Plugin SDKv2 resources or datasources in the Plugin Framework with be enthusiastically accepted. 🙏
+Any PRs reimplementing Plugin SDKv2 resources or datasources in the Plugin Framework will be enthusiastically accepted. 🙏
 
 ### Preview document changes
 
diff --git a/client/recipient.go b/client/recipient.go
index 7d3a06c..27ee4f4 100644
--- a/client/recipient.go
+++ b/client/recipient.go
@@ -66,12 +66,28 @@ type RecipientDetails struct {
 	WebhookURL  string `json:"webhook_url,omitempty"`
 	// webhook only
 	WebhookSecret string `json:"webhook_secret,omitempty"`
+	// custom webhook
+	WebhookPayloads *WebhookPayloads `json:"webhook_payloads,omitempty"`
 }
 
 type NotificationRecipientDetails struct {
 	PDSeverity PagerDutySeverity `json:"pagerduty_severity,omitempty"`
 }
 
+type WebhookPayloads struct {
+	PayloadTemplates PayloadTemplates `json:"payload_templates"`
+}
+
+type PayloadTemplates struct {
+	Trigger        *PayloadTemplate `json:"trigger,omitempty"`
+	ExhaustionTime *PayloadTemplate `json:"exhaustion_time,omitempty"`
+	BudgetRate     *PayloadTemplate `json:"budget_rate,omitempty"`
+}
+
+type PayloadTemplate struct {
+	Body string `json:"body"`
+}
+
 // 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 260c183..5030c92 100644
--- a/docs/resources/webhook_recipient.md
+++ b/docs/resources/webhook_recipient.md
@@ -2,7 +2,7 @@
 
 `honeycombio_webhook_recipient` allows you to define and manage a Webhook recipient that can be used by Triggers or BurnAlerts notifications.
 
-## Example Usage
+## Standard Webhook Example
 
 ```hcl
 resource "honeycombio_webhook_recipient" "prod" {
@@ -12,6 +12,31 @@ resource "honeycombio_webhook_recipient" "prod" {
 }
 ```
 
+## Custom Webhook Example
+
+```hcl
+resource "honeycombio_webhook_recipient" "prod" {
+  name   = "Production Alerts"
+  secret = "a63dab148496ecbe04a1a802ca9b95b8"
+  url    = "https://my.url.corp.net"
+
+  template {
+    type = "trigger"
+    body = <<EOT
+		{
+			"name": " {{ .Name }}",
+			"id": " {{ .ID }}",
+			"description": " {{ .Description }}",
+            "threshold": {
+              "op": "{{ .Operator }}",
+              "value": "{{ .Threshold }}"
+            },
+		}
+		EOT
+  }
+}
+```
+
 ## Argument Reference
 
 The following arguments are supported:
@@ -19,6 +44,13 @@ The following arguments are supported:
 * `name` - (Required) The name of the Webhook Integration to create.
 * `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.
+
+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.
+
 
 ## Attribute Reference
 
diff --git a/internal/models/recipients.go b/internal/models/recipients.go
index c98586e..bef6859 100644
--- a/internal/models/recipients.go
+++ b/internal/models/recipients.go
@@ -1,12 +1,24 @@
 package models
 
 import (
+	"github.com/hashicorp/terraform-plugin-framework/attr"
 	"github.com/hashicorp/terraform-plugin-framework/types"
 )
 
 type WebhookRecipientModel struct {
-	ID     types.String `tfsdk:"id"`
-	Name   types.String `tfsdk:"name"`
-	Secret types.String `tfsdk:"secret"`
-	URL    types.String `tfsdk:"url"`
+	ID        types.String `tfsdk:"id"`
+	Name      types.String `tfsdk:"name"`
+	Secret    types.String `tfsdk:"secret"`
+	URL       types.String `tfsdk:"url"`
+	Templates types.Set    `tfsdk:"template"` // WebhookTemplateModel
+}
+
+type WebhookTemplateModel struct {
+	Type types.String `tfsdk:"type"`
+	Body types.String `tfsdk:"body"`
+}
+
+var WebhookTemplateAttrType = map[string]attr.Type{
+	"type": types.StringType,
+	"body": types.StringType,
 }
diff --git a/internal/provider/webhook_recipient_resource.go b/internal/provider/webhook_recipient_resource.go
index 1066f7c..895fd7a 100644
--- a/internal/provider/webhook_recipient_resource.go
+++ b/internal/provider/webhook_recipient_resource.go
@@ -5,12 +5,16 @@ import (
 	"errors"
 
 	"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
+	"github.com/hashicorp/terraform-plugin-framework/attr"
+	"github.com/hashicorp/terraform-plugin-framework/diag"
+	"github.com/hashicorp/terraform-plugin-framework/path"
 	"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/stringplanmodifier"
 	"github.com/hashicorp/terraform-plugin-framework/schema/validator"
 	"github.com/hashicorp/terraform-plugin-framework/types"
+	"github.com/hashicorp/terraform-plugin-framework/types/basetypes"
 
 	"github.com/honeycombio/terraform-provider-honeycombio/client"
 	"github.com/honeycombio/terraform-provider-honeycombio/internal/helper"
@@ -20,9 +24,12 @@ import (
 
 // Ensure the implementation satisfies the expected interfaces.
 var (
-	_ resource.Resource                = &webhookRecipientResource{}
-	_ resource.ResourceWithConfigure   = &webhookRecipientResource{}
-	_ resource.ResourceWithImportState = &webhookRecipientResource{}
+	_ resource.Resource                   = &webhookRecipientResource{}
+	_ resource.ResourceWithConfigure      = &webhookRecipientResource{}
+	_ resource.ResourceWithImportState    = &webhookRecipientResource{}
+	_ resource.ResourceWithValidateConfig = &webhookRecipientResource{}
+
+	webhookTemplateTypes = []string{"trigger", "exhaustion_time", "budget_rate"}
 )
 
 type webhookRecipientResource struct {
@@ -88,6 +95,26 @@ func (*webhookRecipientResource) Schema(_ context.Context, _ resource.SchemaRequ
 				},
 			},
 		},
+		Blocks: map[string]schema.Block{
+			"template": schema.SetNestedBlock{
+				Description: "Template for custom webhook payloads",
+				NestedObject: schema.NestedBlockObject{
+					Attributes: map[string]schema.Attribute{
+						"type": schema.StringAttribute{
+							Required:    true,
+							Description: "The type of the webhook template",
+							Validators: []validator.String{
+								stringvalidator.OneOf(webhookTemplateTypes...),
+							},
+						},
+						"body": schema.StringAttribute{
+							Required:    true,
+							Description: "JSON formatted string of the webhook payload",
+						},
+					},
+				},
+			},
+		},
 	}
 }
 
@@ -98,10 +125,24 @@ func (r *webhookRecipientResource) ImportState(ctx context.Context, req resource
 	}
 
 	resp.Diagnostics.Append(resp.State.Set(ctx, &models.WebhookRecipientModel{
-		ID: types.StringValue(req.ID),
+		ID:        types.StringValue(req.ID),
+		Templates: types.SetUnknown(types.ObjectType{AttrTypes: models.WebhookTemplateAttrType}),
 	})...)
 }
 
+func (r *webhookRecipientResource) ValidateConfig(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) {
+	var data models.WebhookRecipientModel
+
+	resp.Diagnostics.Append(req.Config.Get(ctx, &data)...)
+
+	if resp.Diagnostics.HasError() {
+		return
+	}
+
+	// only allow one template of each type (trigger, budget_rate, exhaustion_time)
+	validateAttributesWhenTemplatesIncluded(ctx, data, resp)
+}
+
 func (r *webhookRecipientResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
 	var plan models.WebhookRecipientModel
 	resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
@@ -112,9 +153,10 @@ func (r *webhookRecipientResource) Create(ctx context.Context, req resource.Crea
 	rcpt, err := r.client.Recipients.Create(ctx, &client.Recipient{
 		Type: client.RecipientTypeWebhook,
 		Details: client.RecipientDetails{
-			WebhookName:   plan.Name.ValueString(),
-			WebhookURL:    plan.URL.ValueString(),
-			WebhookSecret: plan.Secret.ValueString(),
+			WebhookName:     plan.Name.ValueString(),
+			WebhookURL:      plan.URL.ValueString(),
+			WebhookSecret:   plan.Secret.ValueString(),
+			WebhookPayloads: webhookTemplatesToClientPayloads(ctx, plan.Templates, &resp.Diagnostics),
 		},
 	})
 	if helper.AddDiagnosticOnError(&resp.Diagnostics, "Creating Honeycomb Webhook Recipient", err) {
@@ -130,6 +172,11 @@ func (r *webhookRecipientResource) Create(ctx context.Context, req resource.Crea
 	} else {
 		state.Secret = types.StringNull()
 	}
+	if rcpt.Details.WebhookPayloads != nil {
+		state.Templates = plan.Templates
+	} else {
+		state.Templates = types.SetNull(types.ObjectType{AttrTypes: models.WebhookTemplateAttrType})
+	}
 
 	resp.Diagnostics.Append(resp.State.Set(ctx, state)...)
 }
@@ -179,6 +226,11 @@ 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)
+	} else {
+		state.Templates = types.SetNull(types.ObjectType{AttrTypes: models.WebhookTemplateAttrType})
+	}
 
 	resp.Diagnostics.Append(resp.State.Set(ctx, state)...)
 }
@@ -194,9 +246,10 @@ func (r *webhookRecipientResource) Update(ctx context.Context, req resource.Upda
 		ID:   plan.ID.ValueString(),
 		Type: client.RecipientTypeWebhook,
 		Details: client.RecipientDetails{
-			WebhookName:   plan.Name.ValueString(),
-			WebhookURL:    plan.URL.ValueString(),
-			WebhookSecret: plan.Secret.ValueString(),
+			WebhookName:     plan.Name.ValueString(),
+			WebhookURL:      plan.URL.ValueString(),
+			WebhookSecret:   plan.Secret.ValueString(),
+			WebhookPayloads: webhookTemplatesToClientPayloads(ctx, plan.Templates, &resp.Diagnostics),
 		},
 	})
 	if helper.AddDiagnosticOnError(&resp.Diagnostics, "Updating Honeycomb Webhook Recipient", err) {
@@ -204,7 +257,7 @@ func (r *webhookRecipientResource) Update(ctx context.Context, req resource.Upda
 	}
 
 	rcpt, err := r.client.Recipients.Get(ctx, plan.ID.ValueString())
-	if helper.AddDiagnosticOnError(&resp.Diagnostics, "Updating Honeycomb Burn Alert", err) {
+	if helper.AddDiagnosticOnError(&resp.Diagnostics, "Updating Honeycomb Webhook Recipient", err) {
 		return
 	}
 
@@ -217,6 +270,11 @@ func (r *webhookRecipientResource) Update(ctx context.Context, req resource.Upda
 	} else {
 		state.Secret = types.StringNull()
 	}
+	if rcpt.Details.WebhookPayloads != nil {
+		state.Templates = plan.Templates
+	} else {
+		state.Templates = types.SetNull(types.ObjectType{AttrTypes: models.WebhookTemplateAttrType})
+	}
 
 	resp.Diagnostics.Append(resp.State.Set(ctx, state)...)
 }
@@ -247,3 +305,118 @@ func (r *webhookRecipientResource) Delete(ctx context.Context, req resource.Dele
 		}
 	}
 }
+
+func webhookTemplatesToClientPayloads(ctx context.Context, set types.Set, diags *diag.Diagnostics) *client.WebhookPayloads {
+	var templates []models.WebhookTemplateModel
+	diags.Append(set.ElementsAs(ctx, &templates, false)...)
+	if diags.HasError() {
+		return nil
+	}
+
+	clientWebhookPayloads := &client.WebhookPayloads{}
+
+	for _, t := range templates {
+		switch t.Type {
+		case types.StringValue("trigger"):
+			clientWebhookPayloads.PayloadTemplates.Trigger = &client.PayloadTemplate{
+				Body: t.Body.ValueString(),
+			}
+		case types.StringValue("exhaustion_time"):
+			clientWebhookPayloads.PayloadTemplates.ExhaustionTime = &client.PayloadTemplate{
+				Body: t.Body.ValueString(),
+			}
+		case types.StringValue("budget_rate"):
+			clientWebhookPayloads.PayloadTemplates.BudgetRate = &client.PayloadTemplate{
+				Body: t.Body.ValueString(),
+			}
+		}
+	}
+
+	return clientWebhookPayloads
+}
+
+func clientPayloadsToWebhookTemplates(ctx context.Context, p *client.WebhookPayloads, diags *diag.Diagnostics) types.Set {
+	if p == nil {
+		return types.SetNull(types.ObjectType{AttrTypes: models.WebhookTemplateAttrType})
+	}
+
+	values := webhookTemplatesToObjectValues(p.PayloadTemplates, diags)
+	result, d := types.SetValueFrom(ctx, types.ObjectType{AttrTypes: models.WebhookTemplateAttrType}, values)
+	diags.Append(d...)
+
+	return result
+}
+
+func webhookTemplatesToObjectValues(templates client.PayloadTemplates, diags *diag.Diagnostics) []basetypes.ObjectValue {
+	var templateObjs []basetypes.ObjectValue
+
+	if templates.Trigger != nil {
+		templateObjVal, d := types.ObjectValue(models.WebhookTemplateAttrType, map[string]attr.Value{
+			"type": types.StringValue("trigger"),
+			"body": types.StringValue(templates.Trigger.Body),
+		})
+		templateObjs = append(templateObjs, templateObjVal)
+		diags.Append(d...)
+	}
+
+	if templates.BudgetRate != nil {
+		templateObjVal, d := types.ObjectValue(models.WebhookTemplateAttrType, map[string]attr.Value{
+			"type": types.StringValue("budget_rate"),
+			"body": types.StringValue(templates.BudgetRate.Body),
+		})
+		templateObjs = append(templateObjs, templateObjVal)
+		diags.Append(d...)
+	}
+
+	if templates.ExhaustionTime != nil {
+		templateObjVal, d := types.ObjectValue(models.WebhookTemplateAttrType, map[string]attr.Value{
+			"type": types.StringValue("exhaustion_time"),
+			"body": types.StringValue(templates.ExhaustionTime.Body),
+		})
+		templateObjs = append(templateObjs, templateObjVal)
+		diags.Append(d...)
+	}
+
+	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
+		}
+
+	}
+}


Test patch:
diff --git a/client/recipient_test.go b/client/recipient_test.go
index cce341e..adde531 100644
--- a/client/recipient_test.go
+++ b/client/recipient_test.go
@@ -80,6 +80,57 @@ func TestRecipientsEmail(t *testing.T) {
 	})
 }
 
+func TestRecipientsCustomWebhook(t *testing.T) {
+	t.Parallel()
+
+	ctx := context.Background()
+	c := newTestClient(t)
+
+	body := `{"hello": "world"}`
+
+	testCases := []struct {
+		rcpt      client.Recipient
+		expectErr bool
+	}{
+		{
+			rcpt: client.Recipient{
+				Type: client.RecipientTypeWebhook,
+				Details: client.RecipientDetails{
+					WebhookName:   test.RandomStringWithPrefix("test.", 10),
+					WebhookURL:    test.RandomURL(),
+					WebhookSecret: "secret",
+					WebhookPayloads: &client.WebhookPayloads{
+						PayloadTemplates: client.PayloadTemplates{Trigger: &client.PayloadTemplate{Body: body}}},
+				},
+			},
+		},
+	}
+
+	for _, tc := range testCases {
+		tr := tc.rcpt
+		t.Run(tr.Type.String(), func(t *testing.T) {
+			r, err := c.Recipients.Create(ctx, &tr)
+			t.Cleanup(func() {
+				_ = c.Recipients.Delete(ctx, r.ID)
+			})
+
+			if tc.expectErr {
+				require.Error(t, err, "expected error creating %s recipient", tr.Type)
+				return
+			}
+			require.NoError(t, err, "failed to create %s recipient", tr.Type)
+			r, err = c.Recipients.Get(ctx, r.ID)
+			require.NoError(t, err)
+
+			assert.Equal(t, tr.Type, r.Type)
+			assert.Equal(t, tr.Details.WebhookName, r.Details.WebhookName)
+			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)
+		})
+	}
+}
+
 func TestRecipientsWebhooksandMSTeams(t *testing.T) {
 	t.Parallel()
 
diff --git a/internal/provider/webhook_recipient_resource_test.go b/internal/provider/webhook_recipient_resource_test.go
index 538e0bf..8677880 100644
--- a/internal/provider/webhook_recipient_resource_test.go
+++ b/internal/provider/webhook_recipient_resource_test.go
@@ -3,6 +3,7 @@ package provider
 import (
 	"context"
 	"fmt"
+	"regexp"
 	"testing"
 
 	"github.com/hashicorp/terraform-plugin-testing/helper/resource"
@@ -12,13 +13,14 @@ import (
 )
 
 func TestAcc_WebhookRecipientResource(t *testing.T) {
-	t.Run("happy path", func(t *testing.T) {
+	t.Run("happy path standard webhook", func(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(`
@@ -32,6 +34,7 @@ resource "honeycombio_webhook_recipient" "test" {
 						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "name", name),
 						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "url", url),
 						resource.TestCheckNoResourceAttr("honeycombio_webhook_recipient.test", "secret"),
+						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "template.#", "0"),
 					),
 				},
 				{
@@ -48,6 +51,7 @@ 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", "template.#", "0"),
 					),
 				},
 				{
@@ -57,6 +61,105 @@ resource "honeycombio_webhook_recipient" "test" {
 			},
 		})
 	})
+
+	t.Run("happy path custom webhook", 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,
+			CheckDestroy:             testAccEnsureRecipientDestroyed(t),
+			Steps: []resource.TestStep{
+				{
+					Config: fmt.Sprintf(`
+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"
+
+	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"),
+					),
+				},
+				{
+					ResourceName: "honeycombio_webhook_recipient.test",
+					ImportState:  true,
+				},
+			},
+		})
+	})
+
+	t.Run("custom webhook validations error when they should", func(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"
+    }
+
+	template {
+	  type   = "trigger"
+      body = "another body"
+    }
+}`, name, url),
+					ExpectError: regexp.MustCompile(`cannot have more than one "template" of type "trigger"`),
+				},
+			},
+		})
+	})
 }
 
 // TestAcc_WebhookRecipientResource_UpgradeFromVersion027 tests the migration case from the
@@ -118,3 +221,25 @@ func testAccEnsureRecipientExists(t *testing.T, name string) resource.TestCheckF
 		return nil
 	}
 }
+
+func testAccEnsureRecipientDestroyed(t *testing.T) resource.TestCheckFunc {
+	return func(s *terraform.State) error {
+		for _, resourceState := range s.RootModule().Resources {
+			if resourceState.Type != "honeycombio_webhook_recipient" {
+				continue
+			}
+
+			if resourceState.Primary.ID == "" {
+				return fmt.Errorf("no ID set for recipient")
+			}
+
+			client := testAccClient(t)
+			_, err := client.Recipients.Get(context.Background(), resourceState.Primary.ID)
+			if err == nil {
+				return fmt.Errorf("recipient %s was not deleted on destroy", resourceState.Primary.ID)
+			}
+		}
+
+		return nil
+	}
+}