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.
Handle consent and preference changes#
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 |
|---|---|
| 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:
- requested, suppressed, accepted, and delivered messages
- delivery, bounce, and failure rates with denominators
- delivery latency and webhook freshness
- click and downstream product outcomes
- unsubscribe and complaint signals
- provider, channel, template, and campaign version
- repeated attempts and deduplication health
- 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.