IaC AWS

Terraform Module: AWS Pinpoint — a governed multi-channel messaging app in one block

Quick take — Provision an AWS Pinpoint application with SMS, email, and event-stream wiring through a single reusable Terraform module — consistent naming, channel toggles, and Kinesis export for analytics. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.

Quickstart (copy-paste)

Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):

provider "aws" {
  region = "us-east-1"
}

module "pinpoint" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-pinpoint?ref=v1.0.0"

  name_prefix = "..."  # Prefix for the application name (2-40 chars, lowercase/…
  environment = "..."  # Deployment environment; one of `dev`, `staging`, `prod`…
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

Amazon Pinpoint is AWS’s customer-engagement service. At its core sits a Pinpoint application (often called a “project”) — the container that holds your endpoints, segments, campaigns, journeys, and the per-channel configuration for SMS, email, push, and voice. Almost everything else in Pinpoint hangs off that application’s ID, so getting the app and its channels provisioned cleanly is the foundation for any messaging workload.

In the console it looks like a few clicks, but in practice a production Pinpoint app needs several distinct resources stitched together: the aws_pinpoint_app itself (with quota and campaign-limit settings), one resource per channel you intend to use (aws_pinpoint_sms_channel, aws_pinpoint_email_channel, and so on), and an aws_pinpoint_event_stream so engagement and delivery events flow into Kinesis for analytics and auditing. Doing that by hand per environment drifts fast — someone forgets to cap daily sends in dev, or wires email to an unverified identity in prod.

This module wraps all of that behind a small set of variables. You declare which channels you want and the limits you need; the module creates the application, conditionally enables each channel, attaches the IAM-backed event stream, and returns the application ID plus the channel attributes that downstream campaigns and journeys depend on.

When to use it

If you only ever send raw SMS with no segmentation, analytics, or campaign tooling, aws_sns or the End User Messaging (aws_pinpointsmsvoicev2_*) resources may be lighter. Reach for this module when you actually want the Pinpoint application object and its channel/event-stream ecosystem.

Module structure

terraform-module-aws-pinpoint/
├── versions.tf
├── main.tf
├── variables.tf
└── outputs.tf

versions.tf

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

main.tf

locals {
  app_name = "${var.name_prefix}-${var.environment}"

  # Event stream needs a role; only build IAM when streaming is enabled.
  create_event_stream = var.event_stream_kinesis_arn != null
}

# ---------------------------------------------------------------------------
# Pinpoint application (the "project")
# ---------------------------------------------------------------------------
resource "aws_pinpoint_app" "this" {
  name = local.app_name

  # Default per-campaign limits. Campaigns can override, but this caps blast radius.
  limits {
    daily               = var.campaign_limits.daily
    maximum_duration    = var.campaign_limits.maximum_duration
    messages_per_second = var.campaign_limits.messages_per_second
    total               = var.campaign_limits.total
  }

  # Quiet time + campaign hooks applied to every campaign in this project.
  campaign_hook {
    mode = var.campaign_hook_lambda_arn != null ? "FILTER" : "DELIVERY"
    lambda_function_name = var.campaign_hook_lambda_arn
  }

  tags = merge(
    var.tags,
    {
      Name        = local.app_name
      Environment = var.environment
      ManagedBy   = "terraform"
    }
  )
}

# ---------------------------------------------------------------------------
# SMS channel
# ---------------------------------------------------------------------------
resource "aws_pinpoint_sms_channel" "this" {
  count = var.enable_sms ? 1 : 0

  application_id = aws_pinpoint_app.this.application_id
  enabled        = true
  sender_id      = var.sms_sender_id
  short_code     = var.sms_short_code
}

# ---------------------------------------------------------------------------
# Email channel — identity must already be verified in SES
# ---------------------------------------------------------------------------
resource "aws_pinpoint_email_channel" "this" {
  count = var.enable_email ? 1 : 0

  application_id = aws_pinpoint_app.this.application_id
  enabled        = true
  from_address   = var.email_from_address
  identity       = var.email_identity_arn
  role_arn       = var.email_role_arn
}

# ---------------------------------------------------------------------------
# Event stream → Kinesis (delivery + engagement events for analytics/audit)
# ---------------------------------------------------------------------------
data "aws_iam_policy_document" "event_stream_assume" {
  count = local.create_event_stream ? 1 : 0

  statement {
    effect  = "Allow"
    actions = ["sts:AssumeRole"]

    principals {
      type        = "Service"
      identifiers = ["pinpoint.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "event_stream" {
  count = local.create_event_stream ? 1 : 0

  name               = "${local.app_name}-pinpoint-events"
  assume_role_policy = data.aws_iam_policy_document.event_stream_assume[0].json
  tags               = var.tags
}

data "aws_iam_policy_document" "event_stream" {
  count = local.create_event_stream ? 1 : 0

  statement {
    effect = "Allow"
    actions = [
      "kinesis:PutRecords",
      "kinesis:DescribeStream",
    ]
    resources = [var.event_stream_kinesis_arn]
  }
}

resource "aws_iam_role_policy" "event_stream" {
  count = local.create_event_stream ? 1 : 0

  name   = "${local.app_name}-pinpoint-events"
  role   = aws_iam_role.event_stream[0].id
  policy = data.aws_iam_policy_document.event_stream[0].json
}

resource "aws_pinpoint_event_stream" "this" {
  count = local.create_event_stream ? 1 : 0

  application_id         = aws_pinpoint_app.this.application_id
  destination_stream_arn = var.event_stream_kinesis_arn
  role_arn               = aws_iam_role.event_stream[0].arn

  depends_on = [aws_iam_role_policy.event_stream]
}

variables.tf

variable "name_prefix" {
  description = "Prefix for the Pinpoint application name, e.g. 'acme-engage'. Combined with environment."
  type        = string

  validation {
    condition     = can(regex("^[a-z0-9-]{2,40}$", var.name_prefix))
    error_message = "name_prefix must be 2-40 chars of lowercase letters, digits, or hyphens."
  }
}

variable "environment" {
  description = "Deployment environment, used in the app name and tags."
  type        = string

  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "environment must be one of: dev, staging, prod."
  }
}

variable "campaign_limits" {
  description = "Default per-campaign send limits for the application (caps blast radius and cost)."
  type = object({
    daily               = number
    maximum_duration    = number # seconds a campaign may run; min 60
    messages_per_second = number # 1-20000
    total               = number # max messages per endpoint per campaign
  })
  default = {
    daily               = 1000
    maximum_duration    = 600
    messages_per_second = 50
    total               = 10000
  }

  validation {
    condition = (
      var.campaign_limits.maximum_duration >= 60 &&
      var.campaign_limits.messages_per_second >= 1 &&
      var.campaign_limits.messages_per_second <= 20000
    )
    error_message = "maximum_duration must be >= 60 and messages_per_second between 1 and 20000."
  }
}

variable "campaign_hook_lambda_arn" {
  description = "Optional Lambda ARN invoked as a campaign hook (FILTER mode) to mutate or suppress endpoints."
  type        = string
  default     = null
}

# --- SMS channel ---------------------------------------------------------
variable "enable_sms" {
  description = "Create and enable the SMS channel for this application."
  type        = bool
  default     = false
}

variable "sms_sender_id" {
  description = "Alphanumeric sender ID for SMS (where supported by the destination country)."
  type        = string
  default     = null
}

variable "sms_short_code" {
  description = "Dedicated short code to send SMS from, if provisioned."
  type        = string
  default     = null
}

# --- Email channel -------------------------------------------------------
variable "enable_email" {
  description = "Create and enable the email channel. Requires a verified SES identity."
  type        = bool
  default     = false
}

variable "email_from_address" {
  description = "Verified From address for outbound email, e.g. 'no-reply@acme.io'."
  type        = string
  default     = null

  validation {
    condition     = var.email_from_address == null || can(regex("^[^@]+@[^@]+\\.[^@]+$", var.email_from_address))
    error_message = "email_from_address must be a valid email address."
  }
}

variable "email_identity_arn" {
  description = "ARN of the verified SES email/domain identity backing the email channel."
  type        = string
  default     = null
}

variable "email_role_arn" {
  description = "Optional IAM role ARN Pinpoint assumes to submit email events to Kinesis via SES."
  type        = string
  default     = null
}

# --- Event stream --------------------------------------------------------
variable "event_stream_kinesis_arn" {
  description = "ARN of a Kinesis stream/Firehose to receive Pinpoint events. Null disables event streaming."
  type        = string
  default     = null
}

variable "tags" {
  description = "Tags applied to all resources created by the module."
  type        = map(string)
  default     = {}
}

outputs.tf

output "application_id" {
  description = "The unique ID of the Pinpoint application (use for campaigns, segments, journeys, endpoints)."
  value       = aws_pinpoint_app.this.application_id
}

output "application_arn" {
  description = "The ARN of the Pinpoint application."
  value       = aws_pinpoint_app.this.arn
}

output "application_name" {
  description = "The resolved name of the Pinpoint application."
  value       = aws_pinpoint_app.this.name
}

output "sms_channel_enabled" {
  description = "Whether the SMS channel was created and enabled."
  value       = var.enable_sms
}

output "email_channel_from_address" {
  description = "The From address configured on the email channel, if enabled."
  value       = var.enable_email ? var.email_from_address : null
}

output "event_stream_role_arn" {
  description = "ARN of the IAM role Pinpoint uses to publish events to Kinesis, if event streaming is enabled."
  value       = local.create_event_stream ? aws_iam_role.event_stream[0].arn : null
}

How to use it

module "pinpoint" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-pinpoint?ref=v1.0.0"

  name_prefix = "acme-engage"
  environment = "prod"

  # Tighten the default blast radius for a production transactional project.
  campaign_limits = {
    daily               = 50000
    maximum_duration    = 3600
    messages_per_second = 100
    total               = 200000
  }

  # SMS for OTPs
  enable_sms     = true
  sms_sender_id  = "ACME"
  sms_short_code = "247365"

  # Email for receipts — identity already verified in SES
  enable_email       = true
  email_from_address = "no-reply@acme.io"
  email_identity_arn = aws_sesv2_email_identity.acme.arn
  email_role_arn     = aws_iam_role.pinpoint_email.arn

  # Stream all engagement/delivery events into Kinesis for the analytics lake
  event_stream_kinesis_arn = aws_kinesis_stream.pinpoint_events.arn

  tags = {
    CostCenter = "growth"
    Owner      = "messaging-platform"
  }
}

# Downstream: an EventBridge Scheduler target that triggers a daily journey run
# keyed off the application ID this module returns.
resource "aws_scheduler_schedule" "daily_winback" {
  name                = "acme-winback-daily"
  schedule_expression = "cron(0 9 * * ? *)"

  flexible_time_window {
    mode = "OFF"
  }

  target {
    arn      = "arn:aws:scheduler:::aws-sdk:pinpoint:sendMessages"
    role_arn = aws_iam_role.scheduler_pinpoint.arn

    input = jsonencode({
      ApplicationId = module.pinpoint.application_id
    })
  }
}

With Terragrunt

Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.

1. Root configlive/terragrunt.hcl (inherited by every module):

remote_state {
  backend = "s3"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    # ...s3 state bucket/container + key per path...
  }
}

2. Module configlive/prod/pinpoint/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-pinpoint?ref=v1.0.0"
}

inputs = {
  name_prefix = "..."
  environment = "..."
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/pinpoint && terragrunt apply        # this module
terragrunt run-all apply                      # every module under live/prod

Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.

Inputs

Name Type Default Required Description
name_prefix string Yes Prefix for the application name (2-40 chars, lowercase/digits/hyphens); combined with environment.
environment string Yes Deployment environment; one of dev, staging, prod. Used in app name and tags.
campaign_limits object {daily=1000, maximum_duration=600, messages_per_second=50, total=10000} No Default per-campaign send limits applied to the application.
campaign_hook_lambda_arn string null No Optional Lambda ARN run as a campaign hook (FILTER mode) to mutate or suppress endpoints.
enable_sms bool false No Create and enable the SMS channel.
sms_sender_id string null No Alphanumeric sender ID for SMS where supported.
sms_short_code string null No Dedicated short code to send SMS from.
enable_email bool false No Create and enable the email channel (needs a verified SES identity).
email_from_address string null No Verified From address for outbound email.
email_identity_arn string null No ARN of the verified SES identity backing the email channel.
email_role_arn string null No Optional IAM role Pinpoint assumes to emit email events.
event_stream_kinesis_arn string null No ARN of a Kinesis stream/Firehose for Pinpoint events; null disables streaming.
tags map(string) {} No Tags applied to all created resources.

Outputs

Name Description
application_id Unique ID of the Pinpoint application; used by campaigns, segments, journeys, and endpoints.
application_arn ARN of the Pinpoint application.
application_name Resolved name of the Pinpoint application.
sms_channel_enabled Whether the SMS channel was created and enabled.
email_channel_from_address The From address on the email channel, if enabled.
event_stream_role_arn ARN of the IAM role Pinpoint uses to publish events to Kinesis, if streaming is enabled.

Enterprise scenario

A fintech runs OTP delivery and statement-ready notifications through a single Pinpoint application per region. The platform team stamps out one module instance for prod in ap-south-1 and another in us-east-1, each with enable_sms = true capped at 100 messages-per-second and an event_stream_kinesis_arn pointed at a Firehose that lands every delivery and _SMS.SUCCESS/_SMS.FAILURE event in S3. The compliance team queries that S3 lake with Athena to prove OTP delivery SLAs to the regulator, and the campaign_limits block guarantees a buggy marketing journey can never starve the OTP send quota.

Best practices

TerraformAWSPinpointModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading