DocsRecipes

Recipes

Email, push, and notification analytics

5 min readReviewed July 2026

Notification analytics connects three different questions:

Did the application request the message?
Did a provider deliver it?
Did the recipient take an attributable action?

Do not collapse those into a single “sent” event.

GraphJSON can analyze normalized application and provider facts. The messaging provider remains authoritative for transport status, while the application owns consent, audience selection, and product outcomes.

Model the lifecycle#

Common events:

notification_requested
notification_suppressed
provider_accepted
message_delivered
message_delivery_failed
message_bounced
message_open_recorded
message_link_clicked
message_unsubscribed
notification_converted

Not every channel supports every state. A provider acceptance is not proof of delivery, an email open is not proof a human read the message, and a click is not automatically a product conversion.

Define identity and grain#

ID Meaning
message_id One logical recipient-message
provider_message_id Provider’s transport identity
campaign_id A governed audience and message purpose
template_id Content template
delivery_attempt_id One provider attempt
user_id or account_id Permitted product identity

For a campaign sent to 10,000 recipients, use one message_id per recipient. Do not use only campaign_id; it cannot distinguish deliveries, retries, or conversions.

Avoid storing raw email addresses, phone numbers, push tokens, or complete message bodies in GraphJSON. Use stable internal IDs.

Emit the application decision#

{
  "event": "notification_requested",
  "event_id": "msg_91:requested",
  "message_id": "msg_91",
  "campaign_id": "onboarding-day-3",
  "template_id": "activation-help-v4",
  "channel": "email",
  "user_id": "usr_42",
  "account_id": "acct_7",
  "purpose": "product_education",
  "audience_version": 3,
  "consent_state": "allowed",
  "schema_version": 2
}

Emit after the application has selected the recipient, checked policy, and created durable delivery work. A button click in an admin tool is not one requested message.

When a message is suppressed:

{
  "event": "notification_suppressed",
  "message_id": "msg_91",
  "channel": "email",
  "reason_code": "unsubscribed",
  "policy_version": 7
}

Use controlled reasons such as unsubscribed, invalid_destination, frequency_cap, quiet_hours, account_ineligible, or test_recipient.

Normalize provider webhooks#

Provider callback:

{
  "event": "message_delivered",
  "event_id": "provider:evt_123",
  "provider": "sendgrid",
  "provider_event_id": "evt_123",
  "provider_message_id": "sg_91",
  "message_id": "msg_91",
  "campaign_id": "onboarding-day-3",
  "channel": "email",
  "user_id": "usr_42",
  "delivered_at": 1785081600,
  "schema_version": 2
}

Use a server-side adapter:

provider
  → your webhook endpoint
      ├── verify signature
      ├── reject replay outside policy
      ├── map provider message ID
      ├── allowlist fields
      ├── preserve provider event time
      ├── deduplicate business handling
      └── enqueue GraphJSON delivery

Follow Custom-source webhook adapters for verification, retry, and payload controls.

Deduplicate provider retries#

Providers can repeat the same webhook. Preserve the provider event ID as the analytical event_id.

If no stable callback ID exists, derive identity from documented immutable fields:

provider + provider_message_id + event_type + provider_event_time + attempt

Do not use arrival time; each retry would look new.

Some providers legitimately send several delivery attempts or clicks. Decide whether the metric grain is:

  • unique message
  • unique delivery attempt
  • first click per message
  • every click
  • unique recipient per campaign

Name the metric accordingly.

Treat opens as an estimate#

Email opens can be:

  • loaded by privacy proxies
  • prefetched by security scanners
  • blocked by clients
  • triggered without human attention
  • repeated many times

Push delivery receipts and SMS delivery states also vary by provider and platform.

Report:

open signal recorded

not:

recipient read the message

Prefer downstream product actions for meaningful engagement. If opens remain useful for directional diagnostics, document provider and client limitations on the dashboard.

Define click semantics#

Store a bounded link purpose:

{
  "event": "message_link_clicked",
  "message_id": "msg_91",
  "campaign_id": "onboarding-day-3",
  "link_id": "open_activation_guide",
  "channel": "email",
  "user_id": "usr_42"
}

Do not store a full destination URL. It can contain signed tokens, customer identifiers, or sensitive query parameters.

Security scanners can follow links automatically. Use provider signals, browser context, or a subsequent authenticated product action to distinguish a likely human outcome. Do not fingerprint the recipient.

Attribute conversion explicitly#

Choose:

  • eligible outcome event
  • identity key
  • attribution window
  • first or last eligible message
  • channel priority
  • control or holdout behavior
  • conversion event time

Example:

Activation conversion:
first report_published within 7 days after a delivered onboarding message,
where the user had not published before the message.

Delivery-based attribution and click-based attribution answer different questions. A delivered message may receive credit for organic behavior; a click-based model selects a more engaged population.

Do not label the result causal unless the campaign uses a valid randomized holdout and the analysis follows experiment requirements.

Calculate delivery metrics#

WITH message_state AS (
  SELECT
    JSONExtractString(json, 'message_id') AS message_id,
    argMax(JSONExtractString(json, 'campaign_id'), timestamp) AS campaign_id,
    max(JSONExtractString(json, 'event') = 'provider_accepted') AS accepted,
    max(JSONExtractString(json, 'event') = 'message_delivered') AS delivered,
    max(JSONExtractString(json, 'event') = 'message_bounced') AS bounced,
    max(JSONExtractString(json, 'event') = 'message_link_clicked') AS clicked
  FROM notification_events
  WHERE
    timestamp >= toUnixTimestamp(now() - INTERVAL 30 DAY)
    AND JSONExtractString(json, 'message_id') != ''
  GROUP BY message_id
)
SELECT
  campaign_id,
  count() AS messages,
  countIf(accepted) AS accepted,
  countIf(delivered) AS delivered,
  countIf(bounced) AS bounced,
  countIf(clicked) AS clicked,
  if(accepted = 0, NULL, delivered / accepted) AS delivery_rate,
  if(delivered = 0, NULL, clicked / delivered) AS click_per_delivered
FROM message_state
GROUP BY campaign_id
ORDER BY messages DESC

This selects whether each message ever reached a state. For time-to-deliver or attempt analysis, query lifecycle events at the attempt grain.

Measure latency and freshness#

Useful durations:

provider acceptance - request
delivery - provider acceptance
click - delivery
conversion - delivery or click

Preserve occurrence times from the application and provider. Webhooks can arrive late or out of order.

Do not infer that a missing delivery event means failure until the provider’s normal callback delay has elapsed. Use a settled window and monitor webhook freshness separately.

The application’s preference system is authoritative. Before requesting a message, evaluate:

  • channel consent
  • communication purpose
  • regional policy
  • quiet hours
  • frequency cap
  • account lifecycle
  • provider suppression state

Record the bounded decision state and policy version, not the complete preference profile.

An unsubscribe event should update the authoritative preference system idempotently before its analytical copy is delivered. Never use a GraphJSON query as the enforcement check for the next send.

Separate channels#

Channel Important caveat
Email Opens and clicks can be proxied or scanned
Push Permission, token validity, OS delivery, and display differ
SMS Provider delivery does not prove reading; regional rules matter
In-product Display opportunity and actual visibility can differ
Webhook Delivery is machine-to-machine; use attempt and terminal semantics

Do not compare raw click rates across channels as if the opportunity and measurement were equivalent.

Build the dashboard#

Recommended sections:

  1. requested, suppressed, accepted, and delivered messages
  2. delivery, bounce, and failure rates with denominators
  3. delivery latency and webhook freshness
  4. click and downstream product outcomes
  5. unsubscribe and complaint signals
  6. provider, channel, template, and campaign version
  7. repeated attempts and deduplication health
  8. reconciliation with provider exports

Keep recipient-level investigation behind server authorization. Do not put user IDs into a public or broadly shared dashboard.

Reconcile with providers#

For settled intervals, compare:

  • application message requests
  • provider accepted messages
  • provider delivery export
  • verified webhook events
  • stable message and provider event IDs
  • channel and terminal-state distributions

Provider dashboards can use a different date or status model. Align occurrence time, time zone, and terminal-state definition before diagnosing a difference.

Use Reconcile analytics with source systems for the full control process.

Launch checklist#

  • Requested, accepted, delivered, engaged, and converted are distinct.
  • Every recipient-message has a stable internal ID.
  • Provider callbacks are verified and replay-safe.
  • Addresses, phone numbers, tokens, URLs, and bodies are excluded.
  • Consent and suppression are enforced outside GraphJSON.
  • Open tracking is labeled as an imperfect signal.
  • Link purposes are bounded and scanner behavior is considered.
  • Attribution window, identity, and selection rule are explicit.
  • Channel metrics are not compared without measurement context.
  • Settled results reconcile against provider records.

Use Scheduled reports and recurring delivery when the message contains an analytical report. For invitation, exposure, response, and nonresponse semantics, see Survey, NPS, and feedback analytics.

Need a hand?

Tell us what you’re building and we’ll point you in the right direction.

Contact support