Amazon Pinpoint · Multi-Channel Notifications · SMS · Event Analytics · 2026-11-09 · Pinpoint arc

Amazon Pinpoint for MCP Servers: Multi-Channel Notifications, SMS Alerts, and Event Analytics — Complete Playbook

AliveMCP monitors thousands of MCP endpoints every 60 seconds. When a server goes down, we need to reach the right person on the right channel — sometimes email is enough, sometimes only an SMS wakes someone up at 2 AM, and always we need to know whether the alert was actually seen. Amazon Pinpoint is the AWS service that makes all three possible from a single SDK client and a unified endpoint store. An MCP server's notification layer faces a problem that pure SES or SNS does not fully solve: you need to track which users have opted in to which channels, deliver the same alert via email and SMS without duplicating address management, and collect engagement data (did the user acknowledge the alert?) for analytics and alert-fatigue detection. Pinpoint was designed for exactly this pattern. It introduces the endpoint model — a per-user, per-channel delivery record that centralizes opt-in state, channel type, and behavioral attributes — and wraps it in transactional message APIs for email, SMS, push, and voice. The operational complexity of Pinpoint is real: IAM permissions live under the mobiletargeting namespace (not pinpoint), US SMS requires a registered origination number with a carrier approval process that can take weeks, and silently checking the DeliveryStatus field in the response rather than relying on HTTP 200 is non-negotiable. This guide synthesizes four deep-dives — Amazon Pinpoint project setup, endpoint model, and channel architecture, Pinpoint transactional email with templates and bounce handling, Pinpoint SMS with origination numbers, opt-out, and two-way messaging, and Pinpoint event tracking with PutEvents, endpoint attributes, and Kinesis Firehose export — into a complete Pinpoint playbook for MCP server developers building reliable notification tools on AWS.

TL;DR

Part 1: Pinpoint project setup, endpoint model, and channel architecture

An Amazon Pinpoint application (also called a project — the console uses both terms) is the top-level container that scopes all endpoints, channels, events, and campaigns. The application has a UUID ApplicationId that is required on every subsequent API call. Applications are regional — Pinpoint is not available in every AWS region. The supported regions at time of writing include us-east-1, us-west-2, eu-west-1, eu-central-1, ap-southeast-1, ap-southeast-2, ap-northeast-1, and ca-central-1. The SDK throws UnrecognizedClientException (not a 403 AccessDeniedException) if you target an unsupported region — a confusing error for developers who assume all AWS services are globally available.

When to choose Pinpoint over SES or SNS

All three services can send email, and the naming overlaps enough to create real uncertainty. The decision turns on what you need beyond raw message delivery:

Requirement Best fit Reason
High-volume transactional email only SES Lower per-message cost; no endpoint overhead; direct SMTP + API; simpler IAM surface
Pub/sub fan-out to topic subscribers SNS Native topic subscriptions across email, SMS, SQS, Lambda, HTTP; no endpoint management needed
Multi-channel (email + SMS) to known users Pinpoint Single endpoint record per user; channel switching without re-registration; unified opt-out management
OTP / verification codes Pinpoint SendOTPMessageCommand + VerifyOTPMessageCommand manages code generation, delivery, expiry, and attempt counting natively
Engagement analytics per message Pinpoint Automatic event capture for opens, clicks, bounces, opt-outs; event stream to Kinesis for custom analysis

For MCP server notification tools, Pinpoint is the right choice when you need any two of: multi-channel delivery, per-user opt-in state management, or message engagement analytics. If you are only sending email triggered by tool calls and never need SMS or push, SES is simpler and cheaper.

Creating an application and enabling channels

Everything in Pinpoint is scoped to an application. Store the ApplicationId in an environment variable — it never changes for the lifetime of the project:

import {
  PinpointClient,
  CreateAppCommand,
  UpdateEmailChannelCommand,
  UpdateSmsChannelCommand,
} from '@aws-sdk/client-pinpoint';

const client = new PinpointClient({ region: process.env.AWS_REGION ?? 'us-east-1' });

// Create the application (idempotent by name within a region)
const { ApplicationResponse } = await client.send(
  new CreateAppCommand({ CreateApplicationRequest: { Name: 'mcp-alerts' } })
);
const appId = ApplicationResponse!.Id!;
// Store appId — it is required on every subsequent Pinpoint API call

// Enable the email channel
// Identity must be an SES ARN, not an email address
await client.send(new UpdateEmailChannelCommand({
  ApplicationId: appId,
  EmailChannelRequest: {
    Enabled: true,
    FromAddress: 'alerts@example.com',
    // SES identity ARN — must be in the same region as the Pinpoint application
    Identity: `arn:aws:ses:us-east-1:${process.env.AWS_ACCOUNT_ID}:identity/alerts@example.com`,
  },
}));

// Enable the SMS channel
await client.send(new UpdateSmsChannelCommand({
  ApplicationId: appId,
  SMSChannelRequest: {
    Enabled: true,
    // SenderId is ignored in the US; set OriginationNumber per-message instead
  },
}));

The endpoint model and deterministic IDs

An endpoint in Pinpoint represents one delivery destination for one user on one channel. A user monitored by an MCP server typically has two endpoints: email-{userId} and sms-{userId}. The User.UserId field ties them together so Pinpoint can aggregate analytics per user across channels.

Using deterministic endpoint IDs is not just a convention — it is what makes UpdateEndpointCommand idempotent. The same call with the same ID either creates or updates the endpoint. Random UUIDs on each call create duplicate endpoint records that accumulate in the endpoint store, skew analytics, and cause opt-out state to be tracked on a stale record while messages continue to arrive at a new record for the same address:

import { UpdateEndpointCommand } from '@aws-sdk/client-pinpoint';

async function registerEmailEndpoint(
  appId: string,
  userId: string,
  emailAddress: string
): Promise {
  // Deterministic ID: same user + channel always maps to the same endpoint record
  const endpointId = `email-${userId}`;

  await client.send(new UpdateEndpointCommand({
    ApplicationId: appId,
    EndpointId: endpointId,
    EndpointRequest: {
      ChannelType: 'EMAIL',
      Address: emailAddress,
      OptOut: 'NONE',  // 'NONE' = opted in; 'ALL' = opted out
      User: {
        UserId: userId,
        UserAttributes: {
          // All Pinpoint attribute values are string arrays
          Plan: ['team'],
          SignupDate: [new Date().toISOString()],
        },
      },
      Attributes: {
        NotificationTypes: ['downtime', 'schema-drift'],
        Environment: ['production'],
      },
    },
  }));
}

async function registerSmsEndpoint(
  appId: string,
  userId: string,
  phoneNumber: string  // E.164 format: +12125551234
): Promise {
  await client.send(new UpdateEndpointCommand({
    ApplicationId: appId,
    EndpointId: `sms-${userId}`,
    EndpointRequest: {
      ChannelType: 'SMS',
      Address: phoneNumber,
      OptOut: 'NONE',
      User: { UserId: userId },
    },
  }));
}

IAM permissions: mobiletargeting, not pinpoint

Pinpoint's IAM namespace is mobiletargeting — a legacy name from when Pinpoint was part of Amazon Mobile Analytics. This catches every developer who searches the IAM policy reference for "pinpoint" actions and finds nothing. The minimum policy for a notification MCP tool that sends transactional messages and manages endpoints:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "PinpointSendTransactional",
      "Effect": "Allow",
      "Action": [
        "mobiletargeting:SendMessages",
        "mobiletargeting:SendOTPMessage",
        "mobiletargeting:VerifyOTPMessage"
      ],
      "Resource": "arn:aws:mobiletargeting:REGION:ACCOUNT:apps/APP_ID"
    },
    {
      "Sid": "PinpointManageEndpoints",
      "Effect": "Allow",
      "Action": [
        "mobiletargeting:UpdateEndpoint",
        "mobiletargeting:GetEndpoint",
        "mobiletargeting:PutEvents"
      ],
      "Resource": "arn:aws:mobiletargeting:REGION:ACCOUNT:apps/APP_ID/endpoints/*"
    }
  ]
}

The app ARN format is arn:aws:mobiletargeting:REGION:ACCOUNT:apps/APP_ID — note the plural apps and the mobiletargeting service name. For channel setup tools (admin path only), add mobiletargeting:UpdateEmailChannel and mobiletargeting:UpdateSmsChannel scoped to the app ARN. Grant these only to your setup Lambda, not to the hot path that sends messages.

Part 2: Transactional email — SimpleEmail, templates, and bounce handling

Pinpoint email is a thin orchestration layer on top of SES. Before SendMessagesCommand can deliver email, three prerequisites must be met: a verified SES identity in the same region as the Pinpoint application, the email channel enabled with UpdateEmailChannelCommand, and SES out of sandbox mode for production sends (new accounts can only send to verified addresses while in sandbox). The underlying SES quota determines your sending throughput — not a Pinpoint-specific limit. The defaults after production access is granted are 50,000 messages per 24-hour period and 14 messages per second, with a Pinpoint API call rate limit of 25 TPS on SendMessagesCommand itself.

Inline sends with SimpleEmail

SimpleEmail is the fastest path for one-off tool calls that generate email content dynamically. No template needs to be pre-created — the subject, HTML body, and plain-text body are all included inline in the request:

import { SendMessagesCommand } from '@aws-sdk/client-pinpoint';

async function sendAlertEmail(
  appId: string,
  endpointId: string,
  subject: string,
  htmlBody: string,
  textBody: string
): Promise {
  const { MessageResponse } = await client.send(new SendMessagesCommand({
    ApplicationId: appId,
    MessageRequest: {
      Endpoints: { [endpointId]: {} },
      MessageConfiguration: {
        EmailMessage: {
          SimpleEmail: {
            Subject: { Charset: 'UTF-8', Data: subject },
            HtmlPart: { Charset: 'UTF-8', Data: htmlBody },
            TextPart: { Charset: 'UTF-8', Data: textBody },
          },
        },
      },
    },
  }));

  // HTTP 200 does NOT mean the email was delivered
  // Always read the per-endpoint delivery result
  const result = MessageResponse?.Result?.[endpointId];
  if (!result) throw new Error(`No delivery result for endpoint ${endpointId}`);

  switch (result.DeliveryStatus) {
    case 'SUCCESSFUL':
      return;  // SES accepted the message; delivery to inbox not guaranteed
    case 'OPT_OUT':
      throw new Error(`Endpoint ${endpointId} has opted out — do not retry`);
    case 'QUIET_TIME':
      throw new Error(`Email blocked by application quiet-time window`);
    case 'PERMANENT_FAILURE':
      throw new Error(`Permanent failure: ${result.StatusMessage}`);
    case 'TEMPORARY_FAILURE':
      // SES will retry for up to 72 hours; log but do not re-send
      console.warn(`Soft bounce for ${endpointId}: ${result.StatusMessage}`);
      return;
    default:
      throw new Error(`Unexpected status: ${result.DeliveryStatus}`);
  }
}

Template-based sends with Handlebars substitution

Pre-created templates keep email HTML consistent across sends and allow per-recipient variable substitution without passing full HTML in each API call. Pinpoint uses Handlebars-style {{variable_name}} syntax in template bodies; variables are resolved from the endpoint's attributes at send time. The resolution order is: (1) per-endpoint overrides in the Endpoints[id].Attributes map in the send request, (2) the endpoint's stored Attributes from UpdateEndpointCommand, (3) the endpoint's User.UserAttributes. If a variable has no matching value at any level, Pinpoint leaves the literal {{VariableName}} in the sent email without raising an error:

import { CreateEmailTemplateCommand, UpdateTemplateActiveVersionCommand } from '@aws-sdk/client-pinpoint';

// Create the template once (run from setup script, not on each tool call)
await client.send(new CreateEmailTemplateCommand({
  TemplateName: 'mcp-downtime-alert',
  EmailTemplateRequest: {
    Subject: 'Your MCP server {{ServerName}} is down',
    HtmlPart: `
      

Downtime Alert

Your MCP server {{ServerName}} at {{ServerUrl}} has been unreachable since {{DownSince}}.

Last error: {{LastError}}

View on AliveMCP dashboard

`, TextPart: `Downtime Alert Your MCP server {{ServerName}} at {{ServerUrl}} has been unreachable since {{DownSince}}. Last error: {{LastError}} Dashboard: {{DashboardUrl}}`, }, })); // CreateEmailTemplateCommand creates version 1 // UpdateEmailTemplateCommand creates new versions but does NOT auto-activate them // Always call UpdateTemplateActiveVersionCommand after each update: await client.send(new UpdateTemplateActiveVersionCommand({ TemplateName: 'mcp-downtime-alert', TemplateActiveVersionRequest: { Version: '2' }, })); // Send using the template with per-send variable overrides const { MessageResponse } = await client.send(new SendMessagesCommand({ ApplicationId: appId, MessageRequest: { Endpoints: { [endpointId]: { // Per-endpoint attributes override stored endpoint attributes for this send only Attributes: { DownSince: ['2026-11-09T02:34:00Z'], LastError: ['connection refused'], }, }, }, TemplateConfiguration: { EmailTemplate: { Name: 'mcp-downtime-alert', // Omit Version to use the active version }, }, // MessageConfiguration is omitted when using TemplateConfiguration }, }));

Bounce handling and the SES suppression list

SES reports bounces and complaints back to Pinpoint via an internal feedback loop. Pinpoint takes automatic action based on bounce type:

Event Pinpoint action What your code should do
Hard bounce (5xx permanent failure) Sets endpoint OptOut = ALL; adds address to SES account-level suppression list Mark user's email as invalid in your database; do not send until address is corrected and removed from SES suppression list
Soft bounce (4xx transient failure) Retries delivery for up to 72 hours; no OptOut change Log the TEMPORARY_FAILURE status; do not manually retry during the 72h window
Complaint (marked as spam) Sets endpoint OptOut = ALL for abuse/fraud complaint types Treat same as hard bounce; do not send until user explicitly re-subscribes
Unsubscribe (Pinpoint-managed link) Sets endpoint OptOut = ALL Respect the opt-out; remove from future notification lists

The critical interaction to understand: when Pinpoint processes a hard bounce, it adds the email address to SES's account-level suppression list. Even if you create a new endpoint with a corrected email address, if the original address is still on the suppression list, SES will reject the send with MessageRejected: Email address is on suppression list. To remove an address from the SES suppression list after confirming it is valid:

import { SESv2Client, DeleteSuppressedDestinationCommand } from '@aws-sdk/client-sesv2';

const ses = new SESv2Client({ region: 'us-east-1' });

// Check whether an address is on the suppression list before attempting a send
// to a previously-bounced endpoint
async function removeFromSuppression(emailAddress: string): Promise {
  await ses.send(new DeleteSuppressedDestinationCommand({
    EmailAddress: emailAddress,
  }));
  // After removal: update the endpoint's OptOut back to 'NONE' via UpdateEndpointCommand
}

Proactive opt-out checking before sending avoids wasting a SendMessagesCommand call on a suppressed endpoint:

import { GetEndpointCommand } from '@aws-sdk/client-pinpoint';

async function isOptedIn(appId: string, endpointId: string): Promise {
  try {
    const { EndpointResponse } = await client.send(new GetEndpointCommand({
      ApplicationId: appId,
      EndpointId: endpointId,
    }));
    return EndpointResponse?.OptOut === 'NONE';
  } catch (err: any) {
    if (err.name === 'NotFoundException') return false;
    throw err;
  }
}

Part 3: SMS for critical alerts — origination numbers, opt-out, and two-way messaging

SMS is the notification channel that gets read. For critical MCP server downtime alerts — a production endpoint unreachable, a tool returning malformed responses, an SSL certificate expiring — SMS reaches on-call engineers in a way that email often does not. The setup complexity is higher than email: US SMS requires a registered origination number, the carrier opt-out rules are legally enforced, and the character encoding rules can silently inflate your costs. Done right, SMS is the highest-reliability channel in your notification stack.

Origination number selection for US delivery

The origination number is the sender ID shown on the recipient's phone. Outside the US, you can often use an alphanumeric sender ID like AliveMCP. In the US, carriers require a registered phone number. Three types are available, each with different registration requirements and throughput:

Number type US requirements Throughput Provisioning time Best for
Toll-free number (+1800, +1888, etc.) Verification optional for under 500 messages/day; submit verification for higher volume (2–4 weeks) 3 msg/sec unverified; up to 100 msg/sec verified Immediate (number available instantly) Development and staging; early production with low volume
10DLC long code (10-digit US number) Register brand + campaign with The Campaign Registry (TCR) via Pinpoint console; $4–10/month per number ~75–300 msg/sec after registration; 1 msg/sec unregistered (carriers may also silently drop) 1–7 business days for TCR approval Production with moderate volume and local-looking number preference
Short code (5–6 digit number) Dedicated carrier application process through Pinpoint; use-case specific Highest — 100–500 msg/sec 8–12 weeks; $500–1,000/month High-volume production (OTP systems, mass alert infrastructure)

Start with a toll-free number during development — it is immediately available and there is no carrier registration process. The 500 messages per day unverified limit is sufficient for testing. When you are ready for production, submit the toll-free verification application and provision a 10DLC number in parallel so you have both options when volume grows.

Sending transactional SMS

Always use MessageType: 'TRANSACTIONAL' for alert tools. PROMOTIONAL messages face stricter carrier filtering and may be delayed or silently dropped. The OriginationNumber field in SMSMessage selects which provisioned number to use as the sender — required if you have multiple numbers associated with the application:

async function sendSmsAlert(
  appId: string,
  endpointId: string,
  messageBody: string,
  originationNumber: string  // E.164 format: +18005551234
): Promise {
  // Check encoding before sending to avoid unexpected segmentation costs
  const segments = countSmsSegments(messageBody);
  if (segments > 1) {
    console.warn(`SMS will be ${segments} segments (${messageBody.length} chars)`);
  }

  const { MessageResponse } = await client.send(new SendMessagesCommand({
    ApplicationId: appId,
    MessageRequest: {
      Endpoints: { [endpointId]: {} },
      MessageConfiguration: {
        SMSMessage: {
          Body: messageBody,
          MessageType: 'TRANSACTIONAL',  // critical: use TRANSACTIONAL for alerts
          OriginationNumber: originationNumber,
        },
      },
    },
  }));

  const result = MessageResponse?.Result?.[endpointId];
  if (result?.DeliveryStatus !== 'SUCCESSFUL') {
    throw new Error(`SMS failed: ${result?.DeliveryStatus} — ${result?.StatusMessage}`);
  }
}

Character encoding and segment budgeting

SMS carriers break messages longer than the single-part limit into multiple chargeable segments. The limit depends on encoding:

Encoding Triggered by Single-part limit Multi-part segment limit
GSM-7 (default) Standard Latin characters, digits, common punctuation 160 characters 153 characters per segment
UCS-2 Any non-GSM-7 character: emoji, curly quotes, em-dash, arrows (→), accented chars beyond basic Latin 70 characters 67 characters per segment

A single emoji in a 90-character message forces UCS-2 encoding and immediately splits that message into two segments. Build a segment counter into your message construction helper:

function requiresUcs2(text: string): boolean {
  // GSM-7 extended character set — anything outside this triggers UCS-2
  const gsm7 = /^[\x00-\x7F£¥èéùìòÇØøÅåΔΦΓΛΩΠΨΣΘΞÆæßɤ¡ÄÖÜäöüà§¿€]*$/;
  return !gsm7.test(text);
}

function countSmsSegments(body: string): number {
  if (requiresUcs2(body)) {
    return body.length <= 70 ? 1 : Math.ceil(body.length / 67);
  }
  return body.length <= 160 ? 1 : Math.ceil(body.length / 153);
}

// Template for alert messages — stays within GSM-7 single segment
function buildAlertSms(serverName: string, errorCode: string): string {
  // No emoji, no curly quotes, no em-dash — ASCII only
  const body = `AliveMCP: ${serverName} is DOWN (${errorCode}). Check https://alivemcp.com/status`;
  if (countSmsSegments(body) > 1) {
    // Truncate to single-segment length
    return body.slice(0, 157) + '...';
  }
  return body;
}

Opt-out enforcement and the STOP keyword

US carriers mandate that all SMS senders honor STOP opt-outs. When a recipient replies "STOP" to a message from your origination number, the carrier routes that reply to Pinpoint, which automatically sets the corresponding endpoint's OptOut to ALL and adds the phone number to the application's opt-out list. Any subsequent SendMessagesCommand targeting that endpoint returns DeliveryStatus: OPT_OUT. This is correct behavior — Pinpoint is protecting you from a carrier-enforced violation.

Pinpoint also maintains an application-level opt-out list. If a user opts out from one campaign, they are opted out of all messages from that application to that phone number. To create separate opt-out scopes (e.g., "critical alerts" vs "weekly newsletter"), use separate Pinpoint applications or manage opt-out state in your own database and check it before calling SendMessagesCommand. Only re-enable an endpoint's opt-in status if the user has explicitly requested to re-subscribe — never programmatically re-opt-in without a user action, as this violates carrier rules and can result in carrier penalties or number suspension.

Two-way SMS for confirmation flows

Two-way SMS lets your MCP server receive replies — useful for alert acknowledgment flows ("Reply YES to acknowledge this incident") or OTP verification. Pinpoint routes inbound messages to an SNS topic configured on the origination number. The inbound payload uses the Addresses map (targeting by phone number directly) rather than the Endpoints map, allowing you to reply to a user without looking up their endpoint ID:

// Two-way SMS inbound message payload (from Pinpoint via SNS)
interface PinpointSmsInbound {
  originationNumber: string;    // your Pinpoint number
  destinationNumber: string;    // the user's E.164 phone number
  messageBody: string;          // their reply text
  inboundMessageId: string;
  previousPublishedMessageId: string;
}

export async function handleSmsReply(event: { Records: Array<{ Sns: { Message: string } }> }) {
  for (const record of event.Records) {
    const payload: PinpointSmsInbound = JSON.parse(record.Sns.Message);
    const reply = payload.messageBody.trim().toUpperCase();

    if (reply === 'YES' || reply === 'CONFIRM') {
      await markAlertAcknowledged(payload.destinationNumber);
      // Reply using Addresses map — no endpoint ID needed for inbound-triggered replies
      await client.send(new SendMessagesCommand({
        ApplicationId: process.env.PINPOINT_APP_ID!,
        MessageRequest: {
          Addresses: {
            [payload.destinationNumber]: { ChannelType: 'SMS' },
          },
          MessageConfiguration: {
            SMSMessage: {
              Body: 'Alert acknowledged. No further notifications for this incident.',
              MessageType: 'TRANSACTIONAL',
              OriginationNumber: process.env.PINPOINT_ORIGINATION_NUMBER!,
            },
          },
        },
      }));
    }
  }
}

Part 4: Event tracking, Kinesis export, and analytics

Sending alerts is only half the notification loop. Knowing whether an alert was acknowledged, which servers generate the most alert fatigue, and what percentage of users respond within 15 minutes requires event data. Pinpoint's event tracking API records this data as structured events against endpoints, and the event stream pipeline exports it to S3 for Athena analysis. The gap that catches most teams: custom events from PutEventsCommand go to the event stream but do not appear in the Pinpoint analytics console's built-in charts. The console shows campaign and journey metrics only. For custom event analytics, the event stream is the authoritative source.

Custom event tracking with PutEvents

PutEventsCommand records events against one or more endpoints in a batch call. Each event has an EventType string (use dot-separated namespaces: alert.sent, alert.acknowledged, user.preferences.changed), an ISO 8601 timestamp, up to 40 string Attributes, and up to 40 numeric Metrics. The event ID in the Events map must be unique within the batch item (use crypto.randomUUID()):

import { PutEventsCommand, type Event, type EventsBatch } from '@aws-sdk/client-pinpoint';

async function recordEvent(
  appId: string,
  endpointId: string,
  eventType: string,
  attributes?: Record,
  metrics?: Record
): Promise {
  const event: Event = {
    EventType: eventType,
    Timestamp: new Date().toISOString(),
    Attributes: attributes,
    Metrics: metrics,
  };

  await client.send(new PutEventsCommand({
    ApplicationId: appId,
    EventsRequest: {
      BatchItem: {
        [endpointId]: {
          Events: { [crypto.randomUUID()]: event },
          // Endpoint sub-object updates endpoint attributes inline
          // Avoids a separate UpdateEndpointCommand round-trip
          Endpoint: {},
        },
      },
    },
  }));
}

// MCP tool handler: user acknowledges a downtime alert
export async function acknowledgeAlert(
  userId: string,
  serverId: string,
  alertId: string,
  timeToAckSeconds: number
): Promise {
  await recordEvent(
    process.env.PINPOINT_APP_ID!,
    `email-${userId}`,
    'alert.acknowledged',
    { AlertId: alertId, ServerId: serverId, AckMethod: 'mcp_tool' },
    { TimeToAckSeconds: timeToAckSeconds }
  );
}

Automatic delivery event types

Pinpoint captures delivery events for every message sent through its channels without additional instrumentation. These events appear in the event stream automatically:

Event type Channel What it means Operational note
_email.send EMAIL Pinpoint accepted the message and passed it to SES Does not confirm recipient delivery
_email.delivered EMAIL SES confirmed delivery to the recipient's mail server Requires email tracking pixel enabled; may not fire for plain-text
_email.opened EMAIL Recipient opened the email and loaded the tracking pixel Undercount due to image blocking; Gmail proxy inflates unique opens
_email.clicked EMAIL Recipient clicked a tracked link Requires Pinpoint link rewriting; plain HTML links are not tracked
_email.hardbounce EMAIL Permanent delivery failure (invalid address) Triggers automatic opt-out + SES suppression list entry
_SMS.SUCCESS SMS Carrier accepted the message Carrier acceptance ≠ handset delivery; actual delivery confirmation varies
_SMS.OPT_OUT SMS Recipient replied STOP Endpoint OptOut set to ALL automatically

Configuring the Kinesis Firehose event stream

The event stream exports every event (custom and automatic) to a Kinesis Firehose delivery stream in near-real-time. Firehose buffers and delivers GZIP-compressed NDJSON to S3 at events/YYYY/MM/DD/HH/. The default buffer is 5 MB or 300 seconds. The Pinpoint role must trust pinpoint.amazonaws.com and have firehose:PutRecord + firehose:PutRecordBatch permissions on the specific stream ARN:

import { PutEventStreamCommand } from '@aws-sdk/client-pinpoint';

// Configure the event stream (run once during initial setup)
await client.send(new PutEventStreamCommand({
  ApplicationId: appId,
  WriteEventStream: {
    DestinationStreamArn: 'arn:aws:firehose:REGION:ACCOUNT:deliverystream/pinpoint-events',
    // IAM role Pinpoint assumes to put records to Firehose
    // Trust policy: principal = pinpoint.amazonaws.com
    // Permissions: firehose:PutRecord + firehose:PutRecordBatch on the stream ARN
    RoleArn: 'arn:aws:iam::ACCOUNT:role/PinpointEventStreamRole',
  },
}));

// Verify the stream is configured
import { GetEventStreamCommand } from '@aws-sdk/client-pinpoint';
const { EventStream } = await client.send(new GetEventStreamCommand({ ApplicationId: appId }));
console.log('Event stream destination:', EventStream?.DestinationStreamArn);

Each event in the S3 export is one JSON object per line. The key fields for MCP server analytics are event_type, event_timestamp (epoch milliseconds), client.client_id (the endpoint ID), attributes (string map), and metrics (numeric map):

// Sample event stream record (one NDJSON line in the S3 file)
{
  "event_type": "alert.acknowledged",
  "event_timestamp": 1731124800000,
  "arrival_timestamp": 1731124802341,
  "event_version": "3.1",
  "application": { "app_id": "abc123def456", "sdk": {} },
  "client": { "client_id": "email-user-456" },
  "device": {},
  "session": {},
  "attributes": {
    "AlertId": "alert-789",
    "ServerId": "prod-mcp-01",
    "AckMethod": "mcp_tool"
  },
  "metrics": {
    "TimeToAckSeconds": 142
  },
  "awsAccountId": "123456789012"
}

Practical analytics queries for MCP notification tools

Four Athena queries that answer the questions notification tools most need answered — alert acknowledgment rates, response latency distribution, email engagement by severity, and opt-out trend by channel:

-- 1. Alert acknowledgment rate by server (last 7 days)
-- Lowest ack rates first — these are your alert fatigue candidates
SELECT
  attributes['ServerId'] AS server_id,
  COUNT_IF(event_type = 'alert.sent') AS alerts_sent,
  COUNT_IF(event_type = 'alert.acknowledged') AS alerts_acked,
  ROUND(
    100.0 * COUNT_IF(event_type = 'alert.acknowledged')
      / NULLIF(COUNT_IF(event_type = 'alert.sent'), 0),
    1
  ) AS ack_rate_pct
FROM pinpoint_events
WHERE event_timestamp > (UNIX_TIMESTAMP(NOW() - INTERVAL 7 DAY) * 1000)
  AND event_type IN ('alert.sent', 'alert.acknowledged')
GROUP BY attributes['ServerId']
ORDER BY ack_rate_pct ASC;

-- 2. Time-to-acknowledge distribution (p50, p90, p99)
SELECT
  APPROX_PERCENTILE(CAST(metrics['TimeToAckSeconds'] AS double), 0.5)  AS p50_seconds,
  APPROX_PERCENTILE(CAST(metrics['TimeToAckSeconds'] AS double), 0.9)  AS p90_seconds,
  APPROX_PERCENTILE(CAST(metrics['TimeToAckSeconds'] AS double), 0.99) AS p99_seconds
FROM pinpoint_events
WHERE event_type = 'alert.acknowledged'
  AND event_timestamp > (UNIX_TIMESTAMP(NOW() - INTERVAL 30 DAY) * 1000);

-- 3. Email open rate by alert severity
SELECT
  attributes['AlertSeverity'] AS severity,
  COUNT_IF(event_type = '_email.send') AS sent,
  COUNT_IF(event_type = '_email.opened') AS opened,
  ROUND(100.0 * COUNT_IF(event_type = '_email.opened')
    / NULLIF(COUNT_IF(event_type = '_email.send'), 0), 1) AS open_rate_pct
FROM pinpoint_events
WHERE event_timestamp > (UNIX_TIMESTAMP(NOW() - INTERVAL 30 DAY) * 1000)
GROUP BY attributes['AlertSeverity'];

-- 4. Weekly opt-out rate by channel
SELECT
  DATE_TRUNC('week', from_unixtime(event_timestamp / 1000)) AS week,
  CASE
    WHEN event_type = '_email.complaint' THEN 'email'
    WHEN event_type = '_SMS.OPT_OUT' THEN 'sms'
  END AS channel,
  COUNT(*) AS opt_outs
FROM pinpoint_events
WHERE event_type IN ('_email.complaint', '_SMS.OPT_OUT')
GROUP BY 1, 2
ORDER BY week DESC;

The column names in the Athena schema use underscore-separated names (event_type, event_timestamp) while the raw JSON uses a flatter structure. Use the Pinpoint-provided Athena DDL to create the external table rather than manually mapping field names — the DDL normalizes the nested JSON structure for you.

Attribute cardinality: endpoint attributes vs event attributes

Endpoint attributes (set via UpdateEndpointCommand or the Endpoint sub-object in PutEventsCommand) persist on the endpoint and drive dynamic segment membership. Event attributes (in Event.Attributes) are attached to individual event records and go to the event stream. The critical distinction: use low-cardinality values in endpoint attributes that you plan to use for segment filters. Plan names ("team", "enterprise"), notification preferences ("email", "sms"), and account status ("active", "suspended") are good endpoint attributes. Alert IDs, server names from an open-ended set, and ISO timestamps are good event attributes but terrible endpoint attributes — they make it impossible to create useful segment filters and inflate Pinpoint's attribute store with high-cardinality values that never get queried.

Production checklist

  1. Pinpoint application created and ApplicationId stored in environment — never hardcode the ID; retrieve it at startup from SSM Parameter Store or environment variable
  2. IAM permissions use mobiletargeting namespace — verify no policies use pinpoint: prefix (they will silently fail authorization)
  3. Endpoint IDs are deterministic — email-{userId} and sms-{userId} patterns, never random UUIDs per call
  4. All SendMessagesCommand calls read DeliveryStatus per endpoint — HTTP 200 is not delivery confirmation; handle OPT_OUT, QUIET_TIME, PERMANENT_FAILURE, TEMPORARY_FAILURE explicitly
  5. Email channel configured with SES identity ARN — not the email address string; must be in the same region as the Pinpoint application; SES out of sandbox for production
  6. SMS origination number provisioned and associated with the application — toll-free for development, 10DLC for production; PERMANENT_FAILURE: No origination number means this step was skipped
  7. SMS messages use only GSM-7 characters — no emoji, curly quotes, em-dashes, or arrows in alert templates; validate with segment counter before each send
  8. Opt-out check before sending to previously-bounced endpoints — call GetEndpointCommand and verify OptOut === 'NONE'; for email also check SES suppression list via GetSuppressedDestinationCommand
  9. Event stream configured with Kinesis Firehose — verify with GetEventStreamCommand; custom events do not appear in Pinpoint console analytics, only in the S3 event stream
  10. Pinpoint region is supported — verify by calling GetAppCommand before first deploy; UnrecognizedClientException means the region is not supported
  11. Template active versions explicitly managed — after each UpdateEmailTemplateCommand, call UpdateTemplateActiveVersionCommand; Pinpoint does not auto-activate new versions
  12. Two-way SMS SNS topic resource policy allows Pinpoint to publish — missing this policy causes inbound messages to silently fail without an error on the outbound send
  13. PutEvents batch efficiency — each call supports 100 events across multiple endpoints; collect events in-memory and flush in batches rather than one API call per event