Skip to content
iMessage APIs
Measurement8 min read

Building a messaging dashboard worth looking at

Most messaging dashboards show what is easy to count rather than what would change a decision. Six panels is enough.

A dashboard earns its place if someone changes their behaviour because of it. That is a high bar, and it rules out most of what providers put on theirs — cumulative message counts, all-time totals, engagement scores.

The six panels

PanelQuestion it answersWho looks at it
Delivered rate, last 24h and 30dIs the channel healthy right now?Whoever is on call
Sends by play, last 30dWhat are we actually sending?Whoever owns the programme
Outcome rate by playWhich plays pay?The owner
Reply volume and median response timeAre we keeping the promise?Whoever answers
Opt-outs, last 30d, by playAre we burning the list?The owner
Cost per outcomeIs this worth the line fee?The owner

The fifth panel is the one nobody builds and the one that prevents the expensive mistake. An opt-out rate rising on a specific play is the earliest honest signal that you are over-messaging, and it shows up long before the outcome rate falls.

The data model that makes this cheap

sql
create table messages (
  id             uuid primary key,
  provider_ref   text unique,            -- the provider's message handle
  contact_id     uuid not null,
  play           text not null,          -- 'appointment_reminder', 'review_request'
  line_id        text not null,          -- which phone line sent it
  sent_at        timestamptz not null,
  -- The natural key that makes re-sends impossible.
  idempotency_key text unique not null
);

create table message_events (
  id           bigserial primary key,
  message_id   uuid references messages(id),
  type         text not null,            -- sent|delivered|read|failed|replied
  reason       text,                     -- failure reason, when type = 'failed'
  occurred_at  timestamptz not null,
  -- At-least-once webhook delivery means this will collide. Let it.
  provider_event_id text unique
);

create index on message_events (message_id, type);
create index on messages (play, sent_at);

Two tables. `messages` is what you sent; `message_events` is what happened. Keeping them separate is what makes delivered rate a join rather than a guess.

sql
select
  m.play,
  count(*)                                             as sent,
  count(e.id)                                          as delivered,
  round(100.0 * count(e.id) / nullif(count(*), 0), 1)  as delivered_pct
from messages m
left join message_events e
  on e.message_id = m.id and e.type = 'delivered'
where m.sent_at > now() - interval '30 days'
group by m.play
order by delivered_pct asc;

Delivered rate by play. The left join is deliberate: a message with no delivered event counts against you, which is the honest reading.

The `play` column is the one that makes the dashboard useful

Without it every number is a site-wide average, and a site-wide average hides the one play that is causing your opt-outs. Tag every send with what it is, from the first message you ever send. Retrofitting it later means your history is unsegmentable.

What to leave off, deliberately

  • Cumulative totals. "142,000 messages sent" changes nobody's behaviour.
  • Open rate. Read receipts are opt-in, so the denominator is unknowable. Track it if you like; do not report it.
  • Provider uptime. It will be green while your line is throttled. Delivered rate is the real number.
  • Engagement scores. Composite metrics hide which input moved, which is the only thing you needed to know.

Where to build it

Wherever you already look. A dashboard in a tool nobody opens is worse than a weekly email, and a weekly email that someone actually reads beats both. If your provider's dashboard covers delivered rate and you add outcome rate in your own analytics, that is a complete solution — two places is fine if both get looked at.

Tag your links so outcomes are attributable at all. The UTM scheme that survives a year.

analyticsdashboardsoperations