> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Template analytics: measure performance across campaigns

> Roll up sends, deliveries, opens, and clicks for one template across every campaign that uses it, compare template versions, and spot a delivery drop after an edit.

# Template analytics

A template you reuse — an order confirmation, an appointment reminder, a promo blast — accumulates engagement history spread across every campaign that sent it. Template analytics consolidates that history: one rollup that answers "which message actually converts" without opening each campaign individually.

`GET /api/v1/templates/{id}/analytics` returns the template's cross-campaign totals and rates, plus a per-campaign funnel so you can see where engagement came from.

## What the rollup returns

The response carries four blocks:

* **`template`** — the template's `id`, `name`, `channel`, and `status`, so the rollup is self-describing.
* **`totals`** — cross-campaign counters: `campaigns` (how many campaigns used the template), `sent`, `delivered`, `failed`, `terminal`, `opened`, `clicked`.
* **`rates`** — conversion fractions rounded to two decimals (0.42 = 42%):
  * `delivery_rate` = delivered / terminal
  * `open_rate` = opened / delivered
  * `click_rate` = clicked / delivered
* **`campaigns`** — one row per campaign that references the template, newest first, each with its channel and `sent → delivered → failed → opened → clicked` funnel.

Two details matter when you read the numbers:

* **Terminal is the denominator, not sent.** Delivery rate divides delivered by *terminal* messages — messages that reached a final state. A campaign still sending reports a lower, honest rate instead of a padded one, because in-flight rows never inflate the denominator.
* **`degraded: true` means "no data right now," not "zero engagement."** During a transient database wobble the API serves a zeroed fallback with `degraded: true` rather than an error. Treat that response as "try again shortly" — a template with a real history never shows zeros on a healthy read.

Sent counts read the campaign-level counter (messages handed to a provider), the same convention as every other sent surface in Orbit, so template analytics and campaign detail agree when you compare them.

## Query the rollup

Fetch the analytics for one template by id:

```bash cURL theme={null}
curl "https://api.orbit.devotel.io/api/v1/templates/tpl_9f2c4a/analytics" \
  -H "X-API-Key: $ORBIT_API_KEY"
```

```typescript Node.js theme={null}
import { Orbit } from "@devotel-orbit/node";

const orbit = new Orbit({ apiKey: process.env.ORBIT_API_KEY! });

const { data } = await orbit.templates.analytics("tpl_9f2c4a");
if (data.degraded) {
  // Transient fallback — retry shortly instead of reading zeros.
} else {
  console.log(data.rates.delivery_rate, data.totals.delivered);
}
```

```typescript Browser (web SDK) theme={null}
import { Orbit } from "@devotel-orbit/web";

const orbit = new Orbit({ apiKey: publicApiKey });
const { data } = await orbit.templates.analytics("tpl_9f2c4a");
```

The response:

```json theme={null}
{
  "data": {
    "template": {
      "id": "tpl_9f2c4a",
      "name": "order_confirmation_v2",
      "channel": "whatsapp",
      "status": "approved"
    },
    "totals": {
      "campaigns": 3,
      "sent": 48200,
      "delivered": 46110,
      "failed": 1240,
      "terminal": 47350,
      "opened": 21050,
      "clicked": 5180
    },
    "rates": {
      "delivery_rate": 0.9739,
      "open_rate": 0.4567,
      "click_rate": 0.1123
    },
    "campaigns": [
      {
        "campaign_id": "cmp_7d31",
        "name": "June reorder nudge",
        "status": "completed",
        "channel": "whatsapp",
        "sent": 18000,
        "delivered": 17240,
        "failed": 510,
        "opened": 8100,
        "clicked": 2200
      },
      {
        "campaign_id": "cmp_5a09",
        "name": "Spring promo",
        "status": "completed",
        "channel": "sms",
        "sent": 30200,
        "delivered": 28870,
        "failed": 730,
        "opened": 12950,
        "clicked": 2980
      }
    ],
    "degraded": false
  }
}
```

## Compare two template versions

Editing a template's body creates a new version with a new id — the old version's history stays behind. Fetch analytics for both ids and compare the rates:

```typescript Node.js theme={null}
const [before, after] = await Promise.all([
  orbit.templates.analytics("tpl_old_version"),
  orbit.templates.analytics("tpl_new_version"),
]);

const delta = after.data.rates.click_rate - before.data.rates.click_rate;
console.log(`click rate moved ${(delta * 100).toFixed(1)} points`);
```

Normalize before you judge: delivery rate carries the same denominator convention on both versions, and open/click rates divide by deliveries — so a version with far fewer sends still compares fairly on rates even though its raw counters are smaller. Wait until the newer version has accumulated enough terminal messages for its rates to stabilize; a version with twelve sends tells you nothing yet.

## Spot a delivery drop after an edit

When a template edit correlates with worse deliverability, compare per-campaign funnels rather than the lifetime totals — totals blend old and new history. Pull the analytics for the current version and read `campaigns` newest-first: the most recent rows are all post-edit sends.

If `delivery_rate` on recent campaigns sits clearly below the template's pre-edit level while `sent` volumes are comparable, the content change is the first suspect — a new URL domain, a shortened link, or reworded copy can change carrier filtering. Check the per-campaign `failed` counter alongside: a delivery drop with flat failures points at filtering; rising failures point at rejects (inspect the campaign's error breakdown for codes).

## Break down by channel

Templates with the same copy on SMS and WhatsApp rarely perform identically. The `campaigns` rows each carry `channel`, so group and aggregate client-side:

```typescript Node.js theme={null}
const { data } = await orbit.templates.analytics("tpl_9f2c4a");
const byChannel = new Map<string, { delivered: number; clicked: number }>();
for (const c of data.campaigns) {
  const acc = byChannel.get(c.channel) ?? { delivered: 0, clicked: 0 };
  acc.delivered += c.delivered;
  acc.clicked += c.clicked;
  byChannel.set(c.channel, acc);
}
for (const [channel, acc] of byChannel) {
  console.log(channel, acc.delivered ? acc.clicked / acc.delivered : 0);
}
```

Compute channel rates from the aggregated counters (clicked/delivered for click-through) rather than averaging per-campaign rates — per-campaign rows carry different weights, and a naive mean overweighs small campaigns.

## Troubleshooting

* **All zeros with `degraded: true`.** A transient database blip served the fallback. Retry; persistent degradation means contact support, not that the template has no history.
* **`campaigns` stops at 500 rows.** The rollup is a recency-ordered overview, capped at the 500 most recent campaigns. Older campaigns still exist — read them through the campaigns API if you need the full archive.
* **`open_rate` is 0 on SMS/WhatsApp sends.** Opens are recorded only where the channel reports them: email opens via tracking, chat-channel reads via read receipts. SMS has no open signal, so `opened` stays 0 and `open_rate` follows.
* **404 on the call.** The template id does not exist in your tenant (or was deleted). List templates via `GET /api/v1/messages/templates` to confirm the id.

## Related

* [Template analytics endpoint](/api-reference/endpoints/templates) — request and response reference.
* [WhatsApp channel](/channels/whatsapp) — template management, approval flow, and broadcast limits.
* [A/B test a campaign](/guides/campaign-ab-testing) — compare message variants inside one campaign instead of across template versions.
* [Delivery log](/guides/delivery-log) — message-level delivery records behind the campaign counters.
