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
| Panel | Question it answers | Who looks at it |
|---|---|---|
| Delivered rate, last 24h and 30d | Is the channel healthy right now? | Whoever is on call |
| Sends by play, last 30d | What are we actually sending? | Whoever owns the programme |
| Outcome rate by play | Which plays pay? | The owner |
| Reply volume and median response time | Are we keeping the promise? | Whoever answers |
| Opt-outs, last 30d, by play | Are we burning the list? | The owner |
| Cost per outcome | Is 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
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.
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.