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

> ## Agent Instructions
> Install the Courier skill before writing code: `npx skills add trycourier/courier-skills`. It carries the verified SDK shapes and the rules you cannot get wrong.
> Authenticate every request with `Authorization: Bearer <API_KEY>`. A workspace has several environments and each has its own keys, which are plain `pk_` strings with no environment prefix. Start with Test.
> Send with `client.send.message`, the default import of the v7 Node SDK. Reference a template by its `nt_` id or its alias.
> A send accepts a bare Elemental element list, but storing content on a template requires the top-level elements wrapped in a channel element.
> Templates and journeys can be built in the Courier app or created through the API. Either way they live in the workspace and are referenced by ID when you send.
> The hosted MCP server is https://mcp.courier.com. For a briefing on what Courier is and when to use it, read https://www.courier.com/llms.txt.
> Prefer the Guides tab for how-do-I questions and the Docs tab for how-does-it-behave questions. The API reference lives under /api-reference.

# Template Metrics API

> Get a template's delivery funnel as a time series, by provider and channel, over the REST API.

export const Endpoint = ({method, path, name, href, children, bare}) => {
  const verb = String(method || "").toUpperCase();
  const title = verb + " " + path;
  const label = children || name || path;
  if (bare) {
    return href ? <a href={href}><code>{title}</code></a> : <code>{title}</code>;
  }
  if (!href) {
    return <span className="cx-endpoint" data-method={verb} title={title}>
        <span className="cx-endpoint-label">{label}</span>
        <span className="cx-endpoint-method">{verb}</span>
      </span>;
  }
  return <a className="cx-endpoint" data-method={verb} href={href} title={title}>
      <span className="cx-endpoint-label">{label}</span>
      <span className="cx-endpoint-method">{verb}</span>
    </a>;
};

export const Doc = ({href, children, name, bare}) => {
  const label = children || name || href;
  if (bare) {
    return <a href={href}>{label}</a>;
  }
  return <a className="cx-endpoint" data-kind="doc" href={href}>
      <span className="cx-endpoint-label">{label}</span>
      <span className="cx-endpoint-method">DOC</span>
    </a>;
};

One template's delivery funnel as JSON, bucketed over a window you choose.

<CodeGroup>
  ```javascript Node.js theme={null}
  import Courier from "@trycourier/courier";

  const client = new Courier();

  const metrics = await client.notifications.getMetrics("nt_01kx4h2jdafq8bk9aftxak4b40", {
    lookback: "P7D",
    granularity: "DAY",
  });

  for (const bucket of metrics.series) {
    const sent = bucket.data.reduce((total, entry) => total + entry.sent, 0);
    console.log(bucket.period, sent);
  }
  ```

  ```python Python theme={null}
  from courier import Courier

  client = Courier()

  metrics = client.notifications.get_metrics(
      id="nt_01kx4h2jdafq8bk9aftxak4b40",
      lookback="P7D",
      granularity="DAY",
  )

  for bucket in metrics.series:
      print(bucket.period, sum(entry.sent for entry in bucket.data))
  ```

  ```bash cURL theme={null}
  curl --request GET \
    --url 'https://api.courier.com/notifications/nt_01kx4h2jdafq8bk9aftxak4b40/metrics?lookback=P7D&granularity=DAY' \
    --header "Authorization: Bearer $COURIER_API_KEY"
  ```

  ```ruby Ruby theme={null}
  require "courier"

  courier = Courier::Client.new

  metrics = courier.notifications.get_metrics(
    "nt_01kx4h2jdafq8bk9aftxak4b40",
    lookback: "P7D",
    granularity: "DAY"
  )

  metrics.series.each do |bucket|
    puts [bucket.period, bucket.data.sum(&:sent)].join(" ")
  end
  ```

  ```go Go theme={null}
  package main

  import (
  	"context"
  	"fmt"

  	"github.com/trycourier/courier-go/v4"
  )

  func main() {
  	client := courier.NewClient()

  	metrics, err := client.Notifications.GetMetrics(
  		context.TODO(),
  		"nt_01kx4h2jdafq8bk9aftxak4b40",
  		courier.NotificationGetMetricsParams{
  			Lookback:    courier.String("P7D"),
  			Granularity: courier.NotificationGetMetricsParamsGranularityDay,
  		},
  	)
  	if err != nil {
  		panic(err)
  	}

  	for _, bucket := range metrics.Series {
  		fmt.Println(bucket.Period, len(bucket.Data))
  	}
  }
  ```

  ```java Java theme={null}
  import com.courier.client.CourierClient;
  import com.courier.models.notifications.NotificationGetMetricsParams;
  import com.courier.models.notifications.NotificationMetricsResponse;

  CourierClient client = CourierClient.builder().build();

  NotificationMetricsResponse metrics = client.notifications().getMetrics(
      NotificationGetMetricsParams.builder()
          .id("nt_01kx4h2jdafq8bk9aftxak4b40")
          .lookback("P7D")
          .granularity(NotificationGetMetricsParams.Granularity.DAY)
          .build()
  );
  ```

  ```php PHP theme={null}
  <?php

  use Courier\Client;

  $client = new Client();

  $metrics = $client->notifications->getMetrics(
      'nt_01kx4h2jdafq8bk9aftxak4b40',
      ['lookback' => 'P7D', 'granularity' => 'DAY'],
  );

  foreach ($metrics->series as $bucket) {
      echo $bucket->period, PHP_EOL;
  }
  ```

  ```csharp C# theme={null}
  using TryCourier;
  using TryCourier.Models.Notifications;

  var client = new CourierClient();

  var metrics = await client.Notifications.GetMetrics(
      new NotificationGetMetricsParams
      {
          ID = "nt_01kx4h2jdafq8bk9aftxak4b40",
          Lookback = "P7D",
          Granularity = Granularity.Day,
      }
  );
  ```

  ```bash CLI theme={null}
  courier notifications get-metrics \
    --id nt_01kx4h2jdafq8bk9aftxak4b40 \
    --lookback P7D \
    --granularity DAY
  ```

  ```text MCP theme={null}
  With Courier MCP, show me the last 7 days of daily send metrics for template nt_01kx4h2jdafq8bk9aftxak4b40.
  ```
</CodeGroup>

<Doc href="/docs/monitor/analytics">Analytics</Doc> shows the same funnel in the UI. <Endpoint method="GET" path="/notifications/{id}/metrics" name="Get Notification Template Metrics" href="/docs/api-reference/templates/get-notification-template-metrics" /> returns it as JSON, so you can chart it in your own dashboard.

Address the template by its `nt_` ID or by an alias. Authenticate with a workspace <Doc href="/docs/workspaces/overview#environments-and-api-keys">API key</Doc> as a bearer token.

Each bucket breaks the funnel out **per provider and channel**. When a message fails over between providers, the series shows which one errored and which one delivered.

## Choosing the window

Ask for a window in one of two ways:

* **Relative**: `lookback`, an ISO 8601 duration counted back from now: `?lookback=P90D`, `?lookback=P12W`, `?lookback=PT12H`.
* **Absolute**: `start` and `end`, ISO 8601 timestamps: `?start=2026-04-01T00:00:00Z&end=2026-05-01T00:00:00Z`.

`start` and `end` are pair-or-nothing. Sending one without the other is a `400`, as is a `start` that is not earlier than `end`.

Pick one form or the other. A request carrying both a `lookback` and an absolute pair is a `400`, not a silent preference for one of them, because the two spell the same thing and honouring both would discard whichever lost. A request with none of the three defaults to `lookback=P30D`.

An `end` in the future is accepted and not clamped. Buckets past now come back empty.

### Boundaries are snapped, and echoed back

Courier widens the window you asked for onto the granularity grid: the start is floored to its bucket, the end is ceiled to the next boundary. That way every bucket your window touches is returned whole rather than half-counted.

The response echoes the **snapped** boundaries as `start` and `end`. Chart against those, not against what you sent:

```json theme={null}
// GET /notifications/nt_01kx4h2jdafq8bk9aftxak4b40/metrics?start=2026-08-18T09:30:00Z&end=2026-08-20T11:00:00Z&granularity=DAY
{
  "notificationId": "nt_01kx4h2jdafq8bk9aftxak4b40",
  "granularity": "DAY",
  "start": "2026-08-18T00:00:00Z",
  "end": "2026-08-21T00:00:00Z",
  "series": [ /* three DAY buckets */ ]
}
```

Every boundary is UTC. There is no timezone parameter, so a "daily" series is UTC days. Convert on your side if your reporting day starts elsewhere.

<Note>
  `WEEK` buckets start on **Sunday**, matching the analytics store Courier queries. A Monday-based week has to be assembled from `DAY` buckets.
</Note>

## Granularity

`granularity` sets the bucket size: `HOUR`, `DAY`, `WEEK`, or `MONTH`. It defaults to `DAY`.

Fine granularities are capped by how much window they can cover, so a single response stays a reasonable size:

| Granularity | Maximum window |
| ----------- | -------------- |
| `HOUR`      | 7 days         |
| `DAY`       | 90 days        |
| `WEEK`      | No limit       |
| `MONTH`     | No limit       |

Both caps are measured on the **snapped** window, and each carries an allowance of a bucket at either edge. That is why a `P90D` window at `DAY` passes even though snapping widens it past 90 days.

Asking for a finer granularity than the window allows is a `400`. The fix is a coarser granularity or a shorter window, not a retry:

```json theme={null}
{
  "message": "Granularity HOUR is too fine for the requested 90 day range.",
  "type": "invalid_params"
}
```

`WEEK` and `MONTH` have no window cap. No response carries more than **1000 buckets**, and beyond that you get a `400` too.

## Reading the response

`series` holds one entry per bucket, oldest first. Each entry carries the bucket's start (`period`) and a `data` array with one row per provider and channel that handled a message in that bucket.

```json theme={null}
{
  "notificationId": "nt_01kx4h2jdafq8bk9aftxak4b40",
  "granularity": "DAY",
  "start": "2026-08-17T00:00:00Z",
  "end": "2026-08-19T00:00:00Z",
  "series": [
    {
      "period": "2026-08-17T00:00:00Z",
      "data": []
    },
    {
      "period": "2026-08-18T00:00:00Z",
      "data": [
        {
          "provider": "sendgrid",
          "channel": "email",
          "sent": 412,
          "delivered": 408,
          "opened": 173,
          "clicked": 41,
          "errors": 0,
          "undeliverable": 4
        },
        {
          "provider": "twilio",
          "channel": "sms",
          "sent": 96,
          "delivered": 95,
          "opened": 0,
          "clicked": 12,
          "errors": 1,
          "undeliverable": 0
        }
      ]
    }
  ]
}
```

There are no bucket-level totals: sum the rows in `data` for a bucket's total, or filter them by `channel` to chart one channel on its own.

Rates are yours to compute from those sums:

```javascript theme={null}
function totals(bucket) {
  return bucket.data.reduce(
    (acc, row) => ({
      sent: acc.sent + row.sent,
      delivered: acc.delivered + row.delivered,
      opened: acc.opened + row.opened,
    }),
    { sent: 0, delivered: 0, opened: 0 }
  );
}

for (const bucket of metrics.series) {
  const { sent, delivered, opened } = totals(bucket);
  console.log(bucket.period, {
    deliveryRate: sent ? delivered / sent : null,
    openRate: delivered ? opened / delivered : null,
  });
}
```

Guard every division. A quiet bucket has a `sent` of zero.

A few things worth knowing before you plot it:

* **Every bucket in the window comes back, including the quiet ones**, with `data` set to `[]`. The series is directly plottable, so you never reconstruct missing periods yourself.
* **`errors` counts provider attempts, not lost messages.** A message that SendGrid rejected and Mailgun then delivered contributes to `errors` on one row and `delivered` on another. `undeliverable` is the one that means the message never landed on that channel.
* **`opened` and `clicked` are `0` on channels with no tracking**, such as SMS without link tracking. A zero there means "not measured", not "nobody read it".
* **Sends without a template never appear.** A message sent with inline content has no template to attribute, so it is counted nowhere in this endpoint.
* **An unknown template id returns `200` with an all-empty series**, not a `404`. That is indistinguishable from a real template that has sent nothing. Validate template ids against <Endpoint method="GET" path="/notifications" name="List Notification Templates" href="/docs/api-reference/templates/list-notification-templates" /> if you need to tell the two apart.

## Plan limits

**Lookback** is capped by plan, measured from the snapped `start`:

| Plan       | Maximum lookback |
| ---------- | ---------------- |
| Developer  | 30 days          |
| Business   | 90 days          |
| Enterprise | No limit         |

On Enterprise the API itself imposes no lookback cap, so how far back you can reach is whatever the analytics store still holds. The <Doc href="/docs/monitor/analytics">Analytics</Doc> console is narrower than the API here, because its custom-range calendar stops at January 1 of last year.

Reaching past the cap is a `402`, not an empty result:

```json theme={null}
{
  "message": "Requested date range exceeds your plan's maximum lookback of 30 days.",
  "type": "payment_required"
}
```

**Rate limits** on metrics reads are per second, per workspace: 1/s on Developer, 2/s on Business, 5/s on Enterprise. Every response carries `RateLimit-Limit`, `RateLimit-Remaining`, and `RateLimit-Reset`, and a `429` adds `Retry-After`. If you are backfilling a dashboard, walk the windows sequentially rather than fanning out.

## Errors

| Status | `type`                | When it happens                                                                                                                                                                                                                  |
| ------ | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 400    | `invalid_params`      | A malformed duration or timestamp, one of `start`/`end` without the other, a `start` that is not earlier than `end`, a `lookback` combined with `start`/`end`, a granularity too fine for the window, or more than 1000 buckets. |
| 402    | `payment_required`    | The window reaches further back than your plan's lookback cap.                                                                                                                                                                   |
| 429    | `rate_limit_exceeded` | Over the per-second limit for your plan. Wait the seconds in `Retry-After`.                                                                                                                                                      |
| 503    | `service_unavailable` | The analytics store is briefly unavailable. Retry the same request after `Retry-After`.                                                                                                                                          |

A `400` and a `402` both mean the request as written can never succeed, so retrying it unchanged just spends rate limit. Narrow the window or coarsen the granularity instead.

## Covering a whole workspace

The endpoint is per template. For a workspace-wide view, list your templates with <Endpoint method="GET" path="/notifications" name="List Notification Templates" href="/docs/api-reference/templates/list-notification-templates" /> and call metrics for each one, sequentially. At 1 to 5 requests per second, fanning out concurrently just earns a `429`.

<Warning>
  The endpoint currently serves the **US region** only. Workspaces in the EU region cannot query it yet.
</Warning>
