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
- You run transactional or marketing messaging (OTPs, order updates, win-back campaigns) and want every Pinpoint project to look identical across dev, staging, and prod.
- You need send-rate and budget guardrails baked in — Pinpoint’s
limitsblock (daily/total messages, messages-per-second) is your first line of defence against runaway cost or a misfired campaign. - You want engagement events exported to Kinesis (then Firehose → S3 / Redshift) for funnel analytics or compliance retention, without hand-building the IAM role each time.
- You manage multiple business units or tenants, each needing its own isolated Pinpoint application with consistent tagging for cost allocation.
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 config — live/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 config — live/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
- Cap send rate at the application, not just the campaign. Set
campaign_limits.dailyandmessages_per_secondconservatively per environment — Pinpoint charges per message, and an uncapped dev project that loops a segment can run up a real bill overnight. - Verify the SES identity before enabling the email channel.
enable_emailwill fail or silently disable ifemail_identity_arnpoints at an unverified domain; keep identity verification in its own apply stage and pass the ARN in. - Always wire the event stream in production. Pinpoint’s built-in analytics retain limited history; the Kinesis
aws_pinpoint_event_streamis the only way to keep raw delivery/engagement events for audit, attribution, and regulatory retention. - Use a distinct application per business unit or tenant, not one shared project. Endpoints, segments, and quotas are scoped to the application ID, so isolation here is what gives you clean cost allocation and blast-radius separation — the
name_prefix/environmentnaming makes that auditable. - Scope the event-stream IAM role to a single Kinesis ARN. The module grants only
kinesis:PutRecords/DescribeStreamon the supplied stream — never broaden it tokinesis:*orResource = "*", which would let a compromised Pinpoint config exfiltrate to any stream. - Tag for cost allocation from day one. Pinpoint spend (messages, dedicated short codes, phone numbers) is easy to lose in a bill; pass
CostCenter/Ownerthroughtagsso every channel and the app roll up to the owning team.